From 96b364bd66caada088b0689537da3bfaadc26bcf Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Wed, 12 Aug 2026 15:28:56 +0200 Subject: [PATCH] Feat : Streamers page revamp --- README.md | 55 ++- packages/server/src/api.ts | 21 +- packages/server/src/db.ts | 179 ++++++++- packages/server/src/hub.ts | 53 ++- packages/server/src/watchlist.ts | 11 +- packages/shared/src/index.ts | 66 ++++ packages/web/src/App.tsx | 9 + packages/web/src/api.ts | 9 +- .../web/src/components/StreamerDetail.tsx | 342 ++++++++++++++++++ packages/web/src/components/StreamersPage.tsx | 272 +++++++------- packages/web/src/components/Timeline.tsx | 255 +++++++++++++ packages/web/src/components/TimelinePage.tsx | 141 ++++++++ packages/web/src/streamers.ts | 83 +++++ packages/web/src/styles.css | 258 ++++++++++++- packages/web/src/useHashRoute.ts | 2 +- 15 files changed, 1625 insertions(+), 131 deletions(-) create mode 100644 packages/web/src/components/StreamerDetail.tsx create mode 100644 packages/web/src/components/Timeline.tsx create mode 100644 packages/web/src/components/TimelinePage.tsx create mode 100644 packages/web/src/streamers.ts diff --git a/README.md b/README.md index 9512853..befebb7 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`. dédiée, dès qu'il est en direct depuis assez longtemps. - [Presets d'enregistrement](#presets-denregistrement) appliqués à OBS depuis l'interface, réglables VM par VM : de « Économe » à « Sans perte ». +- [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 ». ## Prérequis @@ -546,6 +549,55 @@ Le délai d'amorçage n'est pas une précaution de style : un modèle qui sort d repasse « public » quelques secondes avant de se remettre en place. Déclencher sur la première lecture produirait des fichiers de dix secondes. +## Frise des diffusions + +Deux vues sur le même historique : la fiche d'un profil (clic sur sa vignette, onglet +**Streamers**) montre la sienne ; l'onglet **Timeline** les empile toutes, une voie par +profil. + +Les diffusions publiques s'y lisent en bleu, et **les portions réellement capturées se +superposent en rouge**, sur la même piste. C'est le point de la vue : ce qui intéresse n'est +pas « quand ai-je enregistré » dans l'absolu mais quelle part de chaque diffusion a été +gardée — deux pistes séparées obligeraient l'œil à faire l'alignement lui-même. La vue +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é. + +### Ce qui est enregistré, et comment + +| Table | Écrite par | Une ligne = | +| --- | --- | --- | +| `stream_sessions` | la veille, sur transition d'état | une diffusion publique observée | +| `recording_spans` | le hub, sur changement de statut d'un agent | un intervalle réellement capturé | + +Deux tables et non une : un enregistrement démarre en général après le début du stream, +peut s'arrêter avant sa fin, et une même diffusion peut en compter plusieurs. Une pause OBS +(show privé) ne coupe pas l'intervalle — elle n'écrit rien mais ne ferme pas le fichier, et +la diffusion publique s'est de toute façon interrompue pendant ce temps. + +Le rattachement d'une capture à un profil passe par le pseudo sur lequel la veille de l'agent +est calée — c'est `startTargetRecording()` qui l'y pose. Une capture lancée à la main depuis +l'onglet Agents, sans ce calage, reste sans profil et n'apparaît sur aucune voie. + +**Une capture dont on n'a jamais vu la fin** (VM éteinte en plein enregistrement, serveur +redémarré) est refermée par un balayage périodique — à sa dernière preuve de vie, pas à +l'heure du balayage. Sans cela une VM disparue une semaine passerait pour avoir enregistré +une semaine. La colonne `last_seen_at`, réécrite à chaque statut reçu, sert exactement à ça, +et la tolérance de 60 s laisse un agent qui redémarre retrouver son intervalle plutôt que +d'en ouvrir un second. + +Supprimer un profil suivi efface son historique (`ON DELETE CASCADE`) : ne plus suivre +quelqu'un, c'est aussi l'oublier. + +### « Cette VM enregistre » n'est pas « ce profil est enregistré » + +Plusieurs profils peuvent être assignés à la même VM, mais elle n'en capture qu'un à la +fois. L'interface distingue donc les deux, partout où un état d'enregistrement paraît : + +- le profil réellement capturé porte « ⏺ enregistrement en cours » et le bouton **Arrêter** ; +- les autres profils de la même VM affichent « VM occupée : *nom du profil capturé* », avec + le bouton **Enregistrer** désactivé — ils ne sont pas enregistrés, et la fiche détaillée + l'explique en toutes lettres. + ## Capture de fenêtre OBS et réutilisation La source « capture de fenêtre » d'OBS mémorise un identifiant de fenêtre X11. Fermer la @@ -622,7 +674,8 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer { agentId, notify: typeof req.body?.notify === 'boolean' ? req.body.notify : target.notify, autoRecord, + favorite: typeof req.body?.favorite === 'boolean' ? req.body.favorite : target.favorite, }); const updated = targetsRepo.get(target.id); @@ -372,6 +373,24 @@ api.post('/watchlist/:id/stop', async (req, res) => { } }); +/** + * Frise de diffusion : les streams observés et ce qui en a été capturé. + * + * Une seule route pour la frise globale et celle d'un profil — c'est le même + * assemblage, seul le filtre change. Le client recoupe les deux listes par + * `targetId`, il a déjà les profils par le flux temps réel. + */ +api.get('/timeline', (req, res) => { + const days = Math.min(Math.max(Number.parseInt(String(req.query.days ?? '7'), 10) || 7, 1), 120); + const targetId = typeof req.query.targetId === 'string' ? req.query.targetId : null; + const from = Date.now() - days * 86_400_000; + + res.json({ + sessions: sessionsRepo.since(from, targetId), + spans: spansRepo.since(from, targetId), + }); +}); + // --- Divers ----------------------------------------------------------------- /** Historique d'une VM : les mêmes entrées que le journal, filtrées et bornées. */ diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index 96473bb..d38949d 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -8,6 +8,8 @@ import type { ObsSettings, Platform, RecordingSettings, + RecordingSpan, + StreamSession, StreamState, BrowserSettings, WatchSettings, @@ -81,6 +83,35 @@ db.exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_targets_identity ON watch_targets (provider, username); + -- Diffusions observées, une ligne par passage en direct. Le CASCADE est + -- voulu : ne plus suivre un profil, c'est aussi oublier son historique. + CREATE TABLE IF NOT EXISTS stream_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_id TEXT NOT NULL REFERENCES watch_targets (id) ON DELETE CASCADE, + started_at INTEGER NOT NULL, + ended_at INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_target ON stream_sessions (target_id, started_at DESC); + CREATE INDEX IF NOT EXISTS idx_sessions_started ON stream_sessions (started_at DESC); + + -- Intervalles réellement capturés par une VM. La colonne last_seen_at est + -- réécrite à chaque statut reçu pendant la capture : c'est elle qui permet de + -- refermer honnêtement un intervalle dont on n'a jamais vu la fin (agent + -- disparu, serveur redémarré) sans lui attribuer la durée de l'interruption. + CREATE TABLE IF NOT EXISTS recording_spans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_id TEXT REFERENCES watch_targets (id) ON DELETE CASCADE, + agent_id TEXT, + started_at INTEGER NOT NULL, + ended_at INTEGER, + last_seen_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_spans_target ON recording_spans (target_id, started_at DESC); + CREATE INDEX IF NOT EXISTS idx_spans_started ON recording_spans (started_at DESC); + CREATE INDEX IF NOT EXISTS idx_spans_open ON recording_spans (agent_id) WHERE ended_at IS NULL; + -- Réglages globaux, hors agents et profils : une ligne par clé, en JSON. -- Pensé pour grandir (un jour d'autres canaux que Pushover) sans nouvelle -- table à chaque fois. @@ -119,6 +150,8 @@ addColumnIfMissing('watch_targets', 'last_live_ended_at', 'INTEGER'); // Enregistrement automatique dès que le profil est en direct depuis assez // longtemps. Desactivé par défaut : rien ne doit s'enclencher sans un geste. addColumnIfMissing('watch_targets', 'auto_record', 'INTEGER NOT NULL DEFAULT 0'); +// Épinglage en tête de liste, sans autre effet. +addColumnIfMissing('watch_targets', 'favorite', 'INTEGER NOT NULL DEFAULT 0'); /** * `idle` — le « revient bientôt » de Stripchat — relevait de la clôture et non de @@ -426,6 +459,7 @@ interface TargetRow { agent_id: string | null; notify: number; auto_record: number; + favorite: number; state: string; raw_status: string | null; state_since: number; @@ -449,6 +483,7 @@ function toTarget(row: TargetRow): WatchTarget { url: stripchatProfileUrl(row.username), agentId: row.agent_id, notify: Number(row.notify) === 1, + favorite: Number(row.favorite) === 1, autoRecord: Number(row.auto_record) === 1, state: (row.state as StreamState) ?? 'unknown', rawStatus: row.raw_status, @@ -473,7 +508,7 @@ const targetStmts = { VALUES (?, ?, ?, ?, ?, ?, 'unknown', ?, ?) `), updateSettings: db.prepare( - 'UPDATE watch_targets SET label = ?, agent_id = ?, notify = ?, auto_record = ? WHERE id = ?', + 'UPDATE watch_targets SET label = ?, agent_id = ?, notify = ?, auto_record = ?, favorite = ? WHERE id = ?', ), updateState: db.prepare(` UPDATE watch_targets @@ -531,6 +566,7 @@ export const targetsRepo = { agentId: string | null; notify: boolean; autoRecord: boolean; + favorite: boolean; }, ): void { targetStmts.updateSettings.run( @@ -538,6 +574,7 @@ export const targetsRepo = { settings.agentId, settings.notify ? 1 : 0, settings.autoRecord ? 1 : 0, + settings.favorite ? 1 : 0, id, ); }, @@ -574,6 +611,146 @@ export const targetsRepo = { }, }; +// --- Historique de diffusion ------------------------------------------------- + +const sessionStmts = { + openFor: db.prepare( + 'SELECT * FROM stream_sessions WHERE target_id = ? AND ended_at IS NULL ORDER BY started_at DESC', + ), + insert: db.prepare('INSERT INTO stream_sessions (target_id, started_at) VALUES (?, ?)'), + // MAX() borne la fin au démarrage : l'heure de fin vient de la plateforme et + // rien ne garantit qu'elle soit postérieure à celle du début qu'elle nous + // avait donnée. Une durée négative rendrait la frise illisible. + close: db.prepare(` + UPDATE stream_sessions SET ended_at = MAX(started_at, ?) + WHERE target_id = ? AND ended_at IS NULL + `), + since: db.prepare( + 'SELECT * FROM stream_sessions WHERE started_at >= ? ORDER BY started_at', + ), + sinceForTarget: db.prepare( + 'SELECT * FROM stream_sessions WHERE target_id = ? AND started_at >= ? ORDER BY started_at', + ), +}; + +interface SessionRow { + id: number; + target_id: string; + started_at: number; + ended_at: number | null; +} + +const toSession = (row: SessionRow): StreamSession => ({ + id: Number(row.id), + targetId: row.target_id, + startedAt: Number(row.started_at), + endedAt: num(row.ended_at), +}); + +export const sessionsRepo = { + /** + * Ouvre une diffusion — sans effet s'il en reste déjà une ouverte. + * + * L'idempotence n'est pas un luxe : au redémarrage du serveur, un profil déjà + * en direct ne produit aucune transition, et la diffusion en cours doit être + * reprise telle quelle plutôt que coupée en deux. + */ + open(targetId: string, startedAt: number): void { + const existing = sessionStmts.openFor.get(targetId); + if (existing) return; + sessionStmts.insert.run(targetId, startedAt); + }, + + close(targetId: string, endedAt: number): void { + sessionStmts.close.run(endedAt, targetId); + }, + + since(from: number, targetId?: string | null): StreamSession[] { + const rows = ( + targetId + ? sessionStmts.sinceForTarget.all(targetId, from) + : sessionStmts.since.all(from) + ) as unknown as SessionRow[]; + return rows.map(toSession); + }, +}; + +interface SpanRow { + id: number; + target_id: string | null; + agent_id: string | null; + started_at: number; + ended_at: number | null; + last_seen_at: number; +} + +const toSpan = (row: SpanRow): RecordingSpan => ({ + id: Number(row.id), + targetId: row.target_id, + agentId: row.agent_id, + startedAt: Number(row.started_at), + endedAt: num(row.ended_at), +}); + +const spanStmts = { + openFor: db.prepare( + 'SELECT * FROM recording_spans WHERE agent_id = ? AND ended_at IS NULL ORDER BY started_at DESC', + ), + insert: db.prepare(` + INSERT INTO recording_spans (target_id, agent_id, started_at, last_seen_at) + VALUES (?, ?, ?, ?) + `), + touch: db.prepare('UPDATE recording_spans SET last_seen_at = ? WHERE id = ?'), + // Le profil n'est pas toujours connu à l'ouverture : une capture lancée + // depuis l'onglet Agents précède parfois le calage de la surveillance. + attach: db.prepare('UPDATE recording_spans SET target_id = ? WHERE id = ? AND target_id IS NULL'), + close: db.prepare('UPDATE recording_spans SET ended_at = MAX(started_at, ?) WHERE id = ?'), + sweep: db.prepare(` + UPDATE recording_spans SET ended_at = last_seen_at + WHERE ended_at IS NULL AND last_seen_at < ? + `), + since: db.prepare('SELECT * FROM recording_spans WHERE started_at >= ? ORDER BY started_at'), + sinceForTarget: db.prepare( + 'SELECT * FROM recording_spans WHERE target_id = ? AND started_at >= ? ORDER BY started_at', + ), +}; + +export const spansRepo = { + openForAgent(agentId: string): RecordingSpan | null { + const row = spanStmts.openFor.get(agentId) as unknown as SpanRow | undefined; + return row ? toSpan(row) : null; + }, + + open(agentId: string, targetId: string | null, at: number): void { + spanStmts.insert.run(targetId, agentId, at, at); + }, + + touch(id: number, at: number, targetId: string | null): void { + spanStmts.touch.run(at, id); + if (targetId) spanStmts.attach.run(targetId, id); + }, + + close(id: number, endedAt: number): void { + spanStmts.close.run(endedAt, id); + }, + + /** + * Referme les captures dont on n'a jamais vu la fin, à leur dernière preuve + * de vie plutôt qu'à maintenant : une VM disparue une semaine ne doit pas + * laisser croire à une semaine d'enregistrement. + */ + sweepStale(deadline: number): void { + spanStmts.sweep.run(deadline); + }, + + since(from: number, targetId?: string | null): RecordingSpan[] { + const rows = ( + targetId ? spanStmts.sinceForTarget.all(targetId, from) : spanStmts.since.all(from) + ) as unknown as SpanRow[]; + return rows.map(toSpan); + }, +}; + interface LogRow { id: number; agent_id: string | null; diff --git a/packages/server/src/hub.ts b/packages/server/src/hub.ts index 0e09401..7ebb38e 100644 --- a/packages/server/src/hub.ts +++ b/packages/server/src/hub.ts @@ -13,7 +13,17 @@ import type { } from '@stream-control/shared'; import { emptyStatus } from '@stream-control/shared'; import { config } from './config.ts'; -import { agentsRepo, logsRepo, targetsRepo, type AgentRecord } from './db.ts'; +import { agentsRepo, logsRepo, spansRepo, targetsRepo, type AgentRecord } from './db.ts'; + +/** + * Au-delà de ce silence, une capture en cours est considérée comme terminée. + * + * Généreux face aux deux secondes du cycle de statut : un agent qui redémarre + * pendant un enregistrement doit retrouver son intervalle plutôt que d'en + * ouvrir un second. Le délai ne coûte rien en justesse — l'intervalle est + * refermé à sa dernière preuve de vie, pas à l'heure du balayage. + */ +const SPAN_STALE_MS = 60_000; interface PendingCommand { resolve: (value: unknown) => void; @@ -87,9 +97,44 @@ class Hub { updateStatus(agentId: string, status: AgentStatus): void { this.statuses.set(agentId, status); this.markSeen(agentId); + this.trackRecording(agentId, status); this.publishAgent(agentId); } + /** + * Tient à jour l'intervalle de capture en cours de cette VM. + * + * Une pause OBS (show privé) n'interrompt pas l'intervalle : elle n'écrit + * rien mais ne clôt pas le fichier, et le flux public s'est de toute façon + * arrêté pendant ce temps — la frise le montre déjà comme une coupure de + * diffusion. Découper ici laisserait croire à deux captures distinctes. + */ + private trackRecording(agentId: string, status: AgentStatus): void { + const now = Date.now(); + const open = spansRepo.openForAgent(agentId); + + if (!status.recording) { + if (open) spansRepo.close(open.id, now); + return; + } + + if (open) { + // Le profil n'est cherché que tant qu'il manque : une capture déjà + // rattachée l'est pour de bon, inutile de reposer la question toutes les + // deux secondes. + spansRepo.touch(open.id, now, open.targetId ? null : this.recordedTargetId(agentId)); + } else { + spansRepo.open(agentId, this.recordedTargetId(agentId), now); + } + } + + /** Le profil que cette VM capture, d'après le pseudo sur lequel sa veille est calée. */ + private recordedTargetId(agentId: string): string | null { + const record = agentsRepo.get(agentId); + if (!record?.watch.username) return null; + return targetsRepo.findByUsername(record.watch.provider, record.watch.username)?.id ?? null; + } + resolveCommand(agentId: string, requestId: string, ok: boolean, data: unknown, error?: string): void { const pending = this.connections.get(agentId)?.pending.get(requestId); if (!pending) return; @@ -271,6 +316,12 @@ class Hub { /** Coupe les agents silencieux : le heartbeat n'arrive plus. */ reapStale(): void { + // Même ménage côté captures : une VM disparue en plein enregistrement + // laisse un intervalle ouvert. On le referme à sa dernière preuve de vie, + // pas à maintenant — sans quoi une VM éteinte une semaine passerait pour + // avoir enregistré une semaine. + spansRepo.sweepStale(Date.now() - SPAN_STALE_MS); + const deadline = Date.now() - config.agentTimeoutMs; for (const [agentId, connection] of this.connections) { if (connection.lastSeenAt < deadline) { diff --git a/packages/server/src/watchlist.ts b/packages/server/src/watchlist.ts index b450a5b..c814b43 100644 --- a/packages/server/src/watchlist.ts +++ b/packages/server/src/watchlist.ts @@ -1,7 +1,7 @@ import type { StreamState, WatchTarget } from '@stream-control/shared'; import { DEFAULT_WATCH_SETTINGS, fetchStripchatStatus } from '@stream-control/shared'; import { config } from './config.ts'; -import { targetsRepo } from './db.ts'; +import { sessionsRepo, targetsRepo } from './db.ts'; import { hub } from './hub.ts'; import { notifyTargetLive } from './pushover.ts'; import { startTargetRecording } from './recorder.ts'; @@ -89,6 +89,14 @@ class Watchlist { const changed = next !== target.state; + // Diffusion en cours : sans condition sur `changed`, car l'ouverture est + // idempotente. C'est ce qui rattrape un profil déjà en direct au démarrage + // du serveur — aucune transition ne se produira pour lui, et sa diffusion + // resterait sinon absente de la frise jusqu'à la suivante. + if (next === 'public') { + sessionsRepo.open(target.id, statusChangedAt ?? Date.now()); + } + // Fin de diffusion : on fige le stream qui vient de se terminer. Son début // est le statusChangedAt d'avant la transition, que la plateforme vient de // remplacer par celui du nouveau statut. @@ -97,6 +105,7 @@ class Watchlist { if (changed && target.state === 'public') { lastLiveStartedAt = target.statusChangedAt ?? target.stateSince; lastLiveEndedAt = Date.now(); + sessionsRepo.close(target.id, lastLiveEndedAt); } targetsRepo.updateState(target.id, { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 946741d..3ceaae9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -880,6 +880,8 @@ export interface WatchTarget { /** Agent qui enregistrera ce streamer, s'il est assigné. */ agentId: string | null; notify: boolean; + /** Épinglé en tête de liste. Purement un confort de tri, sans effet ailleurs. */ + favorite: boolean; /** * Lancer l'enregistrement sans intervention dès que ce profil est en direct * depuis assez longtemps. Exige un agent assigné, et un seul profil en @@ -906,6 +908,70 @@ export interface WatchTarget { lastLiveEndedAt: number | null; } +// --------------------------------------------------------------------------- +// Historique de diffusion +// --------------------------------------------------------------------------- + +/** + * Une diffusion publique observée, du passage en direct à sa fin. + * + * Distinct de `WatchTarget.lastLive*`, qui ne garde que la dernière : ici on + * conserve l'historique, c'est ce qui rend une frise possible. + */ +export interface StreamSession { + id: number; + targetId: string; + startedAt: number; + /** Nul tant que la diffusion est en cours. */ + endedAt: number | null; +} + +/** + * Un intervalle réellement capturé par une VM. + * + * Séparé des diffusions à dessein : un enregistrement démarre en général après + * le début du stream, peut s'arrêter avant sa fin, et une même diffusion peut + * en compter plusieurs. Les superposer sur la frise est justement ce qui montre + * ce qui a été gardé et ce qui a été manqué. + */ +export interface RecordingSpan { + id: number; + /** Nul si l'enregistrement n'a pas pu être rattaché à un profil suivi. */ + targetId: string | null; + agentId: string | null; + startedAt: number; + /** Nul tant que la capture est en cours. */ + endedAt: number | null; +} + +export interface TimelineData { + sessions: StreamSession[]; + spans: RecordingSpan[]; +} + +/** + * Le pseudo que cette VM est en train de capturer, ou `null`. + * + * `startTargetRecording()` cale la surveillance de l'agent sur le profil qu'il + * enregistre : ce pseudo est donc le seul lien fiable entre une capture en + * cours et le profil concerné. Sans lui, une VM occupée ferait passer *tous* + * les profils qui lui sont assignés pour « en enregistrement », alors qu'elle + * n'en capture qu'un seul à la fois. + */ +export function recordingUsername(agent: Pick): string | null { + if (!agent.status.recording) return null; + return agent.watch.username || null; +} + +/** Cette VM enregistre-t-elle bien *ce* profil, et non un autre qui lui est assigné ? */ +export function isRecordingTarget( + agent: Pick | null | undefined, + target: Pick, +): boolean { + if (!agent) return false; + return recordingUsername(agent) === target.username; +} + export type ServerToDashboard = | { type: 'snapshot'; agents: AgentView[]; logs: LogEntry[]; targets: WatchTarget[] } | { type: 'agent'; agent: AgentView } diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 4c53070..a916518 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -9,6 +9,7 @@ import { AgentHistory } from './components/AgentHistory'; import { LogPanel } from './components/LogPanel'; import { NotificationsPage } from './components/NotificationsPage'; import { StreamersPage } from './components/StreamersPage'; +import { TimelinePage } from './components/TimelinePage'; import { useHashRoute } from './useHashRoute'; interface Toast { @@ -180,6 +181,12 @@ export function App() { Streamers {liveTargets > 0 && {liveTargets}} + + + +
+
+ {target.avatarUrl && !avatarBroken ? ( + {`Photo setAvatarBroken(true)} + /> + ) : ( + + )} + +
+ + @{target.username} ↗ + + +
+
+
{isLive ? 'En direct depuis' : 'Statut depuis'}
+
+ {isLive + ? formatDuration(liveFor) + : target.statusChangedAt + ? formatRelative(target.statusChangedAt) + : '—'} +
+
+
+
Dernier stream
+
+ {isLive ? ( + en cours + ) : target.lastLiveEndedAt ? ( + formatDateTime(target.lastLiveEndedAt) + ) : ( + jamais observé + )} +
+
+
+
Suivi depuis
+
{formatDateTime(target.createdAt)}
+
+
+
Dernière sonde
+
{target.lastCheckedAt ? formatRelative(target.lastCheckedAt) : 'jamais'}
+
+
+ +
+ + +
+
+
+ + {target.lastError &&

Sonde en échec : {target.lastError}

} + +
+

Enregistrement

+ + + + + + {context.busyWith && ( +

+ ⓘ {agent?.name} enregistre actuellement {context.busyWith} — ce profil n'est pas + capturé. Une VM ne traite qu'un flux à la fois. +

+ )} + +
+ {context.self ? ( + + ) : ( + + )} + +
+
+ +
+
+

Historique de diffusion

+
+ {TIMELINE_RANGES.map((range) => ( + + ))} +
+ +

+ diffusion publique + enregistré +

+ + {loading ? ( +

Chargement…

+ ) : ( + + )} + + {!loading && sessions.length > 0 && ( +
    + {sessions.map((session) => { + const end = session.endedAt ?? now; + const recorded = overlapMs(timeline.spans, session.startedAt, end); + return ( +
  • + {formatDateTime(session.startedAt)} + {formatDuration(end - session.startedAt)} + 0 ? 'session-rec' : 'muted small'}> + {recorded > 0 ? `⏺ ${formatDuration(recorded)} enregistré` : 'non enregistré'} + {session.endedAt === null && · en cours} + +
  • + ); + })} +
+ )} +
+
+ +
+ +
+ +
+ + + ); +} + +/** Part d'une diffusion réellement couverte par des captures. */ +function overlapMs( + spans: Array<{ startedAt: number; endedAt: number | null }>, + from: number, + to: number, +): number { + const now = Date.now(); + let total = 0; + for (const span of spans) { + const start = Math.max(span.startedAt, from); + const end = Math.min(span.endedAt ?? now, to); + if (end > start) total += end - start; + } + return total; +} diff --git a/packages/web/src/components/StreamersPage.tsx b/packages/web/src/components/StreamersPage.tsx index edc00cd..13a8666 100644 --- a/packages/web/src/components/StreamersPage.tsx +++ b/packages/web/src/components/StreamersPage.tsx @@ -1,26 +1,9 @@ -import { useState, type FormEvent } from 'react'; -import type { AgentView, StreamState, WatchTarget } from '@stream-control/shared'; +import { useMemo, useState, type FormEvent } from 'react'; +import type { AgentView, WatchTarget } from '@stream-control/shared'; import { api } from '../api'; import { formatDateTime, formatDuration, formatRelative } from '../format'; - -const STATE_LABELS: Record = { - public: { text: 'en direct', tone: 'rec' }, - private: { text: 'show privé', tone: 'warn' }, - offline: { text: 'hors-ligne', tone: 'offline' }, - unknown: { text: '…', tone: 'offline' }, -}; - -/** - * `idle` et `private` partagent le même état normalisé — les deux mettent - * l'enregistrement en pause sans le clore — mais ne veulent pas dire la même - * chose à l'écran : le premier est une absence, le second un show payant. - */ -function stateLabel(target: WatchTarget): { text: string; tone: string } { - if (target.state === 'private' && target.rawStatus === 'idle') { - return { text: 'revient bientôt', tone: 'warn' }; - } - return STATE_LABELS[target.state]; -} +import { recordingContext, stateLabel, type RecordingContext } from '../streamers'; +import { StreamerDetail } from './StreamerDetail'; interface Props { targets: WatchTarget[]; @@ -30,6 +13,14 @@ interface Props { onEnableNotifications: () => void; } +const FILTERS = [ + { id: 'all', label: 'Tous' }, + { id: 'favorites', label: '★ Favoris' }, + { id: 'live', label: 'En direct' }, +] as const; + +type FilterId = (typeof FILTERS)[number]['id']; + export function StreamersPage({ targets, agents, @@ -39,6 +30,8 @@ export function StreamersPage({ }: Props) { const [url, setUrl] = useState(''); const [busy, setBusy] = useState(false); + const [filter, setFilter] = useState('all'); + const [openId, setOpenId] = useState(null); async function add(event: FormEvent) { event.preventDefault(); @@ -55,13 +48,35 @@ export function StreamersPage({ } } - // En direct d'abord, puis show privé, puis le reste par ordre alphabétique. - const ordered = [...targets].sort((a, b) => { - const rank = (target: WatchTarget) => - target.state === 'public' ? 0 : target.state === 'private' ? 1 : 2; - return rank(a) - rank(b) || a.username.localeCompare(b.username); + /** + * En direct d'abord, puis show privé, puis le reste ; à statut égal, les + * favoris devant. L'ordre du statut prime : un direct en cours appelle une + * décision, un favori hors-ligne non. + */ + const ordered = useMemo( + () => + [...targets].sort((a, b) => { + const rank = (target: WatchTarget) => + target.state === 'public' ? 0 : target.state === 'private' ? 1 : 2; + return ( + rank(a) - rank(b) || + Number(b.favorite) - Number(a.favorite) || + a.username.localeCompare(b.username) + ); + }), + [targets], + ); + + const visible = ordered.filter((target) => { + if (filter === 'favorites') return target.favorite; + if (filter === 'live') return target.state === 'public'; + return true; }); + // La fiche ouverte doit refléter les mises à jour temps réel, et se refermer + // d'elle-même si le profil disparaît de la liste. + const openTarget = openId ? (targets.find((target) => target.id === openId) ?? null) : null; + return (
@@ -81,7 +96,23 @@ export function StreamersPage({ )}
- {ordered.length === 0 ? ( + {targets.length > 0 && ( +
+ {FILTERS.map((entry) => ( + + ))} +
+ {visible.length} profil(s) +
+ )} + + {targets.length === 0 ? (

Aucun streamer suivi

@@ -89,46 +120,60 @@ export function StreamersPage({ et tu pourras lancer l'enregistrement d'un clic.

+ ) : visible.length === 0 ? ( +

Aucun profil pour ce filtre.

) : (
- {ordered.map((target) => ( - + {visible.map((target) => ( + setOpenId(target.id)} + /> ))}
)} + + {openTarget && ( + setOpenId(null)} + /> + )}
); } /** - * Explique, en une ligne, ce que l'automatisme fera ou pourquoi il ne fera rien. - * Un interrupteur coché qui reste sans effet est plus déroutant qu'un - * interrupteur absent. + * Vignette : identité, statut, et la seule action qui presse — lancer ou + * arrêter la capture. Tout le reste (agent assigné, automatisme, historique) + * vit dans la fiche détaillée, qu'un clic sur la vignette ouvre. */ -function autoRecordHint(target: WatchTarget, agent: AgentView | null): string { - if (!target.autoRecord) return ' — dès une minute de direct'; - if (!agent) return ' — agent introuvable'; - if (!agent.online) return ` — en attente : ${agent.name} est hors-ligne`; - if (agent.status.recording) return ` — en attente : ${agent.name} enregistre déjà`; - if (target.state === 'public') return ' — armé, direct en cours'; - return ' — armé, en attente du prochain direct'; -} - function StreamerCard({ target, + targets, agents, notify, + onOpen, }: { target: WatchTarget; + targets: WatchTarget[]; agents: AgentView[]; notify: (message: string, tone?: 'info' | 'error') => void; + onOpen: () => void; }) { const [busy, setBusy] = useState(false); const [avatarBroken, setAvatarBroken] = useState(false); const state = stateLabel(target); const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null; - const recording = agent?.status.recording ?? false; + const context = recordingContext(target, agent, targets); const isLive = target.state === 'public'; const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null; @@ -149,19 +194,27 @@ function StreamerCard({ } } - const recordTitle = !target.agentId - ? 'Assigne un agent à ce streamer' - : !agent?.online - ? 'Agent hors-ligne' - : !isLive - ? "Le streamer n'est pas en direct" - : agent.browser.enabled - ? "Ouvrir la page, passer en plein écran et lancer l'enregistrement" - : "Lancer OBS seul — le pilotage du navigateur est désactivé sur cet agent, " - + "la page ne sera pas ouverte"; + /** Les commandes ne doivent pas ouvrir la fiche en même temps qu'elles s'exécutent. */ + const isolate = (handler: () => void) => (event: React.MouseEvent) => { + event.stopPropagation(); + handler(); + }; return ( -
+
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onOpen(); + } + }} + role="button" + tabIndex={0} + title="Ouvrir la fiche" + >
{target.avatarUrl && !avatarBroken ? ( - - {target.label ?? target.username} - + {target.label ?? target.username} @{target.username}
+ + {state.text} @@ -219,48 +281,8 @@ function StreamerCard({ - {target.lastError &&

Sonde en échec : {target.lastError}

} - - - - - -
- {recording ? ( +
event.stopPropagation()}> + {context.self ? ( - + {agent ? agent.name : 'aucun agent'}
- - {target.lastCheckedAt ? `sondé ${formatRelative(target.lastCheckedAt)}` : 'pas encore sondé'} + + {context.self + ? '⏺ enregistrement en cours' + : context.busyWith + ? `VM occupée : ${context.busyWith}` + : target.lastCheckedAt + ? `sondé ${formatRelative(target.lastCheckedAt)}` + : 'pas encore sondé'} - {target.notify ? 🔔 : 🔕} + {target.notify ? 🔔 : 🔕}
); } + +function recordTitle( + target: WatchTarget, + agent: AgentView | null, + context: RecordingContext, +): string { + if (!target.agentId) return 'Assigne un agent à ce streamer (clique la fiche)'; + if (!agent?.online) return 'Agent hors-ligne'; + if (context.busyWith) { + return `${agent.name} enregistre déjà ${context.busyWith} — une VM ne traite qu'un flux à la fois`; + } + if (target.state !== 'public') return "Le streamer n'est pas en direct"; + return agent.browser.enabled + ? "Ouvrir la page, passer en plein écran et lancer l'enregistrement" + : 'Lancer OBS seul — le pilotage du navigateur est désactivé sur cet agent, ' + + 'la page ne sera pas ouverte'; +} diff --git a/packages/web/src/components/Timeline.tsx b/packages/web/src/components/Timeline.tsx new file mode 100644 index 0000000..b23c7ca --- /dev/null +++ b/packages/web/src/components/Timeline.tsx @@ -0,0 +1,255 @@ +import { useMemo } from 'react'; +import type { RecordingSpan, StreamSession } from '@stream-control/shared'; +import { formatDuration } from '../format'; + +export interface TimelineLane { + id: string; + label: string; + avatarUrl?: string | null; + sessions: StreamSession[]; + spans: RecordingSpan[]; +} + +interface Props { + from: number; + to: number; + lanes: TimelineLane[]; + /** Une seule voie : la colonne des noms n'apporte rien. */ + hideLabels?: boolean; + onSelectLane?: (id: string) => void; +} + +interface Bar { + key: string; + left: number; + width: number; + title: string; + /** Durée réellement visible, en millisecondes. */ + ms: number; + /** Toujours en cours au moment du rendu. */ + open: boolean; +} + +/** + * Frise des diffusions, avec les portions capturées en surimpression. + * + * Deux couches sur une même piste plutôt que deux pistes séparées : ce qui + * intéresse l'opérateur n'est pas « quand ai-je enregistré » dans l'absolu, + * mais quelle part de chaque diffusion a été gardée — et un décalage vertical + * obligerait l'œil à faire lui-même l'alignement. + * + * Sert autant à un seul profil (fiche de détail) qu'à tous (onglet Timeline) : + * seule la liste des voies change. + */ +export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) { + const now = Date.now(); + const ticks = useMemo(() => buildTicks(from, to), [from, to]); + const nowLeft = now >= from && now <= to ? percent(now, from, to) : null; + + return ( +
+
+ {!hideLabels &&
} +
+ {ticks.map((tick) => ( + + {tick.label} + + ))} +
+
+ + {lanes.map((lane) => { + const sessions = toBars(lane.sessions, from, to, now, 'Diffusion'); + const spans = toBars(lane.spans, from, to, now, 'Enregistrement'); + const liveMs = totalMs(sessions); + const recordedMs = totalMs(spans); + + return ( +
+ {!hideLabels && ( +
+ + + {formatDuration(liveMs)} + {recordedMs > 0 ? · {formatDuration(recordedMs)} : null} + +
+ )} + +
+ {ticks.map((tick) => ( + + ))} + + {sessions.map((bar) => ( + + ))} + + {spans.map((bar) => ( + + ))} + + {nowLeft !== null && ( + + )} +
+
+ ); + })} + + {lanes.length === 0 && ( +

Aucune diffusion observée sur cette période.

+ )} +
+ ); +} + +const percent = (at: number, from: number, to: number) => ((at - from) / (to - from)) * 100; + +/** + * Découpe les intervalles à la fenêtre affichée. + * + * Un intervalle encore ouvert court jusqu'à maintenant, et non jusqu'au bord + * de la fenêtre : une diffusion en cours doit se lire comme telle, pas comme + * remplissant tout l'écran. + */ +function toBars( + intervals: Array, + from: number, + to: number, + now: number, + kind: string, +): Bar[] { + const bars: Bar[] = []; + for (const interval of intervals) { + const start = Math.max(interval.startedAt, from); + const end = Math.min(interval.endedAt ?? now, to); + if (end <= start) continue; + + bars.push({ + key: `${kind}-${interval.id}`, + left: percent(start, from, to), + width: percent(end, from, to) - percent(start, from, to), + // 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é. + ms: end - start, + open: interval.endedAt === null, + title: + `${kind} · ${formatStamp(interval.startedAt)} → ` + + `${interval.endedAt === null ? 'en cours' : formatStamp(interval.endedAt)}` + + ` (${formatDuration((interval.endedAt ?? now) - interval.startedAt)})`, + }); + } + return bars; +} + +/** Total réellement visible dans la fenêtre — les bords sont déjà rognés. */ +function totalMs(bars: Bar[]): number { + return bars.reduce((sum, bar) => sum + bar.ms, 0); +} + +function formatStamp(ts: number): string { + return new Date(ts).toLocaleString('fr-FR', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +interface Tick { + at: number; + label: string; + /** Début de journée : trait plus marqué. */ + major: boolean; +} + +/** + * Graduations adaptées à la largeur de la fenêtre. + * + * L'arithmétique passe par `Date` plutôt que par des additions de + * millisecondes : un pas de 24 h dérive d'une heure aux changements d'heure, + * et les graduations finiraient par ne plus tomber sur minuit. + */ +function buildTicks(from: number, to: number): Tick[] { + const span = to - from; + const ticks: Tick[] = []; + const cursor = new Date(from); + + if (span <= 48 * 3_600_000) { + cursor.setMinutes(0, 0, 0); + cursor.setHours(Math.ceil(cursor.getHours() / 6) * 6); + while (cursor.getTime() <= to) { + if (cursor.getTime() >= from) { + const midnight = cursor.getHours() === 0; + ticks.push({ + at: cursor.getTime(), + label: midnight + ? cursor.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }) + : `${String(cursor.getHours()).padStart(2, '0')}h`, + major: midnight, + }); + } + cursor.setHours(cursor.getHours() + 6); + } + return ticks; + } + + cursor.setHours(0, 0, 0, 0); + // Au-delà d'une dizaine de repères les étiquettes se chevauchent : on saute + // des jours plutôt que de les empiler. + const step = Math.max(1, Math.ceil(span / 86_400_000 / 10)); + while (cursor.getTime() < from) cursor.setDate(cursor.getDate() + step); + while (cursor.getTime() <= to) { + ticks.push({ + at: cursor.getTime(), + label: cursor.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }), + major: true, + }); + cursor.setDate(cursor.getDate() + step); + } + return ticks; +} diff --git a/packages/web/src/components/TimelinePage.tsx b/packages/web/src/components/TimelinePage.tsx new file mode 100644 index 0000000..664cc87 --- /dev/null +++ b/packages/web/src/components/TimelinePage.tsx @@ -0,0 +1,141 @@ +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 { StreamerDetail } from './StreamerDetail'; +import { Timeline, type TimelineLane } from './Timeline'; + +interface Props { + targets: WatchTarget[]; + agents: AgentView[]; + notify: (message: string, tone?: 'info' | 'error') => void; +} + +const EMPTY: TimelineData = { sessions: [], spans: [] }; + +/** + * Frise de toutes les diffusions suivies, une voie par profil. + * + * La vue qui manquait pour arbitrer : elle montre d'un coup d'œil les + * chevauchements — deux streamers en direct en même temps sur une seule VM — + * et ce qui, faute de machine libre, n'a pas été capturé. + */ +export function TimelinePage({ targets, agents, notify }: Props) { + const [days, setDays] = useState(7); + const [data, setData] = useState(EMPTY); + const [loading, setLoading] = useState(true); + const [onlyRecorded, setOnlyRecorded] = useState(false); + const [openId, setOpenId] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + api + .timeline(days) + .then((result) => { + if (!cancelled) setData(result); + }) + .catch((err: unknown) => { + if (!cancelled) notify(err instanceof Error ? err.message : 'Frise indisponible', 'error'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [days]); + + 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: [], + }); + } + 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); + } + + // 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]); + + const totalRecorded = data.spans.reduce( + (sum, span) => sum + ((span.endedAt ?? now) - span.startedAt), + 0, + ); + const openTarget = openId ? (targets.find((target) => target.id === openId) ?? null) : null; + + return ( +
+
+

Frise des diffusions

+
+ + {TIMELINE_RANGES.map((range) => ( + + ))} +
+ +

+ diffusion publique + enregistré + + {data.sessions.length} diffusion(s) · {formatDuration(totalRecorded)} enregistré au total +

+ + {loading ? ( +

Chargement…

+ ) : ( +
+ +
+ )} + + {openTarget && ( + setOpenId(null)} + /> + )} +
+ ); +} + +/** Dernier signe d'activité, diffusion ou capture confondues. */ +function lastActivity(lane: TimelineLane): number { + let latest = 0; + for (const session of lane.sessions) latest = Math.max(latest, session.endedAt ?? Date.now()); + for (const span of lane.spans) latest = Math.max(latest, span.endedAt ?? Date.now()); + return latest; +} diff --git a/packages/web/src/streamers.ts b/packages/web/src/streamers.ts new file mode 100644 index 0000000..d890499 --- /dev/null +++ b/packages/web/src/streamers.ts @@ -0,0 +1,83 @@ +import type { AgentView, StreamState, WatchTarget } from '@stream-control/shared'; +import { isRecordingTarget } from '@stream-control/shared'; + +/** Logique d'affichage partagée entre la fiche compacte et la fiche détaillée. */ + +const STATE_LABELS: Record = { + public: { text: 'en direct', tone: 'rec' }, + private: { text: 'show privé', tone: 'warn' }, + offline: { text: 'hors-ligne', tone: 'offline' }, + unknown: { text: '…', tone: 'offline' }, +}; + +/** + * `idle` et `private` partagent le même état normalisé — les deux mettent + * l'enregistrement en pause sans le clore — mais ne veulent pas dire la même + * chose à l'écran : le premier est une absence, le second un show payant. + */ +export function stateLabel(target: WatchTarget): { text: string; tone: string } { + if (target.state === 'private' && target.rawStatus === 'idle') { + return { text: 'revient bientôt', tone: 'warn' }; + } + return STATE_LABELS[target.state]; +} + +/** + * Ce que la VM assignée à un profil est en train de faire, de son point de vue. + * + * Une VM n'enregistre qu'un flux à la fois, mais plusieurs profils peuvent lui + * être assignés : « la VM enregistre » ne veut donc pas dire « ce profil est + * enregistré ». Les confondre affichait un enregistrement en cours sur des + * profils hors-ligne dont la VM captait le voisin — d'où cette distinction + * explicite, que l'interface reprend partout où l'état d'enregistrement paraît. + */ +export interface RecordingContext { + /** C'est bien ce profil que la VM capture. */ + self: boolean; + /** La VM capture, mais autre chose : ce que l'on peut en nommer. */ + busyWith: string | null; +} + +export function recordingContext( + target: WatchTarget, + agent: AgentView | null, + targets: WatchTarget[], +): RecordingContext { + if (!agent?.status.recording) return { self: false, busyWith: null }; + if (isRecordingTarget(agent, target)) return { self: true, busyWith: null }; + + // La VM enregistre sans que sa veille soit calée sur un pseudo : capture + // lancée à la main depuis l'onglet Agents, le plus souvent. On ne peut pas + // dire quoi, mais surtout pas laisser croire que c'est ce profil. + const username = agent.watch.username; + if (!username) return { self: false, busyWith: 'une source non identifiée' }; + + const other = targets.find((candidate) => candidate.username === username); + return { self: false, busyWith: other ? (other.label ?? other.username) : `@${username}` }; +} + +/** + * Explique, en une ligne, ce que l'automatisme fera ou pourquoi il ne fera rien. + * Un interrupteur coché qui reste sans effet est plus déroutant qu'un + * interrupteur absent. + */ +export function autoRecordHint( + target: WatchTarget, + agent: AgentView | null, + context: RecordingContext, +): string { + if (!target.autoRecord) return ' — dès une minute de direct'; + if (!agent) return ' — agent introuvable'; + if (!agent.online) return ` — en attente : ${agent.name} est hors-ligne`; + if (context.self) return ' — enregistrement en cours'; + if (context.busyWith) return ` — en attente : ${agent.name} enregistre ${context.busyWith}`; + if (target.state === 'public') return ' — armé, direct en cours'; + return ' — armé, en attente du prochain direct'; +} + +/** Fenêtres proposées sous les frises. */ +export const TIMELINE_RANGES = [ + { days: 1, label: '24 h' }, + { days: 7, label: '7 jours' }, + { days: 30, label: '30 jours' }, +] as const; diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 41c488d..ecf2a80 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -371,7 +371,9 @@ textarea:focus { white-space: nowrap; } -fieldset.group { +/* `fieldset` dans les formulaires de réglages, `section` dans la fiche d'un + streamer : même encadré, les deux balises portent la classe. */ +.group { display: flex; flex-direction: column; gap: 12px; @@ -399,6 +401,9 @@ fieldset.group { * La fiche standard défile d'un bloc ; ici seul le corps doit défiler, pour que * les filtres et les intitulés de jour restent visibles sur un long historique. */ +.modal.modal-wide { + width: min(760px, 100%); +} .modal.modal-tall { display: flex; flex-direction: column; @@ -608,6 +613,34 @@ fieldset.group > legend { .streamer.tone-offline { opacity: 0.72; } +.streamer.clickable { + cursor: pointer; + transition: border-color 0.12s ease, background 0.12s ease; +} +.streamer.clickable:hover, +.streamer.clickable:focus-visible { + background: var(--panel-2); + border-color: var(--accent); + outline: none; +} +/* Une vignette hors-ligne s'éclaircit au survol : elle reste cliquable, et + l'atténuation ne doit pas la faire passer pour inerte. */ +.streamer.clickable.tone-offline:hover, +.streamer.clickable.tone-offline:focus-visible { + opacity: 1; +} +.icon.star { + font-size: 15px; + line-height: 1; +} +.icon.star.on { + color: var(--warn); +} + +.filter-row { + align-items: center; + flex-wrap: wrap; +} .streamer-head { display: flex; @@ -673,6 +706,229 @@ fieldset.group > legend { white-space: nowrap; } +/* --- Fiche détaillée d'un streamer --- */ + +.streamer-hero { + display: flex; + gap: 16px; + align-items: flex-start; + flex-wrap: wrap; +} +.avatar-large { + width: 132px; + height: 132px; + flex: 0 0 132px; + border-radius: var(--radius); + object-fit: cover; + background: var(--panel-2); + font-size: 44px; +} +.streamer-hero-facts { + flex: 1; + min-width: 240px; + display: flex; + flex-direction: column; + gap: 10px; +} +.streamer-hero .streamer-facts { + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); +} + +.session-list { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 0; + list-style: none; + font-size: 12.5px; +} +.session-list li { + display: grid; + grid-template-columns: 1fr 70px 1.2fr; + gap: 10px; + padding: 3px 0; + border-top: 1px solid var(--border); +} +.session-duration { + font-variant-numeric: tabular-nums; + text-align: right; + color: var(--muted); +} +.session-rec { + color: #fca5a5; +} + +/* --- Frise --- */ + +.timeline { + display: flex; + flex-direction: column; + gap: 3px; +} +/* Défilement horizontal réservé à la frise : sur une fenêtre large avec + beaucoup de profils, c'est elle qui déborde, pas la page. */ +.timeline-scroll { + overflow-x: auto; +} +.timeline-axis, +.timeline-lane { + display: flex; + align-items: center; + gap: 10px; +} +.timeline-name { + flex: 0 0 190px; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; +} +.timeline-name-button { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 2px 4px; + background: transparent; + border-color: transparent; + text-align: left; +} +.timeline-name-button:disabled { + opacity: 1; + cursor: default; +} +.timeline-avatar { + width: 20px; + height: 20px; + flex: 0 0 20px; + border-radius: 50%; + object-fit: cover; + background: var(--panel-2); +} +.timeline-avatar-fallback { + display: grid; + place-items: center; + font-size: 10px; + font-weight: 700; + color: var(--muted); + border: 1px solid var(--border); +} +.timeline-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12.5px; +} +.timeline-totals { + flex: 0 0 auto; + font-variant-numeric: tabular-nums; +} +.timeline-rec-total { + color: #fca5a5; +} + +.timeline-track { + position: relative; + flex: 1; + min-width: 320px; + height: 20px; + background: var(--panel-2); + border-radius: 4px; + overflow: hidden; +} +.timeline-ruler { + height: 16px; + background: transparent; + overflow: visible; +} +.timeline-tick { + position: absolute; + top: 0; + transform: translateX(-50%); + font-size: 10px; + color: var(--muted); + white-space: nowrap; +} +.timeline-tick.major { + color: var(--text); +} +.timeline-grid { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--border); +} +.timeline-grid.major { + background: #3a4553; +} + +/* Diffusion et capture se superposent sur la même piste : la seconde, plus + étroite et posée par-dessus, se lit comme une part de la première. */ +.timeline-bar { + position: absolute; + top: 3px; + bottom: 3px; + min-width: 2px; + border-radius: 2px; + background: var(--accent); +} +.timeline-bar.live { + background: repeating-linear-gradient( + 90deg, + var(--accent) 0 6px, + #2563eb 6px 12px + ); +} +.timeline-rec { + position: absolute; + top: 7px; + bottom: 7px; + min-width: 2px; + border-radius: 2px; + background: var(--rec); +} +.timeline-rec.live { + background: repeating-linear-gradient(90deg, var(--rec) 0 6px, #b91c1c 6px 12px); +} +.timeline-now { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--ok); +} + +.timeline-head { + align-items: center; + flex-wrap: wrap; +} +.timeline-legend { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin: 0; +} +.legend-swatch { + display: inline-block; + width: 14px; + height: 8px; + border-radius: 2px; + margin-left: 8px; +} +.legend-swatch:first-child { + margin-left: 0; +} +.legend-swatch.live { + background: var(--accent); +} +.legend-swatch.rec { + background: var(--rec); +} + /* --- Journal --- */ /* Vue pleine page depuis l'onglet Journal. Elle défile avec la page, comme la diff --git a/packages/web/src/useHashRoute.ts b/packages/web/src/useHashRoute.ts index f8caa42..739ea40 100644 --- a/packages/web/src/useHashRoute.ts +++ b/packages/web/src/useHashRoute.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -export const ROUTES = ['agents', 'streamers', 'logs', 'notifications'] as const; +export const ROUTES = ['agents', 'streamers', 'timeline', 'logs', 'notifications'] as const; export type Route = (typeof ROUTES)[number]; function currentRoute(): Route {