From f9885df0e48039e3ecffe8b0f2e5a384036307f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 03:10:46 +0000 Subject: [PATCH] fix: replace callback motion dot with direct WS subscription component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach (useEffect → onActiveMotionChange callback → parent state update) was unreliable: the dot only appeared if motion was active at the moment of initial mount but did not react to subsequent WS motion events. Root cause: the intermediate state chain breaks because React's useEffect batching and component re-render timing can cause the parent state to lag behind or miss updates when motion changes after mount. Fix: replace the mechanism entirely with a dedicated CameraMotionDot component that calls useCameraActivity directly. Being a proper React component it subscribes to the {camera}/motion WS topic via useSyncExternalStore and re-renders immediately and reliably whenever motion state changes — no intermediate callbacks or parent state needed. - Remove onActiveMotionChange prop from LivePlayer; add showMotionDot boolean prop (default true) to suppress the internal dot in grid view - Remove cameraMotionStates state and setCameraMotionStates from DraggableGridLayout - Add CameraMotionDot component with direct useCameraActivity hook https://claude.ai/code/session_019B4dJXtcxvHn97ZaqHUB62 --- web/src/components/player/LivePlayer.tsx | 16 ++------- web/src/views/live/DraggableGridLayout.tsx | 39 ++++++++++++++-------- 2 files changed, 28 insertions(+), 27 deletions(-) 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;