add downloading record & event
add search add events props decompose events accordion
This commit is contained in:
parent
26c5c6504a
commit
8cdcb6620f
@ -9,6 +9,7 @@
|
|||||||
"@mantine/core": "^6.0.16",
|
"@mantine/core": "^6.0.16",
|
||||||
"@mantine/dates": "^6.0.16",
|
"@mantine/dates": "^6.0.16",
|
||||||
"@mantine/hooks": "^6.0.16",
|
"@mantine/hooks": "^6.0.16",
|
||||||
|
"@mantine/notifications": "6.0.16",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
"@tabler/icons-react": "^2.24.0",
|
"@tabler/icons-react": "^2.24.0",
|
||||||
"@tanstack/react-query": "^5.21.2",
|
"@tanstack/react-query": "^5.21.2",
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { BrowserRouter } from 'react-router-dom';
|
|||||||
import AppBody from './AppBody';
|
import AppBody from './AppBody';
|
||||||
import Forbidden from './pages/403';
|
import Forbidden from './pages/403';
|
||||||
import { QueryClient } from '@tanstack/react-query';
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
import { Notifications } from '@mantine/notifications';
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@ -62,6 +63,7 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<Notifications />
|
||||||
<AppBody />
|
<AppBody />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</MantineProvider >
|
</MantineProvider >
|
||||||
|
|||||||
@ -21,7 +21,9 @@ const HostConfigPage = () => {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const host = await frigateApi.getHost(id || '')
|
const host = await frigateApi.getHost(id || '')
|
||||||
const hostName = mapHostToHostname(host)
|
const hostName = mapHostToHostname(host)
|
||||||
|
if (hostName)
|
||||||
return proxyApi.getHostConfigRaw(hostName)
|
return proxyApi.getHostConfigRaw(hostName)
|
||||||
|
return null
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,22 +1,33 @@
|
|||||||
import { Flex, Grid, Group } from '@mantine/core';
|
import { Flex, Grid, Group, TextInput } from '@mantine/core';
|
||||||
import HeadSearch from '../shared/components/inputs/HeadSearch';
|
import { useContext, useState, useEffect, useMemo } from 'react';
|
||||||
import ViewSelector, { SelectorViewState } from '../shared/components/TableGridViewSelector';
|
|
||||||
import { useContext, useState, useEffect } from 'react';
|
|
||||||
import { getCookie, setCookie } from 'cookies-next';
|
|
||||||
import { Context } from '..';
|
import { Context } from '..';
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import CenterLoader from '../shared/components/loaders/CenterLoader';
|
import CenterLoader from '../shared/components/loaders/CenterLoader';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
|
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
|
||||||
import RetryErrorPage from './RetryErrorPage';
|
import RetryErrorPage from './RetryErrorPage';
|
||||||
import { useMediaQuery } from '@mantine/hooks';
|
|
||||||
import { dimensions } from '../shared/dimensions/dimensions';
|
|
||||||
import CameraCard from '../widgets/CameraCard';
|
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 MainPage = () => {
|
||||||
const { sideBarsStore } = useContext(Context)
|
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(() => {
|
useEffect(() => {
|
||||||
sideBarsStore.rightVisible = false
|
sideBarsStore.rightVisible = false
|
||||||
@ -24,30 +35,27 @@ const MainPage = () => {
|
|||||||
sideBarsStore.setRightChildren(null)
|
sideBarsStore.setRightChildren(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const [viewState, setTableState] = useState(getCookie('aps-main-view') as SelectorViewState || SelectorViewState.GRID)
|
const cards = useMemo(() => {
|
||||||
const handleToggleState = (state: SelectorViewState) => {
|
if (filteredCameras)
|
||||||
setCookie('aps-main-view', state, { maxAge: 60 * 60 * 24 * 30 });
|
return filteredCameras.map(camera => (
|
||||||
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 => (
|
|
||||||
<CameraCard
|
<CameraCard
|
||||||
key={camera.id}
|
key={camera.id}
|
||||||
camera={camera}
|
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 (
|
return (
|
||||||
<Flex direction='column' h='100%' w='100%' >
|
<Flex direction='column' h='100%' w='100%' >
|
||||||
@ -56,12 +64,20 @@ const MainPage = () => {
|
|||||||
style={{
|
style={{
|
||||||
justifyContent: 'center',
|
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>
|
||||||
<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()}
|
{cards}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@ -1,18 +1,14 @@
|
|||||||
|
|
||||||
import { useState, useContext, useEffect, lazy, Suspense } from 'react';
|
import { useState, useContext, useEffect } from 'react';
|
||||||
import { Accordion, Flex, Text } from '@mantine/core';
|
import { Flex, Text } from '@mantine/core';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { Context } from '..';
|
import { Context } from '..';
|
||||||
import RecordingsFiltersRightSide from '../widgets/RecordingsFiltersRightSide';
|
import RecordingsFiltersRightSide from '../widgets/RecordingsFiltersRightSide';
|
||||||
import SelectedCameraList from '../widgets/SelectedCameraList';
|
import SelectedCameraList from '../widgets/SelectedCameraList';
|
||||||
import SelectedHostList from '../widgets/SelectedHostList';
|
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 { dateToQueryString, parseQueryDateToDate } from '../shared/utils/dateUtil';
|
||||||
import SelecteDayList from '../widgets/SelecteDayList';
|
import SelectedDayList from '../widgets/SelectedDayList';
|
||||||
import { useDebouncedValue } from '@mantine/hooks';
|
|
||||||
import CogwheelLoader from '../shared/components/loaders/CogwheelLoader';
|
|
||||||
import CenterLoader from '../shared/components/loaders/CenterLoader';
|
import CenterLoader from '../shared/components/loaders/CenterLoader';
|
||||||
|
|
||||||
|
|
||||||
@ -102,7 +98,7 @@ const RecordingsPage = observer(() => {
|
|||||||
const [startDay, endDay] = period
|
const [startDay, endDay] = period
|
||||||
if (startDay && endDay) {
|
if (startDay && endDay) {
|
||||||
if (startDay.getDate() === endDay.getDate()) { // if select only one day
|
if (startDay.getDate() === endDay.getDate()) { // if select only one day
|
||||||
return <SelecteDayList day={startDay} />
|
return <SelectedDayList day={startDay} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { z } from "zod"
|
|||||||
import {
|
import {
|
||||||
GetConfig, DeleteFrigateHost, GetFrigateHost, PutConfig, PutFrigateHost,
|
GetConfig, DeleteFrigateHost, GetFrigateHost, PutConfig, PutFrigateHost,
|
||||||
GetFrigateHostWithCameras, GetCameraWHost, GetCameraWHostWConfig, GetRole,
|
GetFrigateHostWithCameras, GetCameraWHost, GetCameraWHostWConfig, GetRole,
|
||||||
GetUserByRole, GetRoleWCameras
|
GetUserByRole, GetRoleWCameras, GetExportedFile
|
||||||
} from "./frigate.schema";
|
} from "./frigate.schema";
|
||||||
import { FrigateConfig } from "../../types/frigateConfig";
|
import { FrigateConfig } from "../../types/frigateConfig";
|
||||||
import { url } from "inspector";
|
import { url } from "inspector";
|
||||||
@ -46,15 +46,28 @@ export const frigateApi = {
|
|||||||
}).then(res => res.data)
|
}).then(res => res.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const proxyPrefix = `${proxyURL.protocol}//${proxyURL.host}/proxy/`
|
||||||
|
|
||||||
export const proxyApi = {
|
export const proxyApi = {
|
||||||
getHostConfigRaw: (hostName: string) => instanceApi.get(`proxy/${hostName}/api/config/raw`).then(res => res.data),
|
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),
|
getHostConfig: (hostName: string) => instanceApi.get(`proxy/${hostName}/api/config`).then(res => res.data),
|
||||||
getImageFrigate: async (imageUrl: string) => {
|
getImageFrigate: async (imageUrl: string) => {
|
||||||
const response = await fetch(imageUrl);
|
const response = await axios.get<Blob>(imageUrl, {
|
||||||
if (!response.ok) {
|
responseType: 'blob'
|
||||||
throw new Error('Network response was not ok');
|
})
|
||||||
}
|
return response.data
|
||||||
return response.blob();
|
},
|
||||||
|
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),
|
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) =>
|
cameraWsURL: (hostName: string, cameraName: string) =>
|
||||||
`ws://${proxyURL.host}/proxy-ws/${hostName}/live/jsmpeg/${cameraName}`,
|
`ws://${proxyURL.host}/proxy-ws/${hostName}/live/jsmpeg/${cameraName}`,
|
||||||
cameraImageURL: (hostName: string, cameraName: string) =>
|
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) =>
|
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
|
// 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
|
recordingURL: (hostName: string, cameraName: string, timezone: string, day: string, hour: string) => {// day:2024-02-23 hour:19
|
||||||
const parts = day.split('-')
|
const parts = day.split('-')
|
||||||
const date = `${parts[0]}-${parts[1]}/${parts[2]}/${hour}`
|
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[] => {
|
export const mapCamerasFromConfig = (config: FrigateConfig): string[] => {
|
||||||
return Object.keys(config.cameras)
|
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 url = new URL(host.host)
|
||||||
const hostName = url.host
|
const hostName = url.host
|
||||||
return hostName
|
return hostName
|
||||||
|
|||||||
@ -98,6 +98,13 @@ export const getUserByRoleSchema = z.object({
|
|||||||
email: z.string(),
|
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 GetConfig = z.infer<typeof getConfigSchema>
|
||||||
export type PutConfig = z.infer<typeof putConfigSchema>
|
export type PutConfig = z.infer<typeof putConfigSchema>
|
||||||
export type GetFrigateHost = z.infer<typeof getFrigateHostSchema>
|
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 GetRole = z.infer<typeof getRoleSchema>
|
||||||
export type GetRoleWCameras = z.infer<typeof getRoleWCamerasSchema>
|
export type GetRoleWCameras = z.infer<typeof getRoleWCamerasSchema>
|
||||||
export type GetUserByRole = z.infer<typeof getUserByRoleSchema>
|
export type GetUserByRole = z.infer<typeof getUserByRoleSchema>
|
||||||
|
export type GetExportedFile = z.infer<typeof getExpotedFile>
|
||||||
@ -24,12 +24,12 @@ const CameraAccordion = ({
|
|||||||
|
|
||||||
const camera = recStore.openedCamera || recStore.filteredCamera
|
const camera = recStore.openedCamera || recStore.filteredCamera
|
||||||
const host = recStore.filteredHost
|
const host = recStore.filteredHost
|
||||||
|
const hostName = mapHostToHostname(host)
|
||||||
|
|
||||||
const { data, isPending, isError, refetch } = useQuery({
|
const { data, isPending, isError, refetch } = useQuery({
|
||||||
queryKey: [frigateQueryKeys.getRecordingsSummary, camera?.id],
|
queryKey: [frigateQueryKeys.getRecordingsSummary, camera?.id],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
if (camera && host) {
|
if (camera && hostName) {
|
||||||
const hostName = mapHostToHostname(host)
|
|
||||||
return proxyApi.getRecordingsSummary(hostName, camera.name, getResolvedTimeZone())
|
return proxyApi.getRecordingsSummary(hostName, camera.name, getResolvedTimeZone())
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|||||||
@ -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 React, { useContext, useEffect, useState } from 'react';
|
||||||
import { RecordSummary } from '../../../types/record';
|
import { RecordSummary } from '../../../types/record';
|
||||||
import { observer } from 'mobx-react-lite';
|
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 { mapHostToHostname, proxyApi } from '../../../services/frigate.proxy/frigate.api';
|
||||||
import { Context } from '../../..';
|
import { Context } from '../../..';
|
||||||
import VideoPlayer from '../players/VideoPlayer';
|
import VideoPlayer from '../players/VideoPlayer';
|
||||||
import { getResolvedTimeZone } from '../../utils/dateUtil';
|
import { getResolvedTimeZone, mapDateHourToUnixTime } from '../../utils/dateUtil';
|
||||||
import DayEventsAccordion from './DayEventsAccordion';
|
import DayEventsAccordion from './DayEventsAccordion';
|
||||||
import { strings } from '../../strings/strings';
|
import { strings } from '../../strings/strings';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@ -14,6 +14,7 @@ import AccordionControlButton from '../buttons/AccordionControlButton';
|
|||||||
import { IconExternalLink, IconShare } from '@tabler/icons-react';
|
import { IconExternalLink, IconShare } from '@tabler/icons-react';
|
||||||
import { routesPath } from '../../../router/routes.path';
|
import { routesPath } from '../../../router/routes.path';
|
||||||
import AccordionShareButton from '../buttons/AccordionShareButton';
|
import AccordionShareButton from '../buttons/AccordionShareButton';
|
||||||
|
import VideoDownloader from '../../../widgets/VideoDownloader';
|
||||||
|
|
||||||
interface RecordingAccordionProps {
|
interface RecordingAccordionProps {
|
||||||
recordSummary?: RecordSummary
|
recordSummary?: RecordSummary
|
||||||
@ -26,14 +27,14 @@ const DayAccordion = ({
|
|||||||
const [playedValue, setVideoPlayerState] = useState<string>()
|
const [playedValue, setVideoPlayerState] = useState<string>()
|
||||||
const [openedValue, setOpenedValue] = useState<string>()
|
const [openedValue, setOpenedValue] = useState<string>()
|
||||||
const [playerUrl, setPlayerUrl] = useState<string>()
|
const [playerUrl, setPlayerUrl] = useState<string>()
|
||||||
const [link, setLink] = useState<string>()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const camera = recStore.openedCamera || recStore.filteredCamera
|
const camera = recStore.openedCamera || recStore.filteredCamera
|
||||||
|
const hostName = mapHostToHostname(recStore.filteredHost)
|
||||||
|
|
||||||
const createRecordURL = (recordId: string): string | undefined => {
|
const createRecordURL = (recordId: string): string | undefined => {
|
||||||
const record = {
|
const record = {
|
||||||
hostName: recStore.filteredHost ? mapHostToHostname(recStore.filteredHost) : '',
|
hostName: hostName ? hostName : '',
|
||||||
cameraName: camera?.name,
|
cameraName: camera?.name,
|
||||||
day: recordSummary?.day,
|
day: recordSummary?.day,
|
||||||
hour: recordId,
|
hour: recordId,
|
||||||
@ -129,10 +130,20 @@ const DayAccordion = ({
|
|||||||
onClick={handleOpenPlayer} />
|
onClick={handleOpenPlayer} />
|
||||||
</Group>
|
</Group>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel key={hour.hour + 'Panel'}>
|
<Accordion.Panel key={hour.hour + 'Panel'}>
|
||||||
{playedValue === hour.hour && playerUrl ? <VideoPlayer videoUrl={playerUrl} /> : <></>}
|
{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 ?
|
{hour.events > 0 ?
|
||||||
<DayEventsAccordion day={recordSummary.day} hour={hour.hour} qty={hour.events} />
|
<DayEventsAccordion day={recordSummary.day} hour={hour.hour} qty={hour.events} />
|
||||||
:
|
:
|
||||||
@ -141,7 +152,9 @@ const DayAccordion = ({
|
|||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
58
src/shared/components/accordion/EventPanel.tsx
Normal file
58
src/shared/components/accordion/EventPanel.tsx
Normal 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;
|
||||||
@ -6,7 +6,6 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { frigateQueryKeys, mapHostToHostname, proxyApi } from '../../../services/frigate.proxy/frigate.api';
|
import { frigateQueryKeys, mapHostToHostname, proxyApi } from '../../../services/frigate.proxy/frigate.api';
|
||||||
import { getEventsQuerySchema } from '../../../services/frigate.proxy/frigate.schema';
|
import { getEventsQuerySchema } from '../../../services/frigate.proxy/frigate.schema';
|
||||||
import PlayControl from '../buttons/PlayControl';
|
import PlayControl from '../buttons/PlayControl';
|
||||||
import VideoPlayer from '../players/VideoPlayer';
|
|
||||||
import { getDurationFromTimestamps, getUnixTime, unixTimeToDate } from '../../utils/dateUtil';
|
import { getDurationFromTimestamps, getUnixTime, unixTimeToDate } from '../../utils/dateUtil';
|
||||||
import RetryError from '../RetryError';
|
import RetryError from '../RetryError';
|
||||||
import { strings } from '../../strings/strings';
|
import { strings } from '../../strings/strings';
|
||||||
@ -16,6 +15,7 @@ import { routesPath } from '../../../router/routes.path';
|
|||||||
import AccordionControlButton from '../buttons/AccordionControlButton';
|
import AccordionControlButton from '../buttons/AccordionControlButton';
|
||||||
import AccordionShareButton from '../buttons/AccordionShareButton';
|
import AccordionShareButton from '../buttons/AccordionShareButton';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import EventPanel from './EventPanel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param day frigate format, e.g day: 2024-02-23
|
* @param day frigate format, e.g day: 2024-02-23
|
||||||
@ -47,18 +47,19 @@ const EventsAccordion = observer(({
|
|||||||
const [playerUrl, setPlayerUrl] = useState<string>()
|
const [playerUrl, setPlayerUrl] = useState<string>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const inHost = recStore.filteredHost
|
const host = recStore.filteredHost
|
||||||
const inCamera = recStore.openedCamera || recStore.filteredCamera
|
const hostName = mapHostToHostname(host)
|
||||||
const isRequiredParams = inHost && inCamera
|
const camera = recStore.openedCamera || recStore.filteredCamera
|
||||||
|
const isRequiredParams = host && camera
|
||||||
|
|
||||||
const { data, isPending, isError, refetch } = useQuery({
|
const { data, isPending, isError, refetch } = useQuery({
|
||||||
queryKey: [frigateQueryKeys.getEvents, inHost, inCamera, day, hour],
|
queryKey: [frigateQueryKeys.getEvents, host, camera, day, hour],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
if (!isRequiredParams) return null
|
if (!isRequiredParams) return null
|
||||||
const [startTime, endTime] = getUnixTime(day, hour)
|
const [startTime, endTime] = getUnixTime(day, hour)
|
||||||
const parsed = getEventsQuerySchema.safeParse({
|
const parsed = getEventsQuerySchema.safeParse({
|
||||||
hostName: mapHostToHostname(inHost),
|
hostName: mapHostToHostname(host),
|
||||||
camerasName: [inCamera.name],
|
camerasName: [camera.name],
|
||||||
after: startTime,
|
after: startTime,
|
||||||
before: endTime,
|
before: endTime,
|
||||||
hasClip: true,
|
hasClip: true,
|
||||||
@ -84,15 +85,15 @@ const EventsAccordion = observer(({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const createEventUrl = (eventId: string) => {
|
const createEventUrl = (eventId: string) => {
|
||||||
if (inHost)
|
if (hostName)
|
||||||
return proxyApi.eventURL(mapHostToHostname(inHost), eventId)
|
return proxyApi.eventURL(hostName, eventId)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (playedValue) {
|
if (playedValue) {
|
||||||
// console.log('openVideoPlayer', playedValue)
|
// console.log('openVideoPlayer', playedValue)
|
||||||
if (playedValue && inHost) {
|
if (playedValue && host) {
|
||||||
const url = createEventUrl(playedValue)
|
const url = createEventUrl(playedValue)
|
||||||
console.log('GET EVENT URL: ', url)
|
console.log('GET EVENT URL: ', url)
|
||||||
setPlayerUrl(url)
|
setPlayerUrl(url)
|
||||||
@ -173,15 +174,11 @@ const EventsAccordion = observer(({
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel key={event.id + 'Panel'}>
|
<Accordion.Panel key={event.id + 'Panel'}>
|
||||||
{playedValue === event.id && playerUrl ? <VideoPlayer videoUrl={playerUrl} /> : <></>}
|
<EventPanel
|
||||||
<Group mt='1rem'>
|
event={event}
|
||||||
<Text>{strings.camera}: {event.camera}</Text>
|
playedValue={playedValue}
|
||||||
<Text>{strings.player.object}: {event.label}</Text>
|
playerUrl={playerUrl}
|
||||||
</Group>
|
hostName={hostName} />
|
||||||
<Group>
|
|
||||||
<Text>{strings.player.startTime}: {unixTimeToDate(event.start_time)}</Text>
|
|
||||||
<Text>{strings.player.duration}: {getDurationFromTimestamps(event.start_time, event.end_time)}</Text>
|
|
||||||
</Group>
|
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { CameraConfig } from "../../../types/frigateConfig";
|
import { CameraConfig } from "../../../types/frigateConfig";
|
||||||
import { Flex, Text, Image } from "@mantine/core";
|
import { Flex, Text, Image } from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { frigateApi, proxyApi } from "../../../services/frigate.proxy/frigate.api";
|
import { frigateApi, proxyApi } from "../../../services/frigate.proxy/frigate.api";
|
||||||
import { useIntersection } from "@mantine/hooks";
|
import { useIntersection } from "@mantine/hooks";
|
||||||
import CogwheelLoader from "../loaders/CogwheelLoader";
|
import CogwheelLoader from "../loaders/CogwheelLoader";
|
||||||
|
import RetryError from "../RetryError";
|
||||||
|
|
||||||
interface AutoUpdatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
interface AutoUpdatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -21,9 +22,10 @@ const AutoUpdatedImage = ({
|
|||||||
}: AutoUpdatedImageProps) => {
|
}: AutoUpdatedImageProps) => {
|
||||||
const { ref, entry } = useIntersection({ threshold: 0.1, })
|
const { ref, entry } = useIntersection({ threshold: 0.1, })
|
||||||
const isVisible = entry?.isIntersecting
|
const isVisible = entry?.isIntersecting
|
||||||
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: imageBlob, refetch, isPending, isError } = useQuery({
|
const { data: imageBlob, refetch, isPending, isError } = useQuery({
|
||||||
queryKey: ['image', imageUrl],
|
queryKey: [imageUrl],
|
||||||
queryFn: () => proxyApi.getImageFrigate(imageUrl),
|
queryFn: () => proxyApi.getImageFrigate(imageUrl),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
gcTime: Infinity,
|
gcTime: Infinity,
|
||||||
@ -33,27 +35,38 @@ const AutoUpdatedImage = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
refetch();
|
refetch()
|
||||||
}, 60 * 1000);
|
}, 60 * 1000)
|
||||||
return () => clearInterval(intervalId);
|
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 (isPending) return <CogwheelLoader />
|
||||||
|
|
||||||
if (isError) return (
|
if (isError) return (
|
||||||
<Flex direction="column" justify="center" h="100%">
|
<Flex direction="column" justify="center" h="100%">
|
||||||
<Text align="center">Error loading!</Text>
|
<RetryError onRetry={refetch}/>
|
||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!imageBlob || !(imageBlob instanceof Blob)) console.error('imageBlob not Blob object:', imageBlob)
|
if (!imageSrc) return null
|
||||||
|
|
||||||
const image = URL.createObjectURL(imageBlob!)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" justify="center" h="100%">
|
<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>
|
<Text align="center">Camera is disabled in config, no stream or snapshot available!</Text>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,22 @@
|
|||||||
import { Flex, TextInput } from '@mantine/core';
|
import { Flex, TextInput } from '@mantine/core';
|
||||||
import { IconSearch } from '@tabler/icons-react';
|
import { IconSearch } from '@tabler/icons-react';
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import ViewSelector from '../TableGridViewSelector';
|
|
||||||
|
|
||||||
interface HeadSearchProps {
|
interface HeadSearchProps {
|
||||||
search?: string
|
search?: string
|
||||||
handleSearchChange?(): void
|
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const HeadSearch = ({ search, handleSearchChange }: HeadSearchProps) => {
|
const HeadSearch = ({ search, onChange }: HeadSearchProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
maw={400}
|
maw={400}
|
||||||
style={{ flexGrow: 1 }}
|
style={{ flexGrow: 1 }}
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
icon={<IconSearch size="0.9rem" stroke={1.5} />}
|
icon={<IconSearch size="0.9rem" stroke={1.5} />}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={handleSearchChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,8 @@ const HostSettingsMenu = ({ host }: HostSettingsMenuProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleRestart = () => {
|
const handleRestart = () => {
|
||||||
mutation.mutate(mapHostToHostname(host))
|
const hostName = mapHostToHostname(host)
|
||||||
|
if (hostName) mutation.mutate(hostName)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Menu shadow="md" width={200}>
|
<Menu shadow="md" width={200}>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export const strings = {
|
|||||||
startTime: 'Начало',
|
startTime: 'Начало',
|
||||||
endTime: 'Конец',
|
endTime: 'Конец',
|
||||||
doubleClickToFullHint: 'Двойное нажатие мышью для полноэкранного просмотра',
|
doubleClickToFullHint: 'Двойное нажатие мышью для полноэкранного просмотра',
|
||||||
|
rating: 'Рейтинг',
|
||||||
},
|
},
|
||||||
|
|
||||||
empty: 'Пусто',
|
empty: 'Пусто',
|
||||||
|
|||||||
@ -4,6 +4,41 @@ export const longToDate = (long: number): Date => new Date(long * 1000);
|
|||||||
export const epochToLong = (date: number): number => date / 1000;
|
export const epochToLong = (date: number): number => date / 1000;
|
||||||
export const dateToLong = (date: Date): number => epochToLong(date.getTime());
|
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 getDateTimeYesterday = (dateTime: Date): Date => {
|
||||||
const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000;
|
const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000;
|
||||||
return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds);
|
return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds);
|
||||||
|
|||||||
@ -44,7 +44,8 @@ const CameraCard = ({
|
|||||||
}: CameraCardProps) => {
|
}: CameraCardProps) => {
|
||||||
const { classes } = useStyles();
|
const { classes } = useStyles();
|
||||||
const navigate = useNavigate()
|
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 handleOpenLiveView = () => {
|
||||||
const url = routesPath.LIVE_PATH.replace(':id', camera.id)
|
const url = routesPath.LIVE_PATH.replace(':id', camera.id)
|
||||||
|
|||||||
@ -9,14 +9,14 @@ import CenterLoader from '../shared/components/loaders/CenterLoader';
|
|||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import DayAccordion from '../shared/components/accordion/DayAccordion';
|
import DayAccordion from '../shared/components/accordion/DayAccordion';
|
||||||
|
|
||||||
interface SelecteDayListProps {
|
interface SelectedDayListProps {
|
||||||
day: Date
|
day: Date
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelecteDayList = ({
|
const SelectedDayList = ({
|
||||||
day
|
day
|
||||||
}: SelecteDayListProps) => {
|
}: SelectedDayListProps) => {
|
||||||
const { recordingsStore: recStore } = useContext(Context)
|
const { recordingsStore: recStore } = useContext(Context)
|
||||||
const camera = recStore.filteredCamera
|
const camera = recStore.filteredCamera
|
||||||
const host = recStore.filteredHost
|
const host = recStore.filteredHost
|
||||||
@ -27,9 +27,11 @@ const SelecteDayList = ({
|
|||||||
if (camera && host) {
|
if (camera && host) {
|
||||||
const stringDay = dateToQueryString(day)
|
const stringDay = dateToQueryString(day)
|
||||||
const hostName = mapHostToHostname(host)
|
const hostName = mapHostToHostname(host)
|
||||||
|
if (hostName){
|
||||||
const res = await proxyApi.getRecordingsSummary(hostName, camera.name, getResolvedTimeZone())
|
const res = await proxyApi.getRecordingsSummary(hostName, camera.name, getResolvedTimeZone())
|
||||||
return res.find(record => record.day === stringDay)
|
return res.find(record => record.day === stringDay)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -50,4 +52,4 @@ const SelecteDayList = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default observer(SelecteDayList);
|
export default observer(SelectedDayList);
|
||||||
165
src/widgets/VideoDownloader.tsx
Normal file
165
src/widgets/VideoDownloader.tsx
Normal 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;
|
||||||
30
yarn.lock
30
yarn.lock
@ -1140,7 +1140,7 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
regenerator-runtime "^0.13.11"
|
regenerator-runtime "^0.13.11"
|
||||||
|
|
||||||
"@babel/runtime@^7.5.5":
|
"@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7":
|
||||||
version "7.23.9"
|
version "7.23.9"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7"
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7"
|
||||||
integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==
|
integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==
|
||||||
@ -1802,6 +1802,14 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@mantine/hooks/-/hooks-6.0.16.tgz#507c322347659424a915ec6b541d848f53abcf6a"
|
resolved "https://registry.yarnpkg.com/@mantine/hooks/-/hooks-6.0.16.tgz#507c322347659424a915ec6b541d848f53abcf6a"
|
||||||
integrity sha512-DnfMYSTSjYxbQJ80TzKHO5gRXGTIQKxBnRQVc+n4RANTwgWMiwEmxIwqRjbulfLzIhEvplskhqGgElunIAfw7g==
|
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":
|
"@mantine/styles@6.0.16":
|
||||||
version "6.0.16"
|
version "6.0.16"
|
||||||
resolved "https://registry.yarnpkg.com/@mantine/styles/-/styles-6.0.16.tgz#625e5be80fc964fa6634f10e798c19f5a4c265ce"
|
resolved "https://registry.yarnpkg.com/@mantine/styles/-/styles-6.0.16.tgz#625e5be80fc964fa6634f10e798c19f5a4c265ce"
|
||||||
@ -4327,6 +4335,14 @@ dom-converter@^0.2.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
utila "~0.4"
|
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:
|
dom-serializer@0:
|
||||||
version "0.2.2"
|
version "0.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51"
|
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"
|
kleur "^3.0.3"
|
||||||
sisteransi "^1.0.5"
|
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"
|
version "15.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||||
@ -8481,6 +8497,16 @@ react-textarea-autosize@8.3.4:
|
|||||||
use-composed-ref "^1.3.0"
|
use-composed-ref "^1.3.0"
|
||||||
use-latest "^1.2.1"
|
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:
|
react-use-websocket@^4.7.0:
|
||||||
version "4.7.0"
|
version "4.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-use-websocket/-/react-use-websocket-4.7.0.tgz#e45545ed48eb60171bf6401d1884cc80c700a0ea"
|
resolved "https://registry.yarnpkg.com/react-use-websocket/-/react-use-websocket-4.7.0.tgz#e45545ed48eb60171bf6401d1884cc80c700a0ea"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user