diff --git a/web/package-lock.json b/web/package-lock.json index fe1bad521..371defaaa 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,6 +14,7 @@ "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-aspect-ratio": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.6", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.6", @@ -1380,6 +1381,171 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", diff --git a/web/package.json b/web/package.json index c00bd77dd..27256bd81 100644 --- a/web/package.json +++ b/web/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-aspect-ratio": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.6", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.6", diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json index 77c626adf..732533ef2 100644 --- a/web/public/locales/en/views/events.json +++ b/web/public/locales/en/views/events.json @@ -18,6 +18,17 @@ "aria": "Select events", "noFoundForTimePeriod": "No events found for this time period." }, + "detail": { + "noDataFound": "No detail data to review", + "aria": "Toggle detail view", + "trackedObject_one": "tracked object", + "trackedObject_other": "tracked objects", + "noObjectDetailData": "No object detail data available." + }, + "objectTrack": { + "trackedPoint": "Tracked point", + "clickToSeek": "Click to seek to this time" + }, "documentTitle": "Review - Frigate", "recordings": { "documentTitle": "Recordings - Frigate" diff --git a/web/src/components/overlay/ObjectTrackOverlay.tsx b/web/src/components/overlay/ObjectTrackOverlay.tsx new file mode 100644 index 000000000..17526bb09 --- /dev/null +++ b/web/src/components/overlay/ObjectTrackOverlay.tsx @@ -0,0 +1,395 @@ +import { useMemo, useCallback } from "react"; +import { ObjectLifecycleSequence, LifecycleClassType } from "@/types/timeline"; +import { FrigateConfig } from "@/types/frigateConfig"; +import useSWR from "swr"; +import { useDetailStream } from "@/context/detail-stream-context"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { TooltipPortal } from "@radix-ui/react-tooltip"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; + +type ObjectTrackOverlayProps = { + camera: string; + selectedObjectId: string; + currentTime: number; + videoWidth: number; + videoHeight: number; + className?: string; + onSeekToTime?: (timestamp: number) => void; + objectTimeline?: ObjectLifecycleSequence[]; +}; + +export default function ObjectTrackOverlay({ + camera, + selectedObjectId, + currentTime, + videoWidth, + videoHeight, + className, + onSeekToTime, + objectTimeline, +}: ObjectTrackOverlayProps) { + const { t } = useTranslation("views/events"); + const { data: config } = useSWR("config"); + const { annotationOffset } = useDetailStream(); + + const effectiveCurrentTime = currentTime - annotationOffset / 1000; + + // Fetch the full event data to get saved path points + const { data: eventData } = useSWR(["event_ids", { ids: selectedObjectId }]); + + const typeColorMap = useMemo( + () => ({ + [LifecycleClassType.VISIBLE]: [0, 255, 0], // Green + [LifecycleClassType.GONE]: [255, 0, 0], // Red + [LifecycleClassType.ENTERED_ZONE]: [255, 165, 0], // Orange + [LifecycleClassType.ATTRIBUTE]: [128, 0, 128], // Purple + [LifecycleClassType.ACTIVE]: [255, 255, 0], // Yellow + [LifecycleClassType.STATIONARY]: [128, 128, 128], // Gray + [LifecycleClassType.HEARD]: [0, 255, 255], // Cyan + [LifecycleClassType.EXTERNAL]: [165, 42, 42], // Brown + }), + [], + ); + + const getObjectColor = useMemo(() => { + return (label: string) => { + const objectColor = config?.model?.colormap[label]; + if (objectColor) { + const reversed = [...objectColor].reverse(); + return `rgb(${reversed.join(",")})`; + } + return "rgb(255, 0, 0)"; // fallback red + }; + }, [config]); + + const getZoneColor = useCallback( + (zoneName: string) => { + const zoneColor = config?.cameras?.[camera]?.zones?.[zoneName]?.color; + if (zoneColor) { + const reversed = [...zoneColor].reverse(); + return `rgb(${reversed.join(",")})`; + } + return "rgb(255, 0, 0)"; // fallback red + }, + [config, camera], + ); + + const currentObjectZones = useMemo(() => { + if (!objectTimeline) return []; + + // Find the most recent timeline event at or before effective current time + const relevantEvents = objectTimeline + .filter((event) => event.timestamp <= effectiveCurrentTime) + .sort((a, b) => b.timestamp - a.timestamp); // Most recent first + + // Get zones from the most recent event + return relevantEvents[0]?.data?.zones || []; + }, [objectTimeline, effectiveCurrentTime]); + + const zones = useMemo(() => { + if (!config?.cameras?.[camera]?.zones || !currentObjectZones.length) + return []; + + return Object.entries(config.cameras[camera].zones) + .filter(([name]) => currentObjectZones.includes(name)) + .map(([name, zone]) => ({ + name, + coordinates: zone.coordinates, + color: getZoneColor(name), + })); + }, [config, camera, getZoneColor, currentObjectZones]); + + // get saved path points from event + const savedPathPoints = useMemo(() => { + return ( + eventData?.[0].data?.path_data?.map( + ([coords, timestamp]: [number[], number]) => ({ + x: coords[0], + y: coords[1], + timestamp, + lifecycle_item: undefined, + }), + ) || [] + ); + }, [eventData]); + + // timeline points for selected event + const eventSequencePoints = useMemo(() => { + return ( + objectTimeline + ?.filter((event) => event.data.box !== undefined) + .map((event) => { + const [left, top, width, height] = event.data.box!; + + return { + x: left + width / 2, // Center x + y: top + height, // Bottom y + timestamp: event.timestamp, + lifecycle_item: event, + }; + }) || [] + ); + }, [objectTimeline]); + + // final object path with timeline points included + const pathPoints = useMemo(() => { + // don't display a path for autotracking cameras + if (config?.cameras[camera]?.onvif.autotracking.enabled_in_config) + return []; + + const combinedPoints = [...savedPathPoints, ...eventSequencePoints].sort( + (a, b) => a.timestamp - b.timestamp, + ); + + // Filter points around current time (within a reasonable window) + const timeWindow = 30; // 30 seconds window + return combinedPoints.filter( + (point) => + point.timestamp >= currentTime - timeWindow && + point.timestamp <= currentTime + timeWindow, + ); + }, [savedPathPoints, eventSequencePoints, config, camera, currentTime]); + + // get absolute positions on the svg canvas for each point + const absolutePositions = useMemo(() => { + if (!pathPoints) return []; + + return pathPoints.map((point) => { + // Find the corresponding timeline entry for this point + const timelineEntry = objectTimeline?.find( + (entry) => entry.timestamp == point.timestamp, + ); + return { + x: point.x * videoWidth, + y: point.y * videoHeight, + timestamp: point.timestamp, + lifecycle_item: + timelineEntry || + (point.box // normal path point + ? { + timestamp: point.timestamp, + camera: camera, + source: "tracked_object", + source_id: selectedObjectId, + class_type: "visible" as LifecycleClassType, + data: { + camera: camera, + label: point.label, + sub_label: "", + box: point.box, + region: [0, 0, 0, 0], // placeholder + attribute: "", + zones: [], + }, + } + : undefined), + }; + }); + }, [ + pathPoints, + videoWidth, + videoHeight, + objectTimeline, + camera, + selectedObjectId, + ]); + + const generateStraightPath = useCallback( + (points: { x: number; y: number }[]) => { + if (!points || points.length < 2) return ""; + let path = `M ${points[0].x} ${points[0].y}`; + for (let i = 1; i < points.length; i++) { + path += ` L ${points[i].x} ${points[i].y}`; + } + return path; + }, + [], + ); + + const getPointColor = useCallback( + (baseColor: number[], type?: string) => { + if (type && typeColorMap[type as keyof typeof typeColorMap]) { + const typeColor = typeColorMap[type as keyof typeof typeColorMap]; + if (typeColor) { + return `rgb(${typeColor.join(",")})`; + } + } + // normal path point + return `rgb(${baseColor.map((c) => Math.max(0, c - 10)).join(",")})`; + }, + [typeColorMap], + ); + + const handlePointClick = useCallback( + (timestamp: number) => { + onSeekToTime?.(timestamp); + }, + [onSeekToTime], + ); + + // render bounding box for object at current time if we have a timeline entry + const currentBoundingBox = useMemo(() => { + if (!objectTimeline) return null; + + // Find the most recent timeline event at or before effective current time with a bounding box + const relevantEvents = objectTimeline + .filter( + (event) => event.timestamp <= effectiveCurrentTime && event.data.box, + ) + .sort((a, b) => b.timestamp - a.timestamp); // Most recent first + + const currentEvent = relevantEvents[0]; + + if (!currentEvent?.data.box) return null; + + const [left, top, width, height] = currentEvent.data.box; + return { + left, + top, + width, + height, + centerX: left + width / 2, + centerY: top + height, + }; + }, [objectTimeline, effectiveCurrentTime]); + + const objectColor = useMemo(() => { + return pathPoints[0]?.label + ? getObjectColor(pathPoints[0].label) + : "rgb(255, 0, 0)"; + }, [pathPoints, getObjectColor]); + + const objectColorArray = useMemo(() => { + return pathPoints[0]?.label + ? getObjectColor(pathPoints[0].label).match(/\d+/g)?.map(Number) || [ + 255, 0, 0, + ] + : [255, 0, 0]; + }, [pathPoints, getObjectColor]); + + // render any zones for object at current time + const zonePolygons = useMemo(() => { + return zones.map((zone) => { + // Convert zone coordinates from normalized (0-1) to pixel coordinates + const points = zone.coordinates + .split(",") + .map(Number.parseFloat) + .reduce((acc: string[], value, index) => { + const isXCoordinate = index % 2 === 0; + const coordinate = isXCoordinate + ? value * videoWidth + : value * videoHeight; + acc.push(coordinate.toString()); + return acc; + }, []) + .join(","); + + return { + key: zone.name, + points, + fill: `rgba(${zone.color.replace("rgb(", "").replace(")", "")}, 0.3)`, + stroke: zone.color, + }; + }); + }, [zones, videoWidth, videoHeight]); + + if (!pathPoints.length || !config) { + return null; + } + + return ( + + {zonePolygons.map((zone) => ( + + ))} + + {absolutePositions.length > 1 && ( + + )} + + {absolutePositions.map((pos, index) => ( + + + handlePointClick(pos.timestamp)} + /> + + + + {pos.lifecycle_item + ? `${pos.lifecycle_item.class_type.replace("_", " ")} at ${new Date(pos.timestamp * 1000).toLocaleTimeString()}` + : t("objectTrack.trackedPoint")} + {onSeekToTime && ( +
+ {t("objectTrack.clickToSeek")} +
+ )} + + + + ))} + + {currentBoundingBox && ( + + + + + + )} + + ); +} diff --git a/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx new file mode 100644 index 000000000..03aad4d60 --- /dev/null +++ b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx @@ -0,0 +1,95 @@ +import { useCallback, useState } from "react"; +import { Slider } from "@/components/ui/slider"; +import { Button } from "@/components/ui/button"; +import { useDetailStream } from "@/context/detail-stream-context"; +import axios from "axios"; +import { useSWRConfig } from "swr"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; + +type Props = { + className?: string; +}; + +export default function AnnotationOffsetSlider({ className }: Props) { + const { annotationOffset, setAnnotationOffset, camera } = useDetailStream(); + const { mutate } = useSWRConfig(); + const { t } = useTranslation(["views/explore"]); + const [isSaving, setIsSaving] = useState(false); + + const handleChange = useCallback( + (values: number[]) => { + if (!values || values.length === 0) return; + const valueMs = values[0]; + setAnnotationOffset(valueMs); + }, + [setAnnotationOffset], + ); + + const reset = useCallback(() => { + setAnnotationOffset(0); + }, [setAnnotationOffset]); + + const save = useCallback(async () => { + setIsSaving(true); + try { + // save value in milliseconds to config + await axios.put( + `config/set?cameras.${camera}.detect.annotation_offset=${annotationOffset}`, + { requires_restart: 0 }, + ); + + toast.success( + t("objectLifecycle.annotationSettings.offset.toast.success", { + camera, + }), + { position: "top-center" }, + ); + + // refresh config + await mutate("config"); + } catch (e: unknown) { + const err = e as { + response?: { data?: { message?: string } }; + message?: string; + }; + const errorMessage = + err?.response?.data?.message || err?.message || "Unknown error"; + toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), { + position: "top-center", + }); + } finally { + setIsSaving(false); + } + }, [annotationOffset, camera, mutate, t]); + + return ( +
+
+ Annotation offset (ms): {annotationOffset} +
+
+ +
+
+ + +
+
+ ); +} diff --git a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx index 9e92bc011..56214b99d 100644 --- a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx +++ b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx @@ -174,7 +174,7 @@ export function AnnotationSettingsPane({ {t("objectLifecycle.annotationSettings.offset.label")}
-
+
diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 4b1bfe5ef..881e702ff 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -19,6 +19,8 @@ import { usePersistence } from "@/hooks/use-persistence"; import { cn } from "@/lib/utils"; import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record"; import { useTranslation } from "react-i18next"; +import ObjectTrackOverlay from "@/components/overlay/ObjectTrackOverlay"; +import { useDetailStream } from "@/context/detail-stream-context"; // Android native hls does not seek correctly const USE_NATIVE_HLS = !isAndroid; @@ -47,6 +49,7 @@ type HlsVideoPlayerProps = { onPlayerLoaded?: () => void; onTimeUpdate?: (time: number) => void; onPlaying?: () => void; + onSeekToTime?: (timestamp: number) => void; setFullResolution?: React.Dispatch>; onUploadFrame?: (playTime: number) => Promise | undefined; toggleFullscreen?: () => void; @@ -66,6 +69,7 @@ export default function HlsVideoPlayer({ onPlayerLoaded, onTimeUpdate, onPlaying, + onSeekToTime, setFullResolution, onUploadFrame, toggleFullscreen, @@ -73,6 +77,13 @@ export default function HlsVideoPlayer({ }: HlsVideoPlayerProps) { const { t } = useTranslation("components/player"); const { data: config } = useSWR("config"); + const { + selectedObjectId, + selectedObjectTimeline, + currentTime, + camera, + isDetailMode, + } = useDetailStream(); // playback @@ -84,17 +95,19 @@ export default function HlsVideoPlayer({ const handleLoadedMetadata = useCallback(() => { setLoadedMetadata(true); if (videoRef.current) { + const width = videoRef.current.videoWidth; + const height = videoRef.current.videoHeight; + if (setFullResolution) { setFullResolution({ - width: videoRef.current.videoWidth, - height: videoRef.current.videoHeight, + width, + height, }); } - setTallCamera( - videoRef.current.videoWidth / videoRef.current.videoHeight < - ASPECT_VERTICAL_LAYOUT, - ); + setVideoDimensions({ width, height }); + + setTallCamera(width / height < ASPECT_VERTICAL_LAYOUT); } }, [videoRef, setFullResolution]); @@ -174,6 +187,10 @@ export default function HlsVideoPlayer({ const [controls, setControls] = useState(isMobile); const [controlsOpen, setControlsOpen] = useState(false); const [zoomScale, setZoomScale] = useState(1.0); + const [videoDimensions, setVideoDimensions] = useState<{ + width: number; + height: number; + }>({ width: 0, height: 0 }); useEffect(() => { if (!isDesktop) { @@ -296,6 +313,30 @@ export default function HlsVideoPlayer({ height: isMobile ? "100%" : undefined, }} > + {isDetailMode && + selectedObjectId && + camera && + currentTime && + videoDimensions.width > 0 && + videoDimensions.height > 0 && ( +
+ { + if (onSeekToTime) { + onSeekToTime(timestamp); + } + }} + objectTimeline={selectedObjectTimeline} + /> +
+ )}