diff --git a/README.md b/README.md
index 9bdc99a..3fb3b62 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,8 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`.
profil en direct, agrandie au survol.
- [Étiquettes](#étiquettes) libres sur les streamers, créées à la volée, avec filtrage de la
liste par étiquette.
+- [Recherche et tri](#recherche-et-tri) : un champ de recherche sur les deux listes (nom,
+ pseudo ou étiquette, accents et casse ignorés) et quatre ordres de tri sur la frise.
- [Frise des diffusions](#frise-des-diffusions) : l'historique des streams de chaque profil
suivi, avec en surimpression ce qui en a été capturé — par profil sur sa fiche, ou tous
ensemble dans l'onglet « Timeline ». Une
@@ -794,6 +796,64 @@ Une étiquette survit à la suppression du dernier profil qui la portait — ell
pour la suite. Inversement, ne plus suivre un profil n'emporte que ses rattachements
(`ON DELETE CASCADE` des deux côtés de la table de liaison).
+## Recherche et tri
+
+### Chercher un streamer
+
+Un champ de recherche ouvre la barre de filtres des onglets **Streamers** et **Timeline**. Il
+filtre **à la frappe**, sans bouton ni validation : les profils sont déjà chargés côté
+navigateur, il n'y a rien à attendre d'un aller-retour serveur — et en imposer un
+transformerait un tri visuel en formulaire.
+
+Il cherche **à la fois dans le nom affiché, le pseudo et les étiquettes**. Ce sont les trois
+façons dont on désigne un profil ; exiger de savoir laquelle s'applique reviendrait à demander
+de chercher ce qu'on a déjà trouvé.
+
+**Ni la casse ni les accents ne comptent** : taper `lea` trouve « Léa ». On saisit le pseudo
+qu'on a en tête, pas celui qu'on a sous les yeux, et un accent oublié n'est pas une faute qui
+mérite une liste vide. Plusieurs mots se cumulent, **dans n'importe quel ordre** — `blonde lea`
+retrouve « Léa » étiquetée « blonde ».
+
+La recherche **se cumule** avec les autres filtres (statut, étiquettes, « Enregistrés ») : ce
+sont des critères de nature différente, les laisser s'élargir les uns les autres ne filtrerait
+plus rien. Seules les étiquettes entre elles forment une union (voir
+[Étiquettes](#étiquettes)).
+
+Une **croix** apparaît dès que le champ n'est plus vide, et la touche **Échap** fait la même
+chose : un filtre qu'on ne sait pas défaire finit par mentir sur ce qu'on croit voir. Pour la
+même raison, quand une liste est vide **le message nomme la recherche en cours** plutôt que de
+parler d'un « filtre » indéterminé — c'est le critère qu'on oublie le plus facilement, les
+autres se voyant à l'écran.
+
+### Trier la frise
+
+L'onglet **Timeline** propose quatre ordres, sélecteur *Trier par* :
+
+| Ordre | Ce qu'il classe | À quoi il répond |
+| --- | --- | --- |
+| **Activité récente** (défaut) | Dernier signe de vie, diffusion ou capture | « Que s'est-il passé récemment ? » |
+| **Temps enregistré** | Durée capturée, **pauses déduites** | « Sur quoi ai-je réellement du fichier ? » |
+| **Temps de diffusion** | Durée en public, capturée ou non | « Qui diffuse le plus ? » |
+| **Nom (A→Z)** | Libellé, ou pseudo à défaut | Retrouver une voie précise |
+
+Les deux tris par durée **retombent sur l'activité récente à égalité** : des voies à zéro
+seconde enregistrée sont fréquentes, et les laisser dans un ordre arbitraire donnerait une
+liste qui se réorganise sans raison visible d'un rafraîchissement à l'autre. L'instant de
+référence est figé une fois pour toutes au début du tri — relu à chaque comparaison, il ferait
+bouger la durée des voies encore ouvertes en plein classement, et l'ordre obtenu pourrait alors
+se contredire lui-même.
+
+**Le tri par temps enregistré déduit les pauses**, comme les totaux. Sans ça, un profil souvent
+en show privé passerait devant un profil réellement capturé, sur la foi d'un intervalle qui
+n'écrivait rien.
+
+**Les totaux de la légende suivent les filtres** : le résumé est posé sous la barre, il décrit
+la frise qu'on a sous les yeux et non les données brutes — annoncer un total qui ne correspond
+à rien de visible ferait douter du filtre autant que du chiffre. Le libellé le dit (« sur la
+sélection » plutôt que « au total »). Une capture jamais rattachée à un profil suivi (lancée à
+la main depuis l'onglet **Agents**) n'entre dans aucun total : elle n'a pas de voie où
+s'afficher.
+
## Vignette du flux en direct
Chaque fiche compacte d'un profil **en direct** (onglet Streamers) affiche une image du
diff --git a/packages/web/src/components/SearchField.tsx b/packages/web/src/components/SearchField.tsx
new file mode 100644
index 0000000..d557e42
--- /dev/null
+++ b/packages/web/src/components/SearchField.tsx
@@ -0,0 +1,57 @@
+interface Props {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder: string;
+ /** Libellé lu par les lecteurs d'écran : le champ n'a pas d'étiquette visible. */
+ label: string;
+}
+
+/**
+ * Champ de recherche à filtrage immédiat.
+ *
+ * Pas de bouton « Chercher » ni de validation : la liste se réduit à la frappe.
+ * Sur une poignée de profils déjà chargés côté navigateur, il n'y a rien à
+ * attendre — et un aller-retour serveur transformerait un tri visuel en
+ * formulaire.
+ *
+ * La croix et la touche Échap font la même chose : revenir à la liste entière.
+ * Un filtre qu'on ne sait pas défaire est un filtre qui finit par mentir sur ce
+ * qu'on croit voir.
+ */
+export function SearchField({ value, onChange, placeholder, label }: Props) {
+ return (
+
+
+ ⌕
+
+ onChange(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Escape' && value) {
+ // Sans ça, Firefox vide le champ sans que React le sache : l'écran
+ // afficherait un champ vide et une liste toujours filtrée.
+ event.preventDefault();
+ onChange('');
+ }
+ }}
+ />
+ {value && (
+
+ )}
+
+ );
+}
diff --git a/packages/web/src/components/StreamersPage.tsx b/packages/web/src/components/StreamersPage.tsx
index 38b4820..b4849c4 100644
--- a/packages/web/src/components/StreamersPage.tsx
+++ b/packages/web/src/components/StreamersPage.tsx
@@ -6,18 +6,21 @@ import { formatDateTime, formatDuration, formatRelative } from '../format';
import {
PREVIEW_SIZES,
loadPreviewSize,
+ matchesSearch,
+ matchesTags,
preemption,
previewSrc,
recordingContext,
savePreviewSize,
stateLabel,
- tagStyle,
+ toggleId,
usedTags,
type PreviewSize,
type RecordingContext,
} from '../streamers';
+import { SearchField } from './SearchField';
import { StreamerDetail } from './StreamerDetail';
-import { TagChip, TagManager } from './Tags';
+import { TagChip, TagFilter, TagManager } from './Tags';
interface Props {
targets: WatchTarget[];
@@ -45,6 +48,7 @@ export function StreamersPage({
const [url, setUrl] = useState('');
const [busy, setBusy] = useState(false);
const [filter, setFilter] = useState('all');
+ const [query, setQuery] = useState('');
const [openId, setOpenId] = useState(null);
const [selectedTags, setSelectedTags] = useState>(new Set());
const [managingTags, setManagingTags] = useState(false);
@@ -63,12 +67,7 @@ export function StreamersPage({
const tags = useMemo(() => usedTags(targets), [targets]);
function toggleTag(id: string) {
- setSelectedTags((current) => {
- const next = new Set(current);
- if (next.has(id)) next.delete(id);
- else next.add(id);
- return next;
- });
+ setSelectedTags((current) => toggleId(current, id));
}
async function add(event: FormEvent) {
@@ -107,13 +106,12 @@ export function StreamersPage({
);
const visible = ordered.filter((target) => {
- // Les deux filtres se cumulent — « en direct » ET portant l'une des
- // étiquettes retenues. Entre étiquettes en revanche, c'est une union : en
- // cocher une seconde élargit la sélection, ce qui est le geste attendu
- // quand on empile des critères de même nature.
- if (selectedTags.size > 0 && !target.tags.some((tag) => selectedTags.has(tag.id))) {
- return false;
- }
+ // Les trois filtres se cumulent — la recherche ET « en direct » ET portant
+ // l'une des étiquettes retenues. C'est une union entre étiquettes seulement
+ // (voir `matchesTags`) : des critères de nature différente qui s'élargiraient
+ // les uns les autres ne filtreraient plus rien.
+ if (!matchesSearch(target, query)) return false;
+ if (!matchesTags(target, selectedTags)) return false;
if (filter === 'favorites') return target.favorite;
if (filter === 'live') return target.state === 'public';
return true;
@@ -144,6 +142,12 @@ export function StreamersPage({
{targets.length > 0 && (
+
{FILTERS.map((entry) => (
)}
{targets.length > 0 && (
- {tags.map((tag) => (
-
- ))}
+ setSelectedTags(new Set())}
+ />
{tags.length === 0 && (
Aucune étiquette — pose-en une depuis la fiche d'un streamer pour trier la liste
d'ici.
)}
- {selectedTags.size > 0 && (
-
- )}
) : visible.length === 0 ? (
-
Aucun profil pour ce filtre.
+ // La recherche est nommée dans le message : c'est le filtre qu'on oublie
+ // le plus facilement — les autres se voient, une chaîne tapée puis
+ // laissée dans le champ passe inaperçue.
+
+ {query.trim()
+ ? `Aucun profil ne correspond à « ${query.trim()} ».`
+ : 'Aucun profil pour ce filtre.'}
+
) : (
{visible.map((target) => (
diff --git a/packages/web/src/components/Tags.tsx b/packages/web/src/components/Tags.tsx
index e2c770c..8670fbc 100644
--- a/packages/web/src/components/Tags.tsx
+++ b/packages/web/src/components/Tags.tsx
@@ -35,6 +35,46 @@ export function TagChip({
);
}
+/**
+ * Rangée d'étiquettes servant de filtre, suivie de quoi tout réafficher.
+ *
+ * Rendue en fragment plutôt qu'en bloc : chaque page compose sa propre barre
+ * autour — la liste des streamers y ajoute la gestion des étiquettes, la frise
+ * son sélecteur de tri.
+ */
+export function TagFilter({
+ tags,
+ selected,
+ onToggle,
+ onClear,
+}: {
+ tags: Array<{ id: string; name: string }>;
+ selected: ReadonlySet;
+ onToggle: (id: string) => void;
+ onClear: () => void;
+}) {
+ return (
+ <>
+ {tags.map((tag) => (
+
+ ))}
+ {selected.size > 0 && (
+
+ )}
+ >
+ );
+}
+
/**
* Pose et retire les étiquettes d'un profil.
*
diff --git a/packages/web/src/components/Timeline.tsx b/packages/web/src/components/Timeline.tsx
index 7b48812..5ec5581 100644
--- a/packages/web/src/components/Timeline.tsx
+++ b/packages/web/src/components/Timeline.tsx
@@ -2,15 +2,11 @@ import { useMemo, useState } from 'react';
import type { RecordingPause, RecordingSpan, StreamSession } from '@stream-control/shared';
import { sessionPreviewSrc } from '../api';
import { formatDuration } from '../format';
+// Décrite là où elle est construite et filtrée, plutôt qu'ici où elle n'est que
+// dessinée : le composant reçoit ses voies, il ne les compose pas.
+import type { TimelineLane } from '../streamers';
-export interface TimelineLane {
- id: string;
- label: string;
- avatarUrl?: string | null;
- sessions: StreamSession[];
- spans: RecordingSpan[];
- pauses: RecordingPause[];
-}
+export type { TimelineLane };
interface Props {
from: number;
diff --git a/packages/web/src/components/TimelinePage.tsx b/packages/web/src/components/TimelinePage.tsx
index 059a4cc..679ba0b 100644
--- a/packages/web/src/components/TimelinePage.tsx
+++ b/packages/web/src/components/TimelinePage.tsx
@@ -2,9 +2,20 @@ import { useEffect, useMemo, useState } from 'react';
import type { AgentView, TimelineData, WatchTarget } from '@stream-control/shared';
import { api } from '../api';
import { formatDuration } from '../format';
-import { TIMELINE_RANGES } from '../streamers';
+import {
+ TIMELINE_RANGES,
+ TIMELINE_SORTS,
+ buildTimelineLanes,
+ recordedMs,
+ toggleId,
+ usedTags,
+ type TimelineLane,
+ type TimelineSort,
+} from '../streamers';
+import { SearchField } from './SearchField';
import { StreamerDetail } from './StreamerDetail';
-import { Timeline, type TimelineLane } from './Timeline';
+import { Timeline } from './Timeline';
+import { TagFilter } from './Tags';
interface Props {
targets: WatchTarget[];
@@ -27,6 +38,9 @@ export function TimelinePage({ targets, agents, notify }: Props) {
const [loading, setLoading] = useState(true);
const [onlyRecorded, setOnlyRecorded] = useState(false);
const [openId, setOpenId] = useState(null);
+ const [query, setQuery] = useState('');
+ const [selectedTags, setSelectedTags] = useState>(new Set());
+ const [sort, setSort] = useState('activity');
useEffect(() => {
let cancelled = false;
@@ -51,49 +65,35 @@ export function TimelinePage({ targets, agents, notify }: Props) {
const now = Date.now();
const from = now - days * 86_400_000;
- const lanes = useMemo(() => {
- const byTarget = new Map();
- for (const target of targets) {
- byTarget.set(target.id, {
- id: target.id,
- label: target.label ?? target.username,
- avatarUrl: target.avatarUrl,
- sessions: [],
- spans: [],
- pauses: [],
- });
- }
- for (const session of data.sessions) byTarget.get(session.targetId)?.sessions.push(session);
- for (const span of data.spans) {
- if (span.targetId) byTarget.get(span.targetId)?.spans.push(span);
- }
- for (const pause of data.pauses) {
- if (pause.targetId) byTarget.get(pause.targetId)?.pauses.push(pause);
- }
+ // Tirées des profils et non du serveur : ils arrivent entiers par le flux
+ // temps réel, donc toujours à jour.
+ const tags = useMemo(() => usedTags(targets), [targets]);
- // Un profil sans la moindre diffusion sur la période n'apporte qu'une ligne
- // vide : la frise sert à comparer des activités, pas à recenser les suivis.
- return [...byTarget.values()]
- .filter((lane) => lane.sessions.length > 0 || lane.spans.length > 0)
- .filter((lane) => !onlyRecorded || lane.spans.length > 0)
- .sort((a, b) => lastActivity(b) - lastActivity(a));
- }, [data, targets, onlyRecorded]);
-
- // Pauses déduites : la capture tournait, mais n'écrivait rien pendant ce
- // temps-là. L'annoncer comme enregistré surestimerait ce qu'on a réellement.
- const elapsed = (interval: { startedAt: number; endedAt: number | null }) =>
- (interval.endedAt ?? now) - interval.startedAt;
- const totalRecorded = Math.max(
- 0,
- data.spans.reduce((sum, span) => sum + elapsed(span), 0) -
- data.pauses.reduce((sum, pause) => sum + elapsed(pause), 0),
+ const lanes = useMemo(
+ () =>
+ buildTimelineLanes(targets, data, { query, tags: selectedTags, onlyRecorded, sort }),
+ [data, targets, onlyRecorded, query, selectedTags, sort],
);
+
+ // Comptés sur les voies affichées et non sur les données brutes : le résumé
+ // est posé sous les filtres, il doit décrire la frise qu'on a sous les yeux.
+ // Annoncer un total qui ne correspond à rien de visible ferait douter du
+ // filtre autant que du chiffre.
+ const shownSessions = lanes.reduce((sum, lane) => sum + lane.sessions.length, 0);
+ const totalRecorded = lanes.reduce((sum, lane) => sum + recordedMs(lane), 0);
+ const filtered = query.trim() !== '' || selectedTags.size > 0 || onlyRecorded;
const openTarget = openId ? (targets.find((target) => target.id === openId) ?? null) : null;
return (