mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-14 09:25:47 +01:00
@@ -4,8 +4,8 @@
|
||||
"description": "Embed a video stream or video from a camera or a website",
|
||||
"settings": {
|
||||
"title": "Settings for video stream widget",
|
||||
"cameraFeedUrl": {
|
||||
"label": "Camera feed url"
|
||||
"FeedUrl": {
|
||||
"label": "Feed url"
|
||||
},
|
||||
"autoPlay": {
|
||||
"label": "Auto play"
|
||||
|
||||
274
src/widgets/download-speed/Tile.tsx
Normal file
274
src/widgets/download-speed/Tile.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { useElementSize, useListState } from '@mantine/hooks';
|
||||
import { linearGradientDef } from '@nivo/core';
|
||||
import { Serie, Datum, ResponsiveLine } from '@nivo/line';
|
||||
import { IconDownload, IconUpload } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed';
|
||||
import { useColorTheme } from '../../tools/color';
|
||||
import { humanFileSize } from '../../tools/humanFileSize';
|
||||
import {
|
||||
NormalizedDownloadQueueResponse,
|
||||
TorrentTotalDownload,
|
||||
} from '../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
||||
import definition, { ITorrentNetworkTraffic } from './TorrentNetworkTrafficTile';
|
||||
|
||||
interface TorrentNetworkTrafficTileProps {
|
||||
widget: ITorrentNetworkTraffic;
|
||||
}
|
||||
|
||||
export default function TorrentNetworkTrafficTile({ widget }: TorrentNetworkTrafficTileProps) {
|
||||
const { config } = useConfigContext();
|
||||
const { ref: refRoot, height: heightRoot } = useElementSize();
|
||||
const { ref: refTitle, height: heightTitle } = useElementSize();
|
||||
const { ref: refFooter, height: heightFooter } = useElementSize();
|
||||
const { primaryColor, secondaryColor } = useColorTheme();
|
||||
const { t } = useTranslation(`modules/${definition.id}`);
|
||||
|
||||
const [clientDataHistory, setClientDataHistory] = useListState<NormalizedDownloadQueueResponse>();
|
||||
|
||||
const { data, dataUpdatedAt } = useGetDownloadClientsQueue();
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setClientDataHistory.append(data);
|
||||
}
|
||||
|
||||
if (clientDataHistory.length < 30) {
|
||||
return;
|
||||
}
|
||||
setClientDataHistory.remove(0);
|
||||
}, [dataUpdatedAt]);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recoredAppsOverTime = clientDataHistory.flatMap((x) => x.apps.map((app) => app));
|
||||
|
||||
// removing duplicates the "naive" way: https://stackoverflow.com/a/9229821/15257712
|
||||
const uniqueRecordedAppsOverTime = recoredAppsOverTime
|
||||
.map((x) => x.appId)
|
||||
.filter((item, position) => recoredAppsOverTime.map((y) => y.appId).indexOf(item) === position);
|
||||
|
||||
const lineChartData: Serie[] = uniqueRecordedAppsOverTime.flatMap((appId) => {
|
||||
const records = recoredAppsOverTime.filter((x) => x.appId === appId);
|
||||
|
||||
const series: Serie[] = [
|
||||
{
|
||||
id: `download_${appId}`,
|
||||
data: records.map((record, index) => ({
|
||||
x: index,
|
||||
y: record.totalDownload,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
if (records.some((x) => x.type === 'torrent')) {
|
||||
const torrentRecords = records.map((record, index): Datum | null => {
|
||||
if (record.type !== 'torrent') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: index,
|
||||
y: record.totalUpload,
|
||||
};
|
||||
});
|
||||
const filteredRecords = torrentRecords.filter((x) => x !== null) as Datum[];
|
||||
series.push({
|
||||
id: `upload_${appId}`,
|
||||
data: filteredRecords,
|
||||
});
|
||||
}
|
||||
|
||||
return series;
|
||||
});
|
||||
|
||||
const totalDownload = uniqueRecordedAppsOverTime
|
||||
.map((appId) => {
|
||||
const records = recoredAppsOverTime.filter((x) => x.appId === appId);
|
||||
const lastRecord = records.at(-1);
|
||||
return lastRecord?.totalDownload ?? 0;
|
||||
})
|
||||
.reduce((acc, n) => acc + n, 0);
|
||||
|
||||
const totalUpload = uniqueRecordedAppsOverTime
|
||||
.map((appId) => {
|
||||
const records = recoredAppsOverTime.filter((x) => x.appId === appId && x.type === 'torrent');
|
||||
const lastRecord = records.at(-1) as TorrentTotalDownload;
|
||||
return lastRecord?.totalUpload ?? 0;
|
||||
})
|
||||
.reduce((acc, n) => acc + n, 0);
|
||||
|
||||
const graphHeight = heightRoot - heightFooter - heightTitle;
|
||||
|
||||
const { colors } = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Stack ref={refRoot} style={{ height: '100%' }}>
|
||||
<Group ref={refTitle}>
|
||||
<IconDownload />
|
||||
<Title order={4}>{t('card.lineChart.title')}</Title>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
height: graphHeight,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box style={{ height: '100%', width: '100%', position: 'absolute' }}>
|
||||
<ResponsiveLine
|
||||
isInteractive
|
||||
enableSlices="x"
|
||||
sliceTooltip={({ slice }) => {
|
||||
const { points } = slice;
|
||||
|
||||
const recordsFromPoints = uniqueRecordedAppsOverTime.map((appId) => {
|
||||
const records = recoredAppsOverTime.filter((x) => x.appId === appId);
|
||||
const point = points.find((x) => x.id.includes(appId));
|
||||
const pointIndex = Number(point?.data.x) ?? 0;
|
||||
const color = point?.serieColor;
|
||||
return {
|
||||
record: records[pointIndex],
|
||||
color,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Card p="xs" radius="md" withBorder>
|
||||
<Card.Section p="xs">
|
||||
<Stack spacing="xs">
|
||||
{recordsFromPoints.map((entry, index) => {
|
||||
const app = config?.apps.find((x) => x.id === entry.record.appId);
|
||||
|
||||
if (!app) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Group key={`download-client-tooltip-${index}`}>
|
||||
<AppAvatar iconUrl={app.appearance.iconUrl} />
|
||||
|
||||
<Stack spacing={0}>
|
||||
<Text size="sm">{app.name}</Text>
|
||||
<Group>
|
||||
<Group spacing="xs">
|
||||
<IconDownload opacity={0.6} size={14} />
|
||||
<Text size="xs" color="dimmed">
|
||||
{humanFileSize(entry.record.totalDownload, false)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{entry.record.type === 'torrent' && (
|
||||
<Group spacing="xs">
|
||||
<IconUpload opacity={0.6} size={14} />
|
||||
<Text size="xs" color="dimmed">
|
||||
{humanFileSize(entry.record.totalUpload, false)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
data={lineChartData}
|
||||
curve="monotoneX"
|
||||
yFormat=" >-.2f"
|
||||
axisLeft={null}
|
||||
axisBottom={null}
|
||||
axisRight={null}
|
||||
enablePoints={false}
|
||||
enableGridX={false}
|
||||
enableGridY={false}
|
||||
enableArea
|
||||
defs={[
|
||||
linearGradientDef('gradientA', [
|
||||
{ offset: 0, color: 'inherit' },
|
||||
{ offset: 100, color: 'inherit', opacity: 0 },
|
||||
]),
|
||||
]}
|
||||
colors={lineChartData.flatMap((data) =>
|
||||
data.id.toString().startsWith('upload_')
|
||||
? colors[secondaryColor][5]
|
||||
: colors[primaryColor][5]
|
||||
)}
|
||||
fill={[{ match: '*', id: 'gradientA' }]}
|
||||
margin={{ bottom: 5 }}
|
||||
animate={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Group position="apart" ref={refFooter}>
|
||||
<Group>
|
||||
<Group spacing="xs">
|
||||
<IconDownload color={colors[primaryColor][5]} opacity={0.6} size={18} />
|
||||
<Text color="dimmed" size="sm">
|
||||
{humanFileSize(totalDownload, false)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group spacing="xs">
|
||||
<IconUpload color={colors[secondaryColor][5]} opacity={0.6} size={18} />
|
||||
<Text color="dimmed" size="sm">
|
||||
{humanFileSize(totalUpload, false)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Avatar.Group>
|
||||
{uniqueRecordedAppsOverTime.map((appId, index) => {
|
||||
const app = config?.apps.find((x) => x.id === appId);
|
||||
|
||||
if (!app) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={app.name}
|
||||
key={`download-client-app-tooltip-${index}`}
|
||||
withArrow
|
||||
withinPortal
|
||||
>
|
||||
<AppAvatar iconUrl={app.appearance.iconUrl} />
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Avatar.Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const AppAvatar = ({ iconUrl }: { iconUrl: string }) => {
|
||||
const { colors, colorScheme } = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
src={iconUrl}
|
||||
bg={colorScheme === 'dark' ? colors.gray[8] : colors.gray[2]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
p={4}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +1,13 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { useElementSize, useListState } from '@mantine/hooks';
|
||||
import { linearGradientDef } from '@nivo/core';
|
||||
import { Datum, ResponsiveLine, Serie } from '@nivo/line';
|
||||
import { IconArrowsUpDown, IconDownload, IconUpload } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed';
|
||||
import { useColorTheme } from '../../tools/color';
|
||||
import { humanFileSize } from '../../tools/humanFileSize';
|
||||
import {
|
||||
NormalizedDownloadQueueResponse,
|
||||
TorrentTotalDownload,
|
||||
} from '../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
||||
import { IconArrowsUpDown } from '@tabler/icons';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
|
||||
const torrentNetworkTrafficTile = dynamic(() => import('./Tile'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'dlspeed',
|
||||
icon: IconArrowsUpDown,
|
||||
@@ -37,257 +19,9 @@ const definition = defineWidget({
|
||||
maxWidth: 12,
|
||||
maxHeight: 6,
|
||||
},
|
||||
component: TorrentNetworkTrafficTile,
|
||||
component: torrentNetworkTrafficTile,
|
||||
});
|
||||
|
||||
export type ITorrentNetworkTraffic = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
|
||||
interface TorrentNetworkTrafficTileProps {
|
||||
widget: ITorrentNetworkTraffic;
|
||||
}
|
||||
|
||||
function TorrentNetworkTrafficTile({ widget }: TorrentNetworkTrafficTileProps) {
|
||||
const { config } = useConfigContext();
|
||||
const { ref: refRoot, height: heightRoot } = useElementSize();
|
||||
const { ref: refTitle, height: heightTitle } = useElementSize();
|
||||
const { ref: refFooter, height: heightFooter } = useElementSize();
|
||||
const { primaryColor, secondaryColor } = useColorTheme();
|
||||
const { t } = useTranslation(`modules/${definition.id}`);
|
||||
|
||||
const [clientDataHistory, setClientDataHistory] = useListState<NormalizedDownloadQueueResponse>();
|
||||
|
||||
const { data, dataUpdatedAt } = useGetDownloadClientsQueue();
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setClientDataHistory.append(data);
|
||||
}
|
||||
|
||||
if (clientDataHistory.length < 30) {
|
||||
return;
|
||||
}
|
||||
setClientDataHistory.remove(0);
|
||||
}, [dataUpdatedAt]);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recoredAppsOverTime = clientDataHistory.flatMap((x) => x.apps.map((app) => app));
|
||||
|
||||
// removing duplicates the "naive" way: https://stackoverflow.com/a/9229821/15257712
|
||||
const uniqueRecordedAppsOverTime = recoredAppsOverTime
|
||||
.map((x) => x.appId)
|
||||
.filter((item, position) => recoredAppsOverTime.map((y) => y.appId).indexOf(item) === position);
|
||||
|
||||
const lineChartData: Serie[] = uniqueRecordedAppsOverTime.flatMap((appId) => {
|
||||
const records = recoredAppsOverTime.filter((x) => x.appId === appId);
|
||||
|
||||
const series: Serie[] = [
|
||||
{
|
||||
id: `download_${appId}`,
|
||||
data: records.map((record, index) => ({
|
||||
x: index,
|
||||
y: record.totalDownload,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
if (records.some((x) => x.type === 'torrent')) {
|
||||
const torrentRecords = records.map((record, index): Datum | null => {
|
||||
if (record.type !== 'torrent') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: index,
|
||||
y: record.totalUpload,
|
||||
};
|
||||
});
|
||||
const filteredRecords = torrentRecords.filter((x) => x !== null) as Datum[];
|
||||
series.push({
|
||||
id: `upload_${appId}`,
|
||||
data: filteredRecords,
|
||||
});
|
||||
}
|
||||
|
||||
return series;
|
||||
});
|
||||
|
||||
const totalDownload = uniqueRecordedAppsOverTime
|
||||
.map((appId) => {
|
||||
const records = recoredAppsOverTime.filter((x) => x.appId === appId);
|
||||
const lastRecord = records.at(-1);
|
||||
return lastRecord?.totalDownload ?? 0;
|
||||
})
|
||||
.reduce((acc, n) => acc + n, 0);
|
||||
|
||||
const totalUpload = uniqueRecordedAppsOverTime
|
||||
.map((appId) => {
|
||||
const records = recoredAppsOverTime.filter((x) => x.appId === appId && x.type === 'torrent');
|
||||
const lastRecord = records.at(-1) as TorrentTotalDownload;
|
||||
return lastRecord?.totalUpload ?? 0;
|
||||
})
|
||||
.reduce((acc, n) => acc + n, 0);
|
||||
|
||||
const graphHeight = heightRoot - heightFooter - heightTitle;
|
||||
|
||||
const { colors } = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Stack ref={refRoot} style={{ height: '100%' }}>
|
||||
<Group ref={refTitle}>
|
||||
<IconDownload />
|
||||
<Title order={4}>{t('card.lineChart.title')}</Title>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
height: graphHeight,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box style={{ height: '100%', width: '100%', position: 'absolute' }}>
|
||||
<ResponsiveLine
|
||||
isInteractive
|
||||
enableSlices="x"
|
||||
sliceTooltip={({ slice }) => {
|
||||
const { points } = slice;
|
||||
|
||||
const recordsFromPoints = uniqueRecordedAppsOverTime.map((appId) => {
|
||||
const records = recoredAppsOverTime.filter((x) => x.appId === appId);
|
||||
const point = points.find((x) => x.id.includes(appId));
|
||||
const pointIndex = Number(point?.data.x) ?? 0;
|
||||
const color = point?.serieColor;
|
||||
return {
|
||||
record: records[pointIndex],
|
||||
color,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Card p="xs" radius="md" withBorder>
|
||||
<Card.Section p="xs">
|
||||
<Stack spacing="xs">
|
||||
{recordsFromPoints.map((entry, index) => {
|
||||
const app = config?.apps.find((x) => x.id === entry.record.appId);
|
||||
|
||||
if (!app) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Group key={`download-client-tooltip-${index}`}>
|
||||
<AppAvatar iconUrl={app.appearance.iconUrl} />
|
||||
|
||||
<Stack spacing={0}>
|
||||
<Text size="sm">{app.name}</Text>
|
||||
<Group>
|
||||
<Group spacing="xs">
|
||||
<IconDownload opacity={0.6} size={14} />
|
||||
<Text size="xs" color="dimmed">
|
||||
{humanFileSize(entry.record.totalDownload, false)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{entry.record.type === 'torrent' && (
|
||||
<Group spacing="xs">
|
||||
<IconUpload opacity={0.6} size={14} />
|
||||
<Text size="xs" color="dimmed">
|
||||
{humanFileSize(entry.record.totalUpload, false)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
data={lineChartData}
|
||||
curve="monotoneX"
|
||||
yFormat=" >-.2f"
|
||||
axisLeft={null}
|
||||
axisBottom={null}
|
||||
axisRight={null}
|
||||
enablePoints={false}
|
||||
enableGridX={false}
|
||||
enableGridY={false}
|
||||
enableArea
|
||||
defs={[
|
||||
linearGradientDef('gradientA', [
|
||||
{ offset: 0, color: 'inherit' },
|
||||
{ offset: 100, color: 'inherit', opacity: 0 },
|
||||
]),
|
||||
]}
|
||||
colors={lineChartData.flatMap((data) =>
|
||||
data.id.toString().startsWith('upload_')
|
||||
? colors[secondaryColor][5]
|
||||
: colors[primaryColor][5]
|
||||
)}
|
||||
fill={[{ match: '*', id: 'gradientA' }]}
|
||||
margin={{ bottom: 5 }}
|
||||
animate={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Group position="apart" ref={refFooter}>
|
||||
<Group>
|
||||
<Group spacing="xs">
|
||||
<IconDownload color={colors[primaryColor][5]} opacity={0.6} size={18} />
|
||||
<Text color="dimmed" size="sm">
|
||||
{humanFileSize(totalDownload, false)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group spacing="xs">
|
||||
<IconUpload color={colors[secondaryColor][5]} opacity={0.6} size={18} />
|
||||
<Text color="dimmed" size="sm">
|
||||
{humanFileSize(totalUpload, false)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Avatar.Group>
|
||||
{uniqueRecordedAppsOverTime.map((appId, index) => {
|
||||
const app = config?.apps.find((x) => x.id === appId);
|
||||
|
||||
if (!app) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={app.name}
|
||||
key={`download-client-app-tooltip-${index}`}
|
||||
withArrow
|
||||
withinPortal
|
||||
>
|
||||
<AppAvatar iconUrl={app.appearance.iconUrl} />
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Avatar.Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const AppAvatar = ({ iconUrl }: { iconUrl: string }) => {
|
||||
const { colors, colorScheme } = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
src={iconUrl}
|
||||
bg={colorScheme === 'dark' ? colors.gray[8] : colors.gray[2]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
p={4}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default definition;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Center, Group, Stack, Title } from '@mantine/core';
|
||||
import { IconDeviceCctv, IconHeartBroken } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
import VideoFeed from './VideoFeed';
|
||||
|
||||
const VideoFeed = dynamic(() => import('./VideoFeed'), { ssr: false });
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'video-stream',
|
||||
icon: IconDeviceCctv,
|
||||
options: {
|
||||
cameraFeedUrl: {
|
||||
FeedUrl: {
|
||||
type: 'text',
|
||||
defaultValue: '',
|
||||
},
|
||||
@@ -43,7 +45,7 @@ interface VideoStreamWidgetProps {
|
||||
|
||||
function VideoStreamWidget({ widget }: VideoStreamWidgetProps) {
|
||||
const { t } = useTranslation('modules/video-stream');
|
||||
if (!widget.properties.cameraFeedUrl) {
|
||||
if (!widget.properties.FeedUrl) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center">
|
||||
@@ -56,7 +58,7 @@ function VideoStreamWidget({ widget }: VideoStreamWidgetProps) {
|
||||
return (
|
||||
<Group position="center" w="100%" h="100%">
|
||||
<VideoFeed
|
||||
source={widget?.properties.cameraFeedUrl}
|
||||
source={widget?.properties.FeedUrl}
|
||||
muted={widget?.properties.muted}
|
||||
autoPlay={widget?.properties.autoPlay}
|
||||
controls={widget?.properties.controls}
|
||||
|
||||
Reference in New Issue
Block a user