add downloading record & event

add search
add events props
decompose events accordion
This commit is contained in:
NlightN22 2024-02-27 22:39:05 +07:00
parent 26c5c6504a
commit 8cdcb6620f
20 changed files with 476 additions and 117 deletions

View File

@ -9,6 +9,7 @@
"@mantine/core": "^6.0.16",
"@mantine/dates": "^6.0.16",
"@mantine/hooks": "^6.0.16",
"@mantine/notifications": "6.0.16",
"@monaco-editor/react": "^4.6.0",
"@tabler/icons-react": "^2.24.0",
"@tanstack/react-query": "^5.21.2",

View File

@ -8,6 +8,7 @@ import { BrowserRouter } from 'react-router-dom';
import AppBody from './AppBody';
import Forbidden from './pages/403';
import { QueryClient } from '@tanstack/react-query';
import { Notifications } from '@mantine/notifications';
function App() {
@ -62,6 +63,7 @@ function App() {
}}
>
<BrowserRouter>
<Notifications />
<AppBody />
</BrowserRouter>
</MantineProvider >

View File

@ -21,7 +21,9 @@ const HostConfigPage = () => {
queryFn: async () => {
const host = await frigateApi.getHost(id || '')
const hostName = mapHostToHostname(host)
if (hostName)
return proxyApi.getHostConfigRaw(hostName)
return null
},
})

View File

@ -1,22 +1,33 @@
import { Flex, Grid, Group } from '@mantine/core';
import HeadSearch from '../shared/components/inputs/HeadSearch';
import ViewSelector, { SelectorViewState } from '../shared/components/TableGridViewSelector';
import { useContext, useState, useEffect } from 'react';
import { getCookie, setCookie } from 'cookies-next';
import { Flex, Grid, Group, TextInput } from '@mantine/core';
import { useContext, useState, useEffect, useMemo } from 'react';
import { Context } from '..';
import { observer } from 'mobx-react-lite'
import CenterLoader from '../shared/components/loaders/CenterLoader';
import { useQuery } from '@tanstack/react-query';
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
import RetryErrorPage from './RetryErrorPage';
import { useMediaQuery } from '@mantine/hooks';
import { dimensions } from '../shared/dimensions/dimensions';
import CameraCard from '../widgets/CameraCard';
import { IconSearch } from '@tabler/icons-react';
import React from 'react';
import { GetCameraWHostWConfig } from '../services/frigate.proxy/frigate.schema';
const MainPage = () => {
const { sideBarsStore } = useContext(Context)
const isMobile = useMediaQuery(dimensions.mobileSize)
const [searchQuery, setSearchQuery] = useState<string>()
const [filteredCameras, setFilteredCameras] = useState<GetCameraWHostWConfig[]>()
const { data: cameras, isPending, isError, refetch } = useQuery({
queryKey: [frigateQueryKeys.getCamerasWHost],
queryFn: frigateApi.getCamerasWHost
})
useEffect(() => {
if (searchQuery && cameras) {
setFilteredCameras(cameras.filter(camera => camera.name.toLowerCase().includes(searchQuery.toLowerCase())))
} else {
setFilteredCameras(undefined)
}
}, [searchQuery, cameras])
useEffect(() => {
sideBarsStore.rightVisible = false
@ -24,30 +35,27 @@ const MainPage = () => {
sideBarsStore.setRightChildren(null)
}, [])
const [viewState, setTableState] = useState(getCookie('aps-main-view') as SelectorViewState || SelectorViewState.GRID)
const handleToggleState = (state: SelectorViewState) => {
setCookie('aps-main-view', state, { maxAge: 60 * 60 * 24 * 30 });
setTableState(state)
}
const { data: cameras, isPending, isError, refetch } = useQuery({
queryKey: [frigateQueryKeys.getCamerasWHost],
queryFn: frigateApi.getCamerasWHost
})
if (isPending) return <CenterLoader />
if (isError) return <RetryErrorPage onRetry={refetch} />
const cards = () => {
// return cameras.filter(cam => cam.frigateHost?.host.includes('5000')).slice(0, 25).map(camera => (
return cameras.map(camera => (
const cards = useMemo(() => {
if (filteredCameras)
return filteredCameras.map(camera => (
<CameraCard
key={camera.id}
camera={camera}
/>)
)
}
else
return cameras?.map(
camera => (
<CameraCard
key={camera.id}
camera={camera}
/>)
)
}, [cameras, filteredCameras, searchQuery])
if (isPending) return <CenterLoader />
if (isError) return <RetryErrorPage onRetry={refetch} />
return (
<Flex direction='column' h='100%' w='100%' >
@ -56,12 +64,20 @@ const MainPage = () => {
style={{
justifyContent: 'center',
}}
><HeadSearch /></Flex>
{/* <ViewSelector state={viewState} onChange={handleToggleState} /> */}
>
<TextInput
maw={400}
style={{ flexGrow: 1 }}
placeholder="Search..."
icon={<IconSearch size="0.9rem" stroke={1.5} />}
value={searchQuery}
onChange={(event) => setSearchQuery(event.currentTarget.value)}
/>
</Flex>
</Flex>
<Flex justify='center' h='100%' direction='column' w='100%' >
<Grid mt='sm' justify="center" mb='sm' align='stretch'>
{cards()}
{cards}
</Grid>
</Flex>
</Flex>

View File

@ -1,18 +1,14 @@
import { useState, useContext, useEffect, lazy, Suspense } from 'react';
import { Accordion, Flex, Text } from '@mantine/core';
import { useState, useContext, useEffect } from 'react';
import { Flex, Text } from '@mantine/core';
import { useLocation, useNavigate } from 'react-router-dom';
import { observer } from 'mobx-react-lite';
import { Context } from '..';
import RecordingsFiltersRightSide from '../widgets/RecordingsFiltersRightSide';
import SelectedCameraList from '../widgets/SelectedCameraList';
import SelectedHostList from '../widgets/SelectedHostList';
import { useQuery } from '@tanstack/react-query';
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
import { dateToQueryString, parseQueryDateToDate } from '../shared/utils/dateUtil';
import SelecteDayList from '../widgets/SelecteDayList';
import { useDebouncedValue } from '@mantine/hooks';
import CogwheelLoader from '../shared/components/loaders/CogwheelLoader';
import SelectedDayList from '../widgets/SelectedDayList';
import CenterLoader from '../shared/components/loaders/CenterLoader';
@ -102,7 +98,7 @@ const RecordingsPage = observer(() => {
const [startDay, endDay] = period
if (startDay && endDay) {
if (startDay.getDate() === endDay.getDate()) { // if select only one day
return <SelecteDayList day={startDay} />
return <SelectedDayList day={startDay} />
}
}

View File

@ -4,7 +4,7 @@ import { z } from "zod"
import {
GetConfig, DeleteFrigateHost, GetFrigateHost, PutConfig, PutFrigateHost,
GetFrigateHostWithCameras, GetCameraWHost, GetCameraWHostWConfig, GetRole,
GetUserByRole, GetRoleWCameras
GetUserByRole, GetRoleWCameras, GetExportedFile
} from "./frigate.schema";
import { FrigateConfig } from "../../types/frigateConfig";
import { url } from "inspector";
@ -46,15 +46,28 @@ export const frigateApi = {
}).then(res => res.data)
}
export const proxyPrefix = `${proxyURL.protocol}//${proxyURL.host}/proxy/`
export const proxyApi = {
getHostConfigRaw: (hostName: string) => instanceApi.get(`proxy/${hostName}/api/config/raw`).then(res => res.data),
getHostConfig: (hostName: string) => instanceApi.get(`proxy/${hostName}/api/config`).then(res => res.data),
getImageFrigate: async (imageUrl: string) => {
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.blob();
const response = await axios.get<Blob>(imageUrl, {
responseType: 'blob'
})
return response.data
},
getVideoFrigate: async (videoUrl: string, onProgress: (percentage: number | undefined) => void) => {
const response = await axios.get<Blob>(videoUrl, {
responseType: 'blob',
onDownloadProgress: (progressEvent) => {
const total = progressEvent.total
const current = progressEvent.loaded;
const percentage = total ? (current / total) * 100 : undefined
onProgress(percentage);
},
})
return response.data
},
getHostRestart: (hostName: string) => instanceApi.get(`proxy/${hostName}/api/restart`).then(res => res.data),
@ -108,23 +121,35 @@ export const proxyApi = {
cameraWsURL: (hostName: string, cameraName: string) =>
`ws://${proxyURL.host}/proxy-ws/${hostName}/live/jsmpeg/${cameraName}`,
cameraImageURL: (hostName: string, cameraName: string) =>
`${proxyURL.protocol}//${proxyURL.host}/proxy/${hostName}/api/${cameraName}/latest.jpg`,
`${proxyPrefix}${hostName}/api/${cameraName}/latest.jpg`,
eventURL: (hostName: string, event: string) =>
`${proxyURL.protocol}//${proxyURL.host}/proxy/${hostName}/vod/event/${event}/master.m3u8`,
`${proxyPrefix}${hostName}/vod/event/${event}/master.m3u8`,
eventThumbnailUrl: (hostName: string, eventId: string) => `${proxyPrefix}${hostName}/api/events/${eventId}/thumbnail.jpg`,
eventDownloadURL: (hostName: string, eventId: string) => `${proxyPrefix}${hostName}/api/events/${eventId}/clip.mp4?download=true`,
// http://127.0.0.1:5000/vod/2024-02/23/19/CameraName/Asia,Krasnoyarsk/master.m3u8
recordingURL: (hostName: string, cameraName: string, timezone: string, day: string, hour: string) => {// day:2024-02-23 hour:19
const parts = day.split('-')
const date = `${parts[0]}-${parts[1]}/${parts[2]}/${hour}`
return `${proxyURL.protocol}//${proxyURL.host}/proxy/${hostName}/vod/${date}/${cameraName}/${timezone}/master.m3u8`
return `${proxyPrefix}${hostName}/vod/${date}/${cameraName}/${timezone}/master.m3u8`
},
// linkURL: (hostName: string, link: string) => `${proxyURL.protocol}//${proxyURL.host}/proxy/${hostName}${link}`, TODO delete
postExportVideoTask: (hostName: string, cameraName: string, startUnixTime: number, endUnixTime: number) => {
const url = `proxy/${hostName}/api/export/${cameraName}/start/${startUnixTime}/end/${endUnixTime}`
return instanceApi.post(url, { playback: 'realtime' }).then(res => res.data) // Payload: {"playback":"realtime"}
},
getExportedVideoList: (hostName: string) => instanceApi.get<GetExportedFile[]>(`proxy/${hostName}/exports/`).then(res => res.data),
getVideoUrl: (hostName: string, fileName: string) => `${proxyPrefix}${hostName}/exports/${fileName}`,
deleteExportedVideo: (hostName: string, videoName: string) => instanceApi.delete(`proxy/${hostName}/api/export/${videoName}`).then(res => res.data)
// filename example Home_1_Backyard_2024_02_26_16_25__2024_02_26_16_26.mp4
}
export const mapCamerasFromConfig = (config: FrigateConfig): string[] => {
return Object.keys(config.cameras)
}
export const mapHostToHostname = (host: GetFrigateHost): string => {
export const mapHostToHostname = (host?: GetFrigateHost): string | undefined => {
if (!host) return
const url = new URL(host.host)
const hostName = url.host
return hostName

View File

@ -98,6 +98,13 @@ export const getUserByRoleSchema = z.object({
email: z.string(),
})
export const getExpotedFile = z.object({
name: z.string(),
type: z.string(),
mtime: z.string(),
size: z.number(),
})
export type GetConfig = z.infer<typeof getConfigSchema>
export type PutConfig = z.infer<typeof putConfigSchema>
export type GetFrigateHost = z.infer<typeof getFrigateHostSchema>
@ -110,3 +117,4 @@ export type DeleteFrigateHost = z.infer<typeof deleteFrigateHostSchema>
export type GetRole = z.infer<typeof getRoleSchema>
export type GetRoleWCameras = z.infer<typeof getRoleWCamerasSchema>
export type GetUserByRole = z.infer<typeof getUserByRoleSchema>
export type GetExportedFile = z.infer<typeof getExpotedFile>

View File

@ -24,12 +24,12 @@ const CameraAccordion = ({
const camera = recStore.openedCamera || recStore.filteredCamera
const host = recStore.filteredHost
const hostName = mapHostToHostname(host)
const { data, isPending, isError, refetch } = useQuery({
queryKey: [frigateQueryKeys.getRecordingsSummary, camera?.id],
queryFn: () => {
if (camera && host) {
const hostName = mapHostToHostname(host)
if (camera && hostName) {
return proxyApi.getRecordingsSummary(hostName, camera.name, getResolvedTimeZone())
}
return null

View File

@ -1,4 +1,4 @@
import { Accordion, Center, Flex, Group, NavLink, Text, UnstyledButton } from '@mantine/core';
import { Accordion, Center, Flex, Group, NavLink, Progress, Text, UnstyledButton } from '@mantine/core';
import React, { useContext, useEffect, useState } from 'react';
import { RecordSummary } from '../../../types/record';
import { observer } from 'mobx-react-lite';
@ -6,7 +6,7 @@ import PlayControl from '../buttons/PlayControl';
import { mapHostToHostname, proxyApi } from '../../../services/frigate.proxy/frigate.api';
import { Context } from '../../..';
import VideoPlayer from '../players/VideoPlayer';
import { getResolvedTimeZone } from '../../utils/dateUtil';
import { getResolvedTimeZone, mapDateHourToUnixTime } from '../../utils/dateUtil';
import DayEventsAccordion from './DayEventsAccordion';
import { strings } from '../../strings/strings';
import { useNavigate } from 'react-router-dom';
@ -14,6 +14,7 @@ import AccordionControlButton from '../buttons/AccordionControlButton';
import { IconExternalLink, IconShare } from '@tabler/icons-react';
import { routesPath } from '../../../router/routes.path';
import AccordionShareButton from '../buttons/AccordionShareButton';
import VideoDownloader from '../../../widgets/VideoDownloader';
interface RecordingAccordionProps {
recordSummary?: RecordSummary
@ -26,14 +27,14 @@ const DayAccordion = ({
const [playedValue, setVideoPlayerState] = useState<string>()
const [openedValue, setOpenedValue] = useState<string>()
const [playerUrl, setPlayerUrl] = useState<string>()
const [link, setLink] = useState<string>()
const navigate = useNavigate()
const camera = recStore.openedCamera || recStore.filteredCamera
const hostName = mapHostToHostname(recStore.filteredHost)
const createRecordURL = (recordId: string): string | undefined => {
const record = {
hostName: recStore.filteredHost ? mapHostToHostname(recStore.filteredHost) : '',
hostName: hostName ? hostName : '',
cameraName: camera?.name,
day: recordSummary?.day,
hour: recordId,
@ -119,7 +120,7 @@ const DayAccordion = ({
<Flex justify='space-between'>
{hourLabel(hour.hour, hour.events)}
<Group>
<AccordionShareButton recordUrl={createRecordURL(hour.hour)}/>
<AccordionShareButton recordUrl={createRecordURL(hour.hour)} />
<AccordionControlButton onClick={() => hanleOpenNewLink(hour.hour)}>
<IconExternalLink />
</AccordionControlButton>
@ -129,10 +130,20 @@ const DayAccordion = ({
onClick={handleOpenPlayer} />
</Group>
</Flex>
</Accordion.Control>
<Accordion.Panel key={hour.hour + 'Panel'}>
{playedValue === hour.hour && playerUrl ? <VideoPlayer videoUrl={playerUrl} /> : <></>}
{ }
{recStore.filteredHost && camera && hostName ?
<Flex w='100%' justify='center' mb='0.5rem'>
<VideoDownloader
cameraName={camera.name}
hostName={hostName}
startUnixTime={mapDateHourToUnixTime(recordSummary.day, hour.hour)[0]}
endUnixTime={mapDateHourToUnixTime(recordSummary.day, hour.hour)[1]}
/>
</Flex>
: ''}
{hour.events > 0 ?
<DayEventsAccordion day={recordSummary.day} hour={hour.hour} qty={hour.events} />
:
@ -141,7 +152,9 @@ const DayAccordion = ({
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
)
}

View File

@ -0,0 +1,58 @@
import { Flex, Group, Button, Text, Image } from '@mantine/core';
import { IconExternalLink } from '@tabler/icons-react';
import React from 'react';
import { proxyApi } from '../../../services/frigate.proxy/frigate.api';
import { strings } from '../../strings/strings';
import { unixTimeToDate, getDurationFromTimestamps } from '../../utils/dateUtil';
import VideoPlayer from '../players/VideoPlayer';
import { EventFrigate } from '../../../types/event';
interface EventPanelProps {
event: EventFrigate
playedValue?: string
playerUrl?: string
hostName?: string
}
const EventPanel = ({
event,
playedValue,
playerUrl,
hostName,
}: EventPanelProps) => {
return (
<>
{playedValue === event.id && playerUrl ? <VideoPlayer videoUrl={playerUrl} /> : <></>}
<Flex w='100%' justify='space-between'>
{!hostName ? <></> :
<Image
maw={200}
fit="contain"
withPlaceholder
src={proxyApi.eventThumbnailUrl(hostName, event.id)} />
}
<Flex direction='column' align='end' justify='center'>
{!hostName ? '' :
<Flex>
<Button
component="a"
href={proxyApi.eventDownloadURL(hostName, event.id)}
download
variant="outline"
leftIcon={<IconExternalLink size="0.9rem" />}>
Download event
</Button>
</Flex>
}
<Text mt='1rem'>{strings.camera}: {event.camera}</Text>
<Text>{strings.player.object}: {event.label}</Text>
<Text>{strings.player.startTime}: {unixTimeToDate(event.start_time)}</Text>
<Text>{strings.player.duration}: {getDurationFromTimestamps(event.start_time, event.end_time)}</Text>
<Text>{strings.player.rating}: {(event.data.score * 100).toFixed(2)}%</Text>
</Flex>
</Flex>
</>
)
}
export default EventPanel;

View File

@ -6,7 +6,6 @@ import { useQuery } from '@tanstack/react-query';
import { frigateQueryKeys, mapHostToHostname, proxyApi } from '../../../services/frigate.proxy/frigate.api';
import { getEventsQuerySchema } from '../../../services/frigate.proxy/frigate.schema';
import PlayControl from '../buttons/PlayControl';
import VideoPlayer from '../players/VideoPlayer';
import { getDurationFromTimestamps, getUnixTime, unixTimeToDate } from '../../utils/dateUtil';
import RetryError from '../RetryError';
import { strings } from '../../strings/strings';
@ -16,6 +15,7 @@ import { routesPath } from '../../../router/routes.path';
import AccordionControlButton from '../buttons/AccordionControlButton';
import AccordionShareButton from '../buttons/AccordionShareButton';
import { useNavigate } from 'react-router-dom';
import EventPanel from './EventPanel';
/**
* @param day frigate format, e.g day: 2024-02-23
@ -47,18 +47,19 @@ const EventsAccordion = observer(({
const [playerUrl, setPlayerUrl] = useState<string>()
const navigate = useNavigate()
const inHost = recStore.filteredHost
const inCamera = recStore.openedCamera || recStore.filteredCamera
const isRequiredParams = inHost && inCamera
const host = recStore.filteredHost
const hostName = mapHostToHostname(host)
const camera = recStore.openedCamera || recStore.filteredCamera
const isRequiredParams = host && camera
const { data, isPending, isError, refetch } = useQuery({
queryKey: [frigateQueryKeys.getEvents, inHost, inCamera, day, hour],
queryKey: [frigateQueryKeys.getEvents, host, camera, day, hour],
queryFn: () => {
if (!isRequiredParams) return null
const [startTime, endTime] = getUnixTime(day, hour)
const parsed = getEventsQuerySchema.safeParse({
hostName: mapHostToHostname(inHost),
camerasName: [inCamera.name],
hostName: mapHostToHostname(host),
camerasName: [camera.name],
after: startTime,
before: endTime,
hasClip: true,
@ -84,15 +85,15 @@ const EventsAccordion = observer(({
})
const createEventUrl = (eventId: string) => {
if (inHost)
return proxyApi.eventURL(mapHostToHostname(inHost), eventId)
if (hostName)
return proxyApi.eventURL(hostName, eventId)
return undefined
}
useEffect(() => {
if (playedValue) {
// console.log('openVideoPlayer', playedValue)
if (playedValue && inHost) {
if (playedValue && host) {
const url = createEventUrl(playedValue)
console.log('GET EVENT URL: ', url)
setPlayerUrl(url)
@ -173,15 +174,11 @@ const EventsAccordion = observer(({
</Flex>
</Accordion.Control>
<Accordion.Panel key={event.id + 'Panel'}>
{playedValue === event.id && playerUrl ? <VideoPlayer videoUrl={playerUrl} /> : <></>}
<Group mt='1rem'>
<Text>{strings.camera}: {event.camera}</Text>
<Text>{strings.player.object}: {event.label}</Text>
</Group>
<Group>
<Text>{strings.player.startTime}: {unixTimeToDate(event.start_time)}</Text>
<Text>{strings.player.duration}: {getDurationFromTimestamps(event.start_time, event.end_time)}</Text>
</Group>
<EventPanel
event={event}
playedValue={playedValue}
playerUrl={playerUrl}
hostName={hostName} />
</Accordion.Panel>
</Accordion.Item>
))}

View File

@ -1,10 +1,11 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { CameraConfig } from "../../../types/frigateConfig";
import { Flex, Text, Image } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { frigateApi, proxyApi } from "../../../services/frigate.proxy/frigate.api";
import { useIntersection } from "@mantine/hooks";
import CogwheelLoader from "../loaders/CogwheelLoader";
import RetryError from "../RetryError";
interface AutoUpdatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
className?: string;
@ -21,9 +22,10 @@ const AutoUpdatedImage = ({
}: AutoUpdatedImageProps) => {
const { ref, entry } = useIntersection({ threshold: 0.1, })
const isVisible = entry?.isIntersecting
const [imageSrc, setImageSrc] = useState<string | null>(null);
const { data: imageBlob, refetch, isPending, isError } = useQuery({
queryKey: ['image', imageUrl],
queryKey: [imageUrl],
queryFn: () => proxyApi.getImageFrigate(imageUrl),
staleTime: 60 * 1000,
gcTime: Infinity,
@ -33,27 +35,38 @@ const AutoUpdatedImage = ({
useEffect(() => {
if (isVisible) {
const intervalId = setInterval(() => {
refetch();
}, 60 * 1000);
refetch()
}, 60 * 1000)
return () => clearInterval(intervalId);
}
}, [refetch, isVisible]);
}, [refetch, isVisible])
useEffect(() => {
if (imageBlob && imageBlob instanceof Blob) {
const objectURL = URL.createObjectURL(imageBlob);
setImageSrc(objectURL);
return () => {
if (objectURL) {
URL.revokeObjectURL(objectURL);
}
}
}
}, [imageBlob])
if (isPending) return <CogwheelLoader />
if (isError) return (
<Flex direction="column" justify="center" h="100%">
<Text align="center">Error loading!</Text>
<RetryError onRetry={refetch}/>
</Flex>
)
if (!imageBlob || !(imageBlob instanceof Blob)) console.error('imageBlob not Blob object:', imageBlob)
const image = URL.createObjectURL(imageBlob!)
if (!imageSrc) return null
return (
<Flex direction="column" justify="center" h="100%">
{enabled ? <Image ref={ref} src={image} alt="Dynamic Content" {...rest} />
{enabled ? <Image ref={ref} src={imageSrc} alt="Dynamic Content" {...rest} />
:
<Text align="center">Camera is disabled in config, no stream or snapshot available!</Text>
}

View File

@ -1,25 +1,22 @@
import { Flex, TextInput } from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import React from 'react';
import ViewSelector from '../TableGridViewSelector';
import React, { useState } from 'react';
interface HeadSearchProps {
search?: string
handleSearchChange?(): void
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
}
const HeadSearch = ({ search, handleSearchChange }: HeadSearchProps) => {
const HeadSearch = ({ search, onChange }: HeadSearchProps) => {
return (
<>
<TextInput
maw={400}
style={{ flexGrow: 1 }}
placeholder="Search..."
icon={<IconSearch size="0.9rem" stroke={1.5} />}
value={search}
onChange={handleSearchChange}
onChange={onChange}
/>
</>
);
};

View File

@ -31,7 +31,8 @@ const HostSettingsMenu = ({ host }: HostSettingsMenuProps) => {
}
const handleRestart = () => {
mutation.mutate(mapHostToHostname(host))
const hostName = mapHostToHostname(host)
if (hostName) mutation.mutate(hostName)
}
return (
<Menu shadow="md" width={200}>

View File

@ -13,6 +13,7 @@ export const strings = {
startTime: 'Начало',
endTime: 'Конец',
doubleClickToFullHint: 'Двойное нажатие мышью для полноэкранного просмотра',
rating: 'Рейтинг',
},
empty: 'Пусто',

View File

@ -4,6 +4,41 @@ export const longToDate = (long: number): Date => new Date(long * 1000);
export const epochToLong = (date: number): number => date / 1000;
export const dateToLong = (date: Date): number => epochToLong(date.getTime());
export const formatFileTimestamps = (startUnixTime: number, endUnixTime: number, cameraName: string) => {
const formatTime = (time: number) => {
const date = new Date(time * 1000);
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${year}_${month}_${day}_${hours}_${minutes}`;
};
const startTimeFormatted = formatTime(startUnixTime);
const endTimeFormatted = formatTime(endUnixTime);
const fileName = `${cameraName}_${startTimeFormatted}__${endTimeFormatted}.mp4`;
return fileName;
}
/**
*
* @param date e.g. "2024-02-27"
* @param hour e.g. "20"
* @returns [startUnixTime, endUnixTime]
*/
export const mapDateHourToUnixTime = (date: string, hour: string) => {
const startDateTime = new Date(`${date}T${hour}:00:00`)
const startUnixTime = startDateTime.getTime() / 1000
const endDateTime = new Date(startDateTime);
endDateTime.setMinutes(59)
endDateTime.setSeconds(59)
const endUnixTime = endDateTime.getTime() / 1000
return [startUnixTime, endUnixTime]
}
const getDateTimeYesterday = (dateTime: Date): Date => {
const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000;
return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds);

View File

@ -44,7 +44,8 @@ const CameraCard = ({
}: CameraCardProps) => {
const { classes } = useStyles();
const navigate = useNavigate()
const imageUrl = camera.frigateHost ? proxyApi.cameraImageURL(mapHostToHostname(camera.frigateHost), camera.name) : '' //todo implement get URL from live cameras
const hostName = mapHostToHostname(camera.frigateHost)
const imageUrl = hostName ? proxyApi.cameraImageURL(hostName, camera.name) : '' //todo implement get URL from live cameras
const handleOpenLiveView = () => {
const url = routesPath.LIVE_PATH.replace(':id', camera.id)

View File

@ -9,14 +9,14 @@ import CenterLoader from '../shared/components/loaders/CenterLoader';
import { observer } from 'mobx-react-lite';
import DayAccordion from '../shared/components/accordion/DayAccordion';
interface SelecteDayListProps {
interface SelectedDayListProps {
day: Date
}
const SelecteDayList = ({
const SelectedDayList = ({
day
}: SelecteDayListProps) => {
}: SelectedDayListProps) => {
const { recordingsStore: recStore } = useContext(Context)
const camera = recStore.filteredCamera
const host = recStore.filteredHost
@ -27,9 +27,11 @@ const SelecteDayList = ({
if (camera && host) {
const stringDay = dateToQueryString(day)
const hostName = mapHostToHostname(host)
if (hostName){
const res = await proxyApi.getRecordingsSummary(hostName, camera.name, getResolvedTimeZone())
return res.find(record => record.day === stringDay)
}
}
return null
}
})
@ -50,4 +52,4 @@ const SelecteDayList = ({
);
};
export default observer(SelecteDayList);
export default observer(SelectedDayList);

View File

@ -0,0 +1,165 @@
import { Button, Loader, Text, Notification, Progress } from '@mantine/core';
import { useMutation } from '@tanstack/react-query';
import { frigateApi, proxyApi } from '../services/frigate.proxy/frigate.api';
import { IconAlertCircle, IconExternalLink } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import RetryError from '../shared/components/RetryError';
import { formatFileTimestamps, unixTimeToDate } from '../shared/utils/dateUtil';
import { notifications } from '@mantine/notifications';
interface VideoDownloaderProps {
cameraName: string
hostName: string
startUnixTime: number
endUnixTime: number
}
const VideoDownloader = ({
cameraName,
hostName,
startUnixTime,
endUnixTime,
}: VideoDownloaderProps) => {
const maxVideoTime = 70 * 60
const [createName, setCreateName] = useState<string>()
const [link, setLink] = useState<string>()
const [error, setError] = useState<boolean>()
const [timer, setTimer] = useState<NodeJS.Timer>()
const [videoBlob, setVideoBlob] = useState<Blob>()
const [videoSrc, setVideoSrc] = useState<string>()
const [progress, setProgress] = useState<number>()
const createVideo = useMutation({
mutationFn: () => {
setError(false)
return proxyApi.postExportVideoTask(hostName, cameraName, startUnixTime, endUnixTime)
},
onSuccess: () => {
const fileName = formatFileTimestamps(startUnixTime, endUnixTime, cameraName)
setCreateName(fileName)
},
onError: () => setError(true)
})
const checkVideo = useMutation({ mutationFn: () => proxyApi.getExportedVideoList(hostName) })
const getVideBlob = useMutation({
mutationKey: [link],
mutationFn: (inlink: string) => {
return proxyApi.getVideoFrigate(inlink, (progress) => {
setProgress(progress)
})
},
onSuccess: (data) => {
setVideoBlob(data)
},
})
const deleteVideo = useMutation({
mutationFn: (videoName: string) => proxyApi.deleteExportedVideo(hostName, videoName),
onSuccess: () => setLink(undefined)
})
useEffect(() => {
if (createName && !link && !videoBlob) {
const intervalId = setInterval(() => {
checkVideo.mutate(undefined, {
onSuccess: (data) => {
const createdFile = data.find(file => file.name === createName);
if (createdFile && !link && !videoBlob) {
const link = proxyApi.getVideoUrl(hostName, createdFile.name)
setLink(link);
getVideBlob.mutateAsync(link)
clearInterval(intervalId)
setTimer(undefined)
}
}
})
}, 5 * 1000)
if (intervalId) setTimer(intervalId)
setTimeout(() => {
clearInterval(timer)
setTimer(undefined)
}, 5 * 60 * 1000)
}
}, [createName, link, videoBlob])
useEffect(() => {
if (videoBlob && videoBlob instanceof Blob && createName) {
if (link) deleteVideo.mutateAsync(createName)
const objectURL = URL.createObjectURL(videoBlob);
setVideoSrc(objectURL);
return () => {
if (objectURL) {
URL.revokeObjectURL(objectURL);
}
}
}
}, [videoBlob, createName, link])
const checkTime = () => {
const duration = endUnixTime - startUnixTime
if (duration > maxVideoTime) {
notifications.show({
id: 'too-much-time',
withCloseButton: true,
onClose: () => console.log('unmounted'),
onOpen: () => console.log('mounted'),
autoClose: 5000,
title: "Max duration",
message: `Time can not be higher than ${maxVideoTime / 60} hour`,
color: 'red',
icon: <IconAlertCircle />,
})
return false
}
return true
}
const handleDownload = () => {
if (!checkTime()) return
createVideo.mutate()
}
const handleCancel = () => {
clearTimeout(timer)
setTimer(undefined)
setCreateName(undefined)
setLink(undefined)
}
if (startUnixTime === 0 || endUnixTime === 0) return null
if (error) return <RetryError onRetry={() => createVideo.mutate()} />
if (link && progress && !videoSrc) return (
<Progress w='100%' value={progress} size='xl' radius='xl' label={`${progress.toFixed(2)}%`} />
)
const getReadyDownload = (
// link example http://my-proxy-server/hostname:4000/exports/HOME_1_Backyard_2024_02_26_16_25__2024_02_26_16_26.mp4
<Button component="a" href={videoSrc} download variant="outline" leftIcon={<IconExternalLink size="0.9rem" />}>
Ready! Download?
</Button>
)
if (videoSrc) return getReadyDownload
const preparingVideo = (
<>
<Text>Preparing video...<Loader /></Text>
</>
)
if (createName) return preparingVideo
return (
<Button
onClick={handleDownload}
>
Download
</Button>
);
};
export default VideoDownloader;

View File

@ -1140,7 +1140,7 @@
dependencies:
regenerator-runtime "^0.13.11"
"@babel/runtime@^7.5.5":
"@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7":
version "7.23.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7"
integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==
@ -1802,6 +1802,14 @@
resolved "https://registry.yarnpkg.com/@mantine/hooks/-/hooks-6.0.16.tgz#507c322347659424a915ec6b541d848f53abcf6a"
integrity sha512-DnfMYSTSjYxbQJ80TzKHO5gRXGTIQKxBnRQVc+n4RANTwgWMiwEmxIwqRjbulfLzIhEvplskhqGgElunIAfw7g==
"@mantine/notifications@6.0.16":
version "6.0.16"
resolved "https://registry.yarnpkg.com/@mantine/notifications/-/notifications-6.0.16.tgz#e3259f9bea564ae58d34810096b9288be47ca815"
integrity sha512-KqlPW51sxgQoJmIC2lEWMVlwPqy04D35iRMkCSget8aNgzk0K5csJppXo6qwMFn2GHKVGXFKJMBUp06IXQbiig==
dependencies:
"@mantine/utils" "6.0.16"
react-transition-group "4.4.2"
"@mantine/styles@6.0.16":
version "6.0.16"
resolved "https://registry.yarnpkg.com/@mantine/styles/-/styles-6.0.16.tgz#625e5be80fc964fa6634f10e798c19f5a4c265ce"
@ -4327,6 +4335,14 @@ dom-converter@^0.2.0:
dependencies:
utila "~0.4"
dom-helpers@^5.0.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==
dependencies:
"@babel/runtime" "^7.8.7"
csstype "^3.0.2"
dom-serializer@0:
version "0.2.2"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51"
@ -8204,7 +8220,7 @@ prompts@^2.0.1, prompts@^2.4.2:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@ -8481,6 +8497,16 @@ react-textarea-autosize@8.3.4:
use-composed-ref "^1.3.0"
use-latest "^1.2.1"
react-transition-group@4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470"
integrity sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==
dependencies:
"@babel/runtime" "^7.5.5"
dom-helpers "^5.0.1"
loose-envify "^1.4.0"
prop-types "^15.6.2"
react-use-websocket@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/react-use-websocket/-/react-use-websocket-4.7.0.tgz#e45545ed48eb60171bf6401d1884cc80c700a0ea"