Feat : Interface scalable size
All checks were successful
release / build (push) Successful in 28s
release / verify-windows (push) Successful in 1m26s

This commit is contained in:
jeanotx32
2026-08-12 22:44:10 +02:00
parent 449a6d917f
commit a1295003fd
5 changed files with 92 additions and 3 deletions

View File

@@ -708,6 +708,18 @@ inutile de forcer l'invalidation du cache navigateur en plus.
Se rafraîchit donc au rythme de la veille (`WATCHLIST_INTERVAL_MS`, 30 s par défaut) — un 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. aperçu, pas un flux temps réel.
**Sa taille se règle** depuis la barre de filtres de l'onglet Streamers : *Aucun*, *Petit*,
*Moyen* (par défaut) ou *Grand*. Selon la largeur de l'écran et le nombre de profils suivis,
un aperçu pleine largeur rend la liste agréable ou la rend illisible à force de faire défiler
— d'où un réglage plutôt qu'un choix imposé. C'est la **largeur** qui est bridée, pas la
hauteur : la boîte 16/9 entière rétrécit, là où une hauteur maximale l'aurait recadrée en
bandeau et rogné l'image.
*Aucun* ne se contente pas de masquer : la vignette n'est alors pas rendue du tout, donc pas
téléchargée — ce sont des images distantes, autant ne pas les demander quand personne ne les
regarde. Le choix est mémorisé dans le navigateur : c'est un confort propre à l'écran qu'on a
sous les yeux, pas un réglage à imposer aux autres postes.
## 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

View File

@@ -63,8 +63,11 @@ export function AgentCard({
</label> </label>
<div className="identity"> <div className="identity">
<h2>{agent.name}</h2> <h2>{agent.name}</h2>
{/* Le nom de machine ne figure plus ici : il redoublait le nom affiché
de la VM sans rien en dire de plus. Il reste porté par l'agent, et
donc consultable via l'API. */}
<span className="muted small"> <span className="muted small">
{agent.hostname ?? '—'} · {agent.platform} {agent.platform}
{agent.agentVersion ? ` · v${agent.agentVersion}` : ''} {agent.agentVersion ? ` · v${agent.agentVersion}` : ''}
{status.buildId ? ` · build ${status.buildId}` : ''} {status.buildId ? ` · build ${status.buildId}` : ''}
</span> </span>

View File

@@ -4,12 +4,16 @@ 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 { import {
PREVIEW_SIZES,
loadPreviewSize,
preemption, preemption,
previewSrc, previewSrc,
recordingContext, recordingContext,
savePreviewSize,
stateLabel, stateLabel,
tagStyle, tagStyle,
usedTags, usedTags,
type PreviewSize,
type RecordingContext, type RecordingContext,
} from '../streamers'; } from '../streamers';
import { StreamerDetail } from './StreamerDetail'; import { StreamerDetail } from './StreamerDetail';
@@ -44,6 +48,14 @@ export function StreamersPage({
const [openId, setOpenId] = useState<string | null>(null); const [openId, setOpenId] = useState<string | null>(null);
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set()); const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
const [managingTags, setManagingTags] = useState(false); const [managingTags, setManagingTags] = useState(false);
const [previewSize, setPreviewSize] = useState<PreviewSize>(loadPreviewSize);
function changePreviewSize(size: PreviewSize) {
setPreviewSize(size);
// Mémorisé côté navigateur : c'est un confort d'affichage propre à l'écran
// qu'on a sous les yeux, pas un réglage à imposer aux autres postes.
savePreviewSize(size);
}
// Tirées des profils plutôt que du serveur : la liste arrive déjà entière par // Tirées des profils plutôt que du serveur : la liste arrive déjà entière par
// le flux temps réel, donc toujours à jour, et une étiquette que personne ne // le flux temps réel, donc toujours à jour, et une étiquette que personne ne
@@ -142,6 +154,21 @@ export function StreamersPage({
</button> </button>
))} ))}
<div className="spacer" /> <div className="spacer" />
<span className="muted small">Aperçus</span>
{PREVIEW_SIZES.map((size) => (
<button
key={size.id}
className={previewSize === size.id ? 'chip active' : 'chip'}
onClick={() => changePreviewSize(size.id)}
title={
size.id === 'off'
? "N'afficher aucune image du flux — et ne pas la charger"
: `Aperçu ${size.label.toLowerCase()}`
}
>
{size.label}
</button>
))}
<span className="muted small">{visible.length} profil(s)</span> <span className="muted small">{visible.length} profil(s)</span>
</div> </div>
)} )}
@@ -188,7 +215,7 @@ export function StreamersPage({
) : visible.length === 0 ? ( ) : visible.length === 0 ? (
<p className="muted small">Aucun profil pour ce filtre.</p> <p className="muted small">Aucun profil pour ce filtre.</p>
) : ( ) : (
<div className="streamer-grid"> <div className={`streamer-grid preview-${previewSize}`}>
{visible.map((target) => ( {visible.map((target) => (
<StreamerCard <StreamerCard
key={target.id} key={target.id}
@@ -196,6 +223,7 @@ export function StreamersPage({
targets={targets} targets={targets}
agents={agents} agents={agents}
notify={notify} notify={notify}
previewSize={previewSize}
onOpen={() => setOpenId(target.id)} onOpen={() => setOpenId(target.id)}
/> />
))} ))}
@@ -235,12 +263,14 @@ function StreamerCard({
targets, targets,
agents, agents,
notify, notify,
previewSize,
onOpen, onOpen,
}: { }: {
target: WatchTarget; target: WatchTarget;
targets: WatchTarget[]; targets: WatchTarget[];
agents: AgentView[]; agents: AgentView[];
notify: (message: string, tone?: 'info' | 'error') => void; notify: (message: string, tone?: 'info' | 'error') => void;
previewSize: PreviewSize;
onOpen: () => void; onOpen: () => void;
}) { }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -255,7 +285,9 @@ function StreamerCard({
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; // Rien n'est calculé — ni chargé — quand les aperçus sont coupés : ce sont des
// images distantes, autant ne pas les demander si personne ne les regarde.
const preview = isLive && previewSize !== 'off' ? previewSrc(target) : null;
const showPreview = preview !== null && preview !== brokenPreview; 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;

View File

@@ -166,6 +166,36 @@ export function usedTags(targets: WatchTarget[]): Array<{ id: string; name: stri
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name)); return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
} }
/**
* Tailles proposées pour l'aperçu du flux sur les vignettes.
*
* Réglable plutôt que fixé : selon la largeur de l'écran et le nombre de
* profils suivis, un aperçu pleine largeur rend la liste agréable ou la rend
* illisible à force de faire défiler. `off` va jusqu'à ne rien charger du tout —
* ce sont des images distantes, autant ne pas les demander quand on ne les
* regarde pas.
*/
export const PREVIEW_SIZES = [
{ id: 'off', label: 'Aucun' },
{ id: 'sm', label: 'Petit' },
{ id: 'md', label: 'Moyen' },
{ id: 'lg', label: 'Grand' },
] as const;
export type PreviewSize = (typeof PREVIEW_SIZES)[number]['id'];
const PREVIEW_SIZE_KEY = 'stream-control.previewSize';
/** Préférence enregistrée, ou « Moyen » à défaut. */
export function loadPreviewSize(): PreviewSize {
const saved = localStorage.getItem(PREVIEW_SIZE_KEY);
return PREVIEW_SIZES.some((entry) => entry.id === saved) ? (saved as PreviewSize) : 'md';
}
export function savePreviewSize(size: PreviewSize): void {
localStorage.setItem(PREVIEW_SIZE_KEY, size);
}
/** 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' },

View File

@@ -725,6 +725,18 @@ fieldset.group > legend {
background: var(--panel-2); background: var(--panel-2);
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
cursor: zoom-in; cursor: zoom-in;
/* Bride la largeur, pas la hauteur : c'est la boîte 16/9 entière qui rétrécit,
là où une hauteur maximale l'aurait recadrée en bandeau et rogné l'image. */
max-width: var(--preview-max, none);
}
/* Réglé sur la grille et hérité par les vignettes — voir le sélecteur de taille
sur la page Streamers. La taille « Aucun » ne passe pas par le CSS : la
vignette n'est alors pas rendue du tout, donc pas chargée. */
.streamer-grid.preview-sm {
--preview-max: 150px;
}
.streamer-grid.preview-md {
--preview-max: 240px;
} }
.streamer-preview img { .streamer-preview img {
width: 100%; width: 100%;