Simplify basic image view

This commit is contained in:
Nicolas Mowen 2024-02-09 05:34:27 -07:00
parent 5bb382703a
commit a004890a7e
6 changed files with 139 additions and 368 deletions

View File

@ -18,7 +18,6 @@ export default function AutoUpdatingCameraImage({
showFps = true, showFps = true,
className, className,
reloadInterval = MIN_LOAD_TIMEOUT_MS, reloadInterval = MIN_LOAD_TIMEOUT_MS,
fitAspect,
}: AutoUpdatingCameraImageProps) { }: AutoUpdatingCameraImageProps) {
const [key, setKey] = useState(Date.now()); const [key, setKey] = useState(Date.now());
const [fps, setFps] = useState<string>("0"); const [fps, setFps] = useState<string>("0");
@ -43,7 +42,6 @@ export default function AutoUpdatingCameraImage({
<CameraImage <CameraImage
camera={camera} camera={camera}
onload={handleLoad} onload={handleLoad}
fitAspect={fitAspect}
searchParams={`cache=${key}&${searchParams}`} searchParams={`cache=${key}&${searchParams}`}
/> />
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null} {showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}

View File

@ -1,16 +1,13 @@
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import ActivityIndicator from "../ui/activity-indicator"; import ActivityIndicator from "../ui/activity-indicator";
import { useResizeObserver } from "@/hooks/resize-observer";
type CameraImageProps = { type CameraImageProps = {
className?: string; className?: string;
camera: string; camera: string;
onload?: (event: Event) => void; onload?: () => void;
searchParams?: {}; searchParams?: {};
stretch?: boolean; // stretch to fit width
fitAspect?: number; // shrink to fit height
}; };
export default function CameraImage({ export default function CameraImage({
@ -18,73 +15,25 @@ export default function CameraImage({
camera, camera,
onload, onload,
searchParams = "", searchParams = "",
stretch = false,
fitAspect,
}: CameraImageProps) { }: CameraImageProps) {
const { data: config } = useSWR("config"); const { data: config } = useSWR("config");
const apiHost = useApiHost(); const apiHost = useApiHost();
const [hasLoaded, setHasLoaded] = useState(false); const [hasLoaded, setHasLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null); const imgRef = useRef<HTMLImageElement | null>(null);
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
// Add scrollbar width (when visible) to the available observer width to eliminate screen juddering.
// https://github.com/blakeblackshear/frigate/issues/1657
let scrollBarWidth = 0;
if (window.innerWidth && document.body.offsetWidth) {
scrollBarWidth = window.innerWidth - document.body.offsetWidth;
}
const availableWidth = scrollBarWidth
? containerWidth + scrollBarWidth
: containerWidth;
const { name } = config ? config.cameras[camera] : ""; const { name } = config ? config.cameras[camera] : "";
const enabled = config ? config.cameras[camera].enabled : "True"; const enabled = config ? config.cameras[camera].enabled : "True";
const { width, height } = config
? config.cameras[camera].detect
: { width: 1, height: 1 };
const aspectRatio = width / height;
const scaledHeight = useMemo(() => {
const scaledHeight =
aspectRatio < (fitAspect ?? 0)
? Math.floor(containerHeight)
: Math.floor(availableWidth / aspectRatio);
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height);
if (finalHeight > 0) {
return finalHeight;
}
return 100;
}, [availableWidth, aspectRatio, height, stretch]);
const scaledWidth = useMemo(
() => Math.ceil(scaledHeight * aspectRatio - scrollBarWidth),
[scaledHeight, aspectRatio, scrollBarWidth]
);
const img = useMemo(() => new Image(), []);
img.onload = useCallback(
(event: Event) => {
setHasLoaded(true);
if (canvasRef.current) {
const ctx = canvasRef.current.getContext("2d");
ctx?.drawImage(img, 0, 0, scaledWidth, scaledHeight);
}
onload && onload(event);
},
[img, scaledHeight, scaledWidth, setHasLoaded, onload, canvasRef]
);
useEffect(() => { useEffect(() => {
if (!config || scaledHeight === 0 || !canvasRef.current) { if (!config || !imgRef.current) {
return; return;
} }
img.src = `${apiHost}api/${name}/latest.jpg?h=${scaledHeight}${
searchParams ? `&${searchParams}` : "" imgRef.current.src = `${apiHost}api/${name}/latest.jpg${
searchParams ? `?${searchParams}` : ""
}`; }`;
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]); }, [apiHost, name, imgRef, searchParams, config]);
return ( return (
<div <div
@ -92,12 +41,16 @@ export default function CameraImage({
ref={containerRef} ref={containerRef}
> >
{enabled ? ( {enabled ? (
<canvas <img
className="rounded-2xl" ref={imgRef}
data-testid="cameraimage-canvas" className="object-contain rounded-2xl"
height={scaledHeight} onLoad={() => {
ref={canvasRef} setHasLoaded(true);
width={scaledWidth}
if (onload) {
onload();
}
}}
/> />
) : ( ) : (
<div className="text-center pt-6"> <div className="text-center pt-6">
@ -105,10 +58,7 @@ export default function CameraImage({
</div> </div>
)} )}
{!hasLoaded && enabled ? ( {!hasLoaded && enabled ? (
<div <div className="absolute left-0 right-0 top-0 bottom-0 flex justify-center items-center">
className="absolute inset-0 flex justify-center"
style={{ height: `${scaledHeight}px` }}
>
<ActivityIndicator /> <ActivityIndicator />
</div> </div>
) : null} ) : null}

View File

@ -1,120 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { AspectRatio } from "../ui/aspect-ratio";
import CameraImage from "./CameraImage";
import { LuEar } from "react-icons/lu";
import { CameraConfig } from "@/types/frigateConfig";
import { TbUserScan } from "react-icons/tb";
import { MdLeakAdd } from "react-icons/md";
import {
useAudioActivity,
useFrigateEvents,
useMotionActivity,
} from "@/api/ws";
type DynamicCameraImageProps = {
camera: CameraConfig;
aspect: number;
};
const INTERVAL_INACTIVE_MS = 60000; // refresh once a minute
const INTERVAL_ACTIVE_MS = 1000; // refresh once a second
export default function DynamicCameraImage({
camera,
aspect,
}: DynamicCameraImageProps) {
const [key, setKey] = useState(Date.now());
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | undefined>(
undefined
);
const [activeObjects, setActiveObjects] = useState<string[]>([]);
const hasActiveObjects = useMemo(
() => activeObjects.length > 0,
[activeObjects]
);
const { payload: detectingMotion } = useMotionActivity(camera.name);
const { payload: event } = useFrigateEvents();
const { payload: audioRms } = useAudioActivity(camera.name);
useEffect(() => {
if (!event) {
return;
}
if (event.after.camera != camera.name) {
return;
}
if (event.type == "end") {
const eventIndex = activeObjects.indexOf(event.after.id);
if (eventIndex != -1) {
const newActiveObjects = [...activeObjects];
newActiveObjects.splice(eventIndex, 1);
setActiveObjects(newActiveObjects);
}
} else {
if (!event.after.stationary) {
const eventIndex = activeObjects.indexOf(event.after.id);
if (eventIndex == -1) {
const newActiveObjects = [...activeObjects, event.after.id];
setActiveObjects(newActiveObjects);
clearTimeout(timeoutId);
setKey(Date.now());
}
}
}
}, [event, activeObjects]);
const handleLoad = useCallback(() => {
const loadTime = Date.now() - key;
const loadInterval = hasActiveObjects
? INTERVAL_ACTIVE_MS
: INTERVAL_INACTIVE_MS;
const tId = setTimeout(
() => {
setKey(Date.now());
},
loadTime > loadInterval ? 1 : loadInterval
);
setTimeoutId(tId);
}, [key]);
return (
<AspectRatio
ratio={aspect}
className="bg-black flex justify-center items-center relative"
>
<CameraImage
camera={camera.name}
fitAspect={aspect}
searchParams={`cache=${key}`}
onload={handleLoad}
/>
<div className="flex absolute right-0 bottom-0 bg-black bg-opacity-20 rounded p-1">
<MdLeakAdd
className={`${
detectingMotion == "ON" ? "text-motion" : "text-gray-600"
}`}
/>
<TbUserScan
className={`${
activeObjects.length > 0 ? "text-object" : "text-gray-600"
}`}
/>
{camera.audio.enabled_in_config && (
<LuEar
className={`${
audioRms >= camera.audio.min_volume
? "text-audio"
: "text-gray-600"
}`}
/>
)}
</div>
</AspectRatio>
);
}

View File

@ -0,0 +1,117 @@
import { useApiHost } from "@/api";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import ActivityIndicator from "../ui/activity-indicator";
import { useResizeObserver } from "@/hooks/resize-observer";
type CameraImageProps = {
className?: string;
camera: string;
onload?: (event: Event) => void;
searchParams?: {};
stretch?: boolean; // stretch to fit width
fitAspect?: number; // shrink to fit height
};
export default function CameraImage({
className,
camera,
onload,
searchParams = "",
stretch = false,
fitAspect,
}: CameraImageProps) {
const { data: config } = useSWR("config");
const apiHost = useApiHost();
const [hasLoaded, setHasLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
// Add scrollbar width (when visible) to the available observer width to eliminate screen juddering.
// https://github.com/blakeblackshear/frigate/issues/1657
let scrollBarWidth = 0;
if (window.innerWidth && document.body.offsetWidth) {
scrollBarWidth = window.innerWidth - document.body.offsetWidth;
}
const availableWidth = scrollBarWidth
? containerWidth + scrollBarWidth
: containerWidth;
const { name } = config ? config.cameras[camera] : "";
const enabled = config ? config.cameras[camera].enabled : "True";
const { width, height } = config
? config.cameras[camera].detect
: { width: 1, height: 1 };
const aspectRatio = width / height;
const scaledHeight = useMemo(() => {
const scaledHeight =
aspectRatio < (fitAspect ?? 0)
? Math.floor(containerHeight)
: Math.floor(availableWidth / aspectRatio);
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height);
if (finalHeight > 0) {
return finalHeight;
}
return 100;
}, [availableWidth, aspectRatio, height, stretch]);
const scaledWidth = useMemo(
() => Math.ceil(scaledHeight * aspectRatio - scrollBarWidth),
[scaledHeight, aspectRatio, scrollBarWidth]
);
const img = useMemo(() => new Image(), []);
img.onload = useCallback(
(event: Event) => {
setHasLoaded(true);
if (canvasRef.current) {
const ctx = canvasRef.current.getContext("2d");
ctx?.drawImage(img, 0, 0, scaledWidth, scaledHeight);
}
onload && onload(event);
},
[img, scaledHeight, scaledWidth, setHasLoaded, onload, canvasRef]
);
useEffect(() => {
if (!config || scaledHeight === 0 || !canvasRef.current) {
return;
}
img.src = `${apiHost}api/${name}/latest.jpg?h=${scaledHeight}${
searchParams ? `&${searchParams}` : ""
}`;
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
return (
<div
className={`relative w-full h-full flex justify-center ${className}`}
ref={containerRef}
>
{enabled ? (
<canvas
className="rounded-2xl"
data-testid="cameraimage-canvas"
height={scaledHeight}
ref={canvasRef}
width={scaledWidth}
/>
) : (
<div className="text-center pt-6">
Camera is disabled in config, no stream or snapshot available!
</div>
)}
{!hasLoaded && enabled ? (
<div
className="absolute inset-0 flex justify-center"
style={{ height: `${scaledHeight}px` }}
>
<ActivityIndicator />
</div>
) : null}
</div>
);
}

View File

@ -216,7 +216,9 @@ export default function LivePlayer({
</div> </div>
)} )}
<Chip className="absolute right-2 top-2 bg-gray-500 bg-gradient-to-br"> <Chip className="absolute right-2 top-2 bg-gray-500 bg-gradient-to-br">
{cameraConfig.record.enabled && (
<MdCircle className="w-2 h-2 text-danger" /> <MdCircle className="w-2 h-2 text-danger" />
)}
<div className="ml-1 capitalize text-white text-xs"> <div className="ml-1 capitalize text-white text-xs">
{cameraConfig.name.replaceAll("_", " ")} {cameraConfig.name.replaceAll("_", " ")}
</div> </div>

View File

@ -1,176 +0,0 @@
import { useMemo } from "react";
import ActivityIndicator from "@/components/ui/activity-indicator";
import {
useAudioState,
useDetectState,
useRecordingsState,
useSnapshotsState,
} from "@/api/ws";
import useSWR from "swr";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import Heading from "@/components/ui/heading";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { AiOutlinePicture } from "react-icons/ai";
import { FaWalking } from "react-icons/fa";
import { LuEar } from "react-icons/lu";
import { TbMovie } from "react-icons/tb";
import MiniEventCard from "@/components/card/MiniEventCard";
import { Event as FrigateEvent } from "@/types/event";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import DynamicCameraImage from "@/components/camera/DynamicCameraImage";
export function Dashboard() {
const { data: config } = useSWR<FrigateConfig>("config");
const now = new Date();
now.setMinutes(now.getMinutes() - 30, 0, 0);
const recentTimestamp = now.getTime() / 1000;
const { data: events, mutate: updateEvents } = useSWR<FrigateEvent[]>([
"events",
{ limit: 10, after: recentTimestamp },
]);
const sortedCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
return (
<>
<Heading as="h2">Dashboard</Heading>
{!config && <ActivityIndicator />}
{config && (
<div>
{events && events.length > 0 && (
<>
<Heading as="h4">Recent Events</Heading>
<ScrollArea>
<div className="flex">
{events.map((event) => {
return (
<MiniEventCard
key={event.id}
event={event}
onUpdate={() => updateEvents()}
/>
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</>
)}
<Heading as="h4">Cameras</Heading>
<div className="mt-2 grid gap-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{sortedCameras.map((camera) => {
return <Camera key={camera.name} camera={camera} />;
})}
</div>
</div>
)}
</>
);
}
function Camera({ camera }: { camera: CameraConfig }) {
const { payload: detectValue, send: sendDetect } = useDetectState(
camera.name
);
const { payload: recordValue, send: sendRecord } = useRecordingsState(
camera.name
);
const { payload: snapshotValue, send: sendSnapshot } = useSnapshotsState(
camera.name
);
const { payload: audioValue, send: sendAudio } = useAudioState(camera.name);
return (
<>
<Card>
<a href={`/live/${camera.name}`}>
<DynamicCameraImage aspect={16 / 9} camera={camera} />
<div className="flex justify-between items-center">
<div className="text-lg capitalize p-2">
{camera.name.replaceAll("_", " ")}
</div>
<div>
<Button
variant="ghost"
size="sm"
className={`${
detectValue == "ON" ? "text-primary" : "text-gray-400"
}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
sendDetect(detectValue == "ON" ? "OFF" : "ON");
}}
>
<FaWalking />
</Button>
<Button
variant="ghost"
size="sm"
className={
camera.record.enabled_in_config
? recordValue == "ON"
? "text-primary"
: "text-gray-400"
: "text-danger"
}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
camera.record.enabled_in_config
? sendRecord(recordValue == "ON" ? "OFF" : "ON")
: {};
}}
>
<TbMovie />
</Button>
<Button
variant="ghost"
size="sm"
className={`${
snapshotValue == "ON" ? "text-primary" : "text-gray-400"
}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
sendSnapshot(detectValue == "ON" ? "OFF" : "ON");
}}
>
<AiOutlinePicture />
</Button>
{camera.audio.enabled_in_config && (
<Button
variant="ghost"
size="sm"
className={`${
audioValue == "ON" ? "text-primary" : "text-gray-400"
}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
sendAudio(audioValue == "ON" ? "OFF" : "ON");
}}
>
<LuEar />
</Button>
)}
</div>
</div>
</a>
</Card>
</>
);
}
export default Dashboard;