({});
123 | const locale = useAppSelector(selectLocaleRegion);
124 |
125 | useEffect(() => {
126 | if (locale && regionTranslationsMap[locale]) {
127 | const [translationsLangCode, translationsMessages] =
128 | regionTranslationsMap[locale];
129 | setLangCode(translationsLangCode);
130 | setMessages(translationsMessages);
131 | }
132 | }, [locale]);
133 |
134 | return [langCode, messages] as [LangCode, Lang];
135 | };
136 |
137 | export const withTranslations =
138 | (
139 | Component: NavigationFunctionComponent
,
140 | ): NavigationFunctionComponent
=>
141 | (props: P & NavigationProps) => {
142 | const [langCode, messages] = useTranslations();
143 |
144 | return (
145 |
146 |
147 |
148 | );
149 | };
150 |
151 | export const makeMessages = (
152 | scope: string,
153 | dict: Record,
154 | ) =>
155 | defineMessages(
156 | Object.entries(dict).reduce(
157 | (msgs, [id, defaultMessage]) => ({
158 | ...msgs,
159 | [id]: {id: `${scope}.${id}`, defaultMessage},
160 | }),
161 | {},
162 | ) as Record,
163 | );
164 |
--------------------------------------------------------------------------------
/helpers/redux.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | NavigationFunctionComponent,
4 | NavigationProps,
5 | } from 'react-native-navigation';
6 | import {PersistGate} from 'redux-persist/integration/react';
7 | import {Provider} from 'react-redux';
8 | import {persistor, store} from '../store/store';
9 |
10 | export const withRedux =
11 | (
12 | Component: NavigationFunctionComponent
,
13 | ): NavigationFunctionComponent
=>
14 | (props: P & NavigationProps) =>
15 | (
16 |
17 |
18 |
19 |
20 |
21 | );
22 |
--------------------------------------------------------------------------------
/helpers/rest.messages.ts:
--------------------------------------------------------------------------------
1 | import {MessageDescriptor} from 'react-intl';
2 | import {makeMessages} from './locale';
3 |
4 | export const messages = makeMessages('api', {
5 | 'frigateAuth.wrongCredentials':
6 | 'Authorization error, check your credentials.',
7 | 'error.unauthorized': 'Wrong credentials when tried to reach {url}',
8 | });
9 |
10 | export type MessageKey = typeof messages extends Record<
11 | infer R,
12 | MessageDescriptor
13 | >
14 | ? R
15 | : never;
16 |
--------------------------------------------------------------------------------
/helpers/rest.ts:
--------------------------------------------------------------------------------
1 | import {Buffer} from 'buffer';
2 | import {ToastAndroid} from 'react-native';
3 | import crashlytics from '@react-native-firebase/crashlytics';
4 | import {Server} from '../store/settings';
5 | import {useIntl} from 'react-intl';
6 | import {messages} from './rest.messages';
7 |
8 | export const buildServerUrl = (server: Server) => {
9 | const {protocol, host, port, path} = server;
10 | const pathPart = path
11 | ? `${path
12 | .split('/')
13 | .filter(p => p !== '')
14 | .join('/')}/`
15 | : '';
16 | return protocol && host
17 | ? `${protocol}://${host}${port ? `:${port}` : ''}/${pathPart}`
18 | : undefined;
19 | };
20 |
21 | export const buildServerApiUrl = (server: Server) => {
22 | const serverUrl = buildServerUrl(server);
23 | return serverUrl ? `${serverUrl}api` : undefined;
24 | };
25 |
26 | export const authorizationHeader: (server: Server) => {
27 | Authorization?: string;
28 | } = server =>
29 | server.auth === 'basic'
30 | ? {
31 | Authorization: `Basic ${Buffer.from(
32 | `${server.credentials.username}:${server.credentials.password}`,
33 | ).toString('base64')}`,
34 | }
35 | : {};
36 |
37 | export const useRest = () => {
38 | const intl = useIntl();
39 |
40 | const login = async (server: Server) => {
41 | try {
42 | const url = `${buildServerApiUrl(server)}/login`;
43 | crashlytics().log(`POST ${url}`);
44 | const response = await fetch(url, {
45 | method: 'POST',
46 | headers: {
47 | 'Content-Type': 'application/json',
48 | },
49 | body: JSON.stringify({
50 | user: server.credentials.username,
51 | password: server.credentials.password,
52 | }),
53 | });
54 | if (response.status === 400) {
55 | throw new Error(
56 | intl.formatMessage(messages['frigateAuth.wrongCredentials']),
57 | );
58 | }
59 | return response.json();
60 | } catch (error) {
61 | crashlytics().recordError(error as Error);
62 | const e = error as {message: string};
63 | ToastAndroid.show(e.message, ToastAndroid.LONG);
64 | return Promise.reject();
65 | }
66 | };
67 |
68 | interface QueryOptions {
69 | queryParams?: Record;
70 | json?: boolean;
71 | }
72 |
73 | const query = async (
74 | server: Server,
75 | method: 'GET' | 'POST' | 'DELETE',
76 | endpoint: string,
77 | options: QueryOptions = {},
78 | ): Promise => {
79 | try {
80 | const {queryParams, json} = options;
81 | const url = `${buildServerApiUrl(server)}/${endpoint}`;
82 | const executeFetch = () =>
83 | fetch(
84 | `${url}${queryParams ? `?${new URLSearchParams(queryParams)}` : ''}`,
85 | {
86 | method,
87 | headers: {
88 | ...authorizationHeader(server),
89 | },
90 | },
91 | );
92 | crashlytics().log(`${method} ${url}`);
93 | const response = await executeFetch();
94 | if (!response.ok) {
95 | crashlytics().log(`HTTP/${response.status}: ${method} ${url}`);
96 | }
97 | if (response.status === 401) {
98 | if (server.auth === 'frigate') {
99 | await login(server);
100 | const retriedResponse = await executeFetch();
101 | return retriedResponse[json === false ? 'text' : 'json']();
102 | } else {
103 | crashlytics().log(`Unauthorized`);
104 | throw new Error(
105 | intl.formatMessage(messages['error.unauthorized'], {url}),
106 | );
107 | }
108 | }
109 | return response[json === false ? 'text' : 'json']();
110 | } catch (error) {
111 | crashlytics().recordError(error as Error);
112 | const e = error as {message: string};
113 | ToastAndroid.show(e.message, ToastAndroid.LONG);
114 | return Promise.reject();
115 | }
116 | };
117 |
118 | const get = async (
119 | server: Server,
120 | endpoint: string,
121 | options?: QueryOptions,
122 | ): Promise => {
123 | return query(server, 'GET', endpoint, options);
124 | };
125 |
126 | const post = async (
127 | server: Server,
128 | endpoint: string,
129 | options?: QueryOptions,
130 | ): Promise => {
131 | return query(server, 'POST', endpoint, options);
132 | };
133 |
134 | const del = async (
135 | server: Server,
136 | endpoint: string,
137 | options?: QueryOptions,
138 | ): Promise => {
139 | return query(server, 'DELETE', endpoint, options);
140 | };
141 |
142 | return {
143 | get,
144 | post,
145 | del,
146 | };
147 | };
148 |
--------------------------------------------------------------------------------
/helpers/screen.ts:
--------------------------------------------------------------------------------
1 | import {useEffect, useState} from 'react';
2 | import {Dimensions} from 'react-native';
3 | import {EventSubscription, Navigation} from 'react-native-navigation';
4 |
5 | export const useOrientation = () => {
6 | const [componentId, setComponentId] = useState();
7 | const [orientation, setOrientation] = useState<'portrait' | 'landscape'>();
8 |
9 | const checkOrientation = () => {
10 | const screen = Dimensions.get('screen');
11 | const newOrientation =
12 | screen.width > screen.height ? 'landscape' : 'portrait';
13 | if (orientation !== newOrientation) {
14 | setOrientation(newOrientation);
15 | }
16 | };
17 |
18 | useEffect(() => {
19 | checkOrientation();
20 | const sub = Dimensions.addEventListener('change', checkOrientation);
21 | return () => {
22 | sub.remove();
23 | };
24 | }, []);
25 |
26 | useEffect(() => {
27 | let listener: EventSubscription | undefined;
28 | if (componentId) {
29 | listener = Navigation.events().registerComponentListener(
30 | {
31 | componentDidDisappear() {
32 | checkOrientation();
33 | },
34 | },
35 | componentId,
36 | );
37 | }
38 | return () => {
39 | if (listener) {
40 | listener.remove();
41 | }
42 | };
43 | }, [componentId]);
44 |
45 | return {
46 | orientation,
47 | setComponentId,
48 | };
49 | };
50 |
--------------------------------------------------------------------------------
/helpers/table.ts:
--------------------------------------------------------------------------------
1 | import {useStyles} from '../helpers/colors';
2 |
3 | export const useTableStyles = () =>
4 | useStyles(({theme}) => ({
5 | mainHeader: {
6 | flex: 2,
7 | backgroundColor: theme.tableColumnHeaderBg,
8 | },
9 | mainHeaderText: {
10 | padding: 2,
11 | color: theme.tableText,
12 | fontWeight: '600',
13 | },
14 | header: {
15 | flex: 1,
16 | backgroundColor: theme.tableColumnHeaderBg,
17 | },
18 | headerText: {
19 | padding: 2,
20 | color: theme.tableText,
21 | textAlign: 'center',
22 | fontWeight: '600',
23 | },
24 | row: {
25 | flexDirection: 'row',
26 | },
27 | dataHeader: {
28 | backgroundColor: theme.tableRowHeaderBg,
29 | flex: 2,
30 | },
31 | dataHeaderText: {
32 | padding: 2,
33 | color: theme.tableText,
34 | },
35 | data: {
36 | backgroundColor: theme.tableCellBg,
37 | flex: 1,
38 | },
39 | dataText: {
40 | padding: 2,
41 | color: theme.tableText,
42 | textAlign: 'right',
43 | },
44 | }));
45 |
46 | export const formatSize = (mb: number) => `${(mb / 1024).toFixed(2)} GB`;
47 | export const formatBandwidth = (mb: number) => `${(mb / 1024).toFixed(2)} GB/h`;
48 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import {Navigation} from 'react-native-navigation';
2 | import {gestureHandlerRootHOC} from 'react-native-gesture-handler';
3 | import {TopBarButton} from './components/icons/TopBarButton';
4 | import {Author} from './views/author/Author';
5 | import {Report} from './views/report/Report';
6 | import {CameraEventClip} from './views/camera-event-clip/CameraEventClip';
7 | import {CameraPreview} from './views/camera-preview/CameraPreview';
8 | import {CameraEvents} from './views/camera-events/CameraEvents';
9 | import {CamerasList} from './views/cameras-list/CamerasList';
10 | import {EventsFilters} from './views/events-filters/EventsFilters';
11 | import {Menu} from './views/menu/Menu';
12 | import {Settings} from './views/settings/Settings';
13 | import {withRedux} from './helpers/redux';
14 | import {withTranslations} from './helpers/locale';
15 | import {Logs} from './views/logs/Logs';
16 | import {Storage} from './views/storage/Storage';
17 | import {System} from './views/system/System';
18 | import {ServerForm} from './views/settings/ServerForm';
19 |
20 | const registerComponent = (name, component, decorators = []) => {
21 | Navigation.registerComponent(
22 | name,
23 | () =>
24 | decorators.reduce(
25 | (decoratedComponent, decorator) => decorator(decoratedComponent),
26 | component,
27 | ),
28 | () => component,
29 | );
30 | };
31 |
32 | const viewDecorators = [gestureHandlerRootHOC, withTranslations, withRedux];
33 |
34 | registerComponent('CamerasList', CamerasList, viewDecorators);
35 | registerComponent('CameraEvents', CameraEvents, viewDecorators);
36 | registerComponent('CameraEventClip', CameraEventClip, viewDecorators);
37 | registerComponent('CameraPreview', CameraPreview, viewDecorators);
38 | registerComponent('Storage', Storage, viewDecorators);
39 | registerComponent('System', System, viewDecorators);
40 | registerComponent('Logs', Logs, viewDecorators);
41 | registerComponent('Settings', Settings, viewDecorators);
42 | registerComponent('ServerForm', ServerForm, viewDecorators);
43 | registerComponent('Author', Author, viewDecorators);
44 | registerComponent('Report', Report, viewDecorators);
45 |
46 | registerComponent('Menu', Menu, [
47 | gestureHandlerRootHOC,
48 | withTranslations,
49 | withRedux,
50 | ]);
51 | registerComponent('EventsFilters', EventsFilters, [
52 | withTranslations,
53 | withRedux,
54 | ]);
55 | registerComponent('TopBarButton', TopBarButton);
56 |
57 | Navigation.events().registerAppLaunchedListener(() => {
58 | Navigation.setRoot({
59 | root: {
60 | sideMenu: {
61 | center: {
62 | stack: {
63 | id: 'MainMenu',
64 | children: [
65 | {
66 | component: {
67 | name: 'CamerasList',
68 | },
69 | },
70 | ],
71 | },
72 | },
73 | left: {
74 | component: {
75 | id: 'Menu',
76 | name: 'Menu',
77 | },
78 | },
79 | right: {
80 | component: {
81 | id: 'EventsFilters',
82 | name: 'EventsFilters',
83 | },
84 | },
85 | options: {
86 | sideMenu: {
87 | left: {
88 | enabled: false,
89 | },
90 | right: {
91 | enabled: false,
92 | },
93 | },
94 | },
95 | },
96 | },
97 | });
98 | });
99 |
100 | Navigation.setDefaultOptions({
101 | statusBar: {
102 | backgroundColor: 'black',
103 | },
104 | topBar: {
105 | title: {
106 | color: 'white',
107 | },
108 | backButton: {
109 | color: 'white',
110 | },
111 | background: {
112 | color: 'black',
113 | },
114 | },
115 | });
116 |
--------------------------------------------------------------------------------
/ios/.xcode.env:
--------------------------------------------------------------------------------
1 | # This `.xcode.env` file is versioned and is used to source the environment
2 | # used when running script phases inside Xcode.
3 | # To customize your local environment, you can create an `.xcode.env.local`
4 | # file that is not versioned.
5 |
6 | # NODE_BINARY variable contains the PATH to the node executable.
7 | #
8 | # Customize the NODE_BINARY variable here.
9 | # For example, to use nvm with brew, add the following line
10 | # . "$(brew --prefix nvm)/nvm.sh" --no-use
11 | export NODE_BINARY=$(command -v node)
12 |
--------------------------------------------------------------------------------
/ios/FrigateViewer.xcodeproj/xcshareddata/xcschemes/FrigateViewer.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
33 |
39 |
40 |
41 |
42 |
43 |
53 |
55 |
61 |
62 |
63 |
64 |
70 |
72 |
78 |
79 |
80 |
81 |
83 |
84 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/ios/FrigateViewer/AppDelegate.h:
--------------------------------------------------------------------------------
1 | #import "RNNAppDelegate.h"
2 | #import
3 |
4 | @interface AppDelegate : RNNAppDelegate
5 |
6 | @end
7 |
--------------------------------------------------------------------------------
/ios/FrigateViewer/AppDelegate.mm:
--------------------------------------------------------------------------------
1 | #import "AppDelegate.h"
2 | #import
3 |
4 | #import
5 |
6 | @implementation AppDelegate
7 |
8 | - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
9 | {
10 |
11 | return [super application:application didFinishLaunchingWithOptions:launchOptions];
12 | }
13 |
14 | - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
15 | {
16 | return [self bundleURL];
17 | }
18 |
19 | - (NSURL *)bundleURL
20 | {
21 | #if DEBUG
22 | return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
23 | #else
24 | return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
25 | #endif
26 | }
27 |
28 | @end
29 |
--------------------------------------------------------------------------------
/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"}]}
--------------------------------------------------------------------------------
/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/1024.png
--------------------------------------------------------------------------------
/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/114.png
--------------------------------------------------------------------------------
/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/120.png
--------------------------------------------------------------------------------
/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/180.png
--------------------------------------------------------------------------------
/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/29.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/29.png
--------------------------------------------------------------------------------
/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/40.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/40.png
--------------------------------------------------------------------------------
/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/57.png
--------------------------------------------------------------------------------
/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/58.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/58.png
--------------------------------------------------------------------------------
/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/60.png
--------------------------------------------------------------------------------
/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/80.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/80.png
--------------------------------------------------------------------------------
/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/87.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/ios/FrigateViewer/Images.xcassets/AppIcon.appiconset/_/87.png
--------------------------------------------------------------------------------
/ios/FrigateViewer/Images.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/ios/FrigateViewer/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleDisplayName
8 | FrigateViewer
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | APPL
19 | CFBundleShortVersionString
20 | $(MARKETING_VERSION)
21 | CFBundleSignature
22 | ????
23 | CFBundleVersion
24 | $(CURRENT_PROJECT_VERSION)
25 | LSRequiresIPhoneOS
26 |
27 | NSAppTransportSecurity
28 |
29 |
30 | NSAllowsArbitraryLoads
31 |
32 | NSAllowsLocalNetworking
33 |
34 |
35 | NSLocationWhenInUseUsageDescription
36 |
37 | UILaunchStoryboardName
38 | LaunchScreen
39 | UIRequiredDeviceCapabilities
40 |
41 | arm64
42 |
43 | UISupportedInterfaceOrientations
44 |
45 | UIInterfaceOrientationPortrait
46 | UIInterfaceOrientationLandscapeLeft
47 | UIInterfaceOrientationLandscapeRight
48 |
49 | UIViewControllerBasedStatusBarAppearance
50 |
51 | UIAppFonts
52 |
53 | antfill.ttf
54 | antoutline.ttf
55 |
56 | UIUserInterfaceStyle
57 | Light
58 |
59 |
--------------------------------------------------------------------------------
/ios/FrigateViewer/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
24 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/ios/FrigateViewer/PrivacyInfo.xcprivacy:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSPrivacyCollectedDataTypes
6 |
7 |
8 | NSPrivacyAccessedAPITypes
9 |
10 |
11 | NSPrivacyAccessedAPIType
12 | NSPrivacyAccessedAPICategoryFileTimestamp
13 | NSPrivacyAccessedAPITypeReasons
14 |
15 | C617.1
16 |
17 |
18 |
19 | NSPrivacyAccessedAPIType
20 | NSPrivacyAccessedAPICategoryUserDefaults
21 | NSPrivacyAccessedAPITypeReasons
22 |
23 | CA92.1
24 |
25 |
26 |
27 | NSPrivacyAccessedAPIType
28 | NSPrivacyAccessedAPICategorySystemBootTime
29 | NSPrivacyAccessedAPITypeReasons
30 |
31 | 35F9.1
32 |
33 |
34 |
35 | NSPrivacyTracking
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/ios/FrigateViewer/RCHTTPRequestHandler+ignoreSSL.m:
--------------------------------------------------------------------------------
1 | #import "React/RCTBridgeModule.h"
2 | #import "React/RCTHTTPRequestHandler.h"
3 |
4 | @implementation RCTHTTPRequestHandler(ignoreSSL)
5 |
6 | - (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential *credential))completionHandler
7 | {
8 | completionHandler(NSURLSessionAuthChallengeUseCredential, [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust]);
9 | }
10 | @end
11 |
--------------------------------------------------------------------------------
/ios/FrigateViewer/main.m:
--------------------------------------------------------------------------------
1 | #import
2 |
3 | #import "AppDelegate.h"
4 |
5 | int main(int argc, char *argv[])
6 | {
7 | @autoreleasepool {
8 | return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/ios/FrigateViewerTests/FrigateViewerTests.m:
--------------------------------------------------------------------------------
1 | #import
2 | #import
3 |
4 | #import
5 | #import
6 |
7 | #define TIMEOUT_SECONDS 600
8 | #define TEXT_TO_LOOK_FOR @"Welcome to React"
9 |
10 | @interface FrigateViewerTests : XCTestCase
11 |
12 | @end
13 |
14 | @implementation FrigateViewerTests
15 |
16 | - (BOOL)findSubviewInView:(UIView *)view matching:(BOOL (^)(UIView *view))test
17 | {
18 | if (test(view)) {
19 | return YES;
20 | }
21 | for (UIView *subview in [view subviews]) {
22 | if ([self findSubviewInView:subview matching:test]) {
23 | return YES;
24 | }
25 | }
26 | return NO;
27 | }
28 |
29 | - (void)testRendersWelcomeScreen
30 | {
31 | UIViewController *vc = [[[RCTSharedApplication() delegate] window] rootViewController];
32 | NSDate *date = [NSDate dateWithTimeIntervalSinceNow:TIMEOUT_SECONDS];
33 | BOOL foundElement = NO;
34 |
35 | __block NSString *redboxError = nil;
36 | #ifdef DEBUG
37 | RCTSetLogFunction(
38 | ^(RCTLogLevel level, RCTLogSource source, NSString *fileName, NSNumber *lineNumber, NSString *message) {
39 | if (level >= RCTLogLevelError) {
40 | redboxError = message;
41 | }
42 | });
43 | #endif
44 |
45 | while ([date timeIntervalSinceNow] > 0 && !foundElement && !redboxError) {
46 | [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
47 | [[NSRunLoop mainRunLoop] runMode:NSRunLoopCommonModes beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
48 |
49 | foundElement = [self findSubviewInView:vc.view
50 | matching:^BOOL(UIView *view) {
51 | if ([view.accessibilityLabel isEqualToString:TEXT_TO_LOOK_FOR]) {
52 | return YES;
53 | }
54 | return NO;
55 | }];
56 | }
57 |
58 | #ifdef DEBUG
59 | RCTSetLogFunction(RCTDefaultLogFunction);
60 | #endif
61 |
62 | XCTAssertNil(redboxError, @"RedBox error: %@", redboxError);
63 | XCTAssertTrue(foundElement, @"Couldn't find element with text '%@' in %d seconds", TEXT_TO_LOOK_FOR, TIMEOUT_SECONDS);
64 | }
65 |
66 | @end
67 |
--------------------------------------------------------------------------------
/ios/FrigateViewerTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | BNDL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | 1
23 |
24 |
25 |
--------------------------------------------------------------------------------
/ios/Podfile:
--------------------------------------------------------------------------------
1 | # Resolve react_native_pods.rb with node to allow for hoisting
2 | require Pod::Executable.execute_command('node', ['-p',
3 | 'require.resolve(
4 | "react-native/scripts/react_native_pods.rb",
5 | {paths: [process.argv[1]]},
6 | )', __dir__]).strip
7 |
8 | platform :ios, min_ios_version_supported
9 | prepare_react_native_project!
10 |
11 | linkage = ENV['USE_FRAMEWORKS']
12 | if linkage != nil
13 | Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green
14 | use_frameworks! :linkage => linkage.to_sym
15 | end
16 |
17 | target 'FrigateViewer' do
18 | config = use_native_modules!
19 |
20 | use_react_native!(
21 | :path => config[:reactNativePath],
22 | # An absolute path to your application root.
23 | :app_path => "#{Pod::Config.instance.installation_root}/.."
24 | )
25 |
26 | target 'FrigateViewerTests' do
27 | inherit! :complete
28 | # Pods for testing
29 | end
30 |
31 | post_install do |installer|
32 | # https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb#L197-L202
33 | react_native_post_install(
34 | installer,
35 | config[:reactNativePath],
36 | :mac_catalyst_enabled => false,
37 | # :ccache_enabled => true
38 | )
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/ios/link-assets-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "migIndex": 1,
3 | "data": [
4 | {
5 | "path": "node_modules/@ant-design/icons-react-native/fonts/antfill.ttf",
6 | "sha1": "56960e7721fc92b62e0f7c4d131ffe34ed042c49"
7 | },
8 | {
9 | "path": "node_modules/@ant-design/icons-react-native/fonts/antoutline.ttf",
10 | "sha1": "66720607b7496a48f145425386b2082b73662fd1"
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'react-native',
3 | };
4 |
--------------------------------------------------------------------------------
/metro.config.js:
--------------------------------------------------------------------------------
1 | const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
2 |
3 | /**
4 | * Metro configuration
5 | * https://reactnative.dev/docs/metro
6 | *
7 | * @type {import('metro-config').MetroConfig}
8 | */
9 | const config = {};
10 |
11 | module.exports = mergeConfig(getDefaultConfig(__dirname), config);
12 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "FrigateViewer",
3 | "version": "14.3.0",
4 | "private": true,
5 | "scripts": {
6 | "android": "react-native run-android",
7 | "android:release": "react-native run-android --mode=release",
8 | "android:build": "react-native build-android --mode=release",
9 | "android:bundle": "cd android && ./gradlew bundleRelease",
10 | "ios": "react-native run-ios",
11 | "lint": "eslint .",
12 | "start": "react-native start",
13 | "test": "jest",
14 | "copy-assets": "react-native-asset"
15 | },
16 | "dependencies": {
17 | "@ant-design/icons-react-native": "^2.3.2",
18 | "@lunarr/vlc-player": "^1.0.5",
19 | "@react-native-async-storage/async-storage": "2.0.0",
20 | "@react-native-firebase/app": "^21.0.0",
21 | "@react-native-firebase/crashlytics": "^21.0.0",
22 | "@reduxjs/toolkit": "^1.9.5",
23 | "buffer": "^6.0.3",
24 | "date-fns": "^2.30.0",
25 | "formik": "^2.4.5",
26 | "react": "18.2.0",
27 | "react-intl": "^6.4.7",
28 | "react-native": "0.73.9",
29 | "react-native-gesture-handler": "^2.13.1",
30 | "react-native-navigation": "^7.40.1",
31 | "react-native-reanimated": "^3.15.2",
32 | "react-native-reanimated-table": "^0.0.2",
33 | "react-native-share": "^11.0.3",
34 | "react-native-svg-charts": "github:piwko28/react-native-svg-charts",
35 | "react-native-ui-lib": "^7.9.1",
36 | "react-redux": "^8.1.2",
37 | "redux": "^4.2.1",
38 | "redux-persist": "^6.0.0",
39 | "rn-fetch-blob": "^0.12.0",
40 | "yup": "^1.4.0"
41 | },
42 | "devDependencies": {
43 | "@babel/core": "^7.20.0",
44 | "@babel/preset-env": "^7.20.0",
45 | "@babel/runtime": "^7.20.0",
46 | "@react-native/babel-preset": "0.73.21",
47 | "@react-native/eslint-config": "^0.73.2",
48 | "@react-native/metro-config": "^0.73.5",
49 | "@react-native/typescript-config": "0.73.1",
50 | "@types/react": "^18.2.6",
51 | "@types/react-native-svg-charts": "^5.0.14",
52 | "@types/react-test-renderer": "^18.0.0",
53 | "@types/rn-fetch-blob": "^1.2.7",
54 | "babel-jest": "^29.6.3",
55 | "eslint": "^8.19.0",
56 | "jest": "^29.6.3",
57 | "prettier": "^2.8.8",
58 | "react-native-asset": "^2.1.1",
59 | "react-test-renderer": "18.2.0",
60 | "typescript": "5.0.4"
61 | },
62 | "engines": {
63 | "node": ">=18"
64 | }
65 | }
--------------------------------------------------------------------------------
/react-native.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | assets: ['node_modules/@ant-design/icons-react-native/fonts'],
3 | };
4 |
--------------------------------------------------------------------------------
/store/events.ts:
--------------------------------------------------------------------------------
1 | import {createSlice, PayloadAction} from '@reduxjs/toolkit';
2 | import {RootState} from './store';
3 |
4 | /**
5 | * STORE MODEL
6 | **/
7 |
8 | export interface IEventsState {
9 | available: {
10 | cameras: string[];
11 | labels: string[];
12 | zones: string[];
13 | };
14 | filters: {
15 | cameras: string[];
16 | labels: string[];
17 | zones: string[];
18 | retained: boolean;
19 | };
20 | }
21 |
22 | export const initialState: IEventsState = {
23 | available: {
24 | cameras: [],
25 | labels: [],
26 | zones: [],
27 | },
28 | filters: {
29 | cameras: [],
30 | labels: [],
31 | zones: [],
32 | retained: false,
33 | },
34 | };
35 |
36 | /**
37 | * REDUCERS
38 | **/
39 |
40 | export const eventsStore = createSlice({
41 | name: 'events',
42 | initialState,
43 | reducers: {
44 | setAvailableCameras: (state, action: PayloadAction) => {
45 | state.available.cameras = action.payload;
46 | },
47 | setAvailableLabels: (state, action: PayloadAction) => {
48 | state.available.labels = action.payload;
49 | },
50 | setAvailableZones: (state, action: PayloadAction) => {
51 | state.available.zones = action.payload;
52 | },
53 | setFiltersCameras: (state, action: PayloadAction) => {
54 | state.filters.cameras = action.payload;
55 | },
56 | setFiltersLabels: (state, action: PayloadAction) => {
57 | state.filters.labels = action.payload;
58 | },
59 | setFiltersZones: (state, action: PayloadAction) => {
60 | state.filters.zones = action.payload;
61 | },
62 | setFiltersRetained: (state, action: PayloadAction) => {
63 | state.filters.retained = action.payload;
64 | },
65 | },
66 | });
67 |
68 | /**
69 | * ACTIONS
70 | **/
71 |
72 | export const {
73 | setAvailableCameras,
74 | setAvailableLabels,
75 | setAvailableZones,
76 | setFiltersCameras,
77 | setFiltersLabels,
78 | setFiltersZones,
79 | setFiltersRetained
80 | } = eventsStore.actions;
81 |
82 | /**
83 | * SELECTORS
84 | **/
85 |
86 | const eventsState = (state: RootState) => state.events;
87 |
88 | /* available */
89 |
90 | export const selectAvailable = (state: RootState) =>
91 | eventsState(state).available;
92 |
93 | export const selectAvailableCameras = (state: RootState) =>
94 | selectAvailable(state).cameras;
95 |
96 | export const selectAvailableLabels = (state: RootState) =>
97 | selectAvailable(state).labels;
98 |
99 | export const selectAvailableZones = (state: RootState) =>
100 | selectAvailable(state).zones;
101 |
102 | /* filters */
103 |
104 | export const selectFilters = (state: RootState) => eventsState(state).filters;
105 |
106 | export const selectFiltersCameras = (state: RootState) =>
107 | selectFilters(state).cameras;
108 |
109 | export const selectFiltersLabels = (state: RootState) =>
110 | selectFilters(state).labels;
111 |
112 | export const selectFiltersZones = (state: RootState) =>
113 | selectFilters(state).zones;
114 |
115 | export const selectFiltersRetained = (state: RootState) =>
116 | selectFilters(state).retained;
117 |
--------------------------------------------------------------------------------
/store/store.ts:
--------------------------------------------------------------------------------
1 | import {
2 | persistStore,
3 | persistReducer,
4 | FLUSH,
5 | REHYDRATE,
6 | PAUSE,
7 | PERSIST,
8 | PURGE,
9 | REGISTER,
10 | createTransform,
11 | } from 'redux-persist';
12 | import AsyncStorage from '@react-native-async-storage/async-storage';
13 | import {configureStore} from '@reduxjs/toolkit';
14 | import {TypedUseSelectorHook, useDispatch, useSelector} from 'react-redux';
15 | import {
16 | settingsMigrations,
17 | settingsStore,
18 | State as SettingsState,
19 | } from './settings';
20 | import {eventsStore} from './events';
21 |
22 | const settingsReducer = persistReducer(
23 | {
24 | key: 'settings',
25 | storage: AsyncStorage,
26 | transforms: [
27 | createTransform(
28 | state => state,
29 | state => ({...state, ...settingsMigrations(state)}),
30 | ),
31 | ],
32 | },
33 | settingsStore.reducer,
34 | );
35 |
36 | export const store = configureStore({
37 | reducer: {
38 | settings: settingsReducer,
39 | events: eventsStore.reducer,
40 | },
41 | middleware: getDefaultMiddleware =>
42 | getDefaultMiddleware({
43 | serializableCheck: {
44 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
45 | },
46 | }),
47 | });
48 |
49 | export type RootState = ReturnType;
50 | export type AppDispatch = typeof store.dispatch;
51 | export const useAppDispatch: () => AppDispatch = useDispatch;
52 | export const useAppSelector: TypedUseSelectorHook = useSelector;
53 | export const persistor = persistStore(store);
54 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@react-native/typescript-config/tsconfig.json",
3 | "compilerOptions": {
4 | "typeRoots": ["./node_modules/@types", "./typings"]
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/typings/@lunarr/vlc-player/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module '@lunarr/vlc-player' {
2 | type ViewProps = import('react-native').ViewProps;
3 |
4 | interface Source {
5 | uri?: string | undefined;
6 | headers?: {[key: string]: string} | undefined;
7 | type?: string | undefined;
8 | }
9 |
10 | export interface State {
11 | currentTime: number;
12 | duration: number;
13 | }
14 |
15 | interface VLCPlayerProps extends ViewProps {
16 | rate?: number;
17 | seek?: number;
18 | resume?: boolean;
19 | position?: number;
20 | snapshotPath?: string;
21 | paused?: boolean;
22 | autoAspectRatio?: boolean;
23 | videoAspectRatio?: string;
24 |
25 | volume?: number;
26 | volumeUp?: number;
27 | volumeDown?: number;
28 | repeat?: boolean;
29 | muted?: boolean;
30 |
31 | hwDecoderEnabled?: number;
32 | hwDecoderForced?: number;
33 |
34 | /* Internal events */
35 | // onVideoLoadStart?: (loadEvent: any) => void;
36 | // onVideoStateChange?: (stateChangeEvent: any) => void;
37 | // onVideoProgress?: (progressEvent: any) => void;
38 | // onSnapshot?: (snapshotEvent: any) => void;
39 | // onLoadStart?: (loadStartEvent: any) => void;
40 |
41 | source: Source | number;
42 | play?: (paused: boolean) => void;
43 | snapshot?: (path: string) => void;
44 | onError?: (state: State) => void;
45 | onSeek?: (state: State) => void;
46 | onProgress?: (state: State) => void;
47 | onMetadata?: (state: State) => void;
48 | onBuffer?: (state: State) => void;
49 | onEnd?: (state: State) => void;
50 | onStopped?: (state: State) => void;
51 |
52 | scaleX?: number;
53 | scaleY?: number;
54 | translateX?: number;
55 | translateY?: number;
56 | rotation?: number;
57 | }
58 |
59 | export default class VLCPlayer extends React.Component {
60 | setNativeProps(nativeProps: Partial): void;
61 | seek(timeSec: number): void;
62 | autoAspectRatio(isAuto: boolean): void;
63 | changeVideoAspectRatio(ratio: string): void;
64 | snapshot(path: string): void;
65 | play(paused: boolean): void;
66 | position(position: number): void;
67 | resume(isResume: boolean): void;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/views/author/Author.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect} from 'react';
2 | import {useIntl} from 'react-intl';
3 | import {Image, ImageStyle, Text, View} from 'react-native';
4 | import {Navigation, NavigationFunctionComponent} from 'react-native-navigation';
5 | import {menuButton, useMenu} from '../menu/menuHelpers';
6 | import {BuyMeACoffee} from './BuyMeACoffee';
7 | import {messages} from './messages';
8 | import {UsedLibs} from './UsedLibs';
9 | import {useOpenLink} from './useOpenLink';
10 | import {ScrollView} from 'react-native-gesture-handler';
11 | import {palette, useStyles} from '../../helpers/colors';
12 |
13 | export const Author: NavigationFunctionComponent = ({componentId}) => {
14 | useMenu(componentId, 'author');
15 | const intl = useIntl();
16 | const openLink = useOpenLink();
17 |
18 | const styles = useStyles(({theme}) => ({
19 | wrapper: {
20 | width: '100%',
21 | height: '100%',
22 | backgroundColor: theme.background,
23 | },
24 | authorInfo: {
25 | marginTop: 20,
26 | flexDirection: 'column',
27 | alignItems: 'center',
28 | },
29 | logoWrapper: {
30 | backgroundColor: palette.white,
31 | borderRadius: 10,
32 | },
33 | logo: {
34 | width: 100,
35 | height: 100,
36 | marginHorizontal: 12,
37 | resizeMode: 'contain',
38 | },
39 | link: {
40 | color: theme.link,
41 | },
42 | item: {
43 | marginVertical: 10,
44 | marginHorizontal: 20,
45 | },
46 | itemLabel: {
47 | fontWeight: '500',
48 | color: theme.text,
49 | },
50 | itemValue: {
51 | color: theme.text,
52 | textAlign: 'center',
53 | },
54 | repository: {
55 | flexDirection: 'column',
56 | },
57 | }));
58 |
59 | useEffect(() => {
60 | Navigation.mergeOptions(componentId, {
61 | topBar: {
62 | title: {
63 | text: intl.formatMessage(messages['topBar.title']),
64 | },
65 | leftButtons: [menuButton],
66 | },
67 | });
68 | }, [componentId, intl]);
69 |
70 | return (
71 |
72 |
73 |
74 |
78 |
79 |
80 |
81 | {intl.formatMessage(messages['info.authorLabel'])}:{' '}
82 |
83 | SP engineering
84 |
85 |
86 |
87 | {intl.formatMessage(messages['info.contactLabel'])}:{' '}
88 |
89 |
92 | szymon@piwowarczyk.net
93 |
94 |
95 |
96 |
97 | {intl.formatMessage(messages['info.opensourceLabel'])}
98 |
99 |
102 | {intl.formatMessage(messages['info.githubLabel'])}
103 |
104 |
105 |
106 |
109 |
110 |
111 | );
112 | };
113 |
--------------------------------------------------------------------------------
/views/author/BuyMeACoffee.tsx:
--------------------------------------------------------------------------------
1 | import React, {FC} from 'react';
2 | import {useIntl} from 'react-intl';
3 | import {Pressable, Text, View, ViewProps} from 'react-native';
4 | import {messages} from './messages';
5 | import {useStyles} from '../../helpers/colors';
6 |
7 | interface IButMeACoffeeProps extends ViewProps {
8 | onPress: () => void;
9 | }
10 |
11 | export const BuyMeACoffee: FC = ({
12 | onPress,
13 | style,
14 | ...viewProps
15 | }) => {
16 | const intl = useIntl();
17 |
18 | const styles = useStyles(({theme}) => ({
19 | wrapper: {
20 | margin: 20,
21 | paddingTop: 20,
22 | borderColor: theme.border,
23 | borderTopWidth: 1,
24 | flexDirection: 'column',
25 | alignItems: 'center',
26 | },
27 | nonProfitText: {
28 | marginBottom: 10,
29 | color: theme.text,
30 | textAlign: 'center',
31 | },
32 | text: {
33 | fontWeight: '500',
34 | color: theme.text,
35 | textAlign: 'center',
36 | },
37 | buttonInline: {
38 | marginVertical: 15,
39 | flexDirection: 'row',
40 | },
41 | button: {
42 | flexDirection: 'row',
43 | gap: 5,
44 | paddingHorizontal: 10,
45 | paddingVertical: 10,
46 | backgroundColor: '#fd0',
47 | borderRadius: 5,
48 | },
49 | buttonText: {
50 | color: 'black',
51 | },
52 | }));
53 |
54 | return (
55 |
56 |
57 | {intl.formatMessage(messages['buyMeCoffee.nonProfitLabel'])}
58 |
59 |
60 | {intl.formatMessage(messages['buyMeCoffee.doYouLikeLabel'])}
61 |
62 |
63 | {intl.formatMessage(messages['buyMeCoffee.sayThankYouLabel'])}
64 |
65 |
66 |
67 | ☕
68 |
69 | {intl.formatMessage(messages['buyMeCoffee.buttonText'])}
70 |
71 |
72 |
73 |
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/views/author/UsedLibs.tsx:
--------------------------------------------------------------------------------
1 | import {FC, useCallback} from 'react';
2 | import {useIntl} from 'react-intl';
3 | import {Pressable, Text, View} from 'react-native';
4 | import {messages} from './messages';
5 | import {useOpenLink} from './useOpenLink';
6 | import {useStyles} from '../../helpers/colors';
7 |
8 | const libs = [
9 | '@ant-design/icons-react-native',
10 | '@lunarr/vlc-player',
11 | '@react-native-async-storage/async-storage',
12 | '@reduxjs/toolkit',
13 | 'buffer',
14 | 'date-fns',
15 | 'formik',
16 | 'react',
17 | 'react-intl',
18 | 'react-native',
19 | 'react-native-gesture-handler',
20 | 'react-native-navigation',
21 | 'react-native-reanimated',
22 | 'react-native-reanimated-table',
23 | 'react-native-share',
24 | 'react-native-svg',
25 | 'react-native-svg-charts',
26 | 'react-native-ui-lib',
27 | 'react-redux',
28 | 'redux',
29 | 'redux-persist',
30 | 'rn-fetch-blob',
31 | 'yup',
32 | ];
33 |
34 | export const UsedLibs: FC = () => {
35 | const openLink = useOpenLink();
36 | const intl = useIntl();
37 |
38 | const styles = useStyles(({theme}) => ({
39 | wrapper: {
40 | margin: 20,
41 | marginTop: 0,
42 | paddingTop: 20,
43 | borderColor: theme.border,
44 | borderTopWidth: 1,
45 | flexDirection: 'column',
46 | alignItems: 'flex-start',
47 | },
48 | header: {
49 | marginBottom: 10,
50 | color: theme.text,
51 | fontWeight: 'bold',
52 | },
53 | lib: {
54 | marginBottom: 5,
55 | color: theme.link,
56 | },
57 | }));
58 |
59 | const openNpm = useCallback(
60 | (lib: string) => {
61 | const link = `https://npmjs.com/package/${lib}`;
62 | return openLink(link);
63 | },
64 | [openLink],
65 | );
66 |
67 | return (
68 |
69 |
70 | {intl.formatMessage(messages['usedLibs.header'])}
71 |
72 | {libs.map((lib, index) => (
73 |
74 | {lib}
75 |
76 | ))}
77 |
78 | );
79 | };
80 |
--------------------------------------------------------------------------------
/views/author/messages.ts:
--------------------------------------------------------------------------------
1 | import {makeMessages} from '../../helpers/locale';
2 |
3 | export const messages = makeMessages('author', {
4 | 'topBar.title': 'Author',
5 | 'info.authorLabel': 'Author',
6 | 'info.contactLabel': 'Contact',
7 | 'info.opensourceLabel': 'This is open source project.',
8 | 'info.githubLabel': 'See on github',
9 | 'buyMeCoffee.nonProfitLabel': 'The project was created for learning purposes and I don\'t intend to profit from granting licences.',
10 | 'buyMeCoffee.doYouLikeLabel': 'Do you like this application',
11 | 'buyMeCoffee.sayThankYouLabel': 'and want to say "thank you"?',
12 | 'buyMeCoffee.buttonText': 'Buy me a coffee',
13 | 'usedLibs.header': 'Used libraries:',
14 | 'error.cantOpenLink': "Can't find any app to open this link.",
15 | });
16 |
--------------------------------------------------------------------------------
/views/author/sp-engineering-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/author/sp-engineering-logo.png
--------------------------------------------------------------------------------
/views/author/useOpenLink.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { useIntl } from 'react-intl';
3 | import { Alert, Linking } from 'react-native';
4 | import { messages } from './messages';
5 |
6 | export const useOpenLink = () => {
7 | const intl = useIntl();
8 |
9 | return useCallback(
10 | (url: string) => async () => {
11 | const supported = await Linking.canOpenURL(url);
12 | if (supported) {
13 | await Linking.openURL(url);
14 | } else {
15 | Alert.alert(intl.formatMessage(messages['error.cantOpenLink']));
16 | }
17 | },
18 | [],
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/views/camera-events/EventLabels.tsx:
--------------------------------------------------------------------------------
1 | import React, {FC, useMemo} from 'react';
2 | import {useIntl} from 'react-intl';
3 | import {StyleProp, StyleSheet, Text, View, ViewStyle} from 'react-native';
4 | import {messages} from './messages';
5 |
6 | const stylesFn = (numColumns: number) =>
7 | StyleSheet.create({
8 | wrapper: {
9 | position: 'absolute',
10 | left: 2,
11 | bottom: 1,
12 | width: '100%',
13 | padding: 2,
14 | flexDirection: 'row',
15 | flexWrap: 'wrap',
16 | },
17 | label: {
18 | paddingVertical: 1,
19 | paddingHorizontal: 2,
20 | margin: 1,
21 | color: 'white',
22 | backgroundColor: 'blue',
23 | fontSize: 10 / (numColumns / 1.5),
24 | fontWeight: '600',
25 | opacity: 0.7,
26 | },
27 | zone: {
28 | backgroundColor: 'black',
29 | },
30 | score: {
31 | backgroundColor: 'gray',
32 | },
33 | inProgress: {
34 | color: 'black',
35 | backgroundColor: 'gold',
36 | },
37 | });
38 |
39 | interface IEventLabelsProps {
40 | endTime: number;
41 | label: string;
42 | zones: string[];
43 | topScore: number;
44 | style?: StyleProp;
45 | numColumns?: number;
46 | }
47 |
48 | export const EventLabels: FC = ({
49 | endTime,
50 | label,
51 | zones,
52 | topScore,
53 | style,
54 | numColumns,
55 | }) => {
56 | const score = useMemo(() => {
57 | return `${Math.round(topScore * 100)}%`;
58 | }, [topScore]);
59 | const isInProgress = useMemo(() => !endTime, [endTime]);
60 | const intl = useIntl();
61 |
62 | const styles = useMemo(() => stylesFn(numColumns || 1), [numColumns]);
63 |
64 | return (
65 |
66 | {label}
67 | {zones.map(zone => (
68 |
69 | {zone}
70 |
71 | ))}
72 | {score}
73 | {isInProgress && (
74 |
75 | {intl.formatMessage(messages['labels.inProgressLabel'])}
76 |
77 | )}
78 |
79 | );
80 | };
81 |
--------------------------------------------------------------------------------
/views/camera-events/EventSnapshot.tsx:
--------------------------------------------------------------------------------
1 | import {FC, useCallback, useEffect, useMemo, useState} from 'react';
2 | import {ZoomableImage} from '../../components/ZoomableImage';
3 | import {
4 | ImageLoadEventData,
5 | NativeSyntheticEvent,
6 | StyleSheet,
7 | } from 'react-native';
8 | import {useAppSelector} from '../../store/store';
9 | import {selectEventsPhotoPreference, selectServer} from '../../store/settings';
10 | import {authorizationHeader, buildServerApiUrl} from '../../helpers/rest';
11 |
12 | const styles = StyleSheet.create({
13 | image: {
14 | flex: 1,
15 | },
16 | });
17 |
18 | interface IEventSnapshotProps {
19 | id: string;
20 | hasSnapshot: boolean;
21 | onSnapshotLoad?: (url: string) => void;
22 | }
23 |
24 | export const EventSnapshot: FC = ({
25 | id,
26 | hasSnapshot,
27 | onSnapshotLoad,
28 | }) => {
29 | const [snapshot, setSnapshot] = useState();
30 | const photoPreference = useAppSelector(selectEventsPhotoPreference);
31 | const server = useAppSelector(selectServer);
32 |
33 | useEffect(() => {
34 | const apiUrl = buildServerApiUrl(server);
35 | const url =
36 | hasSnapshot && photoPreference === 'snapshot'
37 | ? `${apiUrl}/events/${id}/snapshot.jpg?bbox=1`
38 | : `${apiUrl}/events/${id}/thumbnail.jpg`;
39 | setSnapshot(url);
40 | }, [id, hasSnapshot, server]);
41 |
42 | const onLoad = (event: NativeSyntheticEvent) => {
43 | if (onSnapshotLoad && snapshot) {
44 | onSnapshotLoad(snapshot);
45 | }
46 | };
47 |
48 | return snapshot ? (
49 |
57 | ) : (
58 | <>>
59 | );
60 | };
61 |
--------------------------------------------------------------------------------
/views/camera-events/EventTitle.tsx:
--------------------------------------------------------------------------------
1 | import {format, formatDistance, formatRelative} from 'date-fns';
2 | import React, {FC, useMemo} from 'react';
3 | import {StyleProp, StyleSheet, Text, View, ViewStyle} from 'react-native';
4 | import {formatVideoTime, useDateLocale} from '../../helpers/locale';
5 | import {selectLocaleDatesDisplay} from '../../store/settings';
6 | import {useAppSelector} from '../../store/store';
7 |
8 | const stylesFn = (numColumns: number) => StyleSheet.create({
9 | wrapper: {
10 | position: 'absolute',
11 | display: 'flex',
12 | flexDirection: 'row',
13 | justifyContent: 'space-between',
14 | alignItems: 'center',
15 | left: 2,
16 | top: 1,
17 | width: '100%',
18 | padding: 5 / numColumns,
19 | backgroundColor: '#00000040',
20 | },
21 | timeText: {
22 | fontSize: 12 / (numColumns / 1.5),
23 | fontWeight: '600',
24 | color: 'white',
25 | },
26 | });
27 |
28 | interface IEventTitleProps {
29 | startTime: number;
30 | endTime: number;
31 | retained: boolean;
32 | style?: StyleProp;
33 | numColumns?: number;
34 | }
35 |
36 | export const EventTitle: FC = ({
37 | startTime,
38 | endTime,
39 | retained,
40 | style,
41 | numColumns,
42 | }) => {
43 | const dateLocale = useDateLocale();
44 | const datesDisplay = useAppSelector(selectLocaleDatesDisplay);
45 |
46 | const isInProgress = useMemo(() => !endTime, [endTime]);
47 |
48 | const startDate = useMemo(
49 | () =>
50 | datesDisplay === 'descriptive'
51 | ? formatRelative(new Date(startTime * 1000), new Date(), {
52 | locale: dateLocale,
53 | })
54 | : format(new Date(startTime * 1000), 'Pp', {locale: dateLocale}),
55 | [startTime, dateLocale, datesDisplay],
56 | );
57 |
58 | const duration = useMemo(
59 | () =>
60 | datesDisplay === 'descriptive'
61 | ? formatDistance(new Date(endTime * 1000), new Date(startTime * 1000), {
62 | includeSeconds: true,
63 | locale: dateLocale,
64 | })
65 | : formatVideoTime(Math.round(endTime * 1000 - startTime * 1000)),
66 | [startTime, endTime, dateLocale, datesDisplay],
67 | );
68 |
69 | const styles = useMemo(() => stylesFn(numColumns || 1), [numColumns]);
70 |
71 | return (
72 |
73 |
74 | {startDate} {!isInProgress && ({duration})}
75 |
76 | {retained && ⭐}
77 |
78 | );
79 | };
80 |
--------------------------------------------------------------------------------
/views/camera-events/Share.tsx:
--------------------------------------------------------------------------------
1 | import {FC, useEffect, useMemo, useState} from 'react';
2 | import {ActionSheet, Dialog} from 'react-native-ui-lib';
3 | import {ICameraEvent} from './CameraEvent';
4 | import {useIntl} from 'react-intl';
5 | import RNFetchBlob from 'rn-fetch-blob';
6 | import RNShare from 'react-native-share';
7 | import {messages} from './messages';
8 | import {authorizationHeader, buildServerApiUrl} from '../../helpers/rest';
9 | import {useAppSelector} from '../../store/store';
10 | import {selectServer} from '../../store/settings';
11 | import {ActivityIndicator, Text, ToastAndroid} from 'react-native';
12 | import crashlytics from '@react-native-firebase/crashlytics';
13 | import {clipFilename, snapshotFilename} from './eventHelpers';
14 | import {useStyles} from '../../helpers/colors';
15 |
16 | interface ShareProps {
17 | event?: ICameraEvent;
18 | onDismiss?: () => void;
19 | }
20 |
21 | const stall = (ms: number = 0) =>
22 | new Promise(resolve => setTimeout(resolve, ms));
23 |
24 | export const Share: FC = ({event, onDismiss}) => {
25 | const [isVisible, setIsVisible] = useState(false);
26 | const [loading, setLoading] = useState(false);
27 | const [progress, setProgress] = useState(0);
28 | const intl = useIntl();
29 | const server = useAppSelector(selectServer);
30 |
31 | const styles = useStyles(({theme}) => ({
32 | loadingText: {
33 | textAlign: 'center',
34 | color: 'white',
35 | },
36 | }));
37 |
38 | useEffect(() => {
39 | if (event) {
40 | setIsVisible(true);
41 | }
42 | }, [event]);
43 |
44 | const options = useMemo(() => {
45 | return [
46 | ...(event?.has_snapshot
47 | ? [
48 | {
49 | label: intl.formatMessage(messages['share.snapshot.label']),
50 | onPress: () => shareSnapshot(),
51 | },
52 | ]
53 | : []),
54 | ...(event?.has_clip
55 | ? [
56 | {
57 | label: intl.formatMessage(messages['share.clip.label']),
58 | onPress: () => shareClip(),
59 | },
60 | ]
61 | : []),
62 | ];
63 | }, [event]);
64 |
65 | const download = async (filename: string, url: string) => {
66 | try {
67 | crashlytics().log(`Share ${filename} from ${url}`);
68 | setLoading(true);
69 | const dirs = RNFetchBlob.fs.dirs;
70 | const filePath = `${dirs.CacheDir}/${filename}`;
71 | const downloader = RNFetchBlob.config({
72 | fileCache: true,
73 | session: 'share',
74 | path: filePath,
75 | });
76 | await downloader
77 | .fetch('GET', url, authorizationHeader(server))
78 | .progress((received, total) => {
79 | const progress = Math.round((received / total) * 100);
80 | setProgress(progress);
81 | });
82 | setLoading(false);
83 | return filePath;
84 | } catch (err) {
85 | crashlytics().recordError(err as Error);
86 | setLoading(false);
87 | ToastAndroid.show(JSON.stringify(err), ToastAndroid.LONG);
88 | }
89 | };
90 |
91 | const shareSnapshot = async () => {
92 | const apiUrl = buildServerApiUrl(server);
93 | const filename = snapshotFilename(event!);
94 | const path = await download(
95 | filename,
96 | `${apiUrl}/events/${event!.id}/snapshot.jpg?bbox=1`,
97 | );
98 | await stall(200);
99 | RNShare.open({
100 | url: `file://${path}`,
101 | }).then(() => {
102 | RNFetchBlob.session('share').dispose();
103 | });
104 | };
105 |
106 | const shareClip = async () => {
107 | const apiUrl = buildServerApiUrl(server);
108 | const filename = clipFilename(event!);
109 | const path = await download(
110 | filename,
111 | `${apiUrl}/events/${event!.id}/clip.mp4`,
112 | );
113 | await stall(200);
114 | RNShare.open({
115 | url: `file://${path}`,
116 | }).then(() => {
117 | RNFetchBlob.session('share').dispose();
118 | });
119 | };
120 |
121 | const close = () => {
122 | setIsVisible(false);
123 | if (onDismiss) {
124 | onDismiss();
125 | }
126 | };
127 |
128 | return (
129 | <>
130 |
136 |
140 | >
141 | );
142 | };
143 |
--------------------------------------------------------------------------------
/views/camera-events/eventHelpers.ts:
--------------------------------------------------------------------------------
1 | import {ICameraEvent} from './CameraEvent';
2 |
3 | const eventDateStr = (event: ICameraEvent) => {
4 | const date = new Date(event.start_time * 1000);
5 | const year = date.getFullYear();
6 | const month = String(date.getMonth() + 1).padStart(2, '0');
7 | const day = String(date.getDate()).padStart(2, '0');
8 | const hours = String(date.getHours()).padStart(2, '0');
9 | const minutes = String(date.getMinutes()).padStart(2, '0');
10 | const seconds = String(date.getSeconds()).padStart(2, '0');
11 |
12 | return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`;
13 | };
14 |
15 | export const snapshotFilename = (event: ICameraEvent) => {
16 | return `${event.camera}-${eventDateStr(event)}.jpg`;
17 | };
18 |
19 | export const clipFilename = (event: ICameraEvent) => {
20 | return `${event.camera}-${eventDateStr(event)}.mp4`;
21 | };
22 |
--------------------------------------------------------------------------------
/views/camera-events/icons/delete.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/camera-events/icons/delete.png
--------------------------------------------------------------------------------
/views/camera-events/icons/delete@1.5x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/camera-events/icons/delete@1.5x.png
--------------------------------------------------------------------------------
/views/camera-events/icons/delete@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/camera-events/icons/delete@2x.png
--------------------------------------------------------------------------------
/views/camera-events/icons/delete@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/camera-events/icons/delete@3x.png
--------------------------------------------------------------------------------
/views/camera-events/icons/delete@4x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/camera-events/icons/delete@4x.png
--------------------------------------------------------------------------------
/views/camera-events/icons/share.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/camera-events/icons/share.png
--------------------------------------------------------------------------------
/views/camera-events/icons/share@1.5x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/camera-events/icons/share@1.5x.png
--------------------------------------------------------------------------------
/views/camera-events/icons/share@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/camera-events/icons/share@2x.png
--------------------------------------------------------------------------------
/views/camera-events/icons/share@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/camera-events/icons/share@3x.png
--------------------------------------------------------------------------------
/views/camera-events/icons/share@4x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/camera-events/icons/share@4x.png
--------------------------------------------------------------------------------
/views/camera-events/icons/star.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/camera-events/icons/star.png
--------------------------------------------------------------------------------
/views/camera-events/icons/star@1.5x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/camera-events/icons/star@1.5x.png
--------------------------------------------------------------------------------
/views/camera-events/icons/star@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/camera-events/icons/star@2x.png
--------------------------------------------------------------------------------
/views/camera-events/icons/star@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/camera-events/icons/star@3x.png
--------------------------------------------------------------------------------
/views/camera-events/icons/star@4x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/camera-events/icons/star@4x.png
--------------------------------------------------------------------------------
/views/camera-events/messages.ts:
--------------------------------------------------------------------------------
1 | import {makeMessages} from '../../helpers/locale';
2 |
3 | export const messages = makeMessages('cameraEvents', {
4 | 'topBar.general.title': 'Events',
5 | 'topBar.retained.title': 'Retained',
6 | 'topBar.specificCamera.title': 'Events of {cameraName}',
7 | noEvents: 'No events',
8 | 'labels.inProgressLabel': 'In progress',
9 | 'action.delete': 'Delete',
10 | 'action.retain': 'Retain',
11 | 'action.unretain': 'Unretain',
12 | 'action.share': 'Share',
13 | 'share.snapshot.label': 'Snapshot',
14 | 'share.clip.label': 'Clip',
15 | 'toast.noClip': 'This event has no clip.',
16 | });
17 |
--------------------------------------------------------------------------------
/views/camera-preview/CameraPreview.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {NavigationFunctionComponent} from 'react-native-navigation';
3 | import {useStyles} from '../../helpers/colors';
4 | import {LivePreview} from './LivePreview';
5 | import {View} from 'react-native-ui-lib';
6 |
7 | interface CameraPreviewProps {
8 | cameraName: string;
9 | }
10 |
11 | export const CameraPreview: NavigationFunctionComponent = ({
12 | cameraName,
13 | }) => {
14 | const styles = useStyles(({theme}) => ({
15 | wrapper: {
16 | backgroundColor: theme.background,
17 | },
18 | }));
19 |
20 | return (
21 |
22 |
23 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/views/camera-preview/LivePreview.tsx:
--------------------------------------------------------------------------------
1 | import React, {FC, useEffect, useRef, useState} from 'react';
2 | import type {PropsWithChildren} from 'react';
3 | import {Image, ImageStyle, View} from 'react-native';
4 | import {useAppSelector} from '../../store/store';
5 | import {
6 | selectCamerasRefreshFrequency,
7 | selectServer,
8 | } from '../../store/settings';
9 | import {authorizationHeader, buildServerApiUrl} from '../../helpers/rest';
10 | import {ZoomableImage} from '../../components/ZoomableImage';
11 | import {useStyles} from '../../helpers/colors';
12 |
13 | type LivePreviewProps = PropsWithChildren<{
14 | cameraName: string;
15 | }>;
16 |
17 | export const LivePreview: FC = ({cameraName}) => {
18 | const styles = useStyles(() => ({
19 | image: {
20 | width: '100%',
21 | height: '100%',
22 | },
23 | }));
24 |
25 | const [lastImageSrc, setLastImageSrc] = useState(
26 | undefined,
27 | );
28 | const server = useAppSelector(selectServer);
29 | const refreshFrequency = useAppSelector(selectCamerasRefreshFrequency);
30 | const interval = useRef();
31 |
32 | const getLastImageUrl = () =>
33 | `${buildServerApiUrl(
34 | server,
35 | )}/${cameraName}/latest.jpg?bbox=1&ts=${new Date().toISOString()}`;
36 |
37 | const updateLastImageUrl = async () => {
38 | const lastImageUrl = getLastImageUrl();
39 | Image.getSizeWithHeaders(lastImageUrl, authorizationHeader(server), () => {
40 | setLastImageSrc(lastImageUrl);
41 | });
42 | };
43 |
44 | useEffect(() => {
45 | updateLastImageUrl();
46 | const removeRefreshing = () => {
47 | if (interval.current) {
48 | clearInterval(interval.current);
49 | }
50 | };
51 | removeRefreshing();
52 | interval.current = setInterval(async () => {
53 | updateLastImageUrl();
54 | }, refreshFrequency * 1000);
55 | return removeRefreshing;
56 | }, [cameraName, setLastImageSrc, server, refreshFrequency]);
57 |
58 | return (
59 |
60 | {lastImageSrc && (
61 |
72 | )}
73 |
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/views/cameras-list/CameraLabels.tsx:
--------------------------------------------------------------------------------
1 | import React, {FC, useCallback, useMemo} from 'react';
2 | import {Pressable, StyleSheet, Text} from 'react-native';
3 | import {Colors} from 'react-native-ui-lib';
4 | import {selectAvailableLabels} from '../../store/events';
5 | import {
6 | selectCamerasNumColumns,
7 | selectCamerasPreviewHeight,
8 | } from '../../store/settings';
9 | import {useAppSelector} from '../../store/store';
10 | import {FlatList} from 'react-native-gesture-handler';
11 |
12 | const stylesFn = (numColumns: number) =>
13 | StyleSheet.create({
14 | wrapper: {
15 | width: '100%',
16 | height: '100%',
17 | backgroundColor: Colors.green70,
18 | padding: 2,
19 | marginTop: 35 / numColumns,
20 | },
21 | label: {
22 | display: 'flex',
23 | margin: 2,
24 | padding: 5,
25 | flexDirection: 'column',
26 | alignItems: 'center',
27 | justifyContent: 'flex-end',
28 | flex: 1,
29 | maxWidth: '24%',
30 | height: 80 / numColumns,
31 | backgroundColor: Colors.green40,
32 | },
33 | labelText: {
34 | fontSize: 14 / numColumns,
35 | color: 'white',
36 | },
37 | iconEmoji: {
38 | fontSize: 40 / (numColumns * 1.5),
39 | color: 'white',
40 | },
41 | });
42 |
43 | const labelEmoji: Record = {
44 | person: '🧑',
45 | car: '🚗',
46 | cat: '🐈',
47 | dog: '🐕',
48 | bus: '🚌',
49 | bicycle: '🚲',
50 | plate: '🔢',
51 | };
52 |
53 | interface ICameraLabelsProps {
54 | height?: number;
55 | onLabelPress: (label: string) => void;
56 | }
57 |
58 | export const CameraLabels: FC = ({
59 | height,
60 | onLabelPress,
61 | }) => {
62 | const labels = useAppSelector(selectAvailableLabels);
63 | const previewHeight = useAppSelector(selectCamerasPreviewHeight);
64 | const numColumns = useAppSelector(selectCamerasNumColumns);
65 |
66 | const styles = useMemo(() => stylesFn(numColumns), [numColumns]);
67 |
68 | const onPress = useCallback(
69 | (label: string) => () => {
70 | onLabelPress(label);
71 | },
72 | [onLabelPress],
73 | );
74 |
75 | return (
76 | (
80 |
81 | {labelEmoji[item] && (
82 | {labelEmoji[item]}
83 | )}
84 | {item}
85 |
86 | )}
87 | keyExtractor={label => label}
88 | style={[
89 | styles.wrapper,
90 | {height: (height || previewHeight) - 35 / numColumns},
91 | ]}>
92 | );
93 | };
94 |
--------------------------------------------------------------------------------
/views/cameras-list/CamerasList.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useState} from 'react';
2 | import {useIntl} from 'react-intl';
3 | import {FlatList, Text} from 'react-native';
4 | import {Navigation, NavigationFunctionComponent} from 'react-native-navigation';
5 | import {useRest} from '../../helpers/rest';
6 | import {
7 | selectAvailableCameras,
8 | setAvailableCameras,
9 | setAvailableLabels,
10 | setAvailableZones,
11 | } from '../../store/events';
12 | import {selectCamerasNumColumns, selectServer} from '../../store/settings';
13 | import {useAppDispatch, useAppSelector} from '../../store/store';
14 | import {menuButton, useMenu} from '../menu/menuHelpers';
15 | import {CameraTile} from './CameraTile';
16 | import {messages} from './messages';
17 | import {useNoServer} from '../settings/useNoServer';
18 | import {Background} from '../../components/Background';
19 | import {useStyles} from '../../helpers/colors';
20 | import {View} from 'react-native-ui-lib';
21 | import {Refresh} from '../../components/Refresh';
22 |
23 | interface IConfigResponse {
24 | cameras: Record<
25 | string,
26 | {
27 | zones: Record;
28 | }
29 | >;
30 | objects: {
31 | track: string[];
32 | };
33 | }
34 |
35 | export const CamerasList: NavigationFunctionComponent = ({componentId}) => {
36 | const styles = useStyles(({theme}) => ({
37 | noCameras: {
38 | padding: 20,
39 | color: theme.text,
40 | textAlign: 'center',
41 | },
42 | }));
43 |
44 | useMenu(componentId, 'camerasList');
45 | useNoServer();
46 | const [loading, setLoading] = useState(true);
47 | const server = useAppSelector(selectServer);
48 | const cameras = useAppSelector(selectAvailableCameras);
49 | const numColumns = useAppSelector(selectCamerasNumColumns);
50 | const dispatch = useAppDispatch();
51 | const intl = useIntl();
52 | const {get} = useRest();
53 |
54 | useEffect(() => {
55 | Navigation.mergeOptions(componentId, {
56 | topBar: {
57 | title: {
58 | text: intl.formatMessage(messages['topBar.title']),
59 | },
60 | leftButtons: [menuButton],
61 | },
62 | });
63 | }, [componentId, intl]);
64 |
65 | const refresh = () => {
66 | setLoading(true);
67 | get(server, `config`)
68 | .then(config => {
69 | const availableCameras = Object.keys(config.cameras);
70 | const availableLabels = config.objects.track;
71 | const availableZones = availableCameras.reduce(
72 | (zones, cameraName) => [
73 | ...zones,
74 | ...Object.keys(config.cameras[cameraName].zones).filter(
75 | zoneName => !zones.includes(zoneName),
76 | ),
77 | ],
78 | [] as string[],
79 | );
80 | dispatch(setAvailableCameras(availableCameras));
81 | dispatch(setAvailableLabels(availableLabels));
82 | dispatch(setAvailableZones(availableZones));
83 | })
84 | .catch(() => {
85 | dispatch(setAvailableCameras([]));
86 | return [];
87 | })
88 | .finally(() => {
89 | setLoading(false);
90 | });
91 | };
92 |
93 | useEffect(() => {
94 | if (server.host) {
95 | refresh();
96 | }
97 | }, [server]);
98 |
99 | return (
100 |
101 | {!loading && cameras.length === 0 && (
102 |
103 |
104 |
105 | {intl.formatMessage(messages['noCameras'])}
106 |
107 |
108 | )}
109 | (
112 |
113 | )}
114 | key={numColumns}
115 | keyExtractor={cameraName => cameraName}
116 | numColumns={numColumns}
117 | refreshControl={}
118 | />
119 |
120 | );
121 | };
122 |
--------------------------------------------------------------------------------
/views/cameras-list/ImagePreview.tsx:
--------------------------------------------------------------------------------
1 | import React, {FC} from 'react';
2 | import {TouchableWithoutFeedback} from 'react-native-gesture-handler';
3 | import {StyleSheet, View} from 'react-native';
4 | import {ZoomableImage} from '../../components/ZoomableImage';
5 | import {useAppSelector} from '../../store/store';
6 | import {selectCamerasPreviewHeight, selectServer} from '../../store/settings';
7 | import {authorizationHeader} from '../../helpers/rest';
8 |
9 | const styles = StyleSheet.create({
10 | wrapper: {
11 | paddingVertical: 2,
12 | paddingHorizontal: 1,
13 | },
14 | image: {
15 | flex: 1,
16 | },
17 | });
18 |
19 | interface IImagePreviewProps {
20 | height?: number;
21 | imageUrl?: string;
22 | onPress?: () => void;
23 | onPreviewLoad?: () => void;
24 | }
25 |
26 | export const ImagePreview: FC = ({
27 | height,
28 | imageUrl,
29 | onPress,
30 | onPreviewLoad,
31 | }) => {
32 | const previewHeight = useAppSelector(selectCamerasPreviewHeight);
33 | const server = useAppSelector(selectServer);
34 |
35 | return (
36 |
37 |
42 | {imageUrl && (
43 |
54 | )}
55 |
56 |
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/views/cameras-list/LastEvent.tsx:
--------------------------------------------------------------------------------
1 | import {FC, useCallback} from 'react';
2 | import {ICameraEvent} from '../camera-events/CameraEvent';
3 | import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native';
4 | import {EventSnapshot} from '../camera-events/EventSnapshot';
5 | import {EventLabels} from '../camera-events/EventLabels';
6 | import {EventTitle} from '../camera-events/EventTitle';
7 | import {useAppSelector} from '../../store/store';
8 | import {
9 | selectCamerasNumColumns,
10 | selectCamerasPreviewHeight,
11 | } from '../../store/settings';
12 |
13 | const styles = StyleSheet.create({
14 | eventMetadata: {
15 | position: 'absolute',
16 | bottom: 0,
17 | width: '100%',
18 | },
19 | eventTitle: {
20 | position: 'relative',
21 | },
22 | eventLabels: {
23 | position: 'relative',
24 | },
25 | });
26 |
27 | interface ILastEventProps {
28 | height?: number;
29 | event?: ICameraEvent;
30 | onPress?: () => void;
31 | }
32 |
33 | export const LastEvent: FC = ({height, event, onPress}) => {
34 | const previewHeight = useAppSelector(selectCamerasPreviewHeight);
35 | const numColumns = useAppSelector(selectCamerasNumColumns);
36 |
37 | const onEventPress = useCallback(() => {
38 | if (onPress) {
39 | onPress();
40 | }
41 | }, [onPress]);
42 |
43 | return (
44 |
45 |
50 | {event && (
51 | <>
52 |
53 |
54 |
62 |
69 |
70 | >
71 | )}
72 |
73 |
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/views/cameras-list/messages.ts:
--------------------------------------------------------------------------------
1 | import {makeMessages} from '../../helpers/locale';
2 |
3 | export const messages = makeMessages('camerasList', {
4 | 'topBar.title': 'List of cameras',
5 | 'noCameras': 'No cameras',
6 | });
7 |
--------------------------------------------------------------------------------
/views/cameras-list/useLoadingTime.ts:
--------------------------------------------------------------------------------
1 | import {useEffect, useState} from 'react';
2 |
3 | export const useLoadingTime = () => {
4 | const [startLoadingTime, setStartLoadingTime] = useState();
5 | const [endLoadingTime, setEndLoadingTime] = useState();
6 | const [loadingTime, setLoadingTime] = useState();
7 |
8 | useEffect(() => {
9 | if (
10 | startLoadingTime &&
11 | endLoadingTime &&
12 | endLoadingTime > startLoadingTime
13 | ) {
14 | setLoadingTime(endLoadingTime - startLoadingTime);
15 | }
16 | }, [startLoadingTime, endLoadingTime]);
17 |
18 | return {
19 | loadingTime,
20 | setStartLoadingTime,
21 | setEndLoadingTime,
22 | };
23 | };
24 |
--------------------------------------------------------------------------------
/views/events-filters/EventsFilters.tsx:
--------------------------------------------------------------------------------
1 | import React, {FC, useMemo} from 'react';
2 | import {useIntl} from 'react-intl';
3 | import {ScrollView} from 'react-native';
4 | import {
5 | selectAvailableCameras,
6 | selectAvailableLabels,
7 | selectAvailableZones,
8 | selectFiltersCameras,
9 | selectFiltersLabels,
10 | selectFiltersRetained,
11 | selectFiltersZones,
12 | setFiltersCameras,
13 | setFiltersLabels,
14 | setFiltersRetained,
15 | setFiltersZones,
16 | } from '../../store/events';
17 | import {useAppSelector} from '../../store/store';
18 | import {Filters, IFilter, SectionHeader} from './Filters';
19 | import {messages} from './messages';
20 | import {Section} from '../../components/forms/Section';
21 | import {FilterSwitch} from './FilterSwitch';
22 | import {useStyles} from '../../helpers/colors';
23 |
24 | interface IEventsFiltersProps {
25 | viewedCameraNames?: string[];
26 | }
27 |
28 | export const EventsFilters: FC = ({viewedCameraNames}) => {
29 | const styles = useStyles(({theme}) => ({
30 | wrapper: {
31 | backgroundColor: theme.background,
32 | width: '100%',
33 | height: '100%',
34 | },
35 | }));
36 |
37 | const availableCameras = useAppSelector(selectAvailableCameras);
38 | const filtersCameras = useAppSelector(selectFiltersCameras);
39 | const availableLabels = useAppSelector(selectAvailableLabels);
40 | const filtersLabels = useAppSelector(selectFiltersLabels);
41 | const availableZones = useAppSelector(selectAvailableZones);
42 | const filtersZones = useAppSelector(selectFiltersZones);
43 | const filtersRetained = useAppSelector(selectFiltersRetained);
44 | const intl = useIntl();
45 |
46 | const cameras: IFilter[] = useMemo(
47 | () =>
48 | availableCameras.map(cameraName => ({
49 | name: cameraName,
50 | selected: (viewedCameraNames
51 | ? viewedCameraNames
52 | : filtersCameras
53 | ).includes(cameraName),
54 | })),
55 | [availableCameras, filtersCameras, viewedCameraNames],
56 | );
57 |
58 | const labels: IFilter[] = useMemo(
59 | () =>
60 | availableLabels.map(cameraName => ({
61 | name: cameraName,
62 | selected: filtersLabels.includes(cameraName),
63 | })),
64 | [availableLabels, filtersLabels],
65 | );
66 |
67 | const zones: IFilter[] = useMemo(
68 | () =>
69 | availableZones.map(cameraName => ({
70 | name: cameraName,
71 | selected: filtersZones.includes(cameraName),
72 | })),
73 | [availableZones, filtersZones],
74 | );
75 |
76 | return (
77 |
78 |
84 |
89 |
94 |
99 | }>
100 |
105 |
106 |
107 | );
108 | };
109 |
--------------------------------------------------------------------------------
/views/events-filters/FilterItem.tsx:
--------------------------------------------------------------------------------
1 | import React, {FC, useCallback} from 'react';
2 | import {Pressable, Text} from 'react-native';
3 | import {useStyles} from '../../helpers/colors';
4 |
5 | interface IFilterItemProps {
6 | label: string;
7 | selected: boolean;
8 | disabled?: boolean;
9 | onPress?: (item: string) => void;
10 | }
11 |
12 | export const FilterItem: FC = ({
13 | label,
14 | selected,
15 | disabled,
16 | onPress,
17 | }) => {
18 | const styles = useStyles(({theme}) => ({
19 | wrapper: {
20 | paddingVertical: 10,
21 | paddingHorizontal: 10,
22 | backgroundColor: theme.background,
23 | borderBottomWidth: 1,
24 | borderColor: theme.border,
25 | flexDirection: 'row',
26 | alignItems: 'center',
27 | },
28 | checkmark: {
29 | width: 18,
30 | fontSize: 10,
31 | color: theme.text,
32 | },
33 | text: {
34 | color: theme.text,
35 | },
36 | selectedText: {
37 | fontWeight: '600',
38 | },
39 | disabledText: {
40 | color: theme.disabled,
41 | },
42 | }));
43 |
44 | const onItemPress = useCallback(() => {
45 | if (onPress && !disabled) {
46 | onPress(label);
47 | }
48 | }, [onPress, disabled, label]);
49 |
50 | return (
51 |
52 | {selected ? '✔️' : ''}
53 |
59 | {label}
60 |
61 |
62 | );
63 | };
64 |
--------------------------------------------------------------------------------
/views/events-filters/FilterSwitch.tsx:
--------------------------------------------------------------------------------
1 | import {ActionCreatorWithPayload} from '@reduxjs/toolkit';
2 | import {FC, useCallback} from 'react';
3 | import {Switch, SwitchProps, Text, View} from 'react-native-ui-lib';
4 | import {useAppDispatch} from '../../store/store';
5 | import {useStyles} from '../../helpers/colors';
6 |
7 | interface IFilterSwitchProps extends SwitchProps {
8 | label?: string | JSX.Element;
9 | actionOnChange?: ActionCreatorWithPayload;
10 | }
11 |
12 | export const FilterSwitch: FC = ({
13 | label,
14 | actionOnChange,
15 | ...switchProps
16 | }) => {
17 | const styles = useStyles(({theme}) => ({
18 | wrapper: {
19 | display: 'flex',
20 | flexDirection: 'row',
21 | justifyContent: 'space-between',
22 | alignItems: 'center',
23 | paddingHorizontal: 26,
24 | paddingVertical: 10,
25 | backgroundColor: theme.background,
26 | borderBottomWidth: 1,
27 | borderColor: theme.border,
28 | },
29 | label: {
30 | color: theme.text,
31 | },
32 | }));
33 |
34 | const dispatch = useAppDispatch();
35 |
36 | const onValueChange = useCallback(
37 | (value: boolean) => {
38 | if (actionOnChange) {
39 | dispatch(actionOnChange(value));
40 | }
41 | },
42 | [dispatch, actionOnChange],
43 | );
44 |
45 | return (
46 |
47 | {label && {label}}
48 |
49 |
50 | );
51 | };
52 |
--------------------------------------------------------------------------------
/views/events-filters/Filters.tsx:
--------------------------------------------------------------------------------
1 | import {ActionCreatorWithPayload} from '@reduxjs/toolkit';
2 | import React, {FC, useCallback} from 'react';
3 | import {Text, View} from 'react-native';
4 | import {Section} from '../../components/forms/Section';
5 | import {useAppDispatch} from '../../store/store';
6 | import {FilterItem} from './FilterItem';
7 | import {useStyles} from '../../helpers/colors';
8 |
9 | export const SectionHeader: FC<{label: string}> = ({label}) => {
10 | const styles = useStyles(({theme}) => ({
11 | sectionHeader: {
12 | paddingHorizontal: 28,
13 | },
14 | sectionHeaderText: {
15 | fontSize: 18,
16 | fontWeight: '600',
17 | color: theme.text,
18 | },
19 | }));
20 |
21 | return (
22 |
23 | {label}
24 |
25 | );
26 | };
27 |
28 | export interface IFilter {
29 | name: string;
30 | selected: boolean;
31 | }
32 |
33 | interface IFilters {
34 | header: string;
35 | items: IFilter[];
36 | disabled?: boolean;
37 | actionOnFilter?: ActionCreatorWithPayload;
38 | }
39 |
40 | export const Filters: FC = ({
41 | header,
42 | items,
43 | disabled,
44 | actionOnFilter,
45 | }) => {
46 | const dispatch = useAppDispatch();
47 |
48 | const onPress = useCallback(
49 | (pressedName: string) => {
50 | if (actionOnFilter) {
51 | const filters = items
52 | .filter(item =>
53 | item.name === pressedName ? !item.selected : item.selected,
54 | )
55 | .map(item => item.name);
56 | dispatch(actionOnFilter(filters));
57 | }
58 | },
59 | [items, dispatch, actionOnFilter],
60 | );
61 |
62 | return (
63 | }>
64 | {items.map(item => (
65 |
72 | ))}
73 |
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/views/events-filters/eventsFiltersHelpers.ts:
--------------------------------------------------------------------------------
1 | import {useEffect} from 'react';
2 | import {Navigation, OptionsTopBarButton} from 'react-native-navigation';
3 |
4 | export const useEventsFilters = (
5 | componentId: string,
6 | cameraNames?: string[],
7 | ) => {
8 | useEffect(() => {
9 | Navigation.updateProps('EventsFilters', {
10 | viewedCameraNames: cameraNames,
11 | });
12 | }, [cameraNames]);
13 |
14 | useEffect(() => {
15 | Navigation.mergeOptions(componentId, {
16 | sideMenu: {
17 | right: {
18 | enabled: true,
19 | },
20 | },
21 | });
22 | return () => {
23 | Navigation.mergeOptions(componentId, {
24 | sideMenu: {
25 | right: {
26 | enabled: false,
27 | },
28 | },
29 | });
30 | };
31 | }, [componentId]);
32 | };
33 |
34 | export const filterButton: (count?: number) => OptionsTopBarButton = count => ({
35 | id: 'filter',
36 | component: {
37 | id: 'FilterButton',
38 | name: 'TopBarButton',
39 | passProps: {
40 | icon: 'filter',
41 | count,
42 | onPress: () => {
43 | Navigation.mergeOptions('Menu', {
44 | sideMenu: {
45 | right: {
46 | visible: true,
47 | },
48 | },
49 | });
50 | },
51 | },
52 | },
53 | });
54 |
--------------------------------------------------------------------------------
/views/events-filters/messages.ts:
--------------------------------------------------------------------------------
1 | import {makeMessages} from '../../helpers/locale';
2 |
3 | export const messages = makeMessages('eventsFilters', {
4 | 'cameras.title': 'Cameras',
5 | 'labels.title': 'Labels',
6 | 'zones.title': 'Zones',
7 | 'miscellaneous.title': 'Miscellaneous',
8 | 'miscellaneous.retained.label': 'Retained',
9 | });
10 |
--------------------------------------------------------------------------------
/views/logs/LogPreview.tsx:
--------------------------------------------------------------------------------
1 | import {FC} from 'react';
2 | import {FlatList} from 'react-native-gesture-handler';
3 | import {Text} from 'react-native-ui-lib';
4 | import {useStyles} from '../../helpers/colors';
5 |
6 | export interface Log {
7 | name: string;
8 | data: string[];
9 | }
10 |
11 | interface ILogPreviewProps {
12 | log: Log;
13 | }
14 |
15 | export const LogPreview: FC = ({log}) => {
16 | const styles = useStyles(({theme}) => ({
17 | wrapper: {
18 | padding: 16,
19 | backgroundColor: theme.background,
20 | },
21 | line: {
22 | color: theme.text,
23 | marginVertical: 6,
24 | },
25 | }));
26 |
27 | return (
28 | {item}}
32 | keyExtractor={(_, index) => `${index}`}
33 | inverted={true}
34 | />
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/views/logs/Logs.tsx:
--------------------------------------------------------------------------------
1 | import {useEffect, useMemo, useState} from 'react';
2 | import {useIntl} from 'react-intl';
3 | import {Text} from 'react-native';
4 | import {Navigation, NavigationFunctionComponent} from 'react-native-navigation';
5 | import {
6 | LoaderScreen,
7 | TabController,
8 | TabControllerItemProps,
9 | View,
10 | } from 'react-native-ui-lib';
11 | import {messages} from './messages';
12 | import {menuButton, useMenu} from '../menu/menuHelpers';
13 | import {useAppSelector} from '../../store/store';
14 | import {selectServer} from '../../store/settings';
15 | import {Log, LogPreview} from './LogPreview';
16 | import {refreshButton} from '../../helpers/buttonts';
17 | import {useTheme, useStyles} from '../../helpers/colors';
18 | import {useRest} from '../../helpers/rest';
19 | const {TabBar, TabPage} = TabController;
20 |
21 | export const Logs: NavigationFunctionComponent = ({componentId}) => {
22 | const styles = useStyles(({theme}) => ({
23 | noLogs: {
24 | padding: 20,
25 | color: theme.text,
26 | textAlign: 'center',
27 | },
28 | }));
29 | const theme = useTheme();
30 |
31 | useMenu(componentId, 'logs');
32 | const [logs, setLogs] = useState([]);
33 | const [loading, setLoading] = useState(true);
34 | const server = useAppSelector(selectServer);
35 | const intl = useIntl();
36 | const {get} = useRest();
37 |
38 | useEffect(() => {
39 | Navigation.mergeOptions(componentId, {
40 | topBar: {
41 | title: {
42 | text: intl.formatMessage(messages['topBar.title']),
43 | },
44 | leftButtons: [menuButton],
45 | rightButtons: [refreshButton(refresh)],
46 | },
47 | });
48 | }, [componentId, intl]);
49 |
50 | const refresh = () => {
51 | setLoading(true);
52 | const logsTypes = ['frigate', 'go2rtc', 'nginx'];
53 | Promise.allSettled(
54 | logsTypes.map(logType =>
55 | get(server, `logs/${logType}`, {json: false}),
56 | ),
57 | ).then(logsData => {
58 | const updatedLogs: Log[] = logsTypes
59 | .map((logType, i) => ({logType, result: logsData[i]}))
60 | .filter(log => log.result.status === 'fulfilled')
61 | .map(log => ({
62 | name: log.logType,
63 | data: (log.result as PromiseFulfilledResult).value
64 | .split('\n')
65 | .reverse(),
66 | }));
67 | setLogs(updatedLogs);
68 | setLoading(false);
69 | });
70 | };
71 |
72 | useEffect(() => {
73 | refresh();
74 | }, []);
75 |
76 | const tabBarItems: TabControllerItemProps[] = useMemo(
77 | () =>
78 | logs.map(log => ({
79 | label: log.name,
80 | labelColor: theme.link,
81 | selectedLabelColor: theme.text,
82 | backgroundColor: theme.background,
83 | activeBackgroundColor: theme.background,
84 | })),
85 | [logs, theme],
86 | );
87 |
88 | return loading ? (
89 |
94 | ) : logs.length > 1 ? (
95 |
96 |
97 |
98 | {logs.map((log, index) => (
99 |
100 |
101 |
102 | ))}
103 |
104 |
105 | ) : logs.length > 0 ? (
106 |
107 | ) : (
108 | {intl.formatMessage(messages['noLogs'])}
109 | );
110 | };
111 |
--------------------------------------------------------------------------------
/views/logs/messages.ts:
--------------------------------------------------------------------------------
1 | import {makeMessages} from '../../helpers/locale';
2 |
3 | export const messages = makeMessages('logs', {
4 | 'topBar.title': 'Logs',
5 | 'noLogs': 'No logs',
6 | });
7 |
--------------------------------------------------------------------------------
/views/menu/logo-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/menu/logo-dark.png
--------------------------------------------------------------------------------
/views/menu/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/views/menu/logo.png
--------------------------------------------------------------------------------
/views/menu/menuHelpers.ts:
--------------------------------------------------------------------------------
1 | import {useEffect} from 'react';
2 | import {Navigation, OptionsTopBarButton} from 'react-native-navigation';
3 | import crashlytics from '@react-native-firebase/crashlytics';
4 |
5 | export type MenuId =
6 | | 'camerasList'
7 | | 'cameraEvents'
8 | | 'retained'
9 | | 'storage'
10 | | 'system'
11 | | 'logs'
12 | | 'settings'
13 | | 'author'
14 | | 'report';
15 |
16 | export const useSelectedMenuItem = (current?: MenuId) => {
17 | useEffect(() => {
18 | Navigation.updateProps('Menu', {
19 | current,
20 | });
21 | }, [current]);
22 | };
23 |
24 | export const useMenu = (componentId: string, current?: MenuId) => {
25 | crashlytics().log(`View change: ${current}`);
26 | useSelectedMenuItem(current);
27 |
28 | useEffect(() => {
29 | Navigation.mergeOptions(componentId, {
30 | sideMenu: {
31 | left: {
32 | enabled: true,
33 | },
34 | },
35 | });
36 | return () => {
37 | Navigation.mergeOptions(componentId, {
38 | sideMenu: {
39 | left: {
40 | enabled: false,
41 | },
42 | },
43 | });
44 | };
45 | }, [componentId, current]);
46 | };
47 |
48 | export const menuButton: OptionsTopBarButton = {
49 | id: 'menu',
50 | component: {
51 | name: 'TopBarButton',
52 | passProps: {
53 | icon: 'menu',
54 | onPress: () => {
55 | Navigation.mergeOptions('Menu', {
56 | sideMenu: {
57 | left: {
58 | visible: true,
59 | },
60 | },
61 | });
62 | },
63 | },
64 | },
65 | };
66 |
--------------------------------------------------------------------------------
/views/menu/messages.ts:
--------------------------------------------------------------------------------
1 | import {MessageDescriptor} from 'react-intl';
2 | import {makeMessages} from '../../helpers/locale';
3 |
4 | export const messages = makeMessages('menu', {
5 | 'item.camerasList.label': 'List of cameras',
6 | 'item.cameraEvents.label': 'All events',
7 | 'item.retained.label': 'Retained',
8 | 'item.storage.label': 'Storage',
9 | 'item.system.label': 'System',
10 | 'item.logs.label': 'Logs',
11 | 'item.settings.label': 'Settings',
12 | 'item.author.label': 'Author',
13 | 'item.report.label': 'Report problem',
14 | });
15 |
16 | export type MessageKey = typeof messages extends Record<
17 | infer R,
18 | MessageDescriptor
19 | >
20 | ? R
21 | : never;
22 |
--------------------------------------------------------------------------------
/views/report/messages.ts:
--------------------------------------------------------------------------------
1 | import {MessageDescriptor} from 'react-intl';
2 | import {makeMessages} from '../../helpers/locale';
3 |
4 | export const messages = makeMessages('report', {
5 | 'topBar.title': 'Report problem',
6 | 'introduction.info':
7 | 'The report will contain some logs of how you used the application. It will not contain your authentication info.',
8 | 'issue.header': 'Issue',
9 | 'issue.description.label': 'Describe the problem',
10 | 'action.send': 'Send',
11 | 'toast.success': 'The issue was reported successfully',
12 | 'error.crash-report-disabled':
13 | 'Reporting crashes is disabled. Go to settings and enable it to report an issue. It will help me to better understand the matter of the issue. You can also report it on GitHub.',
14 | });
15 |
16 | export type MessageKey = typeof messages extends Record<
17 | infer R,
18 | MessageDescriptor
19 | >
20 | ? R
21 | : never;
22 |
--------------------------------------------------------------------------------
/views/settings/ServerItem.tsx:
--------------------------------------------------------------------------------
1 | import React, {FC, useMemo} from 'react';
2 | import {useIntl} from 'react-intl';
3 | import {Pressable, Text, View} from 'react-native';
4 | import {Server} from '../../store/settings';
5 | import {useStyles, useTheme} from '../../helpers/colors';
6 | import {buildServerUrl} from '../../helpers/rest';
7 | import {messages} from './messages';
8 | import {IconOutline} from '@ant-design/icons-react-native';
9 |
10 | interface ServerItemProps {
11 | server: Server;
12 | onPress?: () => void;
13 | onRemovePress?: () => void;
14 | }
15 |
16 | export const ServerItem: FC = ({
17 | server,
18 | onPress,
19 | onRemovePress,
20 | }) => {
21 | const styles = useStyles(({theme}) => ({
22 | wrapper: {
23 | flex: 1,
24 | flexDirection: 'row',
25 | alignItems: 'center',
26 | padding: 8,
27 | marginVertical: 8,
28 | backgroundColor: theme.background,
29 | borderWidth: 1,
30 | borderBottomWidth: 2,
31 | borderColor: theme.text,
32 | borderRadius: 4,
33 | },
34 | data: {
35 | flex: 1,
36 | },
37 | url: {
38 | color: theme.text,
39 | },
40 | property: {
41 | flexDirection: 'row',
42 | },
43 | propertyLabel: {
44 | fontSize: 10,
45 | color: theme.text,
46 | fontWeight: 'bold',
47 | paddingRight: 4,
48 | },
49 | propertyValue: {
50 | fontSize: 10,
51 | color: theme.text,
52 | },
53 | }));
54 | const theme = useTheme();
55 | const intl = useIntl();
56 |
57 | const url = useMemo(() => buildServerUrl(server), [server]) ?? '-';
58 |
59 | return (
60 |
61 |
62 | {url}
63 |
64 |
65 | {intl.formatMessage(messages['server.auth.label'])}:
66 |
67 |
68 | {server.auth === 'none'
69 | ? intl.formatMessage(messages['server.auth.option.none'])
70 | : server.auth}
71 |
72 |
73 | {(server.auth === 'basic' || server.auth === 'frigate') && (
74 |
75 |
76 | {intl.formatMessage(messages['server.username.label'])}:
77 |
78 |
79 | {server.credentials.username}
80 |
81 |
82 | )}
83 |
84 |
85 |
91 |
92 |
93 | );
94 | };
95 |
--------------------------------------------------------------------------------
/views/settings/messages.ts:
--------------------------------------------------------------------------------
1 | import {MessageDescriptor} from 'react-intl';
2 | import {makeMessages} from '../../helpers/locale';
3 |
4 | export const messages = makeMessages('settings', {
5 | 'topBar.title': 'Settings',
6 | 'error.required': 'This field is required.',
7 | 'error.min': 'Minimum value is {min}.',
8 | 'error.max': 'Maximum value is {max}',
9 | 'action.save': 'Save',
10 | 'action.cancel': 'Cancel',
11 | 'action.add': 'Add',
12 | 'action.edit': 'Edit',
13 | 'server.header': 'Server',
14 | 'server.address.header': 'Address',
15 | 'server.auth.header': 'Authorization',
16 | 'servers.error.noServer': 'No server added',
17 | 'server.protocol.label': 'Protocol',
18 | 'server.host.label': 'Host',
19 | 'server.port.label': 'Port',
20 | 'server.path.label': 'Path',
21 | 'server.auth.label': 'Type of authorization',
22 | 'server.auth.option.none': 'None',
23 | 'server.username.label': 'Username',
24 | 'server.password.label': 'Password',
25 | 'server.useCredentials.label': 'Use credentials',
26 | 'server.useDemoServerButton': 'Use demo server',
27 | 'locale.header': 'Locale',
28 | 'locale.region.label': 'Region',
29 | 'locale.region.option.en_AU': 'Australia (English)',
30 | 'locale.region.option.en_CA': 'Canada (English)',
31 | 'locale.region.option.en_GB': 'Great Britain (English)',
32 | 'locale.region.option.en_IE': 'Ireland (English)',
33 | 'locale.region.option.en_NZ': 'New Zealand (English)',
34 | 'locale.region.option.en_US': 'United States (English)',
35 | 'locale.region.option.de_AT': 'Austria (German)',
36 | 'locale.region.option.de_DE': 'Germany (German)',
37 | 'locale.region.option.de_LU': 'Luxembourg (German)',
38 | 'locale.region.option.de_CH': 'Switzerland (German)',
39 | 'locale.region.option.es_AR': 'Argentina (Spanish)',
40 | 'locale.region.option.es_BO': 'Bolivia (Spanish)',
41 | 'locale.region.option.es_CL': 'Chile (Spanish)',
42 | 'locale.region.option.es_CO': 'Columbia (Spanish)',
43 | 'locale.region.option.es_CR': 'Costa Rica (Spanish)',
44 | 'locale.region.option.es_DO': 'Dominican Republic (Spanish)',
45 | 'locale.region.option.es_EC': 'Ecuador (Spanish)',
46 | 'locale.region.option.es_ES': 'Spain (Spanish)',
47 | 'locale.region.option.es_GT': 'Guatemala (Spanish)',
48 | 'locale.region.option.es_HN': 'Honduras (Spanish)',
49 | 'locale.region.option.es_MX': 'Mexico (Spanish)',
50 | 'locale.region.option.es_NI': 'Nicaragua (Spanish)',
51 | 'locale.region.option.es_PA': 'Panama (Spanish)',
52 | 'locale.region.option.es_PE': 'Peru (Spanish)',
53 | 'locale.region.option.es_PY': 'Paraguay (Spanish)',
54 | 'locale.region.option.es_SV': 'El Salvador (Spanish)',
55 | 'locale.region.option.es_UY': 'Uruguay (Spanish)',
56 | 'locale.region.option.es_VE': 'Venezuela (Spanish)',
57 | 'locale.region.option.fr_CA': 'Canada (French)',
58 | 'locale.region.option.fr_CH': 'Switzerland (French)',
59 | 'locale.region.option.fr_FR': 'France (French)',
60 | 'locale.region.option.pl_PL': 'Poland (Polish)',
61 | 'locale.region.option.pt_PT': 'Portugal (Portuguese)',
62 | 'locale.region.option.pt_BR': 'Brazil (Portuguese)',
63 | 'locale.region.option.uk_UA': 'Ukraine (Ukrainian)',
64 | 'locale.region.option.it_CH': 'Switzerland (Italian)',
65 | 'locale.region.option.it_IT': 'Italy (Italian)',
66 | 'locale.region.option.sv_SE': 'Sweden (Swedish)',
67 | 'locale.datesDisplay.label': 'Dates display',
68 | 'locale.datesDisplay.option.descriptive': 'Descriptive',
69 | 'locale.datesDisplay.option.numeric': 'Numeric',
70 | 'app.header': 'Application',
71 | 'app.colorScheme.label': 'Color scheme',
72 | 'app.colorScheme.option.auto': 'Auto',
73 | 'app.colorScheme.option.light': 'Light',
74 | 'app.colorScheme.option.dark': 'Dark',
75 | 'app.sendCrashReports.label': 'Send crash reports',
76 | 'cameras.header': 'Cameras',
77 | 'cameras.imageRefreshFrequency.label': 'Image refresh frequency (seconds)',
78 | 'cameras.liveView.label': 'Live view',
79 | 'cameras.liveView.disclaimer':
80 | 'Keep in mind that refresh frequency depends on your network latency',
81 | 'cameras.numberOfColumns.label': 'Number of columns',
82 | 'cameras.actionWhenPressed.label': 'Action on press',
83 | 'cameras.actionWhenPressed.option.events': 'List of events',
84 | 'cameras.actionWhenPressed.option.preview': 'Camera preview',
85 | 'events.header': 'Events',
86 | 'events.numberOfColumns.label': 'Number of columns',
87 | 'events.photoPreference.label': 'Photo preference',
88 | 'events.photoPreference.option.snapshot': 'Snapshot',
89 | 'events.photoPreference.option.thumbnail': 'Thumbnail',
90 | 'events.lockLandscapePlaybackOrientation.label':
91 | 'Lock playback in landscape orientation',
92 | 'toast.noServerData': 'You need to provide frigate nvr server data.',
93 | });
94 |
95 | export type MessageKey = typeof messages extends Record<
96 | infer R,
97 | MessageDescriptor
98 | >
99 | ? R
100 | : never;
101 |
--------------------------------------------------------------------------------
/views/settings/useNoServer.ts:
--------------------------------------------------------------------------------
1 | import {useEffect} from 'react';
2 | import {useIntl} from 'react-intl';
3 | import {ToastAndroid} from 'react-native';
4 | import {useAppSelector} from '../../store/store';
5 | import {navigateToMenuItem, settingsMenuItem} from '../menu/Menu';
6 | import {messages} from './messages';
7 | import {selectServer} from '../../store/settings';
8 |
9 | export const useNoServer = () => {
10 | const server = useAppSelector(selectServer);
11 | const intl = useIntl();
12 |
13 | useEffect(() => {
14 | if (!server.host) {
15 | navigateToMenuItem(settingsMenuItem)();
16 | ToastAndroid.showWithGravity(
17 | intl.formatMessage(messages['toast.noServerData']),
18 | ToastAndroid.LONG,
19 | ToastAndroid.TOP,
20 | );
21 | }
22 | }, [server, intl]);
23 | };
24 |
--------------------------------------------------------------------------------
/views/storage/CamerasStorageChart.tsx:
--------------------------------------------------------------------------------
1 | import {FC, useMemo} from 'react';
2 | import {CamerasStorage} from '../../helpers/interfaces';
3 | import {getColor} from '../../helpers/charts';
4 | import {
5 | UsagePieChart,
6 | UsagePieChartData,
7 | } from '../../components/charts/UsagePieChart';
8 |
9 | interface ICamerasStorageChartProps {
10 | camerasStorage: CamerasStorage;
11 | }
12 |
13 | export const CamerasStorageChart: FC = ({
14 | camerasStorage,
15 | }) => {
16 | const chartData: UsagePieChartData[] = useMemo(
17 | () =>
18 | Object.keys(camerasStorage).map((cameraName, index) => ({
19 | label: cameraName,
20 | value: camerasStorage[cameraName].usage_percent,
21 | color: getColor(index),
22 | })),
23 | [camerasStorage],
24 | );
25 |
26 | return ;
27 | };
28 |
--------------------------------------------------------------------------------
/views/storage/CamerasStorageTable.tsx:
--------------------------------------------------------------------------------
1 | import {FC, useMemo} from 'react';
2 | import {CamerasStorage} from '../../helpers/interfaces';
3 | import {
4 | Cell,
5 | Col,
6 | Rows,
7 | Table,
8 | TableWrapper,
9 | } from 'react-native-reanimated-table';
10 | import {useIntl} from 'react-intl';
11 | import {messages} from './messages';
12 | import {formatBandwidth, formatSize, useTableStyles} from '../../helpers/table';
13 |
14 | interface ICamerasStorageTableProps {
15 | camerasStorage: CamerasStorage;
16 | }
17 |
18 | export const CamerasStorageTable: FC = ({
19 | camerasStorage,
20 | }) => {
21 | const intl = useIntl();
22 | const tableStyles = useTableStyles();
23 |
24 | const dataHeaders = useMemo(
25 | () => Object.keys(camerasStorage),
26 | [camerasStorage],
27 | );
28 |
29 | const data = useMemo(
30 | () =>
31 | Object.values(camerasStorage).map(cameraStorage => [
32 | `${(cameraStorage.usage_percent || 0).toFixed(1)}%`,
33 | formatSize(cameraStorage.usage),
34 | formatBandwidth(cameraStorage.bandwidth),
35 | ]),
36 | [camerasStorage],
37 | );
38 |
39 | return (
40 |
41 |
42 | |
47 | |
52 | |
57 |
58 |
59 |
64 |
70 |
71 |
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/views/storage/Storage.tsx:
--------------------------------------------------------------------------------
1 | import {useIntl} from 'react-intl';
2 | import {Navigation, NavigationFunctionComponent} from 'react-native-navigation';
3 | import {Carousel, LoaderScreen, PageControlPosition} from 'react-native-ui-lib';
4 | import {useAppSelector} from '../../store/store';
5 | import {selectServer} from '../../store/settings';
6 | import {useEffect, useState} from 'react';
7 | import {menuButton, useMenu} from '../menu/menuHelpers';
8 | import {messages} from './messages';
9 | import {useRest} from '../../helpers/rest';
10 | import {
11 | CamerasStorage,
12 | Stats,
13 | StorageInfo,
14 | StorageShortPlace,
15 | } from '../../helpers/interfaces';
16 | import {ScrollView} from 'react-native-gesture-handler';
17 | import {Background} from '../../components/Background';
18 | import {StorageChart} from './StorageChart';
19 | import {StorageTable} from './StorageTable';
20 | import {StyleSheet} from 'react-native';
21 | import {refreshButton} from '../../helpers/buttonts';
22 | import {CamerasStorageChart} from './CamerasStorageChart';
23 | import {CamerasStorageTable} from './CamerasStorageTable';
24 |
25 | const styles = StyleSheet.create({
26 | wrapper: {
27 | margin: 20,
28 | },
29 | });
30 |
31 | export const Storage: NavigationFunctionComponent = ({componentId}) => {
32 | useMenu(componentId, 'storage');
33 | const [storage, setStorage] =
34 | useState>();
35 | const [camerasStorage, setCamerasStorage] = useState();
36 | const [loading, setLoading] = useState(true);
37 | const [page, setPage] = useState(0);
38 | const server = useAppSelector(selectServer);
39 | const intl = useIntl();
40 | const {get} = useRest();
41 |
42 | useEffect(() => {
43 | Navigation.mergeOptions(componentId, {
44 | topBar: {
45 | title: {
46 | text: intl.formatMessage(messages['topBar.title']),
47 | },
48 | leftButtons: [menuButton],
49 | rightButtons: [refreshButton(refresh)],
50 | },
51 | });
52 | }, [componentId, intl]);
53 |
54 | useEffect(() => {
55 | refresh();
56 | }, []);
57 |
58 | const refresh = () => {
59 | setLoading(true);
60 | Promise.allSettled([
61 | get(server, `stats`),
62 | get(server, `recordings/storage`),
63 | ]).then(([stats, cameras]) => {
64 | if (stats.status === 'fulfilled') {
65 | const {service} = stats.value;
66 | setStorage({
67 | clips: service.storage['/media/frigate/clips'],
68 | recordings: service.storage['/media/frigate/recordings'],
69 | cache: service.storage['/tmp/cache'],
70 | shm: service.storage['/dev/shm'],
71 | });
72 | }
73 | if (cameras.status === 'fulfilled') {
74 | setCamerasStorage(cameras.value);
75 | }
76 | setLoading(false);
77 | });
78 | };
79 |
80 | return loading || storage === undefined ? (
81 |
82 | ) : (
83 |
84 |
85 |
88 |
89 | {camerasStorage !== undefined && (
90 |
91 | )}
92 |
93 | {page === 0 && }
94 | {camerasStorage && page === 1 && (
95 |
96 | )}
97 |
98 |
99 | );
100 | };
101 |
--------------------------------------------------------------------------------
/views/storage/StorageChart.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useMemo } from 'react';
2 | import { StorageInfo, StorageShortPlace } from '../../helpers/interfaces';
3 | import { useIntl } from 'react-intl';
4 | import { messages } from './messages';
5 | import { ProgressChartData, ProgressChart } from '../../components/charts/ProgressChart';
6 |
7 | interface IStorageChartProps {
8 | storage: Record;
9 | }
10 |
11 | export const StorageChart: FC = ({storage}) => {
12 | const intl = useIntl();
13 |
14 | const chartData: ProgressChartData[] = useMemo(() => {
15 | const { recordings, cache, shm } = storage;
16 | return [
17 | {
18 | label: intl.formatMessage(messages['location.recordings']),
19 | value: recordings.used / recordings.total,
20 | color: 'rgb(249, 166, 2)',
21 | },
22 | {
23 | label: intl.formatMessage(messages['location.cache']),
24 | value: cache.used / cache.total,
25 | color: 'gold',
26 | },
27 | {
28 | label: intl.formatMessage(messages['location.shm']),
29 | value: shm.used / shm.total,
30 | color: 'yellow',
31 | },
32 | ];
33 | }, [storage]);
34 |
35 | return ;
36 | };
37 |
--------------------------------------------------------------------------------
/views/storage/StorageTable.tsx:
--------------------------------------------------------------------------------
1 | import {FC, useMemo} from 'react';
2 | import {StorageInfo, StorageShortPlace} from '../../helpers/interfaces';
3 | import {
4 | Cell,
5 | Col,
6 | Rows,
7 | Table,
8 | TableWrapper,
9 | } from 'react-native-reanimated-table';
10 | import {useIntl} from 'react-intl';
11 | import {messages} from './messages';
12 | import {formatSize, useTableStyles} from '../../helpers/table';
13 |
14 | interface IStorageTableProps {
15 | storage: Record;
16 | }
17 |
18 | export const StorageTable: FC = ({storage}) => {
19 | const intl = useIntl();
20 | const tableStyles = useTableStyles();
21 |
22 | const dataHeaders = useMemo(
23 | () => [
24 | intl.formatMessage(messages['location.recordings']),
25 | intl.formatMessage(messages['location.cache']),
26 | intl.formatMessage(messages['location.shm']),
27 | ],
28 | [],
29 | );
30 |
31 | const data = useMemo(
32 | () =>
33 | storage !== undefined
34 | ? [
35 | [
36 | formatSize(storage.recordings.used),
37 | formatSize(storage.recordings.total),
38 | ],
39 | [formatSize(storage.cache.used), formatSize(storage.cache.total)],
40 | [formatSize(storage.shm.used), formatSize(storage.shm.total)],
41 | ]
42 | : [],
43 | [storage],
44 | );
45 |
46 | return (
47 |
48 |
49 | |
54 | |
59 | |
64 |
65 |
66 |
71 |
77 |
78 |
79 | );
80 | };
81 |
--------------------------------------------------------------------------------
/views/storage/messages.ts:
--------------------------------------------------------------------------------
1 | import {makeMessages} from '../../helpers/locale';
2 |
3 | export const messages = makeMessages('storage', {
4 | 'topBar.title': 'Storage',
5 | 'location.header': 'Location',
6 | 'location.recordings': 'Clips & Recordings',
7 | 'location.cache': 'Cache',
8 | 'location.shm': 'Shared memory',
9 | 'used.header': 'Used',
10 | 'total.header': 'Total',
11 | 'camera.header': 'Camera',
12 | 'bandwidth.header': 'Bandwidth',
13 | });
14 |
--------------------------------------------------------------------------------
/views/system/CameraInfoChart.tsx:
--------------------------------------------------------------------------------
1 | import {FC, useMemo} from 'react';
2 | import {getColor} from '../../helpers/charts';
3 | import {CameraInfo} from './CameraTable';
4 | import {BarChart, Grid, YAxis} from 'react-native-svg-charts';
5 | import {Text, View} from 'react-native-ui-lib';
6 | import * as scale from 'd3-scale';
7 | import {messages} from './messages';
8 | import {useIntl} from 'react-intl';
9 | import {useStyles} from '../../helpers/colors';
10 |
11 | interface BarChartData {
12 | data: number[];
13 | svg?: {
14 | fill?: string;
15 | };
16 | }
17 |
18 | interface ICameraInfoChartProps {
19 | cameraInfos: Record;
20 | }
21 |
22 | export const CameraInfoChart: FC = ({cameraInfos}) => {
23 | const styles = useStyles(({theme}) => ({
24 | wrapper: {
25 | flexDirection: 'row',
26 | },
27 | chart: {
28 | flex: 1,
29 | },
30 | chartTitle: {
31 | color: theme.text,
32 | fontSize: 10,
33 | textAlign: 'center',
34 | fontWeight: '600',
35 | },
36 | }));
37 |
38 | const intl = useIntl();
39 | const cameraNames = useMemo(() => Object.keys(cameraInfos), [cameraInfos]);
40 |
41 | const chartData: BarChartData[] = useMemo(
42 | () => [
43 | {
44 | data: Object.values(cameraInfos)
45 | .map(info => info.ffmpeg.cpu! || 0)
46 | .filter(v => v !== undefined),
47 | svg: {fill: getColor(0)},
48 | },
49 | {
50 | data: Object.values(cameraInfos)
51 | .map(info => info.capture.cpu! || 0)
52 | .filter(v => v !== undefined),
53 | svg: {fill: getColor(1)},
54 | },
55 | {
56 | data: Object.values(cameraInfos)
57 | .map(info => info.detect.cpu! || 0)
58 | .filter(v => v !== undefined),
59 | svg: {fill: getColor(2)},
60 | },
61 | ],
62 | [cameraInfos],
63 | );
64 |
65 | const chartHeight = useMemo(
66 | () => Math.min(cameraNames.length * 30, 200),
67 | [cameraNames],
68 | );
69 |
70 | const yAxisData = useMemo(
71 | () =>
72 | cameraNames.map(name => {
73 | name;
74 | }),
75 | [cameraNames],
76 | );
77 |
78 | return (
79 |
80 |
81 | {cameraNames.length > 0 && (
82 | index}
85 | scale={scale.scaleBand}
86 | formatLabel={(d, i) => cameraNames[i]}
87 | />
88 | )}
89 |
90 |
98 |
99 |
100 | {/* `${value * 100}%`}
106 | /> */}
107 |
108 |
109 |
110 |
111 | {intl.formatMessage(messages['cameraInfoChart.usage'])}
112 |
113 |
114 |
115 | );
116 | };
117 |
--------------------------------------------------------------------------------
/views/system/CameraTable.tsx:
--------------------------------------------------------------------------------
1 | import {FC, useMemo} from 'react';
2 | import {
3 | Cell,
4 | Col,
5 | Rows,
6 | Table,
7 | TableWrapper,
8 | } from 'react-native-reanimated-table';
9 | import {useIntl} from 'react-intl';
10 | import {messages} from './messages';
11 | import {useTableStyles} from '../../helpers/table';
12 |
13 | interface CameraProcessInfo {
14 | fps?: number;
15 | fps_skipped?: number;
16 | cpu?: number;
17 | mem?: number;
18 | }
19 |
20 | export interface CameraInfo {
21 | ffmpeg: CameraProcessInfo;
22 | capture: CameraProcessInfo;
23 | detect: CameraProcessInfo;
24 | }
25 |
26 | interface ICameraTableProps {
27 | cameraInfo: CameraInfo;
28 | }
29 |
30 | export const CameraTable: FC = ({cameraInfo}) => {
31 | const intl = useIntl();
32 | const tableStyles = useTableStyles();
33 |
34 | const dataHeaders = useMemo(
35 | () => [
36 | intl.formatMessage(messages['cameraInfo.process.ffmpeg']),
37 | intl.formatMessage(messages['cameraInfo.process.capture']),
38 | intl.formatMessage(messages['cameraInfo.process.detect']),
39 | ],
40 | [intl],
41 | );
42 |
43 | const fpsData = useMemo(
44 | () => [
45 | `${cameraInfo.ffmpeg.fps}`,
46 | `${cameraInfo.capture.fps}`,
47 | `${cameraInfo.detect.fps} /${cameraInfo.detect.fps_skipped}`,
48 | ],
49 | [cameraInfo],
50 | );
51 |
52 | const isCpuUsageInfo = useMemo(
53 | () => Object.values(cameraInfo).some(process => process.cpu),
54 | [cameraInfo],
55 | );
56 |
57 | const cpuData = useMemo(() => {
58 | if (isCpuUsageInfo) {
59 | const processCpuData = (process: {cpu?: number; mem?: number}) => [
60 | process.cpu ? `${process.cpu.toFixed(1)}%` : '-',
61 | process.mem ? `${process.mem.toFixed(1)}%` : '-',
62 | ];
63 | return [
64 | processCpuData(cameraInfo.ffmpeg),
65 | processCpuData(cameraInfo.capture),
66 | processCpuData(cameraInfo.detect),
67 | ];
68 | } else {
69 | return undefined;
70 | }
71 | }, [cameraInfo, isCpuUsageInfo]);
72 |
73 | return (
74 |
75 |
76 | |
81 | |
86 | {isCpuUsageInfo ? (
87 | |
92 | ) : (
93 | <>>
94 | )}
95 | {isCpuUsageInfo ? (
96 | |
101 | ) : (
102 | <>>
103 | )}
104 |
105 |
106 |
111 |
116 | {isCpuUsageInfo && cpuData ? (
117 |
123 | ) : (
124 | <>>
125 | )}
126 |
127 |
128 | );
129 | };
130 |
--------------------------------------------------------------------------------
/views/system/CpuUsageChart.tsx:
--------------------------------------------------------------------------------
1 | import {FC, useMemo} from 'react';
2 | import {Text, View} from 'react-native-ui-lib';
3 | import {GpuRow} from './GpusTable';
4 | import {DetectorRow} from './DetectorsTable';
5 | import {getColor} from '../../helpers/charts';
6 | import {
7 | ProgressChart,
8 | ProgressChartData,
9 | } from '../../components/charts/ProgressChart';
10 | import {useIntl} from 'react-intl';
11 | import {messages} from './messages';
12 | import {useStyles} from '../../helpers/colors';
13 |
14 | interface ICpuUsageChartProps {
15 | detectors: DetectorRow[];
16 | gpus: GpuRow[];
17 | }
18 |
19 | export const CpuUsageChart: FC = ({detectors, gpus}) => {
20 | const styles = useStyles(({theme}) => ({
21 | wrapper: {
22 | flexDirection: 'row',
23 | },
24 | chartTitle: {
25 | color: theme.text,
26 | fontSize: 10,
27 | textAlign: 'center',
28 | fontWeight: '600',
29 | },
30 | }));
31 |
32 | const intl = useIntl();
33 |
34 | const chartData: [ProgressChartData[], ProgressChartData[]] = useMemo(() => {
35 | const data = [
36 | ...gpus.map((gpu, gpuIndex) => ({
37 | label: gpu.name.substring(0, 12),
38 | cpu: gpu.gpu / 100,
39 | mem: gpu.mem / 100,
40 | color: getColor(gpuIndex),
41 | })),
42 | ...detectors
43 | .filter(d => d.cpu !== undefined)
44 | .map((detector, detectorIndex) => ({
45 | label: detector.name.substring(0, 12),
46 | cpu: (detector.cpu || 0) / 100,
47 | mem: (detector.mem || 0) / 100,
48 | color: getColor(gpus.length + detectorIndex),
49 | })),
50 | ];
51 | return [
52 | data.map(({label, cpu, color}) => ({label, value: cpu, color})),
53 | data.map(({label, mem, color}) => ({label, value: mem, color})),
54 | ];
55 | }, [detectors, gpus]);
56 |
57 | return (
58 |
59 |
60 |
61 |
62 | {intl.formatMessage(messages['usageChart.usage'])}
63 |
64 |
65 |
66 |
67 |
68 |
69 | {intl.formatMessage(messages['usageChart.memory'])}
70 |
71 |
72 |
73 |
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/views/system/DetectorsTable.tsx:
--------------------------------------------------------------------------------
1 | import {FC, useMemo} from 'react';
2 | import {
3 | Cell,
4 | Col,
5 | Rows,
6 | Table,
7 | TableWrapper,
8 | } from 'react-native-reanimated-table';
9 | import {useIntl} from 'react-intl';
10 | import {messages} from './messages';
11 | import {useTableStyles} from '../../helpers/table';
12 |
13 | export interface DetectorRow {
14 | name: string;
15 | inferenceSpeed: number;
16 | cpu?: number;
17 | mem?: number;
18 | }
19 |
20 | interface IDetectorseTableProps {
21 | detectors: DetectorRow[];
22 | }
23 |
24 | export const DetectorsTable: FC = ({detectors}) => {
25 | const intl = useIntl();
26 | const tableStyles = useTableStyles();
27 |
28 | const dataHeaders = useMemo(
29 | () => detectors.map(detector => detector.name),
30 | [detectors],
31 | );
32 |
33 | const inferenceSpeedData = useMemo(
34 | () => detectors.map(detector => `${detector.inferenceSpeed}`),
35 | [detectors],
36 | );
37 |
38 | const isCpuUsageInfo = useMemo(
39 | () => detectors.some(detector => detector.cpu),
40 | [detectors],
41 | );
42 |
43 | const cpuData = useMemo(
44 | () =>
45 | isCpuUsageInfo
46 | ? detectors.map(detector => [
47 | detector.cpu ? `${detector.cpu.toFixed(1)}%` : '-',
48 | detector.mem ? `${detector.mem.toFixed(1)}%` : '-',
49 | ])
50 | : undefined,
51 | [detectors, isCpuUsageInfo],
52 | );
53 |
54 | return (
55 |
56 |
57 | |
62 | |
69 | {isCpuUsageInfo ? (
70 | |
75 | ) : (
76 | <>>
77 | )}
78 | {isCpuUsageInfo ? (
79 | |
84 | ) : (
85 | <>>
86 | )}
87 |
88 |
89 |
94 |
99 | {isCpuUsageInfo && cpuData ? (
100 |
106 | ) : (
107 | <>>
108 | )}
109 |
110 |
111 | );
112 | };
113 |
--------------------------------------------------------------------------------
/views/system/GpusTable.tsx:
--------------------------------------------------------------------------------
1 | import {FC, useMemo} from 'react';
2 | import {
3 | Cell,
4 | Col,
5 | Rows,
6 | Table,
7 | TableWrapper,
8 | } from 'react-native-reanimated-table';
9 | import {useIntl} from 'react-intl';
10 | import {messages} from './messages';
11 | import {useTableStyles} from '../../helpers/table';
12 |
13 | export interface GpuRow {
14 | name: string;
15 | gpu: number;
16 | mem: number;
17 | }
18 |
19 | interface IGpusTableProps {
20 | gpus: GpuRow[];
21 | }
22 |
23 | export const GpusTable: FC = ({gpus}) => {
24 | const intl = useIntl();
25 | const tableStyles = useTableStyles();
26 |
27 | const dataHeaders = useMemo(
28 | () => gpus.map(detector => detector.name),
29 | [gpus],
30 | );
31 |
32 | const data = useMemo(
33 | () =>
34 | gpus.map(detector => [
35 | detector.gpu ? `${detector.gpu.toFixed(1)}%` : '-',
36 | detector.mem ? `${detector.mem.toFixed(1)}%` : '-',
37 | ]),
38 | [gpus],
39 | );
40 |
41 | return (
42 |
43 |
44 | |
49 | |
54 | |
59 |
60 |
61 |
66 |
72 |
73 |
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/views/system/SectionTitle.tsx:
--------------------------------------------------------------------------------
1 | import {FC, PropsWithChildren} from 'react';
2 | import {Text} from 'react-native-ui-lib';
3 | import {useStyles} from '../../helpers/colors';
4 |
5 | export const SectionTitle: FC = ({children}) => {
6 | const styles = useStyles(({theme}) => ({
7 | text: {
8 | color: theme.text,
9 | fontSize: 16,
10 | fontWeight: '600',
11 | marginVertical: 10,
12 | },
13 | }));
14 |
15 | return {children};
16 | };
17 |
--------------------------------------------------------------------------------
/views/system/SystemInfo.tsx:
--------------------------------------------------------------------------------
1 | import {FC, useMemo} from 'react';
2 | import {Text, View} from 'react-native-ui-lib';
3 | import {messages} from './messages';
4 | import {useIntl} from 'react-intl';
5 | import {formatDistance, formatRelative} from 'date-fns';
6 | import {useDateLocale} from '../../helpers/locale';
7 | import {Service} from '../../helpers/interfaces';
8 | import {useStyles} from '../../helpers/colors';
9 |
10 | interface ISystemInfoProps {
11 | service: Service;
12 | }
13 |
14 | export const SystemInfo: FC = ({service}) => {
15 | const styles = useStyles(({theme}) => ({
16 | info: {
17 | flexDirection: 'column',
18 | borderTopWidth: 1,
19 | borderColor: theme.border,
20 | marginTop: 20,
21 | },
22 | infoRow: {
23 | flexDirection: 'row',
24 | justifyContent: 'flex-end',
25 | },
26 | text: {
27 | color: theme.text,
28 | },
29 | textLabel: {
30 | color: theme.text,
31 | fontWeight: '600',
32 | },
33 | updateAvailable: {
34 | color: theme.text,
35 | fontWeight: '600',
36 | fontStyle: 'italic',
37 | },
38 | version: {
39 | marginTop: 10,
40 | },
41 | }));
42 |
43 | const dateLocale = useDateLocale();
44 | const intl = useIntl();
45 |
46 | const isUpdateAvailable = useMemo(
47 | () =>
48 | service.latest_version !== undefined &&
49 | service.version !== service.latest_version,
50 | [service],
51 | );
52 |
53 | const dataUpdatedTime = useMemo(
54 | () =>
55 | service.last_updated
56 | ? formatRelative(new Date(service.last_updated * 1000), new Date(), {
57 | locale: dateLocale,
58 | })
59 | : '-',
60 | [service],
61 | );
62 |
63 | const uptime = useMemo(
64 | () =>
65 | formatDistance(
66 | new Date(new Date().getTime() - service.uptime * 1000),
67 | new Date(),
68 | {
69 | includeSeconds: true,
70 | locale: dateLocale,
71 | },
72 | ),
73 | [service],
74 | );
75 |
76 | return (
77 |
78 |
79 |
80 | {intl.formatMessage(messages['info.data_updated'])}:{' '}
81 |
82 | {dataUpdatedTime}
83 |
84 |
85 |
86 | {intl.formatMessage(messages['info.uptime'])}:{' '}
87 |
88 | {uptime}
89 |
90 |
91 |
92 | {intl.formatMessage(messages['info.current_version'], {
93 | version: service.version,
94 | })}
95 |
96 |
97 | {isUpdateAvailable && (
98 |
99 |
100 | {intl.formatMessage(messages['info.latest_version'], {
101 | version: service.latest_version,
102 | })}
103 |
104 |
105 | )}
106 |
107 | );
108 | };
109 |
--------------------------------------------------------------------------------
/views/system/messages.ts:
--------------------------------------------------------------------------------
1 | import {makeMessages} from '../../helpers/locale';
2 |
3 | export const messages = makeMessages('system', {
4 | 'topBar.title': 'System',
5 | 'info.data_updated': 'Data updated',
6 | 'info.current_version': 'Current version is {version}',
7 | 'info.latest_version': 'Update available to {version}',
8 | 'info.uptime': 'Uptime',
9 | 'detectors.title': 'Detectors',
10 | 'detectors.detector.header': 'Detector',
11 | 'detectors.inference_speed.header': 'Inference Speed',
12 | 'detectors.cpu_usage.header': 'CPU usage',
13 | 'detectors.mem_usage.header': 'Memory usage',
14 | 'gpus.title': 'GPUs',
15 | 'gpus.name.header': 'Name',
16 | 'gpus.gpu_usage.header': 'GPU usage',
17 | 'gpus.memory.header': 'Memory',
18 | 'cameras.title': 'Cameras',
19 | 'cameraInfo.process.header': 'Process',
20 | 'cameraInfo.process.ffmpeg': 'ffmpeg',
21 | 'cameraInfo.process.capture': 'Capture',
22 | 'cameraInfo.process.detect': 'Detect',
23 | 'cameraInfo.fps.header': 'FPS /skipped',
24 | 'cameraInfo.cpu_usage.header': 'CPU usage',
25 | 'cameraInfo.mem_usage.header': 'Memory usage',
26 | 'usageChart.usage': 'Usage',
27 | 'usageChart.memory': 'Memory',
28 | 'cameraInfoChart.usage': 'Usage',
29 | });
30 |
--------------------------------------------------------------------------------