diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx index 82fb14771..b505130cc 100644 --- a/web/src/components/overlay/detail/TrackingDetails.tsx +++ b/web/src/components/overlay/detail/TrackingDetails.tsx @@ -8,7 +8,7 @@ import Heading from "@/components/ui/heading"; import { FrigateConfig } from "@/types/frigateConfig"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { getIconForLabel } from "@/utils/iconUtil"; -import { LuCircle, LuSettings } from "react-icons/lu"; +import { LuCircle, LuFolderX, LuSettings } from "react-icons/lu"; import { cn } from "@/lib/utils"; import { Tooltip, @@ -37,9 +37,12 @@ import { HiDotsHorizontal } from "react-icons/hi"; import axios from "axios"; import { toast } from "sonner"; import { useDetailStream } from "@/context/detail-stream-context"; -import { isDesktop, isIOS } from "react-device-detect"; +import { isDesktop, isIOS, isMobileOnly, isSafari } from "react-device-detect"; import Chip from "@/components/indicators/Chip"; import { FaDownload, FaHistory } from "react-icons/fa"; +import { useApiHost } from "@/api"; +import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator"; +import ObjectTrackOverlay from "../ObjectTrackOverlay"; type TrackingDetailsProps = { className?: string; @@ -56,9 +59,19 @@ export function TrackingDetails({ const videoRef = useRef(null); const { t } = useTranslation(["views/explore"]); const navigate = useNavigate(); + const apiHost = useApiHost(); + const imgRef = useRef(null); + const [imgLoaded, setImgLoaded] = useState(false); + const [displaySource, _setDisplaySource] = useState<"video" | "image">( + "video", + ); const { setSelectedObjectIds, annotationOffset, setAnnotationOffset } = useDetailStream(); + // manualOverride holds a record-stream timestamp explicitly chosen by the + // user (eg, clicking a lifecycle row). When null we display `currentTime`. + const [manualOverride, setManualOverride] = useState(null); + // event.start_time is detect time, convert to record, then subtract padding const [currentTime, setCurrentTime] = useState( (event.start_time ?? 0) + annotationOffset / 1000 - REVIEW_PADDING, @@ -73,9 +86,13 @@ export function TrackingDetails({ const { data: config } = useSWR("config"); + // Use manualOverride (set when seeking in image mode) if present so + // lifecycle rows and overlays follow image-mode seeks. Otherwise fall + // back to currentTime used for video mode. const effectiveTime = useMemo(() => { - return currentTime - annotationOffset / 1000; - }, [currentTime, annotationOffset]); + const displayedRecordTime = manualOverride ?? currentTime; + return displayedRecordTime - annotationOffset / 1000; + }, [manualOverride, currentTime, annotationOffset]); const containerRef = useRef(null); const [_selectedZone, setSelectedZone] = useState(""); @@ -118,20 +135,30 @@ export function TrackingDetails({ const handleLifecycleClick = useCallback( (item: TrackingDetailsSequence) => { - if (!videoRef.current) return; + if (!videoRef.current && !imgRef.current) return; // Convert lifecycle timestamp (detect stream) to record stream time const targetTimeRecord = item.timestamp + annotationOffset / 1000; - // Convert to video-relative time for seeking + if (displaySource === "image") { + // For image mode: set a manual override timestamp and update + // currentTime so overlays render correctly. + setManualOverride(targetTimeRecord); + setCurrentTime(targetTimeRecord); + return; + } + + // For video mode: convert to video-relative time and seek player const eventStartRecord = (event.start_time ?? 0) + annotationOffset / 1000; const videoStartTime = eventStartRecord - REVIEW_PADDING; const relativeTime = targetTimeRecord - videoStartTime; - videoRef.current.currentTime = relativeTime; + if (videoRef.current) { + videoRef.current.currentTime = relativeTime; + } }, - [event.start_time, annotationOffset], + [event.start_time, annotationOffset, displaySource], ); const formattedStart = config @@ -172,11 +199,20 @@ export function TrackingDetails({ }, [eventSequence]); useEffect(() => { - if (seekToTimestamp === null || !videoRef.current) return; + if (seekToTimestamp === null) return; + + if (displaySource === "image") { + // For image mode, set the manual override so the snapshot updates to + // the exact record timestamp. + setManualOverride(seekToTimestamp); + setSeekToTimestamp(null); + return; + } // seekToTimestamp is a record stream timestamp // event.start_time is detect stream time, convert to record // The video clip starts at (eventStartRecord - REVIEW_PADDING) + if (!videoRef.current) return; const eventStartRecord = event.start_time + annotationOffset / 1000; const videoStartTime = eventStartRecord - REVIEW_PADDING; const relativeTime = seekToTimestamp - videoStartTime; @@ -184,7 +220,14 @@ export function TrackingDetails({ videoRef.current.currentTime = relativeTime; } setSeekToTimestamp(null); - }, [seekToTimestamp, event.start_time, annotationOffset]); + }, [ + seekToTimestamp, + event.start_time, + annotationOffset, + apiHost, + event.camera, + displaySource, + ]); const isWithinEventRange = effectiveTime !== undefined && @@ -287,6 +330,27 @@ export function TrackingDetails({ [event.start_time, annotationOffset], ); + const [src, setSrc] = useState( + `${apiHost}api/${event.camera}/recordings/${currentTime + REVIEW_PADDING}/snapshot.jpg?height=500`, + ); + const [hasError, setHasError] = useState(false); + + // Derive the record timestamp to display: manualOverride if present, + // otherwise use currentTime. + const displayedRecordTime = manualOverride ?? currentTime; + + useEffect(() => { + if (displayedRecordTime) { + const newSrc = `${apiHost}api/${event.camera}/recordings/${displayedRecordTime}/snapshot.jpg?height=500`; + setSrc(newSrc); + } + setImgLoaded(false); + setHasError(false); + + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [displayedRecordTime]); + if (!config) { return ; } @@ -304,9 +368,10 @@ export function TrackingDetails({
- + {displaySource == "video" && ( + + )} + {displaySource == "image" && ( + <> + + {hasError && ( +
+
+ + {t("objectLifecycle.noImageFound")} +
+
+ )} +
+
+ +
+ setImgLoaded(true)} + onError={() => setHasError(true)} + /> +
+ + )}