fix: replace callback motion dot with direct WS subscription component

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
This commit is contained in:
Claude 2026-03-17 03:10:46 +00:00
parent 81f0164e9c
commit f9885df0e4
No known key found for this signature in database
2 changed files with 28 additions and 27 deletions

View File

@ -50,7 +50,7 @@ type LivePlayerProps = {
showStats?: boolean; showStats?: boolean;
onStatsUpdate?: (stats: PlayerStatsType) => void; onStatsUpdate?: (stats: PlayerStatsType) => void;
onLoadingChange?: (loading: boolean) => void; onLoadingChange?: (loading: boolean) => void;
onActiveMotionChange?: (active: boolean) => void; showMotionDot?: boolean;
onClick?: () => void; onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>; setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void; onError?: (error: LivePlayerError) => void;
@ -78,7 +78,7 @@ export default function LivePlayer({
showStats = false, showStats = false,
onStatsUpdate, onStatsUpdate,
onLoadingChange, onLoadingChange,
onActiveMotionChange, showMotionDot = true,
onClick, onClick,
setFullResolution, setFullResolution,
onError, onError,
@ -113,10 +113,8 @@ export default function LivePlayer({
const onStatsUpdateRef = useRef(onStatsUpdate); const onStatsUpdateRef = useRef(onStatsUpdate);
const onLoadingChangeRef = useRef(onLoadingChange); const onLoadingChangeRef = useRef(onLoadingChange);
const onActiveMotionChangeRef = useRef(onActiveMotionChange);
onStatsUpdateRef.current = onStatsUpdate; onStatsUpdateRef.current = onStatsUpdate;
onLoadingChangeRef.current = onLoadingChange; onLoadingChangeRef.current = onLoadingChange;
onActiveMotionChangeRef.current = onActiveMotionChange;
useEffect(() => { useEffect(() => {
onStatsUpdateRef.current?.(stats); onStatsUpdateRef.current?.(stats);
@ -301,14 +299,6 @@ export default function LivePlayer({
onLoadingChangeRef.current?.(loading); onLoadingChangeRef.current?.(loading);
}, [cameraEnabled, offline, showStillWithoutActivity, isReEnabling, liveReady]); }, [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) { if (!cameraConfig) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
@ -566,7 +556,7 @@ export default function LivePlayer({
{cameraName} {cameraName}
</Chip> </Chip>
)} )}
{!onActiveMotionChangeRef.current && {showMotionDot &&
autoLive && autoLive &&
!offline && !offline &&
activeMotion && activeMotion &&

View File

@ -30,6 +30,7 @@ import {
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { PlayerStats } from "@/components/player/PlayerStats"; import { PlayerStats } from "@/components/player/PlayerStats";
import { MdCircle } from "react-icons/md"; import { MdCircle } from "react-icons/md";
import { useCameraActivity } from "@/hooks/use-camera-activity";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
@ -441,9 +442,6 @@ export default function DraggableGridLayout({
const [cameraLoadingStates, setCameraLoadingStates] = useState< const [cameraLoadingStates, setCameraLoadingStates] = useState<
Record<string, boolean> Record<string, boolean>
>({}); >({});
const [cameraMotionStates, setCameraMotionStates] = useState<
Record<string, boolean>
>({});
const cameraZoomViewportRefs = useRef<Record<string, HTMLDivElement | null>>( const cameraZoomViewportRefs = useRef<Record<string, HTMLDivElement | null>>(
{}, {},
); );
@ -881,12 +879,7 @@ export default function DraggableGridLayout({
[camera.name]: loading, [camera.name]: loading,
})) }))
} }
onActiveMotionChange={(active) => showMotionDot={false}
setCameraMotionStates((prev) => ({
...prev,
[camera.name]: active,
}))
}
onClick={() => { onClick={() => {
!isEditMode && onSelectCamera(camera.name); !isEditMode && onSelectCamera(camera.name);
}} }}
@ -918,11 +911,10 @@ export default function DraggableGridLayout({
minimal={true} minimal={true}
/> />
)} )}
{cameraMotionStates[camera.name] && ( <CameraMotionDot
<div className="absolute right-2 top-2 z-40"> camera={camera}
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" /> autoLive={autoLive ?? globalAutoLive}
</div> />
)}
</div> </div>
{isEditMode && showCircles && <CornerCircles />} {isEditMode && showCircles && <CornerCircles />}
</GridLiveContextMenu> </GridLiveContextMenu>
@ -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 (
<div className="absolute right-2 top-2 z-40">
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
</div>
);
}
type GridLiveContextMenuProps = { type GridLiveContextMenuProps = {
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;