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

View File

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

View File

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

View File

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

View File

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

View File

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