Streamline live view (#9772)

* Break out live page

* Improving layouts and add chip component

* Improve default camera player sizing

* Improve live updating

* Cleanup and fit figma

* Use fixed height

* Masonry layout

* Fix stuff

* Don't force heights

* Adjust scaling

* Cleanup

* remove sidebar (#9731)

* remove sidebar

* keep sidebar on mobile for now and add icons

* Fix revalidation

* Cleanup

* Cleanup width

* Add chips for activity on cameras

* Remove dashboard from header

* Use Inter font (#9735)

* Show still image when no activity is occurring

* remove unused search params

* add playing check for webrtc

* Don't use grid at all for single column

* Fix height on mobile

* a few style updates to better match figma (#9745)

* Remove active objects when they become stationary

* Move to sidebar only and make settings separate component

* Fix layout

* Animate visibility of chips

* Sidebar is full screen

* Fix tall aspect ratio cameras

* Fix complicated aspect logic

* remove

* Adjust thumbnail aspect and add text

* margin on single column layout

* Smaller event thumb text

* Simplify basic image view

* Only show the red dot when camera is recording

* Improve typing for camera toggles

* animate chips with react-transition-group (#9763)

* don't flash when going to still image

* revalidate

* tooltips and active tracking outline (#9766)

* tooltips

* fix tooltip provider and add active tracking outline

* remove unused icon

* remove figma comment

* Get live mode working for jsmpeg

* add small gradient below timeago on event thumbnails (#9767)

* Create live mode hook and make sure jsmpeg can be used

* Enforce env var

* Use print

* Remove unstable

* Add tooltips to thumbnails

* Put back vite

* Format

* Update web/src/components/player/JSMpegPlayer.tsx

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Co-authored-by: Blake Blackshear <blake@frigate.video>
This commit is contained in:
Nicolas Mowen
2024-02-10 05:30:53 -07:00
committed by GitHub
parent f6a4c2a7b3
commit 64988c9be0
33 changed files with 1111 additions and 972 deletions

View File

@@ -6,6 +6,7 @@ type AutoUpdatingCameraImageProps = {
searchParams?: {};
showFps?: boolean;
className?: string;
reloadInterval?: number;
};
const MIN_LOAD_TIMEOUT_MS = 200;
@@ -15,6 +16,7 @@ export default function AutoUpdatingCameraImage({
searchParams = "",
showFps = true,
className,
reloadInterval = MIN_LOAD_TIMEOUT_MS,
}: AutoUpdatingCameraImageProps) {
const [key, setKey] = useState(Date.now());
const [fps, setFps] = useState<string>("0");
@@ -23,14 +25,14 @@ export default function AutoUpdatingCameraImage({
const loadTime = Date.now() - key;
if (showFps) {
setFps((1000 / Math.max(loadTime, MIN_LOAD_TIMEOUT_MS)).toFixed(1));
setFps((1000 / Math.max(loadTime, reloadInterval)).toFixed(1));
}
setTimeout(
() => {
setKey(Date.now());
},
loadTime > MIN_LOAD_TIMEOUT_MS ? 1 : MIN_LOAD_TIMEOUT_MS
loadTime > reloadInterval ? 1 : reloadInterval
);
}, [key, setFps]);

View File

@@ -1,102 +1,56 @@
import { useApiHost } from "@/api";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, 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;
onload?: () => 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 imgRef = useRef<HTMLImageElement | null>(null);
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) {
if (!config || !imgRef.current) {
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 (
<div
className={`relative w-full ${
fitAspect && aspectRatio < fitAspect ? "h-full flex justify-center" : ""
}`}
className={`relative w-full h-full flex justify-center ${className}`}
ref={containerRef}
>
{enabled ? (
<canvas
data-testid="cameraimage-canvas"
height={scaledHeight}
ref={canvasRef}
width={scaledWidth}
<img
ref={imgRef}
className="object-contain rounded-2xl"
onLoad={() => {
setHasLoaded(true);
if (onload) {
onload();
}
}}
/>
) : (
<div className="text-center pt-6">
@@ -104,10 +58,7 @@ export default function CameraImage({
</div>
)}
{!hasLoaded && enabled ? (
<div
className="absolute inset-0 flex justify-center"
style={{ height: `${scaledHeight}px` }}
>
<div className="absolute left-0 right-0 top-0 bottom-0 flex justify-center items-center">
<ActivityIndicator />
</div>
) : 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={`${
parseInt(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>
);
}