diff --git a/.env.docker b/.env.docker index e5d0d3d..3c10910 100644 --- a/.env.docker +++ b/.env.docker @@ -1,4 +1,4 @@ -FRIGATE_PROXY=http://localhost:4000 -OPENID_SERVER=https://your.server.com:443/realms/your-realm -REALM=frigate-realm -CLIENT_ID=frontend-client \ No newline at end of file +FRIGATE_PROXY=http://frigate-proxy:4000 +OPENID_SERVER=https://keycloak:8443 +CLIENT_ID=frontend-client +REALM=frigate-realm \ No newline at end of file diff --git a/src/AppBody.tsx b/src/AppBody.tsx index 532163a..116999b 100644 --- a/src/AppBody.tsx +++ b/src/AppBody.tsx @@ -16,7 +16,7 @@ const AppBody = () => { { link: routesPath.SETTINGS_PATH, label: t('header.settings'), admin: true }, { link: routesPath.RECORDINGS_PATH, label: t('header.recordings') }, { link: routesPath.EVENTS_PATH, label: t('header.events') }, - { link: routesPath.HOSTS_PATH, label: t('header.hostsConfig'), admin: true }, + { link: routesPath.HOSTS_PATH, label: t('header.sitesConfig'), admin: true }, { link: routesPath.ACCESS_PATH, label: t('header.acessSettings'), admin: true }, ] @@ -53,7 +53,7 @@ const AppBody = () => { aside={ !pathsWithRightSidebar.includes(location.pathname) ? <> : - + } > diff --git a/src/locales/en.ts b/src/locales/en.ts index a8aaed4..6982971 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -18,6 +18,12 @@ const en = { selectStartTime: 'Select start time:', selectEndTime: 'Select end time:', startTimeBiggerThanEnd: 'Start time bigger than end time', + confidenceThreshold: 'Confidence Threshold', + objectTypeFilter: 'Object Type', + selectObjectType: 'Select object types...', + eventTypeFilter: 'Event Type', + allEvents: 'All Events', + aiDetectionOnly: 'AI Detection', }, frigateConfigPage: { copyConfig: 'Copy Config', @@ -70,10 +76,10 @@ const en = { memory: 'Memory %' }, hostMenu: { - editConfig: 'Редакт. конфиг.', - restart: 'Перезагрузка', - system: 'Система', - storage: 'Хранилище', + editConfig: 'Edit config', + restart: 'Restart', + system: 'System', + storage: 'Storage', }, header: { @@ -81,18 +87,25 @@ const en = { settings: 'Settings', recordings: 'Recordings', events: 'Events', - hostsConfig: 'Frigate servers', + sitesConfig: 'Sites', acessSettings: 'Access settings', - }, - frigateHostTableTitles: { - host: 'Хост', - name: 'Имя хоста', - url: 'Адрес', - enabled: 'Включен', }, - frigateHostTablePlaceholders: { - host: 'http://host.docker.internal:5000 or http://192.168.1.1:5000', - name: 'YourFrigateHostName', + siteTableTitles: { + siteName: 'Site Name', + location: 'Physical Location', + url: 'Frigate URL', + enabled: 'Enabled', + }, + siteTablePlaceholders: { + url: 'http://host.docker.internal:5000 or http://192.168.1.1:5000', + name: 'Warehouse North', + location: 'Building A, Floor 2', + }, + siteStatus: { + online: 'Online', + degraded: 'Degraded', + offline: 'Offline', + camerasOnline: '{{online}}/{{total}} cameras online', }, player: { startVideo: 'Enable Video', @@ -114,7 +127,7 @@ const en = { version: 'Version', uptime: 'Uptime', pleaseSelectRole: 'Please select Role', - pleaseSelectHost: 'Please select Host', + pleaseSelectSite: 'Please select Site', pleaseSelectCamera: 'Please select Camera', pleaseSelectDate: 'Please select Date', nothingHere: 'Nothing here', @@ -131,7 +144,7 @@ const en = { events: 'Events', notHaveEvents: 'No events', notHaveEventsAtThatPeriod: 'Not have events at that period', - selectHost: 'Select host', + selectSite: 'Select site', selectCamera: 'Select Camera', selectRange: 'Select period', changeTheme: "Change theme", diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 10c3ea5..b52ad2a 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -11,11 +11,17 @@ const ru = { width: 'Ширина', height: 'Высота', points: 'Точки', - }, + }, eventsPage: { selectStartTime: 'Выбери время начала:', selectEndTime: 'Выбери время окончания:', startTimeBiggerThanEnd: 'Время начала больше времени окончания', + confidenceThreshold: 'Порог уверенности', + objectTypeFilter: 'Тип объекта', + selectObjectType: 'Выберите типы объектов...', + eventTypeFilter: 'Тип события', + allEvents: 'Все события', + aiDetectionOnly: 'AI обнаружение', }, frigateConfigPage: { copyConfig: 'Копировать Конфиг.', @@ -78,18 +84,25 @@ const ru = { settings: 'Настройки', recordings: 'Записи', events: 'События', - hostsConfig: 'Серверы Frigate', + sitesConfig: 'Объекты', acessSettings: 'Настройка доступа', }, - frigateHostTableTitles: { - host: 'Хост', - name: 'Имя хоста', - url: 'Адрес', + siteTableTitles: { + siteName: 'Название объекта', + location: 'Физ. расположение', + url: 'Frigate URL', enabled: 'Включен', }, - frigateHostTablePlaceholders: { - host: 'http://host.docker.internal:5000 or http://192.168.1.1:5000', - name: 'YourFrigateHostName', + siteTablePlaceholders: { + url: 'http://host.docker.internal:5000 or http://192.168.1.1:5000', + name: 'Склад Северный', + location: 'Здание А, Этаж 2', + }, + siteStatus: { + online: 'В сети', + degraded: 'Частично', + offline: 'Не в сети', + camerasOnline: '{{online}}/{{total}} камер в сети', }, player: { startVideo: 'Вкл. Видео', @@ -111,7 +124,7 @@ const ru = { version: 'Версия', uptime: 'Время работы', pleaseSelectRole: 'Пожалуйста выберите роль', - pleaseSelectHost: 'Пожалуйста выберите хост', + pleaseSelectSite: 'Пожалуйста выберите объект', pleaseSelectCamera: 'Пожалуйста выберите камеру', pleaseSelectDate: 'Пожалуйста выберите дату', nothingHere: 'Ничего нет', @@ -128,7 +141,7 @@ const ru = { events: 'События', notHaveEvents: 'Событий нет', notHaveEventsAtThatPeriod: 'Нет событий за этот период', - selectHost: 'Выбери хост', + selectSite: 'Выбери объект', selectCamera: 'Выбери камеру', selectRange: 'Выбери период', changeTheme: "Изменить тему", diff --git a/src/pages/EditCameraPage.tsx b/src/pages/EditCameraPage.tsx index 436f5ec..22659b9 100644 --- a/src/pages/EditCameraPage.tsx +++ b/src/pages/EditCameraPage.tsx @@ -1,15 +1,16 @@ -import { Button, Center, Flex, Text } from '@mantine/core'; +import { Button, Center, Flex, Text, Divider, Paper, Title } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { IconAlertCircle, IconCircleCheck } from '@tabler/icons-react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { observer } from 'mobx-react-lite'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { useAdminRole } from '../hooks/useAdminRole'; import { frigateApi, frigateQueryKeys, mapHostToHostname, proxyApi } from '../services/frigate.proxy/frigate.api'; import MaskSelect, { MaskItem, MaskType } from '../shared/components/filters/MaskSelect'; import OverlayCogwheelLoader from '../shared/components/loaders/OverlayCogwheelLoader'; +import RetentionSettings from '../shared/components/RetentionSettings'; import { Point, extractMaskNumber } from '../shared/utils/maskPoint'; import CameraMaskDrawer from '../widgets/CameraMaskDrawer'; import CameraPageHeader from '../widgets/header/CameraPageHeader'; @@ -22,6 +23,7 @@ const EditCameraPage = () => { if (!cameraId) throw Error(t('editCameraPage.cameraIdNotExist')) const [selectedMask, setSelectedMask] = useState() const [points, setPoints] = useState() + const [retentionDays, setRetentionDays] = useState(0) const { data: camera, isPending, isError, refetch } = useQuery({ queryKey: [frigateQueryKeys.getCameraWHost, cameraId], @@ -143,9 +145,45 @@ const EditCameraPage = () => { setPoints([]) } + // Initialize retention days from camera config + useEffect(() => { + if (camera?.config?.record?.retain?.days !== undefined) { + setRetentionDays(camera.config.record.retain.days) + } + }, [camera]) + + const handleRetentionChange = (days: number) => { + setRetentionDays(days) + // Note: Actual config update requires modifying the Frigate YAML config + // This could be integrated with a save button that calls proxyApi.postHostConfig + notifications.show({ + id: 'retention-changed', + withCloseButton: true, + autoClose: 3000, + title: t('settings.retentionUpdated'), + message: t('settings.retentionUpdatedMessage', { days }), + color: 'blue', + }) + } + return ( - + + + {/* Retention Settings Section */} + + {t('settings.recordingRetention')} + + + {t('settings.retentionNote')} + + + + + {!camera.config ? null : { @@ -34,14 +37,17 @@ const EventsPage = () => { const paramEndDate = searchParams.get(eventsQueryParams.endDate) || undefined const paramStartTime = searchParams.get(eventsQueryParams.startTime) || undefined const paramEndTime = searchParams.get(eventsQueryParams.endTime) || undefined - eventsStore.loadFiltersFromPage(paramHostId, paramCameraId, paramStartDate, paramEndDate, paramStartTime, paramEndTime) + const paramMinScore = searchParams.get(eventsQueryParams.minScore) || undefined + const paramLabels = searchParams.get(eventsQueryParams.labels) || undefined + const paramEventType = searchParams.get(eventsQueryParams.eventType) || undefined + eventsStore.loadFiltersFromPage(paramHostId, paramCameraId, paramStartDate, paramEndDate, paramStartTime, paramEndTime, paramMinScore, paramLabels, paramEventType) return () => setRightChildren(null) }, []) const { eventsStore } = useContext(Context) - const { hostId, cameraId, period, startTime, endTime } = eventsStore.filters + const { hostId, cameraId, period, startTime, endTime, minScore, labels, eventType } = eventsStore.filters useEffect(() => { @@ -73,6 +79,9 @@ const EventsPage = () => { period={[period[0], period[1]]} startTime={startTime} endTime={endTime} + minScore={minScore} + labels={labels} + eventType={eventType} /> ) } diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index d4abb41..3ca170d 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -1,6 +1,6 @@ -import { Flex, Grid } from '@mantine/core'; +import { Flex, Accordion } from '@mantine/core'; import { IconSearch } from '@tabler/icons-react'; -import { useInfiniteQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { observer } from 'mobx-react-lite'; import { ChangeEvent, useContext, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,15 +10,15 @@ import { Context } from '..'; import { useDebounce } from '../hooks/useDebounce'; import { useRealmUser } from '../hooks/useRealmUser'; import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api'; -import { GetCameraWHostWConfig } from '../services/frigate.proxy/frigate.schema'; +import { GetCameraWHostWConfig, GetFrigateHost } from '../services/frigate.proxy/frigate.schema'; import ClearableTextInput from '../shared/components/inputs/ClearableTextInput'; import CenteredCogwheelLoader from '../shared/components/loaders/CenteredCogwheelLoader'; import CogwheelLoader from '../shared/components/loaders/CogwheelLoader'; import { isProduction } from '../shared/env.const'; -import CameraCard from '../widgets/card/CameraCard'; import MainFiltersRightSide from '../widgets/sidebars/MainFiltersRightSide'; import { SideBarContext } from '../widgets/sidebars/SideBarContext'; import RetryErrorPage from './RetryErrorPage'; +import SiteGroup from '../shared/components/SiteGroup'; export const mainPageParams = { hostId: 'hostId', @@ -43,6 +43,11 @@ const MainPage = () => { const pageSize = 20; + const { data: sitesData } = useQuery({ + queryKey: [frigateQueryKeys.getFrigateHosts], + queryFn: frigateApi.getHosts + }) + const { data, isLoading, @@ -55,7 +60,6 @@ const MainPage = () => { } = useInfiniteQuery({ queryKey: [frigateQueryKeys.getCamerasWHost, selectedHostId, searchQuery, selectedTags], queryFn: ({ pageParam = 0 }) => - // Pass pagination parameters to the backend frigateApi.getCamerasWHost({ name: searchQuery, frigateHostId: selectedHostId, @@ -64,16 +68,13 @@ const MainPage = () => { limit: pageSize, }), getNextPageParam: (lastPage, pages) => { - // If last page size is less than pageSize, no more pages if (lastPage.length < pageSize) return undefined; - // Next page offset is pages.length * pageSize return pages.length * pageSize; }, initialPageParam: 0, }); const cameras: GetCameraWHostWConfig[] = data?.pages.flat() || []; - // const cameras: GetCameraWHostWConfig[] = []; const [visibleCount, setVisibleCount] = useState(pageSize) @@ -84,11 +85,10 @@ const MainPage = () => { } else if (hasNextPage && !isFetchingNextPage) { loadTriggered.current = true; fetchNextPage().then(() => { - // Add a small delay before resetting the flag setTimeout(() => { - loadTriggered.current = false; - }, 300); // delay in milliseconds; adjust as needed - }); + loadTriggered.current = false; + }, 300); + }); } } }, [inView, cameras, visibleCount, hasNextPage, isFetchingNextPage, isFetching, fetchNextPage]) @@ -114,7 +114,6 @@ const MainPage = () => { return () => setRightChildren(null); }, []); - const debouncedHandleSearchQuery = useDebounce((value: string) => { mainStore.setSearchQuery(value, navigate); }, 600); @@ -125,14 +124,29 @@ const MainPage = () => { if (isLoading) return ; if (isError) return - if (!isProduction) console.log('MainPage rendered') + const enabledSites = sitesData?.filter(s => s.enabled) || [] + + // Group cameras by site + const visibleCameras = cameras.slice(0, visibleCount) + const groupedCameras: Record = {} + + visibleCameras.forEach(camera => { + const siteId = camera.frigateHost?.id + if (siteId) { + if (!groupedCameras[siteId]) groupedCameras[siteId] = [] + groupedCameras[siteId].push(camera) + } + }) + + // Determine initial expanded items (all sites with cameras) + const initialExpanded = enabledSites + .filter(site => groupedCameras[site.id]?.length > 0) + .map(site => site.id) return ( - + { onChange={onInputChange} /> - - - {cameras.slice(0, visibleCount).map(camera => ( - - ))} - - { isFetching && !isFetchingNextPage ? : null} - {/* trigger point. Rerender twice when enabled */} + + + {enabledSites.map(site => { + const siteCameras = groupedCameras[site.id] || [] + const onlineCount = siteCameras.filter(c => c.state !== false).length + + // If we are filtering by host, only show that host + if (selectedHostId && site.id !== selectedHostId) return null + + // If we are searching and this site has no matching cameras, skip it? + // User requirement says "Map through the list of Sites", so maybe show even if empty? + // Usually, it's better to show if it has cameras or if no search is active. + if (searchQuery && siteCameras.length === 0) return null + + return ( + + ) + })} + + {isFetching && !isFetchingNextPage ? : null}
diff --git a/src/pages/PlayRecordPage.tsx b/src/pages/PlayRecordPage.tsx index 3d52b2c..c40cef0 100644 --- a/src/pages/PlayRecordPage.tsx +++ b/src/pages/PlayRecordPage.tsx @@ -6,6 +6,7 @@ import NotFound from './404'; export const playRecordPageQuery = { link: 'link', + startTime: 'startTime', } const PlayRecordPage = () => { @@ -13,11 +14,15 @@ const PlayRecordPage = () => { const location = useLocation() const queryParams = new URLSearchParams(location.search) const paramLink = queryParams.get(playRecordPageQuery.link) + const paramStartTime = queryParams.get(playRecordPageQuery.startTime) + + // Parse startTime as Unix timestamp (seconds) and convert to video-relative offset if needed + const initialSeekTime = paramStartTime ? parseFloat(paramStartTime) : undefined if (!paramLink) return () return ( - + ); }; diff --git a/src/services/frigate.proxy/frigate.schema.ts b/src/services/frigate.proxy/frigate.schema.ts index 8b88b59..0ee4903 100644 --- a/src/services/frigate.proxy/frigate.schema.ts +++ b/src/services/frigate.proxy/frigate.schema.ts @@ -28,6 +28,7 @@ export const getFrigateHostSchema = z.object({ updateAt: z.string(), name: z.string(), host: z.string(), + location: z.string().optional(), enabled: z.boolean(), state: z.boolean().nullable().optional() }); diff --git a/src/shared/components/RetentionSettings.tsx b/src/shared/components/RetentionSettings.tsx new file mode 100644 index 0000000..ef709d7 --- /dev/null +++ b/src/shared/components/RetentionSettings.tsx @@ -0,0 +1,79 @@ +import { NumberInput, Text, Alert, Stack, Flex } from '@mantine/core'; +import { IconAlertTriangle } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; + +interface RetentionSettingsProps { + /** Current retention days from camera config */ + currentDays: number; + /** Callback when retention days change */ + onChange: (days: number) => void; + /** Whether the input is disabled */ + disabled?: boolean; +} + +/** + * Component for configuring camera recording retention period. + * Displays a numeric input for days with optional storage warning. + */ +const RetentionSettings = ({ + currentDays, + onChange, + disabled = false, +}: RetentionSettingsProps) => { + const { t } = useTranslation(); + + const handleChange = (value: number | string) => { + const numValue = typeof value === 'string' ? parseInt(value, 10) : value; + if (!isNaN(numValue) && numValue >= 0) { + onChange(numValue); + } + }; + + return ( + + + {t('settings.days')} + + } + rightSectionWidth={60} + /> + {currentDays > 30 && ( + } + color="yellow" + variant="light" + title={t('settings.storageWarningTitle')} + > + {t('settings.storageWarning')} + + )} + {currentDays === 0 && ( + } + color="red" + variant="light" + title={t('settings.noRetentionTitle')} + > + {t('settings.noRetentionWarning')} + + )} + + ); +}; + +export default RetentionSettings; diff --git a/src/shared/components/SiteGroup.tsx b/src/shared/components/SiteGroup.tsx new file mode 100644 index 0000000..151a9d8 --- /dev/null +++ b/src/shared/components/SiteGroup.tsx @@ -0,0 +1,54 @@ +import { Accordion, Flex, Grid, Text, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { GetCameraWHostWConfig, GetFrigateHost } from '../../services/frigate.proxy/frigate.schema'; +import CameraCard from '../../widgets/card/CameraCard'; +import SiteStatusBadge from './SiteStatusBadge'; + +interface SiteGroupProps { + site: GetFrigateHost; + cameras: GetCameraWHostWConfig[]; + onlineCount: number; +} + +const SiteGroup = ({ site, cameras, onlineCount }: SiteGroupProps) => { + const { t } = useTranslation(); + + return ( + + + + + + {site.name} + + {site.location && ( + + {site.location} + + )} + + + + + + {cameras.length === 0 ? ( + + {t('camersDoesNotExist')} + + ) : ( + + {cameras.map((camera) => ( + + ))} + + )} + + + ); +}; + +export default SiteGroup; diff --git a/src/shared/components/SiteStatusBadge.tsx b/src/shared/components/SiteStatusBadge.tsx new file mode 100644 index 0000000..5dc3ed7 --- /dev/null +++ b/src/shared/components/SiteStatusBadge.tsx @@ -0,0 +1,40 @@ +import { Badge, Tooltip } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +interface SiteStatusBadgeProps { + onlineCameras: number; + totalCameras: number; + isReachable: boolean; +} + +const SiteStatusBadge = ({ onlineCameras, totalCameras, isReachable }: SiteStatusBadgeProps) => { + const { t } = useTranslation(); + + if (!isReachable) { + return ( + + {t('siteStatus.offline')} + + ); + } + + if (onlineCameras < totalCameras) { + return ( + + + {t('siteStatus.degraded')} ({onlineCameras}/{totalCameras}) + + + ); + } + + return ( + + + {t('siteStatus.online')} ({onlineCameras}/{totalCameras}) + + + ); +}; + +export default SiteStatusBadge; diff --git a/src/shared/components/accordion/EventsAccordion.tsx b/src/shared/components/accordion/EventsAccordion.tsx index 6453ad0..c99df2f 100644 --- a/src/shared/components/accordion/EventsAccordion.tsx +++ b/src/shared/components/accordion/EventsAccordion.tsx @@ -24,6 +24,9 @@ interface EventsAccordionProps { hour?: string, camera: GetCameraWHostWConfig host: GetFrigateHost + minScore?: number + labels?: string[] + eventType?: 'all' | 'motion' | 'ai_detection' } /** @@ -39,6 +42,9 @@ const EventsAccordion = ({ hour, camera, host, + minScore, + labels, + eventType, }: EventsAccordionProps) => { const { recordingsStore: recStore } = useContext(Context) const [openedItem, setOpenedItem] = useState() @@ -52,8 +58,8 @@ const EventsAccordion = ({ const isRequiredParams = (host && camera) || !(day && hour) || !(startTime && endTime) const { data, isPending, isError, refetch } = useQuery({ - queryKey: [frigateQueryKeys.getEvents, host, camera, day, hour, startTime, endTime], - queryFn: ({signal}) => { + queryKey: [frigateQueryKeys.getEvents, host, camera, day, hour, startTime, endTime, minScore, labels, eventType], + queryFn: ({ signal }) => { if (!isRequiredParams) return null let queryStartTime: number let queryEndTime: number @@ -71,6 +77,8 @@ const EventsAccordion = ({ before: queryEndTime, hasClip: true, includeThumnails: false, + minScore: minScore, + labels: labels, }) if (parsed.success) { return proxyApi.getEvents( @@ -114,6 +122,11 @@ const EventsAccordion = ({ ) + // Client-side filtering for event type (Frigate API doesn't support this filter) + const filteredData = eventType === 'ai_detection' + ? data.filter(event => event.data?.type === 'object') + : data; + const handleOpenPlayer = (value: string | undefined) => { if (value !== recStore.playedItem) { setOpenedItem(value) @@ -141,7 +154,7 @@ const EventsAccordion = ({ value={openedItem} onChange={handleOpenItem} > - {data.map(event => ( + {filteredData.map(event => ( {!hostName ? <> : - + + + } {eventLabel(event)} diff --git a/src/shared/components/filters/EventTypeFilter.tsx b/src/shared/components/filters/EventTypeFilter.tsx new file mode 100644 index 0000000..60ea8bc --- /dev/null +++ b/src/shared/components/filters/EventTypeFilter.tsx @@ -0,0 +1,32 @@ +import { SegmentedControl, Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +type EventType = 'all' | 'motion' | 'ai_detection'; + +interface EventTypeFilterProps { + value?: EventType; + onChange: (eventType: EventType) => void; +} + +const EventTypeFilter = ({ value = 'all', onChange }: EventTypeFilterProps) => { + const { t } = useTranslation(); + + const options = [ + { value: 'all', label: t('eventsPage.allEvents') }, + { value: 'ai_detection', label: t('eventsPage.aiDetectionOnly') }, + ]; + + return ( + + {t('eventsPage.eventTypeFilter')} + onChange(val as EventType)} + data={options} + fullWidth + /> + + ); +}; + +export default EventTypeFilter; diff --git a/src/shared/components/filters/ObjectTypeFilter.tsx b/src/shared/components/filters/ObjectTypeFilter.tsx new file mode 100644 index 0000000..0fa8eae --- /dev/null +++ b/src/shared/components/filters/ObjectTypeFilter.tsx @@ -0,0 +1,48 @@ +import { MultiSelect, Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +// Common Frigate AI labels +const OBJECT_TYPE_OPTIONS = [ + { value: 'person', label: 'Person' }, + { value: 'car', label: 'Car' }, + { value: 'motorcycle', label: 'Motorcycle' }, + { value: 'bicycle', label: 'Bicycle' }, + { value: 'bus', label: 'Bus' }, + { value: 'truck', label: 'Truck' }, + { value: 'dog', label: 'Dog' }, + { value: 'cat', label: 'Cat' }, + { value: 'bird', label: 'Bird' }, + { value: 'horse', label: 'Horse' }, + { value: 'face', label: 'Face' }, + { value: 'license_plate', label: 'License Plate' }, + { value: 'package', label: 'Package' }, +]; + +interface ObjectTypeFilterProps { + value?: string[]; + onChange: (labels: string[] | undefined) => void; +} + +const ObjectTypeFilter = ({ value, onChange }: ObjectTypeFilterProps) => { + const { t } = useTranslation(); + + const handleChange = (selected: string[]) => { + onChange(selected.length > 0 ? selected : undefined); + }; + + return ( + + {t('eventsPage.objectTypeFilter')} + + + ); +}; + +export default ObjectTypeFilter; diff --git a/src/shared/components/filters/HostSelect.tsx b/src/shared/components/filters/SiteSelect.tsx similarity index 76% rename from src/shared/components/filters/HostSelect.tsx rename to src/shared/components/filters/SiteSelect.tsx index 5e680e5..7805409 100644 --- a/src/shared/components/filters/HostSelect.tsx +++ b/src/shared/components/filters/SiteSelect.tsx @@ -5,7 +5,7 @@ import { frigateApi, frigateQueryKeys } from '../../../services/frigate.proxy/fr import RetryError from '../RetryError'; import OneSelectFilter, { OneSelectItem } from './OneSelectFilter'; -interface HostSelectProps extends MantineStyleSystemProps { +interface SiteSelectProps extends MantineStyleSystemProps { label?: string valueId?: string defaultId?: string @@ -15,7 +15,7 @@ interface HostSelectProps extends MantineStyleSystemProps { onSuccess?: () => void } -const HostSelect = ({ +const SiteSelect = ({ label, valueId, defaultId, @@ -24,8 +24,8 @@ const HostSelect = ({ onChange, onSuccess, ...styleProps -}: HostSelectProps) => { - const { data: hosts, isError, isPending, isSuccess, refetch } = useQuery({ +}: SiteSelectProps) => { + const { data: sites, isError, isPending, isSuccess, refetch } = useQuery({ queryKey: [frigateQueryKeys.getFrigateHosts], queryFn: frigateApi.getHosts }) @@ -37,11 +37,11 @@ const HostSelect = ({ if (isPending) return
if (isError) return - if (!hosts || hosts.length < 1) return null + if (!sites || sites.length < 1) return null - const hostItems: OneSelectItem[] = hosts - .filter(host => host.enabled) - .map(host => ({ value: host.id, label: host.name })) + const siteItems: OneSelectItem[] = sites + .filter(site => site.enabled) + .map(site => ({ value: site.id, label: site.name })) const handleSelect = (value: string) => { if (onChange) onChange(value) @@ -49,17 +49,17 @@ const HostSelect = ({ return ( ); }; -export default HostSelect; \ No newline at end of file +export default SiteSelect; \ No newline at end of file diff --git a/src/shared/components/images/BoundingBoxOverlay.tsx b/src/shared/components/images/BoundingBoxOverlay.tsx new file mode 100644 index 0000000..b9a0045 --- /dev/null +++ b/src/shared/components/images/BoundingBoxOverlay.tsx @@ -0,0 +1,122 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Box } from '@mantine/core'; + +interface BoundingBoxOverlayProps { + /** Bounding box coordinates [y_min, x_min, y_max, x_max] normalized 0-1 from Frigate */ + box?: number[]; + /** The label to display on the bounding box */ + label?: string; + /** Confidence score (0-1) to display */ + score?: number; + /** Color for the bounding box */ + color?: string; + /** Children elements (e.g., the image) */ + children: React.ReactNode; +} + +/** + * Overlay component that renders a bounding box on top of its children. + * Frigate provides normalized coordinates [y_min, x_min, y_max, x_max] in range 0-1. + */ +const BoundingBoxOverlay = ({ + box, + label, + score, + color = '#00ff00', + children, +}: BoundingBoxOverlayProps) => { + const containerRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + + useEffect(() => { + const updateDimensions = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setDimensions({ width: rect.width, height: rect.height }); + } + }; + + updateDimensions(); + window.addEventListener('resize', updateDimensions); + + // Also observe for image loading + const observer = new ResizeObserver(updateDimensions); + if (containerRef.current) { + observer.observe(containerRef.current); + } + + return () => { + window.removeEventListener('resize', updateDimensions); + observer.disconnect(); + }; + }, []); + + // Convert Frigate box format [y_min, x_min, y_max, x_max] to pixel coordinates + const getBoxStyle = () => { + if (!box || box.length < 4 || dimensions.width === 0) { + return null; + } + + const [yMin, xMin, yMax, xMax] = box; + + const left = xMin * dimensions.width; + const top = yMin * dimensions.height; + const width = (xMax - xMin) * dimensions.width; + const height = (yMax - yMin) * dimensions.height; + + return { + left: `${left}px`, + top: `${top}px`, + width: `${width}px`, + height: `${height}px`, + }; + }; + + const boxStyle = getBoxStyle(); + const displayLabel = label ? `${label}${score ? ` ${(score * 100).toFixed(0)}%` : ''}` : ''; + + return ( + + {children} + {boxStyle && ( + <> +
+ {displayLabel && ( +
+ {displayLabel} +
+ )} + + )} + + ); +}; + +export default BoundingBoxOverlay; diff --git a/src/shared/components/players/PlaybackSpeedControls.tsx b/src/shared/components/players/PlaybackSpeedControls.tsx new file mode 100644 index 0000000..29fbf04 --- /dev/null +++ b/src/shared/components/players/PlaybackSpeedControls.tsx @@ -0,0 +1,60 @@ +import { Button, Group, Text } from '@mantine/core'; +import { IconPlayerPlay } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import videojs from 'video.js'; + +interface PlaybackSpeedControlsProps { + playerRef: React.MutableRefObject | null>; + currentSpeed: number; + onSpeedChange: (speed: number) => void; +} + +const PLAYBACK_SPEEDS = [0.5, 1, 2, 4, 8, 16]; + +const PlaybackSpeedControls = ({ + playerRef, + currentSpeed, + onSpeedChange, +}: PlaybackSpeedControlsProps) => { + const { t } = useTranslation(); + const isFirefox = typeof navigator !== 'undefined' && navigator.userAgent.toLowerCase().includes('firefox'); + + const availableSpeeds = isFirefox + ? PLAYBACK_SPEEDS.filter(speed => speed <= 8) + : PLAYBACK_SPEEDS; + + const handleSpeedClick = (speed: number) => { + if (playerRef.current) { + playerRef.current.playbackRate(speed); + onSpeedChange(speed); + } + }; + + return ( + + + + {t('player.speed')}: + + {availableSpeeds.map((speed) => ( + + ))} + + ); +}; + +export default PlaybackSpeedControls; diff --git a/src/shared/components/players/VideoPlayer.tsx b/src/shared/components/players/VideoPlayer.tsx index 7bf604b..e8edcd7 100644 --- a/src/shared/components/players/VideoPlayer.tsx +++ b/src/shared/components/players/VideoPlayer.tsx @@ -1,19 +1,23 @@ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useState } from 'react'; import videojs from 'video.js'; import Player from 'video.js/dist/types/player'; import 'video.js/dist/video-js.css' import { isProduction } from '../../env.const'; import { useKeycloak } from '@react-keycloak/web'; +import PlaybackSpeedControls from './PlaybackSpeedControls'; interface VideoPlayerProps { - videoUrl: string + videoUrl: string; + showSpeedControls?: boolean; + initialSeekTime?: number; // Unix timestamp or seconds offset to seek to on load } -const VideoPlayer = ({ videoUrl }: VideoPlayerProps) => { +const VideoPlayer = ({ videoUrl, showSpeedControls = true, initialSeekTime }: VideoPlayerProps) => { const { keycloak } = useKeycloak() const executed = useRef(false) const videoRef = useRef(null); const playerRef = useRef(null); + const [currentSpeed, setCurrentSpeed] = useState(1); useEffect(() => { if (!executed.current) { @@ -56,6 +60,11 @@ const VideoPlayer = ({ videoUrl }: VideoPlayerProps) => { if (!isProduction) console.log('mount new player') playerRef.current = videojs(videoRef.current, { ...defaultOptions }, () => { if (!isProduction) console.log('player is ready') + // Seek to initial time if specified (relative to video start) + if (initialSeekTime !== undefined && playerRef.current) { + playerRef.current.currentTime(initialSeekTime); + if (!isProduction) console.log('Seeking to initial time:', initialSeekTime) + } }); } if (!isProduction) console.log('VideoPlayer rendered') @@ -78,10 +87,24 @@ const VideoPlayer = ({ videoUrl }: VideoPlayerProps) => { } }, [videoUrl]); + const handleSpeedChange = (speed: number) => { + setCurrentSpeed(speed); + if (!isProduction) console.log('Playback speed changed to:', speed); + }; + return ( -
- {/* Setting an empty data-setup is required to override the default values and allow video to be fit the size of its parent */} -