198 lines
6.9 KiB
TypeScript
198 lines
6.9 KiB
TypeScript
import { Button, Icon, Select } from "@nodedc/ui-react";
|
||
|
||
import type { ObservationTimelineMode } from "../core/runtime/contracts";
|
||
|
||
export function ObservationTimeline({
|
||
active,
|
||
sourceCount,
|
||
mode = "live-only",
|
||
seekable = false,
|
||
synchronization = "host-arrival-best-effort",
|
||
rangeNs = null,
|
||
currentNs = null,
|
||
playing = false,
|
||
onSeek,
|
||
onPlayingChange,
|
||
onJumpToEnd,
|
||
showJumpToEnd = true,
|
||
playbackRate,
|
||
onPlaybackRateChange,
|
||
accumulationSeconds,
|
||
onAccumulationChange,
|
||
onAccumulationCommit,
|
||
className = "",
|
||
}: {
|
||
active: boolean;
|
||
sourceCount: number;
|
||
mode?: ObservationTimelineMode;
|
||
seekable?: boolean;
|
||
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
||
rangeNs?: { min: number; max: number } | null;
|
||
currentNs?: number | null;
|
||
playing?: boolean;
|
||
onSeek?: (timeNs: number) => void;
|
||
onPlayingChange?: (playing: boolean) => void;
|
||
onJumpToEnd?: () => void;
|
||
showJumpToEnd?: boolean;
|
||
playbackRate?: number;
|
||
onPlaybackRateChange?: (rate: number) => void;
|
||
accumulationSeconds?: number;
|
||
onAccumulationChange?: (value: number) => void;
|
||
onAccumulationCommit?: () => void;
|
||
className?: string;
|
||
}) {
|
||
const playbackRange = normalizeTimelineRange(rangeNs);
|
||
const buffered = Boolean(
|
||
seekable &&
|
||
(mode === "buffered" || mode === "recorded") &&
|
||
playbackRange,
|
||
);
|
||
const elapsedSeconds = playbackRange
|
||
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
|
||
: 0;
|
||
const durationSeconds = playbackRange
|
||
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
|
||
: 0;
|
||
const synchronizationLabel = {
|
||
"host-arrival-best-effort": "Синхронизация по приходу",
|
||
"shared-clock": "Общие часы",
|
||
"frame-accurate": "Покадровая синхронизация",
|
||
}[synchronization];
|
||
const accumulationValue = accumulationSeconds === undefined
|
||
? null
|
||
: normalizeAccumulationSeconds(accumulationSeconds);
|
||
return (
|
||
<div
|
||
className={`observation-timeline ${className}`.trim()}
|
||
data-active={active ? "true" : undefined}
|
||
data-mode={mode}
|
||
data-accumulation={accumulationValue !== null ? "true" : undefined}
|
||
>
|
||
{accumulationValue !== null ? (
|
||
<div className="observation-timeline__accumulation">
|
||
<span>Накопление</span>
|
||
<input
|
||
className="observation-timeline__track"
|
||
type="range"
|
||
min={0}
|
||
max={120}
|
||
step={1}
|
||
value={accumulationValue}
|
||
aria-label="Окно накопления облака точек"
|
||
aria-valuetext={formatAccumulationDuration(accumulationValue)}
|
||
onChange={(event) =>
|
||
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
|
||
onPointerUp={onAccumulationCommit}
|
||
onTouchEnd={onAccumulationCommit}
|
||
onKeyUp={onAccumulationCommit}
|
||
onBlur={onAccumulationCommit}
|
||
/>
|
||
<code>{formatAccumulationDuration(accumulationValue)}</code>
|
||
</div>
|
||
) : null}
|
||
<div className="observation-timeline__playback">
|
||
<Button
|
||
size="compact"
|
||
variant="ghost"
|
||
disabled={!buffered || !onSeek}
|
||
aria-label="Перейти к началу"
|
||
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
|
||
>
|
||
<Icon name="chevron-left" />
|
||
</Button>
|
||
<Button
|
||
className="observation-timeline__transport"
|
||
size="compact"
|
||
variant="ghost"
|
||
disabled={!buffered || !onPlayingChange}
|
||
aria-label={buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||
icon={<Icon name={playing ? "stop" : "play"} />}
|
||
onClick={() => onPlayingChange?.(!playing)}
|
||
/>
|
||
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
|
||
<Select
|
||
label="Скорость воспроизведения"
|
||
value={String(playbackRate)}
|
||
options={[
|
||
{ value: "0.5", label: "0,5×" },
|
||
{ value: "1", label: "1×" },
|
||
{ value: "2", label: "2×" },
|
||
]}
|
||
variant="inline"
|
||
menuWidth="anchor"
|
||
onChange={(value) => onPlaybackRateChange(Number(value))}
|
||
/>
|
||
) : null}
|
||
<input
|
||
className="observation-timeline__track"
|
||
type="range"
|
||
min={0}
|
||
max={Math.max(durationSeconds, 0.001)}
|
||
step={0.01}
|
||
value={Math.min(elapsedSeconds, durationSeconds)}
|
||
disabled={!buffered || !onSeek}
|
||
aria-label="Позиция воспроизведения"
|
||
onChange={(event) => {
|
||
if (!playbackRange) return;
|
||
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
|
||
}}
|
||
/>
|
||
<div className="observation-timeline__meta">
|
||
<code>
|
||
{buffered
|
||
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
|
||
: active ? "LIVE" : "—:—:—.———"}
|
||
</code>
|
||
<small>
|
||
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
||
</small>
|
||
</div>
|
||
{showJumpToEnd ? (
|
||
<button
|
||
type="button"
|
||
className="observation-timeline__follow"
|
||
data-active={!buffered && active ? "true" : undefined}
|
||
disabled={buffered ? !onJumpToEnd : true}
|
||
onClick={onJumpToEnd}
|
||
>
|
||
{buffered ? "К КОНЦУ" : "ЭФИР"}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function normalizeAccumulationSeconds(value: number): number {
|
||
if (!Number.isFinite(value)) return 0;
|
||
return Math.min(120, Math.max(0, Math.round(value)));
|
||
}
|
||
|
||
export function formatAccumulationDuration(value: number): string {
|
||
const seconds = normalizeAccumulationSeconds(value);
|
||
return seconds === 0 ? "Кадр" : `${seconds} с`;
|
||
}
|
||
|
||
export function normalizeTimelineRange(
|
||
range: { min: number; max: number } | null | undefined,
|
||
): { min: number; max: number } | null {
|
||
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
|
||
if (range.max <= range.min) return null;
|
||
return range;
|
||
}
|
||
|
||
export function timelineOffsetSeconds(
|
||
range: { min: number; max: number },
|
||
currentNs: number,
|
||
): number {
|
||
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
|
||
return Math.max(0, (clamped - range.min) / 1_000_000_000);
|
||
}
|
||
|
||
export function formatTimelineDuration(seconds: number): string {
|
||
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
|
||
const wholeMinutes = Math.floor(seconds / 60);
|
||
const remaining = seconds - wholeMinutes * 60;
|
||
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
|
||
}
|