feat(lab): present chronological perception evidence
This commit is contained in:
@@ -234,21 +234,27 @@ async function mountRecordedEpochStream(
|
||||
export function RecordedFmp4Player({
|
||||
source,
|
||||
playback,
|
||||
interactive = false,
|
||||
prepare = true,
|
||||
sessionGate = "ready",
|
||||
admissionKey = null,
|
||||
onAdmissionChange,
|
||||
onPlaybackChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
playback?: RecordedObservationPlayback | null;
|
||||
interactive?: boolean;
|
||||
prepare?: boolean;
|
||||
sessionGate?: RecordedAdmissionPhase;
|
||||
admissionKey?: string | null;
|
||||
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
||||
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const onAdmissionChangeRef = useRef(onAdmissionChange);
|
||||
onAdmissionChangeRef.current = onAdmissionChange;
|
||||
const onPlaybackChangeRef = useRef(onPlaybackChange);
|
||||
onPlaybackChangeRef.current = onPlaybackChange;
|
||||
const workerRef = useRef<{ admissionKey: string | null; generation: number } | null>(null);
|
||||
if (!workerRef.current || workerRef.current.admissionKey !== admissionKey) {
|
||||
recordedMediaWorkerGeneration += 1;
|
||||
@@ -461,15 +467,49 @@ export function RecordedFmp4Player({
|
||||
}
|
||||
}, [archive?.byteLength, bufferRevision, currentSeconds, epoch, playback?.playing, visualState]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!interactive || !video || !epoch || visualState !== "ready") return;
|
||||
let videoFrameRequest: number | null = null;
|
||||
const emitPlayback = () => {
|
||||
onPlaybackChangeRef.current?.({
|
||||
currentSeconds: epoch.timelineStartSeconds + video.currentTime,
|
||||
playing: !video.paused && !video.ended,
|
||||
});
|
||||
};
|
||||
const scheduleVideoFrame = () => {
|
||||
if (typeof video.requestVideoFrameCallback !== "function") return;
|
||||
videoFrameRequest = video.requestVideoFrameCallback(() => {
|
||||
emitPlayback();
|
||||
scheduleVideoFrame();
|
||||
});
|
||||
};
|
||||
const events = ["play", "pause", "seeking", "seeked", "timeupdate", "ended"] as const;
|
||||
for (const event of events) video.addEventListener(event, emitPlayback);
|
||||
scheduleVideoFrame();
|
||||
emitPlayback();
|
||||
return () => {
|
||||
for (const event of events) video.removeEventListener(event, emitPlayback);
|
||||
if (
|
||||
videoFrameRequest !== null &&
|
||||
typeof video.cancelVideoFrameCallback === "function"
|
||||
) {
|
||||
video.cancelVideoFrameCallback(videoFrameRequest);
|
||||
}
|
||||
};
|
||||
}, [epoch, interactive, visualState]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="recorded-media-player"
|
||||
data-state={visualState}
|
||||
data-interactive={interactive ? "true" : undefined}
|
||||
aria-busy={visualState === "loading"}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="observation-media__asset"
|
||||
controls={interactive}
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
|
||||
@@ -15,7 +15,10 @@ export interface LaboratoryEvidenceViewerMode<T extends string> {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function LaboratoryEvidenceViewer<T extends string>({
|
||||
export function LaboratoryEvidenceViewer<
|
||||
T extends string,
|
||||
U extends string = string,
|
||||
>({
|
||||
label,
|
||||
mode,
|
||||
modes,
|
||||
@@ -23,6 +26,7 @@ export function LaboratoryEvidenceViewer<T extends string>({
|
||||
onModeChange,
|
||||
onExpandedChange,
|
||||
actions,
|
||||
secondaryMode,
|
||||
overlay,
|
||||
children,
|
||||
}: {
|
||||
@@ -33,6 +37,12 @@ export function LaboratoryEvidenceViewer<T extends string>({
|
||||
onModeChange: (mode: T) => void;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
actions?: ReactNode;
|
||||
secondaryMode?: {
|
||||
value: U;
|
||||
modes: readonly LaboratoryEvidenceViewerMode<U>[];
|
||||
label: string;
|
||||
onChange: (mode: U) => void;
|
||||
};
|
||||
overlay?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
@@ -62,6 +72,14 @@ export function LaboratoryEvidenceViewer<T extends string>({
|
||||
{overlay}
|
||||
<div className="laboratory-evidence-viewer__controls">
|
||||
{actions}
|
||||
{secondaryMode ? (
|
||||
<SegmentedControl
|
||||
value={secondaryMode.value}
|
||||
items={[...secondaryMode.modes]}
|
||||
label={secondaryMode.label}
|
||||
onChange={secondaryMode.onChange}
|
||||
/>
|
||||
) : null}
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
items={[...modes]}
|
||||
|
||||
@@ -75,6 +75,7 @@ export function LaboratorySelector<T extends string>({
|
||||
value,
|
||||
options,
|
||||
disabled = false,
|
||||
searchable = false,
|
||||
onChange,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
@@ -84,6 +85,7 @@ export function LaboratorySelector<T extends string>({
|
||||
value: T;
|
||||
options: readonly LaboratoryOption<T>[];
|
||||
disabled?: boolean;
|
||||
searchable?: boolean;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -105,6 +107,8 @@ export function LaboratorySelector<T extends string>({
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
disabled={disabled}
|
||||
searchable={searchable}
|
||||
searchPlaceholder={`Найти: ${label.toLocaleLowerCase("ru-RU")}`}
|
||||
onChange={(next) => onChange(next)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Button, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import type { LaboratoryAnnotationAction } from "../../workspaces/contracts";
|
||||
|
||||
export function useLaboratoryAnnotationHeader(): {
|
||||
control: ReactNode;
|
||||
setAction: (action: LaboratoryAnnotationAction | null) => void;
|
||||
} {
|
||||
const [action, setAction] = useState<LaboratoryAnnotationAction | null>(null);
|
||||
return {
|
||||
setAction,
|
||||
control: action ? (
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant="accent"
|
||||
icon={<Icon name="edit" size={16} />}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user