From ed65fd20f861e86a873008312aae4ddeef5753fb Mon Sep 17 00:00:00 2001 From: NlightN22 Date: Sun, 1 Dec 2024 14:22:21 +0700 Subject: [PATCH] incrementally load filtered cameras fix visible camera card image loading refactoring update version 1.8 --- Dockerfile | 2 +- package.json | 3 +- src/hooks/useOnWhenVisible.ts | 52 ++++++++++++++++++ src/pages/MainPage.tsx | 79 ++++++++++++++++++--------- src/shared/stores/main.store.ts | 2 +- src/widgets/{ => card}/CameraCard.tsx | 28 ++++------ yarn.lock | 5 ++ 7 files changed, 124 insertions(+), 47 deletions(-) create mode 100644 src/hooks/useOnWhenVisible.ts rename src/widgets/{ => card}/CameraCard.tsx (77%) diff --git a/Dockerfile b/Dockerfile index 4ccd9ab..4f6c677 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 # Build commands: -# - $VERSION=1.7 +# - $VERSION=1.8 # - 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 image push oncharterliz/multi-frigate:$VERSION ; docker image push oncharterliz/multi-frigate:latest diff --git a/package.json b/package.json index f7067fb..a722aea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-frigate", - "version": "0.1.7", + "version": "0.1.8", "private": true, "dependencies": { "@cycjimmy/jsmpeg-player": "^6.0.5", @@ -48,6 +48,7 @@ "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", "react-i18next": "^14.1.0", + "react-intersection-observer": "^9.13.1", "react-konva": "^18.2.10", "react-router-dom": "^6.14.1", "react-scripts": "5.0.1", diff --git a/src/hooks/useOnWhenVisible.ts b/src/hooks/useOnWhenVisible.ts new file mode 100644 index 0000000..dfbbbaf --- /dev/null +++ b/src/hooks/useOnWhenVisible.ts @@ -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, boolean] { + const [ref, setRef] = useState(null); + const [isVisible, setIsVisible] = useState( + 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]; +} diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 078e0fe..d41ee78 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -13,10 +13,11 @@ import { GetCameraWHostWConfig } from '../services/frigate.proxy/frigate.schema' import ClearableTextInput from '../shared/components/inputs/ClearableTextInput'; import CenterLoader from '../shared/components/loaders/CenterLoader'; import { isProduction } from '../shared/env.const'; -import CameraCard from '../widgets/CameraCard'; import MainFiltersRightSide from '../widgets/sidebars/MainFiltersRightSide'; import { SideBarContext } from '../widgets/sidebars/SideBarContext'; import RetryErrorPage from './RetryErrorPage'; +import CameraCard from '../widgets/card/CameraCard'; +import { useInView } from 'react-intersection-observer'; export const mainPageParams = { hostId: 'hostId', @@ -32,7 +33,10 @@ const MainPage = () => { const { setRightChildren } = useContext(SideBarContext) const { hostId: selectedHostId, selectedTags, searchQuery } = mainStore.filters - const [filteredCameras, setFilteredCameras] = useState() + const [filteredCameras, setFilteredCameras] = useState([]) + + const { ref, inView } = useInView({ threshold: 0.5 }) + const [visibleCameras, setVisibleCameras] = useState([]) const realmUser = useRealmUser() if (!isProduction) console.log('Realmuser:', realmUser) @@ -42,6 +46,23 @@ const MainPage = () => { 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(() => { const deSerializedTags = mainStore.getArrayParam(mainPageParams.selectedTags) mainStore.loadFiltersFromPage({ @@ -56,7 +77,7 @@ const MainPage = () => { useEffect(() => { if (!cameras) { - setFilteredCameras(undefined) + setFilteredCameras([]) return } @@ -68,32 +89,33 @@ const MainPage = () => { } setFilteredCameras(cameras.filter(filterCameras)) + setVisibleCameras([]) }, [searchQuery, cameras, selectedHostId, selectedTags]) - const cards = useMemo(() => { - if (filteredCameras) - return filteredCameras.filter(camera => { - if (camera.frigateHost && !camera.frigateHost.enabled) return false - return true - }).map(camera => ( - ) - ) - else if (cameras) - return cameras.filter(camera => { - if (camera.frigateHost && !camera.frigateHost.enabled) return false - return true - }).map(camera => ( - ) - ) - else return [] - }, [cameras, filteredCameras]) + // const cards = useMemo(() => { + // if (filteredCameras) + // return filteredCameras.filter(camera => { + // if (camera.frigateHost && !camera.frigateHost.enabled) return false + // return true + // }).map(camera => ( + // ) + // ).slice(0,5) + // else if (cameras) + // return cameras.filter(camera => { + // if (camera.frigateHost && !camera.frigateHost.enabled) return false + // return true + // }).map(camera => ( + // ) + // ).slice(0,5) + // else return [] + // }, [cameras, filteredCameras]) const debouncedHandleSearchQuery = useDebounce((value: string) => { mainStore.setSearchQuery(value, navigate); @@ -126,8 +148,11 @@ const MainPage = () => { - {cards} + {visibleCameras.map(camera => ( + + ))} +
{/* trigger point */} ); diff --git a/src/shared/stores/main.store.ts b/src/shared/stores/main.store.ts index 474e66d..0546d73 100644 --- a/src/shared/stores/main.store.ts +++ b/src/shared/stores/main.store.ts @@ -26,7 +26,7 @@ export class MainStore { const params = new URLSearchParams(); 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.selectedTags) { + if (this.filters.selectedTags && this.filters.selectedTags.length > 0) { const serializedTags = this.filters.selectedTags.join(",") params.set(mainPageParams.selectedTags, serializedTags); } diff --git a/src/widgets/CameraCard.tsx b/src/widgets/card/CameraCard.tsx similarity index 77% rename from src/widgets/CameraCard.tsx rename to src/widgets/card/CameraCard.tsx index 3b6b0f5..ddfb2c4 100644 --- a/src/widgets/CameraCard.tsx +++ b/src/widgets/card/CameraCard.tsx @@ -1,16 +1,15 @@ 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 { useNavigate } from 'react-router-dom'; -import { useAdminRole } from '../hooks/useAdminRole'; -import { recordingsPageQuery } from '../pages/RecordingsPage'; -import { routesPath } from '../router/routes.path'; -import { mapHostToHostname, proxyApi } from '../services/frigate.proxy/frigate.api'; -import { GetCameraWHostWConfig } from '../services/frigate.proxy/frigate.schema'; -import AutoUpdatedImage from '../shared/components/images/AutoUpdatedImage'; -import CameraTagsList from './CameraTagsList'; -import { eventsQueryParams } from '../pages/EventsPage'; +import { useAdminRole } from '../../hooks/useAdminRole'; +import { useOnWhenVisible } from '../../hooks/useOnWhenVisible'; +import { eventsQueryParams } from '../../pages/EventsPage'; +import { recordingsPageQuery } from '../../pages/RecordingsPage'; +import { routesPath } from '../../router/routes.path'; +import { mapHostToHostname, proxyApi } from '../../services/frigate.proxy/frigate.api'; +import { GetCameraWHostWConfig } from '../../services/frigate.proxy/frigate.schema'; +import AutoUpdatedImage from '../../shared/components/images/AutoUpdatedImage'; +import CameraTagsList from '../CameraTagsList'; const useStyles = createStyles((theme) => ({ mainCard: { @@ -45,18 +44,13 @@ const CameraCard = ({ camera }: CameraCardProps) => { const { t } = useTranslation() - const [renderImage, setRenderImage] = useState(false) const { classes } = useStyles(); - const { ref, entry } = useIntersection({ threshold: 0.5, }) + const [ref, isVisible] = useOnWhenVisible({ threshold: 0.5 }) const navigate = useNavigate() const hostName = mapHostToHostname(camera.frigateHost) const imageUrl = hostName ? proxyApi.cameraImageURL(hostName, camera.name) : '' //todo implement get URL from live cameras const { isAdmin } = useAdminRole() - useEffect(() => { - if (entry && entry.isIntersecting) - setRenderImage(true) - }, [entry?.isIntersecting]) const handleOpenLiveView = () => { const url = routesPath.LIVE_PATH.replace(':id', camera.id) @@ -82,7 +76,7 @@ const CameraCard = ({ {camera.name} / {camera.frigateHost?.name} - {!renderImage ? null : + {!isVisible ? null : }