diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index cda25d5cc..16b799ffc 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -50,7 +50,7 @@ type LivePlayerProps = { showStats?: boolean; onStatsUpdate?: (stats: PlayerStatsType) => void; onLoadingChange?: (loading: boolean) => void; - onActiveMotionChange?: (active: boolean) => void; + showMotionDot?: boolean; onClick?: () => void; setFullResolution?: React.Dispatch>; onError?: (error: LivePlayerError) => void; @@ -78,7 +78,7 @@ export default function LivePlayer({ showStats = false, onStatsUpdate, onLoadingChange, - onActiveMotionChange, + showMotionDot = true, onClick, setFullResolution, onError, @@ -113,10 +113,8 @@ export default function LivePlayer({ const onStatsUpdateRef = useRef(onStatsUpdate); const onLoadingChangeRef = useRef(onLoadingChange); - const onActiveMotionChangeRef = useRef(onActiveMotionChange); onStatsUpdateRef.current = onStatsUpdate; onLoadingChangeRef.current = onLoadingChange; - onActiveMotionChangeRef.current = onActiveMotionChange; useEffect(() => { onStatsUpdateRef.current?.(stats); @@ -301,14 +299,6 @@ export default function LivePlayer({ onLoadingChangeRef.current?.(loading); }, [cameraEnabled, offline, showStillWithoutActivity, isReEnabling, liveReady]); - // When the parent manages the dot via callback (grid view), show motion - // without gating on liveReady — the dot should reflect actual motion state - // regardless of stream load status to avoid the dot not appearing for - // cameras in continuous mode while the stream is reconnecting. - useEffect(() => { - onActiveMotionChangeRef.current?.(!!(autoLive && !offline && activeMotion)); - }, [autoLive, offline, activeMotion]); - if (!cameraConfig) { return ; } @@ -566,7 +556,7 @@ export default function LivePlayer({ {cameraName} )} - {!onActiveMotionChangeRef.current && + {showMotionDot && autoLive && !offline && activeMotion && diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index 5cf596cfc..2434c3921 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -30,6 +30,7 @@ import { import ActivityIndicator from "@/components/indicators/activity-indicator"; import { PlayerStats } from "@/components/player/PlayerStats"; import { MdCircle } from "react-icons/md"; +import { useCameraActivity } from "@/hooks/use-camera-activity"; import { Skeleton } from "@/components/ui/skeleton"; import { isEqual } from "lodash"; @@ -441,9 +442,6 @@ export default function DraggableGridLayout({ const [cameraLoadingStates, setCameraLoadingStates] = useState< Record >({}); - const [cameraMotionStates, setCameraMotionStates] = useState< - Record - >({}); const cameraZoomViewportRefs = useRef>( {}, ); @@ -881,12 +879,7 @@ export default function DraggableGridLayout({ [camera.name]: loading, })) } - onActiveMotionChange={(active) => - setCameraMotionStates((prev) => ({ - ...prev, - [camera.name]: active, - })) - } + showMotionDot={false} onClick={() => { !isEditMode && onSelectCamera(camera.name); }} @@ -918,11 +911,10 @@ export default function DraggableGridLayout({ minimal={true} /> )} - {cameraMotionStates[camera.name] && ( -
- -
- )} + {isEditMode && showCircles && } @@ -1089,6 +1081,25 @@ const BirdseyeLivePlayerGridItem = React.forwardRef< }, ); +// Separate component so it can call useCameraActivity as a hook (no hooks in loops). +// Direct WS subscription guarantees the dot reacts to motion changes in real-time +// without relying on an intermediate callback → parent-state chain. +function CameraMotionDot({ + camera, + autoLive, +}: { + camera: CameraConfig; + autoLive: boolean; +}) { + const { activeMotion, offline } = useCameraActivity(camera); + if (!autoLive || offline || !activeMotion) return null; + return ( +
+ +
+ ); +} + type GridLiveContextMenuProps = { className?: string; style?: React.CSSProperties;