diff --git a/apps/control-station/src/components/laboratory/useRecordedEvidencePlayback.ts b/apps/control-station/src/components/laboratory/useRecordedEvidencePlayback.ts index 95e660d..1ad45a0 100644 --- a/apps/control-station/src/components/laboratory/useRecordedEvidencePlayback.ts +++ b/apps/control-station/src/components/laboratory/useRecordedEvidencePlayback.ts @@ -40,6 +40,23 @@ export function advanceRecordedEvidencePlayback( return { ...playback, currentSeconds: clampRecordedEvidenceSeconds(next, range) }; } +export function synchronizeRecordedEvidencePlayback( + current: RecordedObservationPlayback, + observed: RecordedObservationPlayback, + range: RecordedEvidencePlaybackRange, +): RecordedObservationPlayback { + const currentSeconds = clampRecordedEvidenceSeconds(observed.currentSeconds, range); + return { + ...current, + currentSeconds, + // The transport button owns play/pause intent. A native frame callback may + // still report the previous media state immediately after an operator + // command, so it must not restart playback. The sealed end is the only + // media-clock transition allowed to stop the shared LAB clock. + playing: currentSeconds >= range.endSeconds ? false : current.playing, + }; +} + export function useRecordedEvidencePlayback( range: RecordedEvidencePlaybackRange | null, { clock = "animation" }: { clock?: "animation" | "external" } = {}, @@ -106,11 +123,7 @@ export function useRecordedEvidencePlayback( const synchronize = useCallback((next: RecordedObservationPlayback) => { if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return; - setPlayback((current) => ({ - ...current, - currentSeconds: clampRecordedEvidenceSeconds(next.currentSeconds, range), - playing: next.playing, - })); + setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range)); }, [range]); return useMemo(() => ({ diff --git a/apps/control-station/src/styles/m4-replay-threat.css b/apps/control-station/src/styles/m4-replay-threat.css index 1a0af6b..7e575bc 100644 --- a/apps/control-station/src/styles/m4-replay-threat.css +++ b/apps/control-station/src/styles/m4-replay-threat.css @@ -219,10 +219,13 @@ .m4-replay-threat-visual__overlay { box-sizing: border-box; + min-width: 0; align-items: stretch; - width: min(22rem, calc(33.333333% - 0.8rem)); - grid-template-columns: minmax(0, 1fr); + width: max-content; + max-width: calc(var(--m4-replay-threat-overlay-pane-width, 100%) - 1.2rem); + grid-template-columns: minmax(0, max-content); gap: 0.3rem; + overflow: hidden; background: transparent; padding: 0; backdrop-filter: none; @@ -235,9 +238,17 @@ backdrop-filter: blur(var(--nodedc-blur-control)); } +.m4-replay-threat-visual__overlay span, +.m4-replay-threat-visual__overlay strong, +.m4-replay-threat-visual__overlay small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + @media (max-width: 900px) { .m4-replay-threat-visual__overlay { - width: min(22rem, calc(100% - 1.2rem)); + max-width: calc(100% - 1.2rem); } } diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index 0181887..964740c 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { Button, Icon, @@ -442,8 +442,17 @@ export function M4ReplayThreatVisual({ const trailingActions = !splitView && spatialMode ? resetSpatialView : null; + const overlayPanePercent = splitView && splitOrientation === "vertical" + ? splitPrimarySize + : 100; + const overlay = metadata.timeline && frame ? ( -