incrementally load filtered cameras
fix visible camera card image loading refactoring update version 1.8
This commit is contained in:
parent
302f117b44
commit
ed65fd20f8
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
52
src/hooks/useOnWhenVisible.ts
Normal file
52
src/hooks/useOnWhenVisible.ts
Normal 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];
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
@ -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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user