incrementally load filtered cameras

fix visible camera card image loading
refactoring
update version 1.8
This commit is contained in:
NlightN22 2024-12-01 14:22:21 +07:00
parent 302f117b44
commit ed65fd20f8
7 changed files with 124 additions and 47 deletions

View File

@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Build commands: # Build commands:
# - $VERSION=1.7 # - $VERSION=1.8
# - rm build -r -Force ; rm ./node_modules/.cache/babel-loader -r -Force ; yarn build # - rm build -r -Force ; rm ./node_modules/.cache/babel-loader -r -Force ; yarn build
# - docker build --pull --rm -t oncharterliz/multi-frigate:latest -t oncharterliz/multi-frigate:$VERSION "." # - docker build --pull --rm -t oncharterliz/multi-frigate:latest -t oncharterliz/multi-frigate:$VERSION "."
# - docker image push oncharterliz/multi-frigate:$VERSION ; docker image push oncharterliz/multi-frigate:latest # - docker image push oncharterliz/multi-frigate:$VERSION ; docker image push oncharterliz/multi-frigate:latest

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-frigate", "name": "multi-frigate",
"version": "0.1.7", "version": "0.1.8",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@cycjimmy/jsmpeg-player": "^6.0.5", "@cycjimmy/jsmpeg-player": "^6.0.5",
@ -48,6 +48,7 @@
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^14.1.0", "react-i18next": "^14.1.0",
"react-intersection-observer": "^9.13.1",
"react-konva": "^18.2.10", "react-konva": "^18.2.10",
"react-router-dom": "^6.14.1", "react-router-dom": "^6.14.1",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",

View File

@ -0,0 +1,52 @@
import { useState, useEffect } from "react";
const hasIntersectionObserver: boolean =
"IntersectionObserver" in window &&
"IntersectionObserverEntry" in window &&
"isIntersecting" in window.IntersectionObserverEntry.prototype;
interface UseOnWhenVisibleOptions {
alwaysVisible?: boolean;
threshold?: number | number[];
}
export function useOnWhenVisible({
alwaysVisible = false,
threshold = 0.1,
}: UseOnWhenVisibleOptions): [React.RefCallback<HTMLDivElement>, boolean] {
const [ref, setRef] = useState<HTMLDivElement | null>(null);
const [isVisible, setIsVisible] = useState<boolean>(
alwaysVisible || !hasIntersectionObserver
);
if (!hasIntersectionObserver) {
console.warn("IntersectionObserver is not supported in this browser.");
}
useEffect(() => {
if (ref == null || isVisible || !hasIntersectionObserver) {
return undefined;
}
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
}, { threshold });
observer.observe(ref);
const rect = ref.getBoundingClientRect();
const isCurrentlyVisible =
rect.top < window.innerHeight && rect.bottom > 0;
if (isCurrentlyVisible) {
setIsVisible(true);
}
return () => {
observer.disconnect();
};
}, [ref, isVisible, threshold]);
return [setRef, isVisible];
}

View File

@ -13,10 +13,11 @@ import { GetCameraWHostWConfig } from '../services/frigate.proxy/frigate.schema'
import ClearableTextInput from '../shared/components/inputs/ClearableTextInput'; import ClearableTextInput from '../shared/components/inputs/ClearableTextInput';
import CenterLoader from '../shared/components/loaders/CenterLoader'; import CenterLoader from '../shared/components/loaders/CenterLoader';
import { isProduction } from '../shared/env.const'; import { isProduction } from '../shared/env.const';
import CameraCard from '../widgets/CameraCard';
import MainFiltersRightSide from '../widgets/sidebars/MainFiltersRightSide'; import MainFiltersRightSide from '../widgets/sidebars/MainFiltersRightSide';
import { SideBarContext } from '../widgets/sidebars/SideBarContext'; import { SideBarContext } from '../widgets/sidebars/SideBarContext';
import RetryErrorPage from './RetryErrorPage'; import RetryErrorPage from './RetryErrorPage';
import CameraCard from '../widgets/card/CameraCard';
import { useInView } from 'react-intersection-observer';
export const mainPageParams = { export const mainPageParams = {
hostId: 'hostId', hostId: 'hostId',
@ -32,7 +33,10 @@ const MainPage = () => {
const { setRightChildren } = useContext(SideBarContext) const { setRightChildren } = useContext(SideBarContext)
const { hostId: selectedHostId, selectedTags, searchQuery } = mainStore.filters const { hostId: selectedHostId, selectedTags, searchQuery } = mainStore.filters
const [filteredCameras, setFilteredCameras] = useState<GetCameraWHostWConfig[]>() const [filteredCameras, setFilteredCameras] = useState<GetCameraWHostWConfig[]>([])
const { ref, inView } = useInView({ threshold: 0.5 })
const [visibleCameras, setVisibleCameras] = useState<GetCameraWHostWConfig[]>([])
const realmUser = useRealmUser() const realmUser = useRealmUser()
if (!isProduction) console.log('Realmuser:', realmUser) if (!isProduction) console.log('Realmuser:', realmUser)
@ -42,6 +46,23 @@ const MainPage = () => {
queryFn: frigateApi.getCamerasWHost queryFn: frigateApi.getCamerasWHost
}) })
useEffect(() => {
const pageSize = 20;
if (inView && filteredCameras.length > visibleCameras.length) {
const nextBatch = filteredCameras.slice(visibleCameras.length, visibleCameras.length + pageSize);
setVisibleCameras(prev => [...prev, ...nextBatch]);
}
}, [inView, filteredCameras, visibleCameras]);
useEffect(() => {
const hostId = searchParams.get(mainPageParams.hostId) || ''
const searchQuery = searchParams.get(mainPageParams.searchQuery) || ''
const selectedTags = mainStore.getArrayParam(mainPageParams.selectedTags)
mainStore.setHostId(hostId, navigate)
mainStore.setSearchQuery(searchQuery, navigate)
mainStore.setSelectedTags(selectedTags, navigate)
}, [searchParams])
useEffect(() => { useEffect(() => {
const deSerializedTags = mainStore.getArrayParam(mainPageParams.selectedTags) const deSerializedTags = mainStore.getArrayParam(mainPageParams.selectedTags)
mainStore.loadFiltersFromPage({ mainStore.loadFiltersFromPage({
@ -56,7 +77,7 @@ const MainPage = () => {
useEffect(() => { useEffect(() => {
if (!cameras) { if (!cameras) {
setFilteredCameras(undefined) setFilteredCameras([])
return return
} }
@ -68,32 +89,33 @@ const MainPage = () => {
} }
setFilteredCameras(cameras.filter(filterCameras)) setFilteredCameras(cameras.filter(filterCameras))
setVisibleCameras([])
}, [searchQuery, cameras, selectedHostId, selectedTags]) }, [searchQuery, cameras, selectedHostId, selectedTags])
const cards = useMemo(() => { // const cards = useMemo(() => {
if (filteredCameras) // if (filteredCameras)
return filteredCameras.filter(camera => { // return filteredCameras.filter(camera => {
if (camera.frigateHost && !camera.frigateHost.enabled) return false // if (camera.frigateHost && !camera.frigateHost.enabled) return false
return true // return true
}).map(camera => ( // }).map(camera => (
<CameraCard // <CameraCard
key={camera.id} // key={camera.id}
camera={camera} // camera={camera}
/>) // />)
) // ).slice(0,5)
else if (cameras) // else if (cameras)
return cameras.filter(camera => { // return cameras.filter(camera => {
if (camera.frigateHost && !camera.frigateHost.enabled) return false // if (camera.frigateHost && !camera.frigateHost.enabled) return false
return true // return true
}).map(camera => ( // }).map(camera => (
<CameraCard // <CameraCard
key={camera.id} // key={camera.id}
camera={camera} // camera={camera}
/>) // />)
) // ).slice(0,5)
else return [] // else return []
}, [cameras, filteredCameras]) // }, [cameras, filteredCameras])
const debouncedHandleSearchQuery = useDebounce((value: string) => { const debouncedHandleSearchQuery = useDebounce((value: string) => {
mainStore.setSearchQuery(value, navigate); mainStore.setSearchQuery(value, navigate);
@ -126,8 +148,11 @@ const MainPage = () => {
</Flex> </Flex>
<Flex justify='center' h='100%' direction='column' w='100%' > <Flex justify='center' h='100%' direction='column' w='100%' >
<Grid mt='sm' justify="center" mb='sm' align='stretch'> <Grid mt='sm' justify="center" mb='sm' align='stretch'>
{cards} {visibleCameras.map(camera => (
<CameraCard key={camera.id} camera={camera} />
))}
</Grid> </Grid>
<div ref={ref} style={{ height: '50px' }} /> {/* trigger point */}
</Flex> </Flex>
</Flex> </Flex>
); );

View File

@ -26,7 +26,7 @@ export class MainStore {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (this.filters.hostId) params.set(mainPageParams.hostId, this.filters.hostId); if (this.filters.hostId) params.set(mainPageParams.hostId, this.filters.hostId);
if (this.filters.searchQuery) params.set(mainPageParams.searchQuery, this.filters.searchQuery); if (this.filters.searchQuery) params.set(mainPageParams.searchQuery, this.filters.searchQuery);
if (this.filters.selectedTags) { if (this.filters.selectedTags && this.filters.selectedTags.length > 0) {
const serializedTags = this.filters.selectedTags.join(",") const serializedTags = this.filters.selectedTags.join(",")
params.set(mainPageParams.selectedTags, serializedTags); params.set(mainPageParams.selectedTags, serializedTags);
} }

View File

@ -1,16 +1,15 @@
import { Button, Card, Flex, Grid, Group, Text, createStyles } from '@mantine/core'; import { Button, Card, Flex, Grid, Group, Text, createStyles } from '@mantine/core';
import { useIntersection } from '@mantine/hooks';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAdminRole } from '../hooks/useAdminRole'; import { useAdminRole } from '../../hooks/useAdminRole';
import { recordingsPageQuery } from '../pages/RecordingsPage'; import { useOnWhenVisible } from '../../hooks/useOnWhenVisible';
import { routesPath } from '../router/routes.path'; import { eventsQueryParams } from '../../pages/EventsPage';
import { mapHostToHostname, proxyApi } from '../services/frigate.proxy/frigate.api'; import { recordingsPageQuery } from '../../pages/RecordingsPage';
import { GetCameraWHostWConfig } from '../services/frigate.proxy/frigate.schema'; import { routesPath } from '../../router/routes.path';
import AutoUpdatedImage from '../shared/components/images/AutoUpdatedImage'; import { mapHostToHostname, proxyApi } from '../../services/frigate.proxy/frigate.api';
import CameraTagsList from './CameraTagsList'; import { GetCameraWHostWConfig } from '../../services/frigate.proxy/frigate.schema';
import { eventsQueryParams } from '../pages/EventsPage'; import AutoUpdatedImage from '../../shared/components/images/AutoUpdatedImage';
import CameraTagsList from '../CameraTagsList';
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
mainCard: { mainCard: {
@ -45,18 +44,13 @@ const CameraCard = ({
camera camera
}: CameraCardProps) => { }: CameraCardProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [renderImage, setRenderImage] = useState<boolean>(false)
const { classes } = useStyles(); const { classes } = useStyles();
const { ref, entry } = useIntersection({ threshold: 0.5, }) const [ref, isVisible] = useOnWhenVisible({ threshold: 0.5 })
const navigate = useNavigate() const navigate = useNavigate()
const hostName = mapHostToHostname(camera.frigateHost) const hostName = mapHostToHostname(camera.frigateHost)
const imageUrl = hostName ? proxyApi.cameraImageURL(hostName, camera.name) : '' //todo implement get URL from live cameras const imageUrl = hostName ? proxyApi.cameraImageURL(hostName, camera.name) : '' //todo implement get URL from live cameras
const { isAdmin } = useAdminRole() const { isAdmin } = useAdminRole()
useEffect(() => {
if (entry && entry.isIntersecting)
setRenderImage(true)
}, [entry?.isIntersecting])
const handleOpenLiveView = () => { const handleOpenLiveView = () => {
const url = routesPath.LIVE_PATH.replace(':id', camera.id) const url = routesPath.LIVE_PATH.replace(':id', camera.id)
@ -82,7 +76,7 @@ const CameraCard = ({
<Grid.Col md={6} lg={3} p='0.2rem'> <Grid.Col md={6} lg={3} p='0.2rem'>
<Card ref={ref} h='100%' radius="lg" padding='0.5rem' className={classes.mainCard}> <Card ref={ref} h='100%' radius="lg" padding='0.5rem' className={classes.mainCard}>
<Text align='center' size='md' className={classes.headText} >{camera.name} / {camera.frigateHost?.name}</Text> <Text align='center' size='md' className={classes.headText} >{camera.name} / {camera.frigateHost?.name}</Text>
{!renderImage ? null : {!isVisible ? null :
<AutoUpdatedImage onClick={handleOpenLiveView} enabled={camera.config?.enabled} imageUrl={imageUrl} /> <AutoUpdatedImage onClick={handleOpenLiveView} enabled={camera.config?.enabled} imageUrl={imageUrl} />
} }
<Group <Group

View File

@ -8734,6 +8734,11 @@ react-i18next@^14.1.0:
"@babel/runtime" "^7.23.9" "@babel/runtime" "^7.23.9"
html-parse-stringify "^3.0.1" html-parse-stringify "^3.0.1"
react-intersection-observer@^9.13.1:
version "9.13.1"
resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-9.13.1.tgz#6c61a75801162491c6348bad09967f2caf445584"
integrity sha512-tSzDaTy0qwNPLJHg8XZhlyHTgGW6drFKTtvjdL+p6um12rcnp8Z5XstE+QNBJ7c64n5o0Lj4ilUleA41bmDoMw==
react-is@^16.13.1, react-is@^16.7.0: react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"