Detail Stream tweaks (#20533)
Some checks are pending
CI / ARM Extra Build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

This commit is contained in:
Josh Hawkins 2025-10-16 15:15:23 -05:00 committed by GitHub
parent 9599450cff
commit 60789f7096
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 76 additions and 57 deletions

View File

@ -15,17 +15,19 @@ import { useTranslation } from "react-i18next";
type ObjectTrackOverlayProps = {
camera: string;
selectedObjectId: string;
showBoundingBoxes?: boolean;
currentTime: number;
videoWidth: number;
videoHeight: number;
className?: string;
onSeekToTime?: (timestamp: number) => void;
onSeekToTime?: (timestamp: number, play?: boolean) => void;
objectTimeline?: ObjectLifecycleSequence[];
};
export default function ObjectTrackOverlay({
camera,
selectedObjectId,
showBoundingBoxes = false,
currentTime,
videoWidth,
videoHeight,
@ -227,7 +229,7 @@ export default function ObjectTrackOverlay({
const handlePointClick = useCallback(
(timestamp: number) => {
onSeekToTime?.(timestamp);
onSeekToTime?.(timestamp, false);
},
[onSeekToTime],
);
@ -366,7 +368,7 @@ export default function ObjectTrackOverlay({
</Tooltip>
))}
{currentBoundingBox && (
{currentBoundingBox && showBoundingBoxes && (
<g>
<rect
x={currentBoundingBox.left * videoWidth}

View File

@ -49,7 +49,7 @@ type HlsVideoPlayerProps = {
onPlayerLoaded?: () => void;
onTimeUpdate?: (time: number) => void;
onPlaying?: () => void;
onSeekToTime?: (timestamp: number) => void;
onSeekToTime?: (timestamp: number, play?: boolean) => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
toggleFullscreen?: () => void;
@ -324,13 +324,14 @@ export default function HlsVideoPlayer({
key={`${selectedObjectId}-${currentTime}`}
camera={camera}
selectedObjectId={selectedObjectId}
showBoundingBoxes={!isPlaying}
currentTime={currentTime}
videoWidth={videoDimensions.width}
videoHeight={videoDimensions.height}
className="absolute inset-0 z-10"
onSeekToTime={(timestamp) => {
onSeekToTime={(timestamp, play) => {
if (onSeekToTime) {
onSeekToTime(timestamp);
onSeekToTime(timestamp, play);
}
}}
objectTimeline={selectedObjectTimeline}

View File

@ -32,7 +32,7 @@ type DynamicVideoPlayerProps = {
onControllerReady: (controller: DynamicVideoController) => void;
onTimestampUpdate?: (timestamp: number) => void;
onClipEnded?: () => void;
onSeekToTime?: (timestamp: number) => void;
onSeekToTime?: (timestamp: number, play?: boolean) => void;
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
toggleFullscreen: () => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
@ -267,7 +267,11 @@ export default function DynamicVideoPlayer({
onTimeUpdate={onTimeUpdate}
onPlayerLoaded={onPlayerLoaded}
onClipEnded={onValidateClipEnd}
onSeekToTime={onSeekToTime}
onSeekToTime={(timestamp, play) => {
if (onSeekToTime) {
onSeekToTime(timestamp, play);
}
}}
onPlaying={() => {
if (isScrubbing) {
playerRef.current?.pause();

View File

@ -5,7 +5,10 @@ import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { useDetailStream } from "@/context/detail-stream-context";
import scrollIntoView from "scroll-into-view-if-needed";
import useUserInteraction from "@/hooks/use-user-interaction";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import {
formatUnixTimestampToDateTime,
formatSecondsToDuration,
} from "@/utils/dateUtil";
import { useTranslation } from "react-i18next";
import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider";
import { FrigateConfig } from "@/types/frigateConfig";
@ -13,7 +16,7 @@ import useSWR from "swr";
import ActivityIndicator from "../indicators/activity-indicator";
import { Event } from "@/types/event";
import { getIconForLabel } from "@/utils/iconUtil";
import { ReviewSegment, REVIEW_PADDING } from "@/types/review";
import { ReviewSegment } from "@/types/review";
import {
Collapsible,
CollapsibleTrigger,
@ -28,7 +31,7 @@ import { cn } from "@/lib/utils";
type DetailStreamProps = {
reviewItems?: ReviewSegment[];
currentTime: number;
onSeek: (timestamp: number) => void;
onSeek: (timestamp: number, play?: boolean) => void;
};
export default function DetailStream({
@ -49,7 +52,6 @@ export default function DetailStream({
});
const effectiveTime = currentTime + annotationOffset / 1000;
const PAD = 0; // REVIEW_PADDING ?? 2;
const [upload, setUpload] = useState<Event | undefined>(undefined);
// Ensure we initialize the active review when reviewItems first arrive.
@ -64,8 +66,8 @@ export default function DetailStream({
let closest: { r: ReviewSegment; diff: number } | undefined;
for (const r of reviewItems) {
const start = (r.start_time ?? 0) - PAD;
const end = (r.end_time ?? r.start_time ?? start) + PAD;
const start = r.start_time ?? 0;
const end = r.end_time ?? r.start_time ?? start;
if (effectiveTime >= start && effectiveTime <= end) {
target = r;
break;
@ -78,12 +80,12 @@ export default function DetailStream({
if (!target && closest) target = closest.r;
if (target) {
const start = (target.start_time ?? 0) - PAD;
const start = target.start_time ?? 0;
setActiveReviewId(
`review-${target.id ?? target.start_time ?? Math.floor(start)}`,
);
}
}, [reviewItems, activeReviewId, effectiveTime, PAD]);
}, [reviewItems, activeReviewId, effectiveTime]);
// Auto-scroll to current time
useEffect(() => {
@ -99,8 +101,8 @@ export default function DetailStream({
let closest: { r: ReviewSegment; diff: number } | undefined;
for (const r of items) {
const start = (r.start_time ?? 0) - PAD;
const end = (r.end_time ?? r.start_time ?? start) + PAD;
const start = r.start_time ?? 0;
const end = r.end_time ?? r.start_time ?? start;
if (effectiveTime >= start && effectiveTime <= end) {
target = r;
break;
@ -113,7 +115,7 @@ export default function DetailStream({
if (!target && closest) target = closest.r;
if (target) {
const start = (target.start_time ?? 0) - PAD;
const start = target.start_time ?? 0;
const id = `review-${target.id ?? target.start_time ?? Math.floor(start)}`;
const element = scrollRef.current.querySelector(
`[data-review-id="${id}"]`,
@ -132,15 +134,14 @@ export default function DetailStream({
annotationOffset,
userInteracting,
setProgrammaticScroll,
PAD,
]);
// Auto-select active review based on effectiveTime (if inside a review range)
useEffect(() => {
if (!reviewItems || reviewItems.length === 0) return;
for (const r of reviewItems) {
const start = (r.start_time ?? 0) - PAD;
const end = (r.end_time ?? r.start_time ?? start) + PAD;
const start = r.start_time ?? 0;
const end = r.end_time ?? r.start_time ?? start;
if (effectiveTime >= start && effectiveTime <= end) {
setActiveReviewId(
`review-${r.id ?? r.start_time ?? Math.floor(start)}`,
@ -148,7 +149,7 @@ export default function DetailStream({
return;
}
}
}, [effectiveTime, reviewItems, PAD]);
}, [effectiveTime, reviewItems]);
if (!config) {
return <ActivityIndicator />;
@ -173,7 +174,7 @@ export default function DetailStream({
</div>
) : (
reviewItems?.map((review: ReviewSegment) => {
const id = `review-${review.id ?? review.start_time ?? Math.floor((review.start_time ?? 0) - PAD)}`;
const id = `review-${review.id ?? review.start_time ?? Math.floor(review.start_time ?? 0)}`;
return (
<ReviewGroup
key={id}
@ -201,7 +202,7 @@ type ReviewGroupProps = {
review: ReviewSegment;
id: string;
config: FrigateConfig;
onSeek: (timestamp: number) => void;
onSeek: (timestamp: number, play?: boolean) => void;
isActive?: boolean;
onActivate?: () => void;
onOpenUpload?: (e: Event) => void;
@ -219,18 +220,14 @@ function ReviewGroup({
effectiveTime,
}: ReviewGroupProps) {
const { t } = useTranslation("views/events");
const PAD = REVIEW_PADDING ?? 2;
const start = review.start_time ?? 0;
// derive start timestamp from the review
const start = (review.start_time ?? 0) - PAD;
// display time first in the header
const displayTime = formatUnixTimestampToDateTime(start, {
timezone: config.ui.timezone,
date_format:
config.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour", { ns: "common" })
: t("time.formattedTimestamp.12hour", { ns: "common" }),
? t("time.formattedTimestampHourMinuteSecond.24hour", { ns: "common" })
: t("time.formattedTimestampHourMinuteSecond.12hour", { ns: "common" }),
time_style: "medium",
date_style: "medium",
});
@ -268,6 +265,13 @@ function ReviewGroup({
}
}, [review, t, fetchedEvents]);
const reviewDuration =
review.end_time != null
? formatSecondsToDuration(
Math.max(0, Math.floor((review.end_time ?? 0) - start)),
)
: null;
return (
<div
data-review-id={id}
@ -287,6 +291,11 @@ function ReviewGroup({
<div className="flex items-center gap-2">
<div className="flex flex-col">
<div className="text-sm font-medium">{displayTime}</div>
{reviewDuration && (
<div className="text-xs text-muted-foreground">
{reviewDuration}
</div>
)}
<div className="text-xs text-muted-foreground">{reviewInfo}</div>
</div>
</div>
@ -325,7 +334,7 @@ function ReviewGroup({
type EventCollapsibleProps = {
event: Event;
effectiveTime?: number;
onSeek: (ts: number) => void;
onSeek: (ts: number, play?: boolean) => void;
onOpenUpload?: (e: Event) => void;
};
function EventCollapsible({
@ -398,7 +407,7 @@ function EventCollapsible({
event.id != selectedObjectId &&
(effectiveTime ?? 0) >= (event.start_time ?? 0) &&
(effectiveTime ?? 0) <= (event.end_time ?? event.start_time ?? 0) &&
"bg-secondary-highlight/80 outline-[1px] -outline-offset-[0.8px] outline-primary/40",
"bg-secondary-highlight outline-[1.5px] -outline-offset-[1.1px] outline-primary/40",
)}
>
<div className="flex w-full items-center justify-between">
@ -450,9 +459,7 @@ function EventCollapsible({
<div className="mt-2">
<ObjectTimeline
eventId={event.id}
onSeek={(ts) => {
onSeek(ts);
}}
onSeek={onSeek}
effectiveTime={effectiveTime}
/>
</div>
@ -464,11 +471,11 @@ function EventCollapsible({
type LifecycleItemProps = {
event: ObjectLifecycleSequence;
onSeek: (timestamp: number) => void;
isActive?: boolean;
onSeek?: (timestamp: number, play?: boolean) => void;
};
function LifecycleItem({ event, isActive }: LifecycleItemProps) {
function LifecycleItem({ event, isActive, onSeek }: LifecycleItemProps) {
const { t } = useTranslation("views/events");
const { data: config } = useSWR<FrigateConfig>("config");
@ -490,9 +497,15 @@ function LifecycleItem({ event, isActive }: LifecycleItemProps) {
return (
<div
role="button"
onClick={() => {
onSeek?.(event.timestamp ?? 0, false);
}}
className={cn(
"flex items-center gap-2 text-sm text-primary-variant",
isActive ? "text-white" : "duration-500",
"flex cursor-pointer items-center gap-2 text-sm text-primary-variant",
isActive
? "font-semibold text-primary dark:font-normal"
: "duration-500",
)}
>
<div className="flex size-4 items-center justify-center">
@ -513,7 +526,7 @@ function ObjectTimeline({
effectiveTime,
}: {
eventId: string;
onSeek: (ts: number) => void;
onSeek: (ts: number, play?: boolean) => void;
effectiveTime?: number;
}) {
const { t } = useTranslation("views/events");
@ -542,14 +555,12 @@ function ObjectTimeline({
const isActive =
Math.abs((effectiveTime ?? 0) - (event.timestamp ?? 0)) <= 0.5;
return (
<div
key={`${event.timestamp}-${event.source_id ?? idx}`}
onClick={() => {
onSeek(event.timestamp);
}}
>
<LifecycleItem event={event} onSeek={onSeek} isActive={isActive} />
</div>
<LifecycleItem
key={`${event.timestamp}-${event.source_id ?? ""}-${idx}`}
event={event}
onSeek={onSeek}
isActive={isActive}
/>
);
})}
</div>

View File

@ -283,15 +283,14 @@ export function RecordingView({
]);
const manuallySetCurrentTime = useCallback(
(time: number) => {
(time: number, play: boolean = false) => {
if (!currentTimeRange) {
return;
}
setCurrentTime(time);
if (currentTimeRange.after <= time && currentTimeRange.before >= time) {
mainControllerRef.current?.seekToTimestamp(time, true);
mainControllerRef.current?.seekToTimestamp(time, play);
} else {
updateSelectedSegment(time, true);
}
@ -310,7 +309,7 @@ export function RecordingView({
} else {
updateSelectedSegment(currentTime, true);
}
} else if (playerTime != currentTime) {
} else if (playerTime != currentTime && timelineType != "detail") {
mainControllerRef.current?.play();
}
}
@ -1006,7 +1005,9 @@ function Timeline({
) : timelineType == "detail" ? (
<DetailStream
currentTime={currentTime}
onSeek={(timestamp) => manuallySetCurrentTime(timestamp, true)}
onSeek={(timestamp, play) =>
manuallySetCurrentTime(timestamp, play ?? true)
}
reviewItems={mainCameraReviewItems}
/>
) : (

View File

@ -36,8 +36,8 @@
--secondary-foreground: hsl(222.2, 17.4%, 36.2%);
--secondary-foreground: 222.2 17.4% 36.2%;
--secondary-highlight: hsl(0, 0%, 94%);
--secondary-highlight: 0 0% 94%;
--secondary-highlight: hsl(210, 17.4%, 94%);
--secondary-highlight: 210 17.4% 94%;
--neutral: hsl(0, 0%, 45.1%);
--neutral: 0 0% 45.1%;