Feat : pause now showing in another color
This commit is contained in:
31
README.md
31
README.md
@@ -813,8 +813,9 @@ Deux vues sur le même historique : la fiche d'un profil (clic sur sa vignette,
|
|||||||
**Streamers**) montre la sienne ; l'onglet **Timeline** les empile toutes, une voie par
|
**Streamers**) montre la sienne ; l'onglet **Timeline** les empile toutes, une voie par
|
||||||
profil.
|
profil.
|
||||||
|
|
||||||
Les diffusions publiques s'y lisent en bleu, et **les portions réellement capturées se
|
Les diffusions publiques s'y lisent en bleu, **les portions capturées se superposent en
|
||||||
superposent en rouge**, sur la même piste. C'est le point de la vue : ce qui intéresse n'est
|
rouge**, et **les passages en pause en jaune** par-dessus le rouge — la capture tournait, mais
|
||||||
|
n'écrivait rien. Le tout 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é
|
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
|
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
|
globale rend visibles les chevauchements : deux streamers en direct en même temps sur une
|
||||||
@@ -854,12 +855,26 @@ une fois écrit, il est servi avec un cache immuable.
|
|||||||
| Table | Écrite par | Une ligne = |
|
| Table | Écrite par | Une ligne = |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `stream_sessions` | la veille, sur transition d'état | une diffusion publique observée |
|
| `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é |
|
| `recording_spans` | le hub, sur changement de statut d'un agent | un intervalle de capture ouvert |
|
||||||
|
| `recording_pauses` | le hub, sur changement de statut d'un agent | une pause à l'intérieur d'un intervalle |
|
||||||
|
|
||||||
Deux tables et non une : un enregistrement démarre en général après le début du stream,
|
Plusieurs 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
|
peut s'arrêter avant sa fin, et une même diffusion peut en compter plusieurs.
|
||||||
(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.
|
**Une pause OBS (show privé, flux hors-ligne) ne coupe pas l'intervalle** — elle n'écrit rien
|
||||||
|
mais ne ferme pas le fichier. La découper en deux intervalles ferait passer les morceaux pour
|
||||||
|
deux captures distinctes, alors qu'il n'y en a qu'une, et qu'un seul fichier. Elle est donc
|
||||||
|
relevée à part, **en surimpression**, et apparaît en jaune par-dessus le rouge de la capture :
|
||||||
|
la barre se lit d'un coup d'œil comme « la capture tournait ici, mais n'écrivait rien ». Les
|
||||||
|
totaux annoncés déduisent ces pauses — les y inclure surestimerait ce qui est réellement
|
||||||
|
dans le fichier.
|
||||||
|
|
||||||
|
Deux invariants tiennent ce relevé : les ouvertures et fermetures de pause sont **idempotentes**
|
||||||
|
(le hub les rejoue à chaque cycle de statut, toutes les 2 s, et elles n'agissent qu'aux
|
||||||
|
transitions), et **aucune pause ne survit à l'intervalle qui la porte**. Ce second point est
|
||||||
|
réparé après coup plutôt que supposé : une VM disparue en pleine pause voit son intervalle
|
||||||
|
refermé par le balayage périodique, sans passer par le chemin qui referme les pauses — sans
|
||||||
|
cette réparation, la frise resterait jaune jusqu'à maintenant.
|
||||||
|
|
||||||
Le rattachement d'une capture à un profil passe par le pseudo sur lequel la veille de l'agent
|
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
|
est calée — c'est `startTargetRecording()` qui l'y pose. Une capture lancée à la main depuis
|
||||||
@@ -966,7 +981,7 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de ses
|
|||||||
| `POST` | `/api/tags` | Crée l'étiquette, ou renvoie celle qui porte déjà ce nom |
|
| `POST` | `/api/tags` | Crée l'étiquette, ou renvoie celle qui porte déjà ce nom |
|
||||||
| `PATCH` | `/api/tags/:id` | Renomme (409 si le nom est déjà pris) |
|
| `PATCH` | `/api/tags/:id` | Renomme (409 si le nom est déjà pris) |
|
||||||
| `DELETE` | `/api/tags/:id` | Supprime, et la retire de tous les profils |
|
| `DELETE` | `/api/tags/:id` | Supprime, et la retire de tous les profils |
|
||||||
| `GET` | `/api/timeline?days=&targetId=` | Diffusions et captures ; sans `targetId`, tous les profils |
|
| `GET` | `/api/timeline?days=&targetId=` | Diffusions, captures et pauses ; sans `targetId`, tous les profils |
|
||||||
| `GET` | `/api/timeline/sessions/:id/preview` | Vignette conservée d'une diffusion (jeton accepté en query) |
|
| `GET` | `/api/timeline/sessions/:id/preview` | Vignette conservée d'une diffusion (jeton accepté en query) |
|
||||||
| `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) |
|
| `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) |
|
||||||
| `DELETE` | `/api/agents/:id` | Supprime l'agent |
|
| `DELETE` | `/api/agents/:id` | Supprime l'agent |
|
||||||
|
|||||||
@@ -14,7 +14,15 @@ import {
|
|||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
import { config } from './config.ts';
|
import { config } from './config.ts';
|
||||||
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
|
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
|
||||||
import { agentsRepo, logsRepo, sessionsRepo, spansRepo, tagsRepo, targetsRepo } from './db.ts';
|
import {
|
||||||
|
agentsRepo,
|
||||||
|
logsRepo,
|
||||||
|
pausesRepo,
|
||||||
|
sessionsRepo,
|
||||||
|
spansRepo,
|
||||||
|
tagsRepo,
|
||||||
|
targetsRepo,
|
||||||
|
} from './db.ts';
|
||||||
import { hub } from './hub.ts';
|
import { hub } from './hub.ts';
|
||||||
import { previewFileFor } from './previews.ts';
|
import { previewFileFor } from './previews.ts';
|
||||||
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts';
|
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts';
|
||||||
@@ -463,6 +471,7 @@ api.get('/timeline', (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
sessions: sessionsRepo.since(from, targetId),
|
sessions: sessionsRepo.since(from, targetId),
|
||||||
spans: spansRepo.since(from, targetId),
|
spans: spansRepo.since(from, targetId),
|
||||||
|
pauses: pausesRepo.since(from, targetId),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
LogLevel,
|
LogLevel,
|
||||||
ObsSettings,
|
ObsSettings,
|
||||||
Platform,
|
Platform,
|
||||||
|
RecordingPause,
|
||||||
RecordingSettings,
|
RecordingSettings,
|
||||||
RecordingSpan,
|
RecordingSpan,
|
||||||
StreamSession,
|
StreamSession,
|
||||||
@@ -115,6 +116,21 @@ db.exec(`
|
|||||||
CREATE INDEX IF NOT EXISTS idx_spans_started ON recording_spans (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;
|
CREATE INDEX IF NOT EXISTS idx_spans_open ON recording_spans (agent_id) WHERE ended_at IS NULL;
|
||||||
|
|
||||||
|
-- Passages en pause d'une capture, en surimpression de l'intervalle qui les
|
||||||
|
-- porte. Table à part plutôt qu'un découpage des intervalles : une capture
|
||||||
|
-- mise en pause reste une seule capture, un seul fichier — la scinder en
|
||||||
|
-- ferait passer les morceaux pour des enregistrements distincts. Le CASCADE
|
||||||
|
-- suit celui des intervalles, eux-mêmes rattachés à un profil.
|
||||||
|
CREATE TABLE IF NOT EXISTS recording_pauses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
span_id INTEGER NOT NULL REFERENCES recording_spans (id) ON DELETE CASCADE,
|
||||||
|
started_at INTEGER NOT NULL,
|
||||||
|
ended_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pauses_span ON recording_pauses (span_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pauses_started ON recording_pauses (started_at DESC);
|
||||||
|
|
||||||
-- Étiquettes libres, posables sur autant de profils qu'on veut. Table à part
|
-- Étiquettes libres, posables sur autant de profils qu'on veut. Table à part
|
||||||
-- plutôt qu'un texte répété sur chaque profil : c'est ce qui rend le renommage
|
-- plutôt qu'un texte répété sur chaque profil : c'est ce qui rend le renommage
|
||||||
-- possible d'un geste, et garde une étiquette disponible même quand plus aucun
|
-- possible d'un geste, et garde une étiquette disponible même quand plus aucun
|
||||||
@@ -946,8 +962,9 @@ export const spansRepo = {
|
|||||||
return row ? toSpan(row) : null;
|
return row ? toSpan(row) : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
open(agentId: string, targetId: string | null, at: number): void {
|
/** Renvoie l'identifiant du nouvel intervalle : les pauses s'y rattachent. */
|
||||||
spanStmts.insert.run(targetId, agentId, at, at);
|
open(agentId: string, targetId: string | null, at: number): number {
|
||||||
|
return Number(spanStmts.insert.run(targetId, agentId, at, at).lastInsertRowid);
|
||||||
},
|
},
|
||||||
|
|
||||||
touch(id: number, at: number, targetId: string | null): void {
|
touch(id: number, at: number, targetId: string | null): void {
|
||||||
@@ -976,6 +993,86 @@ export const spansRepo = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Pauses de capture --------------------------------------------------------
|
||||||
|
|
||||||
|
interface PauseRow {
|
||||||
|
id: number;
|
||||||
|
span_id: number;
|
||||||
|
target_id: string | null;
|
||||||
|
started_at: number;
|
||||||
|
ended_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toPause = (row: PauseRow): RecordingPause => ({
|
||||||
|
id: Number(row.id),
|
||||||
|
spanId: Number(row.span_id),
|
||||||
|
targetId: row.target_id,
|
||||||
|
startedAt: Number(row.started_at),
|
||||||
|
endedAt: num(row.ended_at),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pauseStmts = {
|
||||||
|
openFor: db.prepare(
|
||||||
|
'SELECT id FROM recording_pauses WHERE span_id = ? AND ended_at IS NULL ORDER BY started_at DESC',
|
||||||
|
),
|
||||||
|
insert: db.prepare('INSERT INTO recording_pauses (span_id, started_at) VALUES (?, ?)'),
|
||||||
|
// MAX() borne la fin au début, comme pour les intervalles : une durée négative
|
||||||
|
// rendrait la frise illisible.
|
||||||
|
close: db.prepare(`
|
||||||
|
UPDATE recording_pauses SET ended_at = MAX(started_at, ?)
|
||||||
|
WHERE span_id = ? AND ended_at IS NULL
|
||||||
|
`),
|
||||||
|
/**
|
||||||
|
* Aucune pause ne survit à l'intervalle qui la porte. Répare l'invariant après
|
||||||
|
* coup plutôt que de le supposer : un intervalle refermé par le balayage
|
||||||
|
* périodique (VM disparue en pleine capture) l'est sans passer par le chemin
|
||||||
|
* qui referme les pauses.
|
||||||
|
*/
|
||||||
|
sweep: db.prepare(`
|
||||||
|
UPDATE recording_pauses
|
||||||
|
SET ended_at = (SELECT ended_at FROM recording_spans WHERE id = span_id)
|
||||||
|
WHERE ended_at IS NULL
|
||||||
|
AND (SELECT ended_at FROM recording_spans WHERE id = span_id) IS NOT NULL
|
||||||
|
`),
|
||||||
|
// Le rattachement au profil vient de l'intervalle : c'est lui qui le porte, et
|
||||||
|
// le dupliquer sur la pause le ferait diverger dès qu'une capture est
|
||||||
|
// rattachée après coup.
|
||||||
|
since: db.prepare(`
|
||||||
|
SELECT p.*, s.target_id FROM recording_pauses p
|
||||||
|
JOIN recording_spans s ON s.id = p.span_id
|
||||||
|
WHERE p.started_at >= ? ORDER BY p.started_at
|
||||||
|
`),
|
||||||
|
sinceForTarget: db.prepare(`
|
||||||
|
SELECT p.*, s.target_id FROM recording_pauses p
|
||||||
|
JOIN recording_spans s ON s.id = p.span_id
|
||||||
|
WHERE s.target_id = ? AND p.started_at >= ? ORDER BY p.started_at
|
||||||
|
`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pausesRepo = {
|
||||||
|
/** Ouvre une pause — sans effet s'il en reste une ouverte sur cet intervalle. */
|
||||||
|
open(spanId: number, at: number): void {
|
||||||
|
if (pauseStmts.openFor.get(spanId)) return;
|
||||||
|
pauseStmts.insert.run(spanId, at);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Referme la pause en cours de cet intervalle, s'il y en a une. */
|
||||||
|
close(spanId: number, at: number): void {
|
||||||
|
pauseStmts.close.run(at, spanId);
|
||||||
|
},
|
||||||
|
|
||||||
|
sweepOrphans(): void {
|
||||||
|
pauseStmts.sweep.run();
|
||||||
|
},
|
||||||
|
|
||||||
|
since(from: number, targetId?: string | null): RecordingPause[] {
|
||||||
|
const rows = (
|
||||||
|
targetId ? pauseStmts.sinceForTarget.all(targetId, from) : pauseStmts.since.all(from)
|
||||||
|
) as unknown as PauseRow[];
|
||||||
|
return rows.map(toPause);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
interface LogRow {
|
interface LogRow {
|
||||||
id: number;
|
id: number;
|
||||||
agent_id: string | null;
|
agent_id: string | null;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
import { emptyStatus } from '@stream-control/shared';
|
import { emptyStatus } from '@stream-control/shared';
|
||||||
import { config } from './config.ts';
|
import { config } from './config.ts';
|
||||||
import { agentsRepo, logsRepo, spansRepo, targetsRepo, type AgentRecord } from './db.ts';
|
import { agentsRepo, logsRepo, pausesRepo, spansRepo, targetsRepo, type AgentRecord } from './db.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Au-delà de ce silence, une capture en cours est considérée comme terminée.
|
* Au-delà de ce silence, une capture en cours est considérée comme terminée.
|
||||||
@@ -102,19 +102,25 @@ class Hub {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tient à jour l'intervalle de capture en cours de cette VM.
|
* Tient à jour l'intervalle de capture en cours de cette VM, et ses pauses.
|
||||||
*
|
*
|
||||||
* Une pause OBS (show privé) n'interrompt pas l'intervalle : elle n'écrit
|
* Une pause OBS (show privé) n'interrompt pas l'intervalle : elle n'écrit rien
|
||||||
* rien mais ne clôt pas le fichier, et le flux public s'est de toute façon
|
* mais ne clôt pas le fichier. Découper ici laisserait croire à deux captures
|
||||||
* arrêté pendant ce temps — la frise le montre déjà comme une coupure de
|
* distinctes, alors qu'il n'y en a qu'une, et qu'un seul fichier. Elle est donc
|
||||||
* diffusion. Découper ici laisserait croire à deux captures distinctes.
|
* relevée à part, en surimpression — ce qui permet à la frise de distinguer le
|
||||||
|
* temps réellement écrit du temps passé à tourner à vide.
|
||||||
*/
|
*/
|
||||||
private trackRecording(agentId: string, status: AgentStatus): void {
|
private trackRecording(agentId: string, status: AgentStatus): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const open = spansRepo.openForAgent(agentId);
|
const open = spansRepo.openForAgent(agentId);
|
||||||
|
|
||||||
if (!status.recording) {
|
if (!status.recording) {
|
||||||
if (open) spansRepo.close(open.id, now);
|
if (open) {
|
||||||
|
// Dans cet ordre : la pause ne doit pas survivre à l'intervalle qui la
|
||||||
|
// porte, et sa fin est celle de la capture.
|
||||||
|
pausesRepo.close(open.id, now);
|
||||||
|
spansRepo.close(open.id, now);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,9 +129,17 @@ class Hub {
|
|||||||
// rattachée l'est pour de bon, inutile de reposer la question toutes les
|
// rattachée l'est pour de bon, inutile de reposer la question toutes les
|
||||||
// deux secondes.
|
// deux secondes.
|
||||||
spansRepo.touch(open.id, now, open.targetId ? null : this.recordingTarget(agentId)?.id ?? null);
|
spansRepo.touch(open.id, now, open.targetId ? null : this.recordingTarget(agentId)?.id ?? null);
|
||||||
} else {
|
// Les deux appels sont idempotents : appelés à chaque cycle de statut, ils
|
||||||
spansRepo.open(agentId, this.recordingTarget(agentId)?.id ?? null, now);
|
// n'ouvrent et ne referment qu'aux transitions.
|
||||||
|
if (status.recordPaused) pausesRepo.open(open.id, now);
|
||||||
|
else pausesRepo.close(open.id, now);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture découverte déjà en pause : rare, mais possible si l'agent se
|
||||||
|
// connecte au milieu d'un show privé.
|
||||||
|
const spanId = spansRepo.open(agentId, this.recordingTarget(agentId)?.id ?? null, now);
|
||||||
|
if (status.recordPaused) pausesRepo.open(spanId, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -328,6 +342,10 @@ class Hub {
|
|||||||
// pas à maintenant — sans quoi une VM éteinte une semaine passerait pour
|
// pas à maintenant — sans quoi une VM éteinte une semaine passerait pour
|
||||||
// avoir enregistré une semaine.
|
// avoir enregistré une semaine.
|
||||||
spansRepo.sweepStale(Date.now() - SPAN_STALE_MS);
|
spansRepo.sweepStale(Date.now() - SPAN_STALE_MS);
|
||||||
|
// Ce balayage referme des intervalles sans passer par le chemin qui referme
|
||||||
|
// les pauses : une capture disparue en plein show privé laisserait sinon
|
||||||
|
// une pause ouverte pour toujours, donc une frise jaune jusqu'à maintenant.
|
||||||
|
pausesRepo.sweepOrphans();
|
||||||
|
|
||||||
const deadline = Date.now() - config.agentTimeoutMs;
|
const deadline = Date.now() - config.agentTimeoutMs;
|
||||||
for (const [agentId, connection] of this.connections) {
|
for (const [agentId, connection] of this.connections) {
|
||||||
|
|||||||
@@ -1042,9 +1042,31 @@ export interface RecordingSpan {
|
|||||||
endedAt: number | null;
|
endedAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un passage en pause à l'intérieur d'une capture.
|
||||||
|
*
|
||||||
|
* En surimpression d'un {@link RecordingSpan}, jamais à sa place : une capture
|
||||||
|
* mise en pause reste une seule capture, un seul fichier. La découper en
|
||||||
|
* plusieurs intervalles ferait passer les morceaux pour des enregistrements
|
||||||
|
* distincts — alors que rien n'a été clos, seulement suspendu.
|
||||||
|
*
|
||||||
|
* C'est ce qui permet de distinguer sur la frise le temps réellement écrit du
|
||||||
|
* temps où la capture tournait à vide (show privé, flux hors-ligne).
|
||||||
|
*/
|
||||||
|
export interface RecordingPause {
|
||||||
|
id: number;
|
||||||
|
spanId: number;
|
||||||
|
/** Repris de l'intervalle porteur, qui seul rattache la capture à un profil. */
|
||||||
|
targetId: string | null;
|
||||||
|
startedAt: number;
|
||||||
|
/** Nul tant que la capture est toujours en pause. */
|
||||||
|
endedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TimelineData {
|
export interface TimelineData {
|
||||||
sessions: StreamSession[];
|
sessions: StreamSession[];
|
||||||
spans: RecordingSpan[];
|
spans: RecordingSpan[];
|
||||||
|
pauses: RecordingPause[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: TimelineData = { sessions: [], spans: [] };
|
const EMPTY: TimelineData = { sessions: [], spans: [], pauses: [] };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fiche détaillée d'un profil : identité, réglages, et son historique de
|
* Fiche détaillée d'un profil : identité, réglages, et son historique de
|
||||||
@@ -343,6 +343,7 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro
|
|||||||
<p className="muted small timeline-legend">
|
<p className="muted small timeline-legend">
|
||||||
<span className="legend-swatch live" /> diffusion publique
|
<span className="legend-swatch live" /> diffusion publique
|
||||||
<span className="legend-swatch rec" /> enregistré
|
<span className="legend-swatch rec" /> enregistré
|
||||||
|
<span className="legend-swatch paused" /> en pause
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -358,6 +359,7 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro
|
|||||||
label: target.label ?? target.username,
|
label: target.label ?? target.username,
|
||||||
sessions: timeline.sessions,
|
sessions: timeline.sessions,
|
||||||
spans: timeline.spans,
|
spans: timeline.spans,
|
||||||
|
pauses: timeline.pauses,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -367,7 +369,12 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro
|
|||||||
<ul className="session-list">
|
<ul className="session-list">
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const end = session.endedAt ?? now;
|
const end = session.endedAt ?? now;
|
||||||
const recorded = overlapMs(timeline.spans, session.startedAt, end);
|
// Pauses déduites : elles n'ont rien écrit dans le fichier.
|
||||||
|
const recorded = Math.max(
|
||||||
|
0,
|
||||||
|
overlapMs(timeline.spans, session.startedAt, end) -
|
||||||
|
overlapMs(timeline.pauses, session.startedAt, end),
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<li key={session.id}>
|
<li key={session.id}>
|
||||||
<span className="session-when">{formatDateTime(session.startedAt)}</span>
|
<span className="session-when">{formatDateTime(session.startedAt)}</span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { RecordingSpan, StreamSession } from '@stream-control/shared';
|
import type { RecordingPause, RecordingSpan, StreamSession } from '@stream-control/shared';
|
||||||
import { sessionPreviewSrc } from '../api';
|
import { sessionPreviewSrc } from '../api';
|
||||||
import { formatDuration } from '../format';
|
import { formatDuration } from '../format';
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ export interface TimelineLane {
|
|||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
sessions: StreamSession[];
|
sessions: StreamSession[];
|
||||||
spans: RecordingSpan[];
|
spans: RecordingSpan[];
|
||||||
|
pauses: RecordingPause[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -81,8 +82,11 @@ export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) {
|
|||||||
{lanes.map((lane) => {
|
{lanes.map((lane) => {
|
||||||
const sessions = toBars(lane.sessions, from, to, now, 'Diffusion');
|
const sessions = toBars(lane.sessions, from, to, now, 'Diffusion');
|
||||||
const spans = toBars(lane.spans, from, to, now, 'Enregistrement');
|
const spans = toBars(lane.spans, from, to, now, 'Enregistrement');
|
||||||
|
const pauses = toBars(lane.pauses, from, to, now, 'En pause');
|
||||||
const liveMs = totalMs(sessions);
|
const liveMs = totalMs(sessions);
|
||||||
const recordedMs = totalMs(spans);
|
// Le temps en pause est retranché : la capture tournait, mais n'écrivait
|
||||||
|
// rien. L'inclure gonflerait le total d'un temps absent du fichier.
|
||||||
|
const recordedMs = Math.max(0, totalMs(spans) - totalMs(pauses));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={lane.id} className="timeline-lane">
|
<div key={lane.id} className="timeline-lane">
|
||||||
@@ -111,7 +115,7 @@ export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
className="muted small timeline-totals"
|
className="muted small timeline-totals"
|
||||||
title={`${formatDuration(liveMs)} en direct, dont ${formatDuration(recordedMs)} enregistré`}
|
title={`${formatDuration(liveMs)} en direct, dont ${formatDuration(recordedMs)} réellement enregistré (pauses déduites)`}
|
||||||
>
|
>
|
||||||
{formatDuration(liveMs)}
|
{formatDuration(liveMs)}
|
||||||
{recordedMs > 0 ? <span className="timeline-rec-total"> · {formatDuration(recordedMs)}</span> : null}
|
{recordedMs > 0 ? <span className="timeline-rec-total"> · {formatDuration(recordedMs)}</span> : null}
|
||||||
@@ -164,6 +168,18 @@ export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Après les intervalles, donc par-dessus : une pause se lit comme
|
||||||
|
une portion de la capture qui l'entoure, pas comme une barre
|
||||||
|
concurrente. */}
|
||||||
|
{pauses.map((bar) => (
|
||||||
|
<span
|
||||||
|
key={bar.key}
|
||||||
|
className={bar.open ? 'timeline-pause live' : 'timeline-pause'}
|
||||||
|
style={{ left: `${bar.left}%`, width: `${bar.width}%` }}
|
||||||
|
title={bar.title}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{nowLeft !== null && (
|
{nowLeft !== null && (
|
||||||
<span className="timeline-now" style={{ left: `${nowLeft}%` }} title="maintenant" />
|
<span className="timeline-now" style={{ left: `${nowLeft}%` }} title="maintenant" />
|
||||||
)}
|
)}
|
||||||
@@ -224,7 +240,7 @@ const percent = (at: number, from: number, to: number) => ((at - from) / (to - f
|
|||||||
* remplissant tout l'écran.
|
* remplissant tout l'écran.
|
||||||
*/
|
*/
|
||||||
function toBars(
|
function toBars(
|
||||||
intervals: Array<StreamSession | RecordingSpan>,
|
intervals: Array<StreamSession | RecordingSpan | RecordingPause>,
|
||||||
from: number,
|
from: number,
|
||||||
to: number,
|
to: number,
|
||||||
now: number,
|
now: number,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface Props {
|
|||||||
notify: (message: string, tone?: 'info' | 'error') => void;
|
notify: (message: string, tone?: 'info' | 'error') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: TimelineData = { sessions: [], spans: [] };
|
const EMPTY: TimelineData = { sessions: [], spans: [], pauses: [] };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Frise de toutes les diffusions suivies, une voie par profil.
|
* Frise de toutes les diffusions suivies, une voie par profil.
|
||||||
@@ -60,12 +60,16 @@ export function TimelinePage({ targets, agents, notify }: Props) {
|
|||||||
avatarUrl: target.avatarUrl,
|
avatarUrl: target.avatarUrl,
|
||||||
sessions: [],
|
sessions: [],
|
||||||
spans: [],
|
spans: [],
|
||||||
|
pauses: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const session of data.sessions) byTarget.get(session.targetId)?.sessions.push(session);
|
for (const session of data.sessions) byTarget.get(session.targetId)?.sessions.push(session);
|
||||||
for (const span of data.spans) {
|
for (const span of data.spans) {
|
||||||
if (span.targetId) byTarget.get(span.targetId)?.spans.push(span);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// Un profil sans la moindre diffusion sur la période n'apporte qu'une ligne
|
// 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.
|
// vide : la frise sert à comparer des activités, pas à recenser les suivis.
|
||||||
@@ -75,9 +79,14 @@ export function TimelinePage({ targets, agents, notify }: Props) {
|
|||||||
.sort((a, b) => lastActivity(b) - lastActivity(a));
|
.sort((a, b) => lastActivity(b) - lastActivity(a));
|
||||||
}, [data, targets, onlyRecorded]);
|
}, [data, targets, onlyRecorded]);
|
||||||
|
|
||||||
const totalRecorded = data.spans.reduce(
|
// Pauses déduites : la capture tournait, mais n'écrivait rien pendant ce
|
||||||
(sum, span) => sum + ((span.endedAt ?? now) - span.startedAt),
|
// 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,
|
0,
|
||||||
|
data.spans.reduce((sum, span) => sum + elapsed(span), 0) -
|
||||||
|
data.pauses.reduce((sum, pause) => sum + elapsed(pause), 0),
|
||||||
);
|
);
|
||||||
const openTarget = openId ? (targets.find((target) => target.id === openId) ?? null) : null;
|
const openTarget = openId ? (targets.find((target) => target.id === openId) ?? null) : null;
|
||||||
|
|
||||||
@@ -107,6 +116,7 @@ export function TimelinePage({ targets, agents, notify }: Props) {
|
|||||||
<p className="muted small timeline-legend">
|
<p className="muted small timeline-legend">
|
||||||
<span className="legend-swatch live" /> diffusion publique
|
<span className="legend-swatch live" /> diffusion publique
|
||||||
<span className="legend-swatch rec" /> enregistré
|
<span className="legend-swatch rec" /> enregistré
|
||||||
|
<span className="legend-swatch paused" /> en pause
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
{data.sessions.length} diffusion(s) · {formatDuration(totalRecorded)} enregistré au total
|
{data.sessions.length} diffusion(s) · {formatDuration(totalRecorded)} enregistré au total
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1021,6 +1021,24 @@ fieldset.group > legend {
|
|||||||
.timeline-rec.live {
|
.timeline-rec.live {
|
||||||
background: repeating-linear-gradient(90deg, var(--rec) 0 6px, #b91c1c 6px 12px);
|
background: repeating-linear-gradient(90deg, var(--rec) 0 6px, #b91c1c 6px 12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Portion en pause, posée par-dessus la capture qui la contient et calée sur sa
|
||||||
|
* géométrie : elle se lit comme une part de l'enregistrement, pas comme une
|
||||||
|
* barre concurrente. Jaune plutôt que rouge — la capture tournait, mais
|
||||||
|
* n'écrivait rien.
|
||||||
|
*/
|
||||||
|
.timeline-pause {
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
bottom: 7px;
|
||||||
|
min-width: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--warn);
|
||||||
|
}
|
||||||
|
.timeline-pause.live {
|
||||||
|
background: repeating-linear-gradient(90deg, var(--warn) 0 6px, #b45309 6px 12px);
|
||||||
|
}
|
||||||
.timeline-now {
|
.timeline-now {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -1056,6 +1074,9 @@ fieldset.group > legend {
|
|||||||
.legend-swatch.rec {
|
.legend-swatch.rec {
|
||||||
background: var(--rec);
|
background: var(--rec);
|
||||||
}
|
}
|
||||||
|
.legend-swatch.paused {
|
||||||
|
background: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Vignette conservée d'une diffusion, montrée au survol de sa barre. En `fixed`
|
* Vignette conservée d'une diffusion, montrée au survol de sa barre. En `fixed`
|
||||||
|
|||||||
Reference in New Issue
Block a user