Make Birdseye clickable (#18628)

* keep track of layout changes and publish on change

* websocket hook

* clickable overlay div to navigate to full camera view
This commit is contained in:
Josh Hawkins
2025-08-16 10:20:33 -05:00
committed by Blake Blackshear
parent 937459be47
commit 40ab7d6c38
6 changed files with 175 additions and 22 deletions
+34
View File
@@ -426,6 +426,40 @@ export function useEmbeddingsReindexProgress(
return { payload: data };
}
export function useBirdseyeLayout(revalidateOnFocus: boolean = true): {
payload: string;
} {
const {
value: { payload },
send: sendCommand,
} = useWs("birdseye_layout", "birdseyeLayout");
const data = useDeepMemo(JSON.parse(payload as string));
useEffect(() => {
let listener = undefined;
if (revalidateOnFocus) {
sendCommand("birdseyeLayout");
listener = () => {
if (document.visibilityState == "visible") {
sendCommand("birdseyeLayout");
}
};
addEventListener("visibilitychange", listener);
}
return () => {
if (listener) {
removeEventListener("visibilitychange", listener);
}
};
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [revalidateOnFocus]);
return { payload: data };
}
export function useMotionActivity(camera: string): { payload: string } {
const {
value: { payload },
@@ -13,6 +13,7 @@ type LivePlayerProps = {
liveMode: LivePlayerMode;
pip?: boolean;
containerRef: React.MutableRefObject<HTMLDivElement | null>;
playerRef?: React.MutableRefObject<HTMLDivElement | null>;
onClick?: () => void;
};
@@ -22,6 +23,7 @@ export default function BirdseyeLivePlayer({
liveMode,
pip,
containerRef,
playerRef,
onClick,
}: LivePlayerProps) {
let player;
@@ -76,7 +78,9 @@ export default function BirdseyeLivePlayer({
>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
<div className="size-full">{player}</div>
<div className="size-full" ref={playerRef}>
{player}
</div>
</div>
);
}
+75 -2
View File
@@ -1,11 +1,13 @@
import { useBirdseyeLayout } from "@/api/ws";
import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useResizeObserver } from "@/hooks/resize-observer";
import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
isDesktop,
isFirefox,
@@ -122,6 +124,72 @@ export default function LiveBirdseyeView({
return "mse";
}, [config]);
const birdseyeLayout = useBirdseyeLayout();
// Click overlay handling
const playerRef = useRef<HTMLDivElement | null>(null);
const handleOverlayClick = useCallback(
(
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
) => {
let clientX;
let clientY;
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
clientX = e.nativeEvent.touches[0].clientX;
clientY = e.nativeEvent.touches[0].clientY;
} else if (e.nativeEvent instanceof MouseEvent) {
clientX = e.nativeEvent.clientX;
clientY = e.nativeEvent.clientY;
}
if (
playerRef.current &&
clientX &&
clientY &&
config &&
birdseyeLayout?.payload
) {
const playerRect = playerRef.current.getBoundingClientRect();
// Calculate coordinates relative to player div, accounting for offset
const rawX = clientX - playerRect.left;
const rawY = clientY - playerRect.top;
// Ensure click is within player bounds
if (
rawX < 0 ||
rawX > playerRect.width ||
rawY < 0 ||
rawY > playerRect.height
) {
return;
}
// Scale click coordinates to birdseye canvas resolution
const canvasX = rawX * (config.birdseye.width / playerRect.width);
const canvasY = rawY * (config.birdseye.height / playerRect.height);
for (const [cameraName, coords] of Object.entries(
birdseyeLayout.payload,
)) {
const parsedCoords =
typeof coords === "string" ? JSON.parse(coords) : coords;
if (
canvasX >= parsedCoords.x &&
canvasX < parsedCoords.x + parsedCoords.width &&
canvasY >= parsedCoords.y &&
canvasY < parsedCoords.y + parsedCoords.height
) {
navigate(`/#${cameraName}`);
break;
}
}
}
},
[playerRef, config, birdseyeLayout, navigate],
);
if (!config) {
return <ActivityIndicator />;
}
@@ -215,16 +283,21 @@ export default function LiveBirdseyeView({
}}
>
<div
className={growClassName}
className={cn(
"flex flex-col items-center justify-center",
growClassName,
)}
style={{
aspectRatio: constrainedAspectRatio,
}}
onClick={handleOverlayClick}
>
<BirdseyeLivePlayer
className={`${fullscreen ? "*:rounded-none" : ""}`}
birdseyeConfig={config.birdseye}
liveMode={preferredLiveMode}
containerRef={containerRef}
playerRef={playerRef}
pip={pip}
/>
</div>