From 2692e0078773aaec6fbe5525b9d94cfaf10c91a0 Mon Sep 17 00:00:00 2001 From: NlightN22 Date: Wed, 21 Feb 2024 01:35:26 +0700 Subject: [PATCH] add grid view add config editor --- package.json | 6 + src/AppBody.tsx | 3 +- src/hooks/use-camera-activity.ts | 65 +++ src/hooks/use-camera-live-mode.ts | 55 +++ src/hooks/use-persistence.ts | 45 ++ src/pages/403.tsx | 8 +- src/pages/404.tsx | 8 +- src/pages/FrigateHostsPage.tsx | 85 +++- src/pages/HostConfigPage.tsx | 119 +++++ src/pages/HostStoragePage.tsx | 14 + src/pages/HostSystemPage.tsx | 14 + src/pages/MainBody.tsx | 77 +++- src/pages/RetryError.tsx | 18 +- src/pages/SettingsPage.tsx | 11 +- src/pages/Test.tsx | 60 +-- src/router/AppRouter.tsx | 4 +- src/router/frigate.routes.ts | 5 - src/router/routes.path.ts | 7 +- src/router/routes.tsx | 31 +- src/services/frigate.proxy/frigate.api.ts | 70 ++- src/services/frigate.proxy/frigate.schema.ts | 63 +++ src/services/ws.tsx | 235 ++++++++++ src/shared/components/CameraCard.tsx | 74 ++++ src/shared/components/SideBar.tsx | 28 +- .../frigate/AutoUpdatingCameraImage.tsx | 96 +++++ src/shared/components/frigate/CameraImage.tsx | 53 +++ .../components/frigate/DebugCameraImage.tsx | 159 +++++++ .../components/{ => frigate}/JSMpegPlayer.tsx | 10 +- src/shared/components/frigate/MsePlayer.tsx | 269 ++++++++++++ src/shared/components/frigate/Player.tsx | 105 +++++ .../components/frigate/WebRTCPlayer.tsx | 168 ++++++++ src/shared/components/frigate/card.tsx | 80 ++++ .../{table.aps => hosts.table}/SwitchCell.tsx | 0 .../TextInputCell.tsx | 0 .../components/menu/HostSettingsMenu.tsx | 61 +++ src/shared/stores/sidebars.store.ts | 37 +- src/shared/strings/header.menu.strings.ts | 2 +- src/shared/strings/strings.ts | 1 + src/types/frigateConfig.ts | 406 ++++++++++++++++++ src/types/live.ts | 1 + src/types/ws.ts | 36 ++ src/widgets/FrigateHostsTable.tsx | 63 +-- src/widgets/LeftSideBar.tsx | 15 - src/widgets/RightSideBar.tsx | 18 - src/widgets/header/HeaderAction.tsx | 4 +- src/widgets/header/header.links.ts | 12 +- yarn.lock | 134 ++++++ 47 files changed, 2613 insertions(+), 222 deletions(-) create mode 100644 src/hooks/use-camera-activity.ts create mode 100644 src/hooks/use-camera-live-mode.ts create mode 100644 src/hooks/use-persistence.ts create mode 100644 src/pages/HostConfigPage.tsx create mode 100644 src/pages/HostStoragePage.tsx create mode 100644 src/pages/HostSystemPage.tsx delete mode 100644 src/router/frigate.routes.ts create mode 100644 src/services/frigate.proxy/frigate.schema.ts create mode 100644 src/services/ws.tsx create mode 100644 src/shared/components/CameraCard.tsx create mode 100644 src/shared/components/frigate/AutoUpdatingCameraImage.tsx create mode 100644 src/shared/components/frigate/CameraImage.tsx create mode 100644 src/shared/components/frigate/DebugCameraImage.tsx rename src/shared/components/{ => frigate}/JSMpegPlayer.tsx (95%) create mode 100644 src/shared/components/frigate/MsePlayer.tsx create mode 100644 src/shared/components/frigate/Player.tsx create mode 100644 src/shared/components/frigate/WebRTCPlayer.tsx create mode 100644 src/shared/components/frigate/card.tsx rename src/shared/components/{table.aps => hosts.table}/SwitchCell.tsx (100%) rename src/shared/components/{table.aps => hosts.table}/TextInputCell.tsx (100%) create mode 100644 src/shared/components/menu/HostSettingsMenu.tsx create mode 100644 src/types/frigateConfig.ts create mode 100644 src/types/live.ts create mode 100644 src/types/ws.ts delete mode 100644 src/widgets/LeftSideBar.tsx delete mode 100644 src/widgets/RightSideBar.tsx diff --git a/package.json b/package.json index 8c72745..0cc909a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@mantine/core": "^6.0.16", "@mantine/dates": "^6.0.16", "@mantine/hooks": "^6.0.16", + "@monaco-editor/react": "^4.6.0", "@tabler/icons-react": "^2.24.0", "@tanstack/react-query": "^5.21.2", "@testing-library/jest-dom": "^5.14.1", @@ -20,19 +21,24 @@ "@types/react-dom": "^18.0.0", "@types/validator": "^13.7.17", "axios": "^1.4.0", + "bson-objectid": "^2.0.4", "cookies-next": "^4.1.1", "dayjs": "^1.11.9", "embla-carousel-react": "^8.0.0-rc10", + "idb-keyval": "^6.2.1", "mantine-react-table": "^1.0.0-beta.25", "mobx": "^6.9.0", "mobx-react-lite": "^3.4.3", "mobx-utils": "^6.0.7", + "monaco-editor": "^0.46.0", + "monaco-yaml": "^5.1.1", "oidc-client-ts": "^2.2.4", "react": "^18.2.0", "react-dom": "^18.2.0", "react-oidc-context": "^2.2.2", "react-router-dom": "^6.14.1", "react-scripts": "5.0.1", + "react-use-websocket": "^4.7.0", "typescript": "^4.4.2", "validator": "^13.9.0", "web-vitals": "^2.1.0", diff --git a/src/AppBody.tsx b/src/AppBody.tsx index 0d38697..f85ab2b 100644 --- a/src/AppBody.tsx +++ b/src/AppBody.tsx @@ -1,10 +1,11 @@ -import React, { useEffect, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { AppShell, useMantineTheme, } from "@mantine/core" import { HeaderAction } from './widgets/header/HeaderAction'; import { testHeaderLinks } from './widgets/header/header.links'; import AppRouter from './router/AppRouter'; import { SideBar } from './shared/components/SideBar'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Context } from '.'; const queryClient = new QueryClient() diff --git a/src/hooks/use-camera-activity.ts b/src/hooks/use-camera-activity.ts new file mode 100644 index 0000000..5732bba --- /dev/null +++ b/src/hooks/use-camera-activity.ts @@ -0,0 +1,65 @@ + +import { useEffect, useMemo, useState } from "react"; +import { CameraConfig } from "../types/frigateConfig"; +import { useAudioActivity, useFrigateEvents, useMotionActivity } from "../services/ws"; + +type useCameraActivityReturn = { + activeTracking: boolean; + activeMotion: boolean; + activeAudio: boolean; +}; + +export default function useCameraActivity( + camera: CameraConfig +): useCameraActivityReturn { + const [activeObjects, setActiveObjects] = useState([]); + const hasActiveObjects = useMemo( + () => activeObjects.length > 0, + [activeObjects] + ); + + const { payload: detectingMotion } = useMotionActivity(camera.name); + const { payload: event } = useFrigateEvents(); + const { payload: audioRms } = useAudioActivity(camera.name); + + useEffect(() => { + if (!event) { + return; + } + + if (event.after.camera != camera.name) { + return; + } + + const eventIndex = activeObjects.indexOf(event.after.id); + + if (event.type == "end") { + if (eventIndex != -1) { + const newActiveObjects = [...activeObjects]; + newActiveObjects.splice(eventIndex, 1); + setActiveObjects(newActiveObjects); + } + } else { + if (eventIndex == -1) { + // add unknown event to list if not stationary + if (!event.after.stationary) { + const newActiveObjects = [...activeObjects, event.after.id]; + setActiveObjects(newActiveObjects); + } + } else { + // remove known event from list if it has become stationary + if (event.after.stationary) { + activeObjects.splice(eventIndex, 1); + } + } + } + }, [event, activeObjects]); + + return { + activeTracking: hasActiveObjects, + activeMotion: detectingMotion == "ON", + activeAudio: camera.audio.enabled_in_config + ? audioRms >= camera.audio.min_volume + : false, + }; +} diff --git a/src/hooks/use-camera-live-mode.ts b/src/hooks/use-camera-live-mode.ts new file mode 100644 index 0000000..1534812 --- /dev/null +++ b/src/hooks/use-camera-live-mode.ts @@ -0,0 +1,55 @@ +import { useMemo } from "react"; +import { usePersistence } from "./use-persistence"; +import { CameraConfig, FrigateConfig } from "../types/frigateConfig"; +import { LivePlayerMode } from "../types/live"; + +export default function useCameraLiveMode( + cameraConfig: CameraConfig, + preferredMode?: string +): LivePlayerMode { + // const { data: config } = useSWR("config"); + const { data: config } = { + data: { + go2rtc: { streams: 'test' }, + ui: {live_mode: ''} + }, + + } + + const restreamEnabled = useMemo(() => { + if (!config) { + return false; + } + + return ( + cameraConfig && + Object.keys(config.go2rtc.streams || {}).includes( + cameraConfig.live.stream_name + ) + ); + }, [config, cameraConfig]); + const defaultLiveMode = useMemo(() => { + if (config && cameraConfig) { + if (restreamEnabled) { + return cameraConfig.ui.live_mode || config?.ui.live_mode; + } + + return "jsmpeg"; + } + + return undefined; + }, [cameraConfig, restreamEnabled]); + const [viewSource] = usePersistence( + `${cameraConfig.name}-source`, + defaultLiveMode + ); + + if ( + restreamEnabled && + (preferredMode == "mse" || preferredMode == "webrtc") + ) { + return preferredMode; + } else { + return viewSource; + } +} diff --git a/src/hooks/use-persistence.ts b/src/hooks/use-persistence.ts new file mode 100644 index 0000000..48a03d7 --- /dev/null +++ b/src/hooks/use-persistence.ts @@ -0,0 +1,45 @@ +import { useEffect, useState, useCallback } from "react"; +import { get as getData, set as setData } from "idb-keyval"; + +type usePersistenceReturn = [ + value: any | undefined, + setValue: (value: string | boolean) => void, + loaded: boolean, +]; + +export function usePersistence( + key: string, + defaultValue: any | undefined = undefined +): usePersistenceReturn { + const [value, setInternalValue] = useState(defaultValue); + const [loaded, setLoaded] = useState(false); + + const setValue = useCallback( + (value: string | boolean) => { + setInternalValue(value); + async function update() { + await setData(key, value); + } + + update(); + }, + [key] + ); + + useEffect(() => { + setLoaded(false); + setInternalValue(defaultValue); + + async function load() { + const value = await getData(key); + if (typeof value !== "undefined") { + setValue(value); + } + setLoaded(true); + } + + load(); + }, [key, defaultValue, setValue]); + + return [value, setValue, loaded]; +} diff --git a/src/pages/403.tsx b/src/pages/403.tsx index d8b42e6..9bbf3d4 100644 --- a/src/pages/403.tsx +++ b/src/pages/403.tsx @@ -3,7 +3,7 @@ import React, { useContext, useEffect, useState } from 'react'; import CogWheelWithText from '../shared/components/CogWheelWithText'; import { strings } from '../shared/strings/strings'; import { redirect, useNavigate } from 'react-router-dom'; -import { pathRoutes } from '../router/routes.path'; +import { routesPath } from '../router/routes.path'; import { Context } from '..'; const Forbidden = () => { @@ -11,12 +11,12 @@ const Forbidden = () => { const { sideBarsStore } = useContext(Context) useEffect(() => { - sideBarsStore.setLeftSidebar(null) - sideBarsStore.setRightSidebar(null) + sideBarsStore.setLeftChildren(null) + sideBarsStore.setRightChildren(null) }, []) const handleGoToMain = () => { - window.location.replace(pathRoutes.MAIN_PATH) + window.location.replace(routesPath.MAIN_PATH) } return ( diff --git a/src/pages/404.tsx b/src/pages/404.tsx index 50685dc..98c7c13 100644 --- a/src/pages/404.tsx +++ b/src/pages/404.tsx @@ -3,7 +3,7 @@ import React, { useContext, useEffect, useState } from 'react'; import CogWheelWithText from '../shared/components/CogWheelWithText'; import { strings } from '../shared/strings/strings'; import { redirect, useNavigate } from 'react-router-dom'; -import { pathRoutes } from '../router/routes.path'; +import { routesPath } from '../router/routes.path'; import { Context } from '..'; const NotFound = () => { @@ -11,12 +11,12 @@ const NotFound = () => { const { sideBarsStore } = useContext(Context) useEffect(() => { - sideBarsStore.setLeftSidebar(null) - sideBarsStore.setRightSidebar(null) + sideBarsStore.setLeftChildren(null) + sideBarsStore.setRightChildren(null) }, []) const handleGoToMain = () => { - window.location.replace(pathRoutes.MAIN_PATH) + window.location.replace(routesPath.MAIN_PATH) } return ( diff --git a/src/pages/FrigateHostsPage.tsx b/src/pages/FrigateHostsPage.tsx index 4e02b66..777d60b 100644 --- a/src/pages/FrigateHostsPage.tsx +++ b/src/pages/FrigateHostsPage.tsx @@ -1,26 +1,89 @@ -import React from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import FrigateHostsTable from '../widgets/FrigateHostsTable'; -import { useQuery } from '@tanstack/react-query'; -import { frigateApi } from '../services/frigate.proxy/frigate.api'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api'; +import { deleteFrigateHostSchema, GetFrigateHost, putFrigateHostSchema} from '../services/frigate.proxy/frigate.schema'; import CenterLoader from '../shared/components/CenterLoader'; import RetryError from './RetryError'; +import { Context } from '..'; +import { strings } from '../shared/strings/strings'; +import { Button, Flex } from '@mantine/core'; +import { observer } from 'mobx-react-lite' -const FrigateHostsPage = () => { - - const { isPending: hostsPending, error: hostsError, data, refetch } = useQuery({ - queryKey: ['frigate-hosts'], +const FrigateHostsPage = observer(() => { + const queryClient = useQueryClient() + const { isPending: hostsPending, error: hostsError, data } = useQuery({ + queryKey: [frigateQueryKeys.getFrigateHosts], queryFn: frigateApi.getHosts, }) + const { sideBarsStore } = useContext(Context) + useEffect(() => { + sideBarsStore.rightVisible = false + sideBarsStore.setLeftChildren(null) + sideBarsStore.setRightChildren(null) + }, []) + + const [pageData, setPageData] = useState(data) + + useEffect(() => { + if (data) setPageData(data) + }, [data]) + + const { mutate } = useMutation({ + mutationFn: (tableData: GetFrigateHost[]) => { + let fetchPromises = [] + const toDelete = data?.filter(host => !tableData.some(table => table.id === host.id)) + if (toDelete && toDelete.length > 0) { + const parsedDelete = deleteFrigateHostSchema.array().parse(toDelete) + fetchPromises.push(frigateApi.deleteHosts(parsedDelete)) + } + if (tableData && tableData.length > 0) { + const parsedChanged = putFrigateHostSchema.array().parse(tableData) + fetchPromises.push(frigateApi.putHosts(parsedChanged)) + } + return Promise.all(fetchPromises) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [frigateQueryKeys.getFrigateHosts] }) + }, + onError: () => { + queryClient.invalidateQueries({ queryKey: [frigateQueryKeys.getFrigateHosts] }) + }, + onSettled: () => { + if (data) setPageData([...data]) + } + }) + + const handleSave = () => { + if (pageData) { + mutate(pageData) + } + } + + const handleChange = (data: GetFrigateHost[]) => { + setPageData(data) + } + + const handleDiscard = () => { + queryClient.invalidateQueries({ queryKey: [frigateQueryKeys.getFrigateHosts] }) + if (data) setPageData([...data]) + } + if (hostsPending) return - if (hostsError) return - return (
- + { + !pageData ? <> : + + } + + + +
); -}; +}) export default FrigateHostsPage; \ No newline at end of file diff --git a/src/pages/HostConfigPage.tsx b/src/pages/HostConfigPage.tsx new file mode 100644 index 0000000..f8590a9 --- /dev/null +++ b/src/pages/HostConfigPage.tsx @@ -0,0 +1,119 @@ +import React, { useCallback, useContext, useEffect, useRef } from 'react'; +import { useParams } from 'react-router-dom'; +import { Context } from '..'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { frigateApi, frigateQueryKeys, mapHostToHostname } from '../services/frigate.proxy/frigate.api'; +import { Button, Flex, Text, useMantineTheme } from '@mantine/core'; +import { useClipboard } from '@mantine/hooks'; +import { configureMonacoYaml } from "monaco-yaml"; +import Editor, { DiffEditor, useMonaco, loader, Monaco } from '@monaco-editor/react' +import * as monaco from "monaco-editor"; +import CenterLoader from '../shared/components/CenterLoader'; +import RetryError from './RetryError'; + + +const HostConfigPage = () => { + let { id } = useParams<'id'>() + const queryClient = useQueryClient() + const theme = useMantineTheme(); + const { isPending: configPending, error: configError, data: config, refetch } = useQuery({ + queryKey: [frigateQueryKeys.getFrigateHost, id], + queryFn: async () => { + const host = await frigateApi.getHost(id || '') + const hostName = mapHostToHostname(host) + return frigateApi.getHostConfigRaw(hostName) + }, + }) + + + const clipboard = useClipboard({ timeout: 500 }) + + const editorRef = useRef(null) + + function handleEditorWillMount(monaco: Monaco) { + monaco.languages.typescript.javascriptDefaults.setEagerModelSync(true); + + // TODO add yaml schema + // const modelUri = monaco.Uri.parse("http://localhost:4000/proxy/api/config/schema.json?hostName=localhost:5001") + // configureMonacoYaml(monaco, { + // enableSchemaRequest: true, + // hover: true, + // completion: true, + // validate: true, + // format: true, + // schemas: [ + // { + // uri: `http://localhost:4000/proxy/api/config/schema.json?hostName=localhost:5001`, + // fileMatch: ['**/.schema.*'], + // }, + // ], + // }) + } + + function handleEditorDidMount(editor: monaco.editor.IStandaloneCodeEditor, monaco: Monaco) { + // here is another way to get monaco instance + // you can also store it in `useRef` for further usage + editorRef.current = editor; + } + + const handleCopyConfig = useCallback(async () => { + if (!editorRef.current) { + return; + } + + clipboard.copy(editorRef.current.getValue()); + }, [editorRef]); + + const onHandleSaveConfig = useCallback( + async (save_option: string) => { + if (!editorRef.current) { + return; + } + console.log('save config', save_option) + }, [editorRef]) + + if (configPending) return + + if (configError) return + + return ( + + + + + + + + + + + ); + +} + +export default HostConfigPage; \ No newline at end of file diff --git a/src/pages/HostStoragePage.tsx b/src/pages/HostStoragePage.tsx new file mode 100644 index 0000000..3dab211 --- /dev/null +++ b/src/pages/HostStoragePage.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +const HostStoragePage = () => { + let { id } = useParams<'id'>() + + return ( +
+ Storage Page - NOT YET IMPLEMENTED +
+ ); +}; + +export default HostStoragePage; \ No newline at end of file diff --git a/src/pages/HostSystemPage.tsx b/src/pages/HostSystemPage.tsx new file mode 100644 index 0000000..991eb87 --- /dev/null +++ b/src/pages/HostSystemPage.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +const HostSystemPage = () => { + let { id } = useParams<'id'>() + + return ( +
+ System Page - NOT YET IMPLEMENTED +
+ ); +}; + +export default HostSystemPage; \ No newline at end of file diff --git a/src/pages/MainBody.tsx b/src/pages/MainBody.tsx index 0519215..6c008d7 100644 --- a/src/pages/MainBody.tsx +++ b/src/pages/MainBody.tsx @@ -1,4 +1,4 @@ -import { Container, Flex, Group, Text } from '@mantine/core'; +import { Container, Flex, Grid, Group, Skeleton, Text } from '@mantine/core'; import ProductTable, { TableAdapter } from '../widgets/ProductTable'; import HeadSearch from '../shared/components/HeadSearch'; import ViewSelector, { SelectorViewState } from '../shared/components/ViewSelector'; @@ -8,11 +8,25 @@ import { getCookie, setCookie } from 'cookies-next'; import { Context } from '..'; import { observer } from 'mobx-react-lite' import CenterLoader from '../shared/components/CenterLoader'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { frigateApi, frigateQueryKeys, mapHostToHostname } from '../services/frigate.proxy/frigate.api'; +import RetryError from './RetryError'; +import { CameraConfig, FrigateConfig } from '../types/frigateConfig'; +import { GetFrigateHostWConfig } from '../services/frigate.proxy/frigate.schema'; +import { host } from '../shared/env.const'; +import AutoUpdatingCameraImage from '../shared/components/frigate/AutoUpdatingCameraImage'; +import CameraCard from '../shared/components/CameraCard'; + + + const MainBody = observer(() => { - const { productStore, cartStore, sideBarsStore } = useContext(Context) - const { updateProductFromServer, products, isLoading: productsLoading } = productStore - const { updateCartFromServer, products: cartProducts, isLoading: cardLoading } = cartStore + const { sideBarsStore } = useContext(Context) + useEffect(() => { + sideBarsStore.rightVisible = false + sideBarsStore.setLeftChildren(null) + sideBarsStore.setRightChildren(null) + }, []) const [viewState, setTableState] = useState(getCookie('aps-main-view') as SelectorViewState || SelectorViewState.GRID) const handleToggleState = (state: SelectorViewState) => { @@ -20,28 +34,50 @@ const MainBody = observer(() => { setTableState(state) } - useEffect(() => { - updateProductFromServer() - updateCartFromServer() - sideBarsStore.setLeftSidebar(
) - }, []) + const { data: hosts, isPending, isError, refetch } = useQuery({ + queryKey: [frigateQueryKeys.getFrigateHostsConfigs], + queryFn: async () => { + const hosts = await frigateApi.getHosts() + let fetchedConfigs = [] + for (const host of hosts) { + if (host.enabled) { + const hostName = mapHostToHostname(host) + const config: FrigateConfig = await frigateApi.getHostConfig(hostName) + if (config) { + const hostWConfig: GetFrigateHostWConfig = { config: config, ...host } + fetchedConfigs.push(hostWConfig) + } + } + } + return fetchedConfigs + } + }) - if (productsLoading || cardLoading) return - if (productsLoading || cardLoading) return
Error
// add state manager + if (isPending) return + + if (isError) return - let tableData: TableAdapter[] = [] - let gridData: GridAdapter[] = [] - if (products && cartProducts) { - tableData = productStore.mapToTable(products, cartProducts) - gridData = productStore.mapToGrid(products, cartProducts) + // const child = () => { + // return ( ) + // } + + const cards = (host: GetFrigateHostWConfig) => { + return Object.entries(host.config.cameras).map( + ([cameraName, cameraConfig]) => ( + )) } return ( { + + {hosts.map(host => ( + + {cards(host)} + + ))} + ); }) diff --git a/src/pages/RetryError.tsx b/src/pages/RetryError.tsx index 908d3b4..7ddaa6b 100644 --- a/src/pages/RetryError.tsx +++ b/src/pages/RetryError.tsx @@ -1,29 +1,33 @@ import { Flex, Button, Text } from '@mantine/core'; import React, { useContext, useEffect } from 'react'; -import { pathRoutes } from '../router/routes.path'; -import { CogWheelHeartSVG } from '../shared/components/svg/CogWheelHeartSVG'; +import { routesPath } from '../router/routes.path'; import { strings } from '../shared/strings/strings'; import { useNavigate } from 'react-router-dom'; import { ExclamationCogWheel } from '../shared/components/svg/ExclamationCogWheel'; import { Context } from '..'; -const RetryError = () => { +interface RetryErrorProps { + onRetry?: () => void +} + +const RetryError = ( {onRetry} : RetryErrorProps) => { const navigate = useNavigate() const { sideBarsStore } = useContext(Context) useEffect(() => { - sideBarsStore.setLeftSidebar(null) - sideBarsStore.setRightSidebar(null) + sideBarsStore.setLeftChildren(null) + sideBarsStore.setRightChildren(null) }, []) const handleGoToMain = () => { - navigate(pathRoutes.MAIN_PATH) + navigate(routesPath.MAIN_PATH) } function handleRetry(event: React.MouseEvent): void { - throw new Error('Function not implemented.'); + if (onRetry) onRetry() + else window.location.reload() } return ( diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 0fc43d4..568da83 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -4,7 +4,7 @@ import { useMutation, useQueryClient, } from '@tanstack/react-query' -import { Config, frigateApi } from '../services/frigate.proxy/frigate.api'; +import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api'; import CenterLoader from '../shared/components/CenterLoader'; import RetryError from './RetryError'; import { Button, Flex, Space } from '@mantine/core'; @@ -12,16 +12,17 @@ import { FloatingLabelInput } from '../shared/components/FloatingLabelInput'; import { strings } from '../shared/strings/strings'; import { dimensions } from '../shared/dimensions/dimensions'; import { useMediaQuery } from '@mantine/hooks'; +import { GetConfig } from '../services/frigate.proxy/frigate.schema'; const SettingsPage = () => { const queryClient = useQueryClient() const { isPending: configPending, error: configError, data, refetch } = useQuery({ - queryKey: ['config'], + queryKey: [frigateQueryKeys.getConfig], queryFn: frigateApi.getConfig, }) const ecryptedValue = '**********' - const mapEncryptedToView = (data: Config[] | undefined): Config[] | undefined => { + const mapEncryptedToView = (data: GetConfig[] | undefined): GetConfig[] | undefined => { return data?.map(item => { const { value, encrypted, ...rest } = item if (encrypted) return { value: ecryptedValue, encrypted, ...rest } @@ -35,7 +36,7 @@ const SettingsPage = () => { const mutation = useMutation({ mutationFn: frigateApi.putConfig, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['config'] }) + queryClient.invalidateQueries({ queryKey: [frigateQueryKeys.getConfig] }) }, }) @@ -82,7 +83,7 @@ const SettingsPage = () => { if (configPending) return - if (configError) return + if (configError) return return ( diff --git a/src/pages/Test.tsx b/src/pages/Test.tsx index 9b47588..c90a119 100644 --- a/src/pages/Test.tsx +++ b/src/pages/Test.tsx @@ -1,51 +1,29 @@ import React, { Fragment, useContext, useEffect } from 'react'; -import { Context } from '..'; import { observer } from 'mobx-react-lite'; -import JSMpegPlayer from '../shared/components/JSMpegPlayer'; -import { cameraLiveViewURL } from '../router/frigate.routes'; +import JSMpegPlayer from '../shared/components/frigate/JSMpegPlayer'; +import { frigateApi } from '../services/frigate.proxy/frigate.api'; +import { Flex } from '@mantine/core'; +import AutoUpdatingCameraImage from '../shared/components/frigate/AutoUpdatingCameraImage'; const Test = observer(() => { - // const { postStore } = useContext(Context) - // const { getPostsAction, posts } = postStore + // const test = { + // camera: 'Buhgalteria', + // host: 'localhost:5000', + // width: 800, + // height: 600, + // url : function() { return frigateApi.cameraWsURL(this.host, this.camera)}, + // } - // useEffect( () => { - // console.log("render Test") - // getPostsAction() - // }, []) + // return ( + // + // + // + // ); - - const test = { - camera: 'Buhgalteria', - host: 'localhost:5000', - width: 800, - height: 600, - url : function() { return cameraLiveViewURL(this.host, this.camera)}, - } - const test2 = { - camera: 'IT', - host: 'localhost:5000', - width: 800, - height: 600, - url : function() { return cameraLiveViewURL(this.host, this.camera)}, - } - const test3 = { - camera: 'Magazin1', - host: 'localhost:5001', - width: 800, - height: 600, - url : function() { return cameraLiveViewURL(this.host, this.camera)}, - } - - - // console.log(posts) return ( - -
- - - -
-
+ + + ); }) diff --git a/src/router/AppRouter.tsx b/src/router/AppRouter.tsx index fb17456..93d155f 100644 --- a/src/router/AppRouter.tsx +++ b/src/router/AppRouter.tsx @@ -1,7 +1,7 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { routes } from "./routes"; import { v4 as uuidv4 } from 'uuid' -import { pathRoutes } from "./routes.path"; +import { routesPath } from "./routes.path"; const AppRouter = () => { return ( @@ -9,7 +9,7 @@ const AppRouter = () => { {routes.map(({ path, component }) => )} - } /> + } /> ) } diff --git a/src/router/frigate.routes.ts b/src/router/frigate.routes.ts deleted file mode 100644 index bea10a7..0000000 --- a/src/router/frigate.routes.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { hostURL } from ".." - -export const cameraLiveViewURL = (host: string, cameraName: string) => { - return `ws://${hostURL.host}/proxy-ws/live/jsmpeg/${cameraName}?hostName=${host}` -} \ No newline at end of file diff --git a/src/router/routes.path.ts b/src/router/routes.path.ts index 210975c..e87a45a 100644 --- a/src/router/routes.path.ts +++ b/src/router/routes.path.ts @@ -1,10 +1,13 @@ -export const pathRoutes = { +export const routesPath = { MAIN_PATH: '/', BIRDSEYE_PATH: '/birdseye', EVENTS_PATH: '/events', RECORDINGS_PATH: '/recordings', SETTINGS_PATH: '/settings', - HOST_CONFIG_PATH: '/host-config', + HOSTS_PATH: '/hosts', + HOST_CONFIG_PATH: '/hosts/:id/config', + HOST_SYSTEM_PATH: '/hosts/:id/system', + HOST_STORAGE_PATH: '/hosts/:id/storage', ROLES_PATH: '/roles', LIVE_PATH: '/live', THANKS_PATH: '/thanks', diff --git a/src/router/routes.tsx b/src/router/routes.tsx index c6136a1..9f8f82d 100644 --- a/src/router/routes.tsx +++ b/src/router/routes.tsx @@ -1,12 +1,15 @@ import {JSX} from "react"; import Test from "../pages/Test" import MainBody from "../pages/MainBody"; -import {pathRoutes} from "./routes.path"; +import {routesPath} from "./routes.path"; import RetryError from "../pages/RetryError"; import Forbidden from "../pages/403"; import NotFound from "../pages/404"; import SettingsPage from "../pages/SettingsPage"; import FrigateHostsPage from "../pages/FrigateHostsPage"; +import HostConfigPage from "../pages/HostConfigPage"; +import HostSystemPage from "../pages/HostSystemPage"; +import HostStoragePage from "../pages/HostStoragePage"; interface IRoute { path: string, @@ -15,31 +18,43 @@ interface IRoute { export const routes: IRoute[] = [ { //todo delete - path: pathRoutes.TEST_PATH, + path: routesPath.TEST_PATH, component: , }, { - path: pathRoutes.SETTINGS_PATH, + path: routesPath.SETTINGS_PATH, component: , }, { - path: pathRoutes.HOST_CONFIG_PATH, + path: routesPath.HOSTS_PATH, component: , }, { - path: pathRoutes.MAIN_PATH, + path: routesPath.HOST_CONFIG_PATH, + component: , + }, + { + path: routesPath.HOST_SYSTEM_PATH, + component: , + }, + { + path: routesPath.HOST_STORAGE_PATH, + component: , + }, + { + path: routesPath.MAIN_PATH, component: , }, { - path: pathRoutes.RETRY_ERROR_PATH, + path: routesPath.RETRY_ERROR_PATH, component: , }, { - path: pathRoutes.FORBIDDEN_ERROR_PATH, + path: routesPath.FORBIDDEN_ERROR_PATH, component: , }, { - path: pathRoutes.NOT_FOUND_ERROR_PATH, + path: routesPath.NOT_FOUND_ERROR_PATH, component: , }, ] \ No newline at end of file diff --git a/src/services/frigate.proxy/frigate.api.ts b/src/services/frigate.proxy/frigate.api.ts index cbd2fb3..a9f1bb6 100644 --- a/src/services/frigate.proxy/frigate.api.ts +++ b/src/services/frigate.proxy/frigate.api.ts @@ -1,25 +1,9 @@ import axios from "axios" import { proxyURL } from "../../shared/env.const" +import { z } from "zod" +import { GetConfig, DeleteFrigateHost, GetFrigateHost, PutConfig, PutFrigateHost, GetFrigateHostWithCameras } from "./frigate.schema"; +import { FrigateConfig } from "../../types/frigateConfig"; -export interface Config { - key: string, - value: string, - description: string, - encrypted: boolean, -} -export interface PutConfig { - key: string, - value: string, -} - -export interface FrigateHost { - id: string - createAt: string - updateAt: string - name: string - host: string - enabled: boolean -} const instance = axios.create({ baseURL: proxyURL.toString(), @@ -27,10 +11,50 @@ const instance = axios.create({ }); export const frigateApi = { - getConfig: () => instance.get('apiv1/config').then(res => res.data), + getConfig: () => instance.get('apiv1/config').then(res => res.data), putConfig: (config: PutConfig[]) => instance.put('apiv1/config', config).then(res => res.data), - getHosts: () => instance.get('apiv1/frigate-hosts').then(res => { - // return new Map(res.data.map(item => [item.id, item])) + getHosts: () => instance.get('apiv1/frigate-hosts').then(res => { return res.data }), -} \ No newline at end of file + getHostWithCameras: () => instance.get('apiv1/frigate-hosts', { params: { include: 'cameras'}}).then(res => { + return res.data + }), + getHost: (id: string) => instance.get(`apiv1/frigate-hosts/${id}`).then(res => { + return res.data + }), + putHosts: (hosts: PutFrigateHost[]) => instance.put('apiv1/frigate-hosts', hosts).then(res => { + return res.data + }), + deleteHosts: (hosts: DeleteFrigateHost[]) => instance.delete('apiv1/frigate-hosts', { data: hosts }).then(res => { + return res.data + }), + getHostConfigRaw: (hostName: string) => instance.get('proxy/api/config/raw', { params: { hostName: hostName } }).then(res => res.data), + getHostConfig: (hostName: string) => instance.get('proxy/api/config', { params: { hostName: hostName } }).then(res => res.data), + cameraWsURL: (hostName: string, cameraName: string) => { + return `ws://${proxyURL.host}/proxy-ws/live/jsmpeg/${cameraName}?hostName=${hostName}` + }, + cameraImageURL: (hostName: string, cameraName: string) => { + return `http://${proxyURL.host}/proxy/api/${cameraName}/latest.jpg?hostName=${hostName}` + }, +} + +export const mapCamerasFromConfig = (config: FrigateConfig): string[] => { + return Object.keys(config.cameras) +} + + + +export const mapHostToHostname = (host: GetFrigateHost): string => { + const url = new URL(host.host) + const hostName = url.host + return hostName +} + + +export const frigateQueryKeys = { + getConfig: 'config', + getFrigateHosts: 'frigate-hosts', + getFrigateHostsConfigs: 'frigate-hosts-configs', + getFrigateHost: 'frigate-host', + getHostConfig: 'host-config', +} diff --git a/src/services/frigate.proxy/frigate.schema.ts b/src/services/frigate.proxy/frigate.schema.ts new file mode 100644 index 0000000..3a0e71f --- /dev/null +++ b/src/services/frigate.proxy/frigate.schema.ts @@ -0,0 +1,63 @@ +import { any, z } from "zod"; +import { FrigateConfig } from "../../types/frigateConfig"; + +export const putConfigSchema = z.object({ + key: z.string(), + value: z.string(), +}) + +export const getConfigSchema = z.object({ + key: z.string(), + value: z.string(), + description: z.string(), + encrypted: z.boolean(), +}) + +export const getFrigateHostSchema = z.object({ + id: z.string(), + createAt: z.string(), + updateAt: z.string(), + name: z.string(), + host: z.string(), + enabled: z.boolean(), +}); + +export const getFrigateHostWConfigSchema = z.object({ + id: z.string(), + createAt: z.string(), + updateAt: z.string(), + name: z.string(), + host: z.string(), + enabled: z.boolean(), + config: z.any(), +}); + +const getCameraSchema = z.object({ + id: z.string(), + createAt: z.string(), + updateAt: z.string(), + name: z.string(), + url: z.string(), + state: z.boolean().nullable(), +}); + +export const getFrigateHostWithCamerasSchema = getFrigateHostSchema.merge(z.object({ + cameras: z.array(getCameraSchema), +})) + +export const putFrigateHostSchema = getFrigateHostSchema.omit({ + createAt: true, + updateAt: true, +}); + +export const deleteFrigateHostSchema = putFrigateHostSchema.pick({ + id: true, +}); + +export type GetConfig = z.infer +export type PutConfig = z.infer +export type GetFrigateHost = z.infer +export type GetFrigateHostWithCameras = z.infer +export type GetFrigateHostWConfig = GetFrigateHost & { config: FrigateConfig} +export type PutFrigateHost = z.infer +export type DeleteFrigateHost = z.infer \ No newline at end of file diff --git a/src/services/ws.tsx b/src/services/ws.tsx new file mode 100644 index 0000000..d9cf649 --- /dev/null +++ b/src/services/ws.tsx @@ -0,0 +1,235 @@ +import { + ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useReducer, +} from "react"; +import { produce, Draft } from "immer"; +import useWebSocket, { ReadyState } from "react-use-websocket"; +import { FrigateConfig } from "../types/frigateConfig"; +import { FrigateEvent, ToggleableSetting } from "../types/ws"; + +type ReducerState = { + [topic: string]: { + lastUpdate: number; + payload: any; + retain: boolean; + }; +}; + +type ReducerAction = { + topic: string; + payload: any; + retain: boolean; +}; + +const initialState: ReducerState = { + _initial_state: { + lastUpdate: 0, + payload: "", + retain: false, + }, +}; + +type WebSocketContextProps = { + state: ReducerState; + readyState: ReadyState; + sendJsonMessage: (message: any) => void; +}; + +export const WS = createContext({ + state: initialState, + readyState: ReadyState.CLOSED, + sendJsonMessage: () => {}, +}); + +export const useWebSocketContext = (): WebSocketContextProps => { + const context = useContext(WS); + if (!context) { + throw new Error( + "useWebSocketContext must be used within a WebSocketProvider" + ); + } + return context; +}; + +function reducer(state: ReducerState, action: ReducerAction): ReducerState { + switch (action.topic) { + default: + return produce(state, (draftState: Draft) => { + let parsedPayload = action.payload; + try { + parsedPayload = action.payload && JSON.parse(action.payload); + } catch (e) {} + draftState[action.topic] = { + lastUpdate: Date.now(), + payload: parsedPayload, + retain: action.retain, + }; + }); + } +} + +type WsProviderType = { + config: FrigateConfig; + children: ReactNode; + wsUrl: string; +}; + +export function WsProvider({ + config, + children, + wsUrl, +}: WsProviderType) { + const [state, dispatch] = useReducer(reducer, initialState); + + const { sendJsonMessage, readyState } = useWebSocket(wsUrl, { + onMessage: (event) => { + dispatch(JSON.parse(event.data)); + }, + onOpen: () => dispatch({ topic: "", payload: "", retain: false }), + shouldReconnect: () => true, + }); + + useEffect(() => { + Object.keys(config.cameras).forEach((camera) => { + const { name, record, detect, snapshots, audio } = config.cameras[camera]; + dispatch({ + topic: `${name}/recordings/state`, + payload: record.enabled ? "ON" : "OFF", + retain: false, + }); + dispatch({ + topic: `${name}/detect/state`, + payload: detect.enabled ? "ON" : "OFF", + retain: false, + }); + dispatch({ + topic: `${name}/snapshots/state`, + payload: snapshots.enabled ? "ON" : "OFF", + retain: false, + }); + dispatch({ + topic: `${name}/audio/state`, + payload: audio.enabled ? "ON" : "OFF", + retain: false, + }); + }); + }, [config]); + + return ( + + {children} + + ); +} + +export function useWs(watchTopic: string, publishTopic: string) { + const { state, readyState, sendJsonMessage } = useWebSocketContext(); + + const value = state[watchTopic] || { payload: null }; + + const send = useCallback( + (payload: any, retain = false) => { + if (readyState === ReadyState.OPEN) { + sendJsonMessage({ + topic: publishTopic || watchTopic, + payload, + retain, + }); + } + }, + [sendJsonMessage, readyState, watchTopic, publishTopic] + ); + + return { value, send }; +} + +export function useDetectState(camera: string): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs(`${camera}/detect/state`, `${camera}/detect/set`); + return { payload, send }; +} + +export function useRecordingsState(camera: string): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs(`${camera}/recordings/state`, `${camera}/recordings/set`); + return { payload, send }; +} + +export function useSnapshotsState(camera: string): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs(`${camera}/snapshots/state`, `${camera}/snapshots/set`); + return { payload, send }; +} + +export function useAudioState(camera: string): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs(`${camera}/audio/state`, `${camera}/audio/set`); + return { payload, send }; +} + +export function usePtzCommand(camera: string): { + payload: string; + send: (payload: string, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs(`${camera}/ptz`, `${camera}/ptz`); + return { payload, send }; +} + +export function useRestart(): { + payload: string; + send: (payload: string, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs("restart", "restart"); + return { payload, send }; +} + +export function useFrigateEvents(): { payload: FrigateEvent } { + const { + value: { payload }, + } = useWs(`events`, ""); + return { payload }; +} + +export function useMotionActivity(camera: string): { payload: string } { + const { + value: { payload }, + } = useWs(`${camera}/motion`, ""); + return { payload }; +} + +export function useAudioActivity(camera: string): { payload: number } { + const { + value: { payload }, + } = useWs(`${camera}/audio/rms`, ""); + return { payload }; +} diff --git a/src/shared/components/CameraCard.tsx b/src/shared/components/CameraCard.tsx new file mode 100644 index 0000000..45a526c --- /dev/null +++ b/src/shared/components/CameraCard.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { CameraConfig } from '../../types/frigateConfig'; +import { AspectRatio, Button, Card, Flex, Grid, Group, Space, Text, createStyles, useMantineTheme } from '@mantine/core'; +import AutoUpdatingCameraImage from './frigate/AutoUpdatingCameraImage'; + +interface CameraCardProps { + cameraName: string, + hostName: string, + cameraConfig: CameraConfig, + imageUrl: string +} + + +const useStyles = createStyles((theme) => ({ + mainCard: { + display: 'flex', + justifyContent: 'space-between', + flexDirection: 'column', + backgroundColor: theme.colorScheme === 'dark' ? theme.fn.darken(theme.colors.gray[7], 0.5) : theme.colors.gray[2], + '&:hover': { + backgroundColor: theme.colorScheme === 'dark' ? theme.fn.darken(theme.colors.cyan[9], 0.5) : theme.colors.cyan[1], + }, + }, + bottomGroup: { + marginTop: 'auto', + }, + headText: { + color: theme.colorScheme === 'dark' ? theme.colors.gray[4] : theme.colors.gray[9], + fontWeight: 'bold' + } +})) + + +const CameraCard = ({ + cameraName, + hostName, + cameraConfig, + imageUrl, +}: CameraCardProps) => { + const { classes } = useStyles(); + + const handleOpenLiveView = () => { + throw Error('Not yet implemented') + } + const handleOpenRecordings = () => { + throw Error('Not yet implemented') + } + const handleOpenEvents = () => { + throw Error('Not yet implemented') + } + return ( + + + {/* */} + {cameraName} / {hostName} + + + + + + + + + + ); +}; + +export default CameraCard; \ No newline at end of file diff --git a/src/shared/components/SideBar.tsx b/src/shared/components/SideBar.tsx index 22ca829..125b6f1 100644 --- a/src/shared/components/SideBar.tsx +++ b/src/shared/components/SideBar.tsx @@ -43,32 +43,38 @@ export const SideBar = observer(({ isHidden, side, children }: SideBarProps) => const { sideBarsStore } = useContext(Context) + useEffect( () => { + if (sideBarsStore.rightVisible && side === 'right' && !visible) { + open() + } else if (!sideBarsStore.rightVisible && side === 'right' && visible) { + close() + } + }, [sideBarsStore.rightVisible]) + const [leftChildren, setLeftChildren] = useState(() => { if (children && side === 'left') return children - else if (sideBarsStore.leftSideBar) return sideBarsStore.leftSideBar + else if (sideBarsStore.leftChildren) return sideBarsStore.leftChildren return null }) const [rightChildren, setRightChildren] = useState(() => { if (children && side === 'right') return children - else if (sideBarsStore.rightSideBar) return sideBarsStore.rightSideBar + else if (sideBarsStore.rightChildren) return sideBarsStore.rightChildren return null }) - useEffect( () => { - setLeftChildren(sideBarsStore.leftSideBar) - }, [sideBarsStore.leftSideBar]) + useEffect(() => { + setLeftChildren(sideBarsStore.leftChildren) + }, [sideBarsStore.leftChildren]) - useEffect( () => { - setRightChildren(sideBarsStore.rightSideBar) - }, [sideBarsStore.rightSideBar]) + useEffect(() => { + setRightChildren(sideBarsStore.rightChildren) + }, [sideBarsStore.rightChildren]) useEffect(() => { isHidden(!visible) }, [visible]) - useEffect(() => { - }, [manualVisible.current]) - + // resize controller useEffect(() => { const checkWindowSize = () => { if (window.innerWidth <= hideSizePx && visible) { diff --git a/src/shared/components/frigate/AutoUpdatingCameraImage.tsx b/src/shared/components/frigate/AutoUpdatingCameraImage.tsx new file mode 100644 index 0000000..3f9a724 --- /dev/null +++ b/src/shared/components/frigate/AutoUpdatingCameraImage.tsx @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import CameraImage from "./CameraImage"; +import { CameraConfig } from "../../../types/frigateConfig"; +import { useDocumentVisibility } from "@mantine/hooks"; +import { AspectRatio, Flex } from "@mantine/core"; + +interface AutoUpdatingCameraImageProps extends React.ImgHTMLAttributes { + cameraConfig: CameraConfig + searchParams?: {}; + showFps?: boolean; + className?: string; + url: string +}; + +export default function AutoUpdatingCameraImage({ + cameraConfig, + searchParams = "", + showFps = true, + className, + url, + ...rest +}: AutoUpdatingCameraImageProps) { + const [key, setKey] = useState(Date.now()); + const [fps, setFps] = useState("0"); + const [timeoutId, setTimeoutId] = useState(); + + const windowVisible = useDocumentVisibility() + + + const reloadInterval = useMemo(() => { + if (windowVisible === "hidden") { + return -1; // no reason to update the image when the window is not visible + } + + // if (liveReady) { + // return 60000; + // } + + // if (cameraActive) { + // return 200; + // } + + return 30000; + }, [windowVisible]); + + useEffect(() => { + if (reloadInterval == -1) { + return; + } + + setKey(Date.now()); + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + setTimeoutId(undefined); + } + }; + }, [reloadInterval]); + + const handleLoad = useCallback(() => { + if (reloadInterval == -1) { + return; + } + + const loadTime = Date.now() - key; + + if (showFps) { + setFps((1000 / Math.max(loadTime, reloadInterval)).toFixed(1)); + } + + setTimeoutId( + setTimeout( + () => { + setKey(Date.now()); + }, + loadTime > reloadInterval ? 1 : reloadInterval + ) + ); + }, [key, setFps]); + + return ( + // + + + {showFps ? Displaying at {fps}fps : null} + + // + ); +} diff --git a/src/shared/components/frigate/CameraImage.tsx b/src/shared/components/frigate/CameraImage.tsx new file mode 100644 index 0000000..e2f0126 --- /dev/null +++ b/src/shared/components/frigate/CameraImage.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef } from "react"; +import { CameraConfig } from "../../../types/frigateConfig"; +import { AspectRatio, Flex, createStyles, Text } from "@mantine/core"; + +interface CameraImageProps extends React.ImgHTMLAttributes { + className?: string; + cameraConfig: CameraConfig; + onload?: () => void; + searchParams?: {}; + url: string +}; + +const useStyles = createStyles((theme) => ({ + + })) + + +export default function CameraImage({ + className, + cameraConfig, + onload, + searchParams = "", + url, ...rest }: CameraImageProps) { + const imgRef = useRef(null); + const { classes } = useStyles(); + + const name = cameraConfig.name + const enabled = cameraConfig.enabled + + useEffect(() => { + if (!cameraConfig || !imgRef.current) { + return; + } + imgRef.current.src = url + }, [name, imgRef, searchParams]); + + return ( + + {enabled ? ( + + + + ) : ( + + Camera is disabled in config, no stream or snapshot available! + + )} + + ); +} diff --git a/src/shared/components/frigate/DebugCameraImage.tsx b/src/shared/components/frigate/DebugCameraImage.tsx new file mode 100644 index 0000000..e129d06 --- /dev/null +++ b/src/shared/components/frigate/DebugCameraImage.tsx @@ -0,0 +1,159 @@ +import { useCallback, useMemo, useState } from "react"; +import AutoUpdatingCameraImage from "./AutoUpdatingCameraImage"; +import { CameraConfig } from "../../../types/frigateConfig"; +import { usePersistence } from "../../../hooks/use-persistence"; +import { Button, Switch, Text } from "@mantine/core"; +import { Card, CardContent, CardHeader, CardTitle } from "./card"; +import { IconSettings } from "@tabler/icons-react"; + + +type Options = { [key: string]: boolean }; + +const emptyObject = Object.freeze({}); + +type DebugCameraImageProps = { + className?: string; + cameraConfig: CameraConfig + url: string +}; + +export default function DebugCameraImage({ + className, + cameraConfig, + url, +}: DebugCameraImageProps) { + const [showSettings, setShowSettings] = useState(false); + const [options, setOptions] = usePersistence( + `${cameraConfig?.name}-feed`, + emptyObject + ); + const handleSetOption = useCallback( + (id: string, value: boolean) => { + const newOptions = { ...options, [id]: value }; + setOptions(newOptions); + }, + [options] + ); + const searchParams = useMemo( + () => + new URLSearchParams( + Object.keys(options).reduce((memo, key) => { + //@ts-ignore we know this is correct + memo.push([key, options[key] === true ? "1" : "0"]); + return memo; + }, []) + ), + [options] + ); + const handleToggleSettings = useCallback(() => { + setShowSettings(!showSettings); + }, [showSettings]); + + return ( +
+ + + {showSettings ? ( + + + Options + + + + + + ) : null} +
+ ); +} + +type DebugSettingsProps = { + handleSetOption: (id: string, value: boolean) => void; + options: Options; +}; + +function DebugSettings({ handleSetOption, options }: DebugSettingsProps) { + return ( +
+
+ { }} + // onCheckedChange={(isChecked) => { + // handleSetOption("bbox", isChecked); + // }} + /> + {/* */} + Bounding Box +
+
+ { + // handleSetOption("timestamp", isChecked); + // }} + /> + {/* */} + Timestamp +
+
+ { + // handleSetOption("zones", isChecked); + // }} + /> + {/* */} + Zones +
+
+ { + // handleSetOption("mask", isChecked); + // }} + /> + {/* */} + Mask +
+
+ { + // handleSetOption("motion", isChecked); + // }} + /> + {/* */} + Motion +
+
+ { + // handleSetOption("regions", isChecked); + // }} + /> + {/* */} + Regions +
+
+ ); +} diff --git a/src/shared/components/JSMpegPlayer.tsx b/src/shared/components/frigate/JSMpegPlayer.tsx similarity index 95% rename from src/shared/components/JSMpegPlayer.tsx rename to src/shared/components/frigate/JSMpegPlayer.tsx index d5f96ea..d9d8f4e 100644 --- a/src/shared/components/JSMpegPlayer.tsx +++ b/src/shared/components/frigate/JSMpegPlayer.tsx @@ -1,11 +1,11 @@ // @ts-ignore we know this doesn't have types import JSMpeg from "@cycjimmy/jsmpeg-player"; import { useEffect, useMemo, useRef } from "react"; -import { useResizeObserver } from "../utils/resize-observer"; +import { useResizeObserver } from "../../utils/resize-observer"; type JSMpegPlayerProps = { className?: string; - url: string; + wsUrl: string; camera: string; width: number; height: number; @@ -13,7 +13,7 @@ type JSMpegPlayerProps = { export default function JSMpegPlayer({ camera, - url, + wsUrl, width, height, className, @@ -60,7 +60,7 @@ export default function JSMpegPlayer({ const video = new JSMpeg.VideoElement( playerRef.current, - url, + wsUrl, {}, { protocols: [], audio: false, videoBufferSize: 1024 * 1024 * 4 } ); @@ -83,7 +83,7 @@ export default function JSMpegPlayer({ playerRef.current = null; } }; - }, [url]); + }, [wsUrl]); return (
diff --git a/src/shared/components/frigate/MsePlayer.tsx b/src/shared/components/frigate/MsePlayer.tsx new file mode 100644 index 0000000..1032490 --- /dev/null +++ b/src/shared/components/frigate/MsePlayer.tsx @@ -0,0 +1,269 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +type MSEPlayerProps = { + camera: string; + className?: string; + playbackEnabled?: boolean; + onPlaying?: () => void; + wsUrl: string, +}; + +function MSEPlayer({ + camera, + className, + playbackEnabled = true, + onPlaying, + wsUrl, +}: MSEPlayerProps) { + let connectTS: number = 0; + + const RECONNECT_TIMEOUT: number = 30000; + + const CODECS: string[] = [ + "avc1.640029", // H.264 high 4.1 (Chromecast 1st and 2nd Gen) + "avc1.64002A", // H.264 high 4.2 (Chromecast 3rd Gen) + "avc1.640033", // H.264 high 5.1 (Chromecast with Google TV) + "hvc1.1.6.L153.B0", // H.265 main 5.1 (Chromecast Ultra) + "mp4a.40.2", // AAC LC + "mp4a.40.5", // AAC HE + "flac", // FLAC (PCM compatible) + "opus", // OPUS Chrome, Firefox + ]; + + const visibilityThreshold: number = 0; + const visibilityCheck: boolean = true; + + const [wsState, setWsState] = useState(WebSocket.CLOSED); + + const videoRef = useRef(null); + const wsRef = useRef(null); + const reconnectTIDRef = useRef(null); + const ondataRef = useRef<((data: any) => void) | null>(null); + const onmessageRef = useRef<{ [key: string]: (msg: any) => void }>({}); + const msRef = useRef(null); + + const wsURL = useMemo(() => { + return wsUrl; + }, [camera]); + + const play = () => { + const currentVideo = videoRef.current; + + if (currentVideo) { + currentVideo.play().catch((er: any) => { + if (er.name === "NotAllowedError" && !currentVideo.muted) { + currentVideo.muted = true; + currentVideo.play().catch(() => {}); + } + }); + } + }; + + const send = useCallback( + (value: any) => { + if (wsRef.current) wsRef.current.send(JSON.stringify(value)); + }, + [wsRef] + ); + + const codecs = useCallback((isSupported: (type: string) => boolean) => { + return CODECS.filter((codec) => + isSupported(`video/mp4; codecs="${codec}"`) + ).join(); + }, []); + + const onConnect = useCallback(() => { + if (!videoRef.current?.isConnected || !wsURL || wsRef.current) return false; + + setWsState(WebSocket.CONNECTING); + + connectTS = Date.now(); + + wsRef.current = new WebSocket(wsURL); + wsRef.current.binaryType = "arraybuffer"; + wsRef.current.addEventListener("open", () => onOpen()); + wsRef.current.addEventListener("close", () => onClose()); + }, [wsURL]); + + const onDisconnect = useCallback(() => { + setWsState(WebSocket.CLOSED); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }, []); + + const onOpen = useCallback(() => { + setWsState(WebSocket.OPEN); + + wsRef.current?.addEventListener("message", (ev) => { + if (typeof ev.data === "string") { + const msg = JSON.parse(ev.data); + for (const mode in onmessageRef.current) { + onmessageRef.current[mode](msg); + } + } else { + ondataRef.current?.(ev.data); + } + }); + + ondataRef.current = null; + onmessageRef.current = {}; + + onMse(); + }, []); + + const onClose = useCallback(() => { + if (wsState === WebSocket.CLOSED) return; + + setWsState(WebSocket.CONNECTING); + wsRef.current = null; + + const delay = Math.max(RECONNECT_TIMEOUT - (Date.now() - connectTS), 0); + + reconnectTIDRef.current = window.setTimeout(() => { + reconnectTIDRef.current = null; + onConnect(); + }, delay); + }, [wsState, connectTS, onConnect]); + + const onMse = () => { + if ("ManagedMediaSource" in window) { + const MediaSource = window.ManagedMediaSource; + + msRef.current?.addEventListener( + "sourceopen", + () => { + send({ + type: "mse", + // @ts-ignore + value: codecs(MediaSource.isTypeSupported), + }); + }, + { once: true } + ); + + if (videoRef.current) { + videoRef.current.disableRemotePlayback = true; + videoRef.current.srcObject = msRef.current; + } + } else { + msRef.current?.addEventListener( + "sourceopen", + () => { + URL.revokeObjectURL(videoRef.current?.src || ""); + send({ + type: "mse", + value: codecs(MediaSource.isTypeSupported), + }); + }, + { once: true } + ); + videoRef.current!.src = URL.createObjectURL(msRef.current!); + videoRef.current!.srcObject = null; + } + play(); + + onmessageRef.current["mse"] = (msg) => { + if (msg.type !== "mse") return; + + const sb = msRef.current?.addSourceBuffer(msg.value); + sb?.addEventListener("updateend", () => { + if (sb.updating) return; + + try { + if (bufLen > 0) { + const data = buf.slice(0, bufLen); + bufLen = 0; + sb.appendBuffer(data); + } else if (sb.buffered && sb.buffered.length) { + const end = sb.buffered.end(sb.buffered.length - 1) - 15; + const start = sb.buffered.start(0); + if (end > start) { + sb.remove(start, end); + msRef.current?.setLiveSeekableRange(end, end + 15); + } + } + } catch (e) { + console.debug(e); + } + }); + + const buf = new Uint8Array(2 * 1024 * 1024); + let bufLen = 0; + + ondataRef.current = (data) => { + if (sb?.updating || bufLen > 0) { + const b = new Uint8Array(data); + buf.set(b, bufLen); + bufLen += b.byteLength; + // console.debug("VideoRTC.buffer", b.byteLength, bufLen); + } else { + try { + sb?.appendBuffer(data); + } catch (e) { + console.debug(e); + } + } + }; + }; + }; + + useEffect(() => { + if (!playbackEnabled) { + return; + } + + // iOS 17.1+ uses ManagedMediaSource + const MediaSourceConstructor = + "ManagedMediaSource" in window ? window.ManagedMediaSource : MediaSource; + + // @ts-ignore + msRef.current = new MediaSourceConstructor(); + + if ("hidden" in document && visibilityCheck) { + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + onDisconnect(); + } else if (videoRef.current?.isConnected) { + onConnect(); + } + }); + } + + if ("IntersectionObserver" in window && visibilityThreshold) { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) { + onDisconnect(); + } else if (videoRef.current?.isConnected) { + onConnect(); + } + }); + }, + { threshold: visibilityThreshold } + ); + observer.observe(videoRef.current!); + } + + onConnect(); + + return () => { + onDisconnect(); + }; + }, [playbackEnabled, onDisconnect, onConnect]); + + return ( +