├── .bundle └── config ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .prettierrc.js ├── .watchmanconfig ├── Gemfile ├── LICENSE ├── PRIVACY-POLICY.md ├── README.md ├── android ├── app │ ├── build.gradle │ ├── debug.keystore │ ├── proguard-rules.pro │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── assets │ │ └── fonts │ │ │ ├── antfill.ttf │ │ │ └── antoutline.ttf │ │ ├── java │ │ └── com │ │ │ └── frigateviewer │ │ │ ├── IgnoreSSLFactory.java │ │ │ ├── MainActivity.java │ │ │ └── MainApplication.java │ │ └── res │ │ ├── drawable │ │ └── rn_edit_text_material.xml │ │ ├── mipmap-hdpi │ │ └── ic_launcher.png │ │ ├── mipmap-mdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxxhdpi │ │ └── ic_launcher.png │ │ ├── values │ │ ├── strings.xml │ │ └── styles.xml │ │ └── xml │ │ └── network_security_config.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── link-assets-manifest.json ├── screenshots │ ├── phone │ │ ├── 1-cameras.png │ │ ├── 2-cameras-labels.png │ │ ├── 3-events.png │ │ ├── 4-events-actions.png │ │ ├── 5-events-filters.png │ │ ├── 6-clip-play.png │ │ ├── 7-main-menu.png │ │ └── 8-settings.png │ ├── tablet-10 │ │ ├── 1-cameras.png │ │ ├── 2-cameras-labels.png │ │ ├── 3-events.png │ │ ├── 4-events-actions.png │ │ ├── 5-events-filters.png │ │ ├── 6-clip-play.png │ │ ├── 7-main-menu.png │ │ └── 8-settings.png │ ├── tablet-7 │ │ ├── 1-cameras.png │ │ ├── 2-cameras-labels.png │ │ ├── 3-events.png │ │ ├── 4-events-actions.png │ │ ├── 5-events-filters.png │ │ ├── 6-clip-play.png │ │ ├── 7-main-menu.png │ │ └── 8-settings.png │ └── texts │ │ ├── en │ │ ├── description.txt │ │ └── summary.txt │ │ └── pl │ │ ├── description.txt │ │ └── summary.txt └── settings.gradle ├── app.json ├── babel.config.js ├── components ├── Background.tsx ├── Refresh.tsx ├── ZoomableImage.tsx ├── ZoomableView.tsx ├── charts │ ├── ProgressChart.tsx │ └── UsagePieChart.tsx ├── forms │ ├── Dropdown.tsx │ ├── Input.tsx │ ├── Label.tsx │ ├── Section.tsx │ └── styles.ts └── icons │ └── TopBarButton.tsx ├── firebase.json ├── helpers ├── buttonts.ts ├── charts.ts ├── colors.ts ├── interfaces.ts ├── locale.tsx ├── redux.tsx ├── rest.messages.ts ├── rest.ts ├── screen.ts └── table.ts ├── i18n ├── de.ts ├── en.ts ├── es.ts ├── fr.ts ├── it.ts ├── pl.ts ├── pt.ts ├── sv.ts └── uk.ts ├── index.js ├── ios ├── .xcode.env ├── FrigateViewer.xcodeproj │ ├── project.pbxproj │ └── xcshareddata │ │ └── xcschemes │ │ └── FrigateViewer.xcscheme ├── FrigateViewer │ ├── AppDelegate.h │ ├── AppDelegate.mm │ ├── Images.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ └── _ │ │ │ │ ├── 1024.png │ │ │ │ ├── 114.png │ │ │ │ ├── 120.png │ │ │ │ ├── 180.png │ │ │ │ ├── 29.png │ │ │ │ ├── 40.png │ │ │ │ ├── 57.png │ │ │ │ ├── 58.png │ │ │ │ ├── 60.png │ │ │ │ ├── 80.png │ │ │ │ └── 87.png │ │ └── Contents.json │ ├── Info.plist │ ├── LaunchScreen.storyboard │ ├── PrivacyInfo.xcprivacy │ ├── RCHTTPRequestHandler+ignoreSSL.m │ └── main.m ├── FrigateViewerTests │ ├── FrigateViewerTests.m │ └── Info.plist ├── Podfile └── link-assets-manifest.json ├── jest.config.js ├── metro.config.js ├── package-lock.json ├── package.json ├── react-native.config.js ├── store ├── events.ts ├── settings.ts └── store.ts ├── tsconfig.json ├── typings └── @lunarr │ └── vlc-player │ └── index.d.ts └── views ├── author ├── Author.tsx ├── BuyMeACoffee.tsx ├── UsedLibs.tsx ├── messages.ts ├── sp-engineering-logo.png └── useOpenLink.ts ├── camera-event-clip ├── CameraEventClip.tsx ├── ProgressBar.tsx └── VideoHUD.tsx ├── camera-events ├── CameraEvent.tsx ├── CameraEvents.tsx ├── EventLabels.tsx ├── EventSnapshot.tsx ├── EventTitle.tsx ├── Share.tsx ├── eventHelpers.ts ├── icons │ ├── delete.png │ ├── delete@1.5x.png │ ├── delete@2x.png │ ├── delete@3x.png │ ├── delete@4x.png │ ├── share.png │ ├── share@1.5x.png │ ├── share@2x.png │ ├── share@3x.png │ ├── share@4x.png │ ├── star.png │ ├── star@1.5x.png │ ├── star@2x.png │ ├── star@3x.png │ └── star@4x.png └── messages.ts ├── camera-preview ├── CameraPreview.tsx └── LivePreview.tsx ├── cameras-list ├── CameraLabels.tsx ├── CameraTile.tsx ├── CamerasList.tsx ├── ImagePreview.tsx ├── LastEvent.tsx ├── messages.ts └── useLoadingTime.ts ├── events-filters ├── EventsFilters.tsx ├── FilterItem.tsx ├── FilterSwitch.tsx ├── Filters.tsx ├── eventsFiltersHelpers.ts └── messages.ts ├── logs ├── LogPreview.tsx ├── Logs.tsx └── messages.ts ├── menu ├── Menu.tsx ├── logo-dark.png ├── logo.png ├── menuHelpers.ts └── messages.ts ├── report ├── Report.tsx └── messages.ts ├── settings ├── ServerForm.tsx ├── ServerItem.tsx ├── Settings.tsx ├── messages.ts └── useNoServer.ts ├── storage ├── CamerasStorageChart.tsx ├── CamerasStorageTable.tsx ├── Storage.tsx ├── StorageChart.tsx ├── StorageTable.tsx └── messages.ts └── system ├── CameraInfoChart.tsx ├── CameraTable.tsx ├── CpuUsageChart.tsx ├── DetectorsTable.tsx ├── GpusTable.tsx ├── SectionTitle.tsx ├── System.tsx ├── SystemInfo.tsx └── messages.ts /.bundle/config: -------------------------------------------------------------------------------- 1 | BUNDLE_PATH: "vendor/bundle" 2 | BUNDLE_FORCE_RUBY_PLATFORM: 1 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{ts,tsx,js}] 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: '@react-native', 4 | overrides: [ 5 | { 6 | files: ['*'], 7 | rules: { 8 | 'linebreak-style': 'crlf', 9 | }, 10 | } 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | ios/.xcode.env.local 24 | 25 | # Android/IntelliJ 26 | # 27 | build/ 28 | .idea 29 | .gradle 30 | local.properties 31 | *.iml 32 | *.hprof 33 | .cxx/ 34 | *.keystore 35 | !debug.keystore 36 | 37 | # node.js 38 | # 39 | node_modules/ 40 | npm-debug.log 41 | yarn-error.log 42 | 43 | # fastlane 44 | # 45 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 46 | # screenshots whenever they are needed. 47 | # For more information about the recommended setup visit: 48 | # https://docs.fastlane.tools/best-practices/source-control/ 49 | 50 | **/fastlane/report.xml 51 | **/fastlane/Preview.html 52 | **/fastlane/screenshots 53 | **/fastlane/test_output 54 | 55 | # Bundle artifact 56 | *.jsbundle 57 | 58 | # Ruby / CocoaPods 59 | **/Pods/ 60 | /vendor/bundle/ 61 | 62 | # Temporary files created by Metro to check the health of the file watcher 63 | .metro-health-check* 64 | 65 | # testing 66 | /coverage 67 | 68 | google-services.json 69 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | bracketSameLine: true, 4 | bracketSpacing: false, 5 | singleQuote: true, 6 | trailingComma: 'all', 7 | }; 8 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # You may use http://rbenv.org/ or https://rvm.io/ to install and use this version 4 | ruby ">= 2.6.10" 5 | 6 | # Cocoapods 1.15 introduced a bug which break the build. We will remove the upper 7 | # bound in the template on Cocoapods with next React Native release. 8 | gem 'cocoapods', '>= 1.13', '< 1.15' 9 | gem 'activesupport', '>= 6.1.7.5', '< 7.1.0' 10 | -------------------------------------------------------------------------------- /PRIVACY-POLICY.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | ## Introduction 4 | 5 | SP engineering built the Frigate Viewer application as a Open Source Application. This App is provided at no cost and is intended for use as is. 6 | 7 | This page is used to inform visibors regarding my policies with the collection, use, and disclosure of Personal Information if anyone decided to use my App. 8 | 9 | ## Information Collection and Use 10 | 11 | The App requires to provide your Frigate NVR server connection data. The data are stored in device internal memory and are not collected by me or any other third-party companies. 12 | 13 | The App can store snapshots and clip from your Frigate NVR server in device internal memory. The files are never transferred outside your device. 14 | 15 | ## Service Providers 16 | 17 | The App uses Frigate NVR server API to show the content. According to my best knowledge the server does not collect any data about connected devices. However Frigate NVR is third party software, its source code may be modified or forked, and I suggest to follow Privacy Policy of used server and software. 18 | 19 | ## Security 20 | 21 | This App is intended to connect to Frigate NVR server of your choice. Remember that security of your data depends on security of your device (data is stored internally) and your server (the app uses server through network connection). Make sure that your device is secure and make sure that you use safe connection to connect to reliable Frigate NVR server. I cannot guarantee its absolute security, because it depends mostly on you. 22 | 23 | ## Links to Other Sites 24 | 25 | The app may contain links to other sites. If you click on a third party link, you will be directed to that site. Note that these external sites are not operated by me. Therefore, I strongly advise you to review the Privacy Policy of these websites. 26 | 27 | ## Children's Privacy 28 | 29 | The app is not indended for anyone under the age of 13. I do not knowingly collect personally identifiable information from children under 13. If you are a parent or guardian and you are aware that your child has provided me with personal information, please contact me so that I will be able to take necessary actions. 30 | 31 | ## Changes to This Privacy Policy 32 | 33 | I may update Privacy Policy from time to time. Thus, you are advised to review this page periodically for any changes. 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Frigate Viewer 2 | 3 | This is mobile application which has been written using React Native to easily browse camera events of Frigate NVR. This is not official app. 4 | 5 | ## Android developing 6 | 7 | Follow the instructions of React Native docs to install Android Studio and the emulator. 8 | 9 | Run `npm install` to install dependencies and `npm run android` to start the emulator, compile the app and install it on the emulator or a connected device. 10 | 11 | `google-services.json` file should be placed in `./android/app` folder - it should contain credentials to Firebase for Crashlytics service. 12 | 13 | ## iOS developing 14 | 15 | I've never run this application on iOS. It should work in theory, but probably needs some enhancements. 16 | -------------------------------------------------------------------------------- /android/app/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/app/debug.keystore -------------------------------------------------------------------------------- /android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 9 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 15 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /android/app/src/main/assets/fonts/antfill.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/app/src/main/assets/fonts/antfill.ttf -------------------------------------------------------------------------------- /android/app/src/main/assets/fonts/antoutline.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/app/src/main/assets/fonts/antoutline.ttf -------------------------------------------------------------------------------- /android/app/src/main/java/com/frigateviewer/IgnoreSSLFactory.java: -------------------------------------------------------------------------------- 1 | package com.frigateviewer; 2 | 3 | import com.facebook.react.modules.network.OkHttpClientFactory; 4 | import com.facebook.react.modules.network.OkHttpClientFactory; 5 | import com.facebook.react.modules.network.OkHttpClientProvider; 6 | import com.facebook.react.modules.network.ReactCookieJarContainer; 7 | import java.security.cert.CertificateException; 8 | import java.util.ArrayList; 9 | import java.util.List; 10 | import java.util.concurrent.TimeUnit; 11 | import android.util.Log; 12 | import javax.net.ssl.HostnameVerifier; 13 | import javax.net.ssl.SSLContext; 14 | import javax.net.ssl.SSLSession; 15 | import javax.net.ssl.SSLSocketFactory; 16 | import javax.net.ssl.TrustManager; 17 | import javax.net.ssl.X509TrustManager; 18 | import okhttp3.CipherSuite; 19 | import okhttp3.ConnectionSpec; 20 | import okhttp3.OkHttpClient; 21 | import okhttp3.TlsVersion; 22 | import static android.content.ContentValues.TAG; 23 | public class IgnoreSSLFactory implements OkHttpClientFactory { 24 | private static final String TAG = "IgnoreSSLFactory"; 25 | 26 | @Override 27 | public OkHttpClient createNewNetworkModuleClient() { 28 | try { 29 | final TrustManager[] trustAllCerts = new TrustManager[]{ 30 | new X509TrustManager() { 31 | @Override 32 | public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) throws CertificateException { 33 | } 34 | @Override 35 | public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) throws CertificateException { 36 | } 37 | @Override 38 | public java.security.cert.X509Certificate[] getAcceptedIssuers() { 39 | return new java.security.cert.X509Certificate[]{}; 40 | } 41 | } 42 | }; 43 | final SSLContext sslContext = SSLContext.getInstance("SSL"); 44 | sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); 45 | final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); 46 | OkHttpClient.Builder builder = new OkHttpClient.Builder() 47 | .connectTimeout(0, TimeUnit.MILLISECONDS).readTimeout(0, TimeUnit.MILLISECONDS) 48 | .writeTimeout(0, TimeUnit.MILLISECONDS).cookieJar(new ReactCookieJarContainer()); 49 | builder.sslSocketFactory(sslSocketFactory, (X509TrustManager) trustAllCerts[0]); 50 | builder.hostnameVerifier(new HostnameVerifier() { 51 | @Override 52 | public boolean verify(String hostname, SSLSession session) { 53 | return true; 54 | } 55 | }); 56 | OkHttpClient okHttpClient = builder.build(); 57 | return okHttpClient; 58 | } catch (Exception e) { 59 | Log.e(TAG, e.getMessage()); 60 | throw new RuntimeException(e); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/frigateviewer/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.frigateviewer; 2 | 3 | import com.reactnativenavigation.NavigationActivity; 4 | import com.facebook.react.ReactActivityDelegate; 5 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; 6 | import com.facebook.react.defaults.DefaultReactActivityDelegate; 7 | 8 | public class MainActivity extends NavigationActivity {} 9 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/frigateviewer/MainApplication.java: -------------------------------------------------------------------------------- 1 | package com.frigateviewer; 2 | 3 | import android.app.Application; 4 | import com.facebook.react.PackageList; 5 | import com.reactnativenavigation.NavigationApplication; 6 | import com.facebook.react.ReactNativeHost; 7 | import com.facebook.react.ReactPackage; 8 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; 9 | import com.facebook.react.defaults.DefaultReactNativeHost; 10 | import com.reactnativenavigation.react.NavigationReactNativeHost; 11 | import com.facebook.react.flipper.ReactNativeFlipper; 12 | import com.facebook.soloader.SoLoader; 13 | import java.util.List; 14 | import com.horcrux.svg.SvgPackage; 15 | import com.facebook.react.modules.network.OkHttpClientProvider; 16 | 17 | public class MainApplication extends NavigationApplication { 18 | 19 | private final ReactNativeHost mReactNativeHost = 20 | new NavigationReactNativeHost(this) { 21 | @Override 22 | public boolean getUseDeveloperSupport() { 23 | return BuildConfig.DEBUG; 24 | } 25 | 26 | @Override 27 | protected List getPackages() { 28 | @SuppressWarnings("UnnecessaryLocalVariable") 29 | List packages = new PackageList(this).getPackages(); 30 | // Packages that cannot be autolinked yet can be added manually here, for example: 31 | // packages.add(new MyReactNativePackage()); 32 | packages.add(new SvgPackage()); 33 | return packages; 34 | } 35 | 36 | @Override 37 | protected String getJSMainModuleName() { 38 | return "index"; 39 | } 40 | 41 | @Override 42 | protected boolean isNewArchEnabled() { 43 | return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; 44 | } 45 | 46 | @Override 47 | protected Boolean isHermesEnabled() { 48 | return BuildConfig.IS_HERMES_ENABLED; 49 | } 50 | }; 51 | 52 | @Override 53 | public ReactNativeHost getReactNativeHost() { 54 | return mReactNativeHost; 55 | } 56 | 57 | @Override 58 | public void onCreate() { 59 | super.onCreate(); 60 | 61 | if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { 62 | // If you opted-in for the New Architecture, we load the native entry point for this app. 63 | DefaultNewArchitectureEntryPoint.load(); 64 | } 65 | OkHttpClientProvider.setOkHttpClientFactory(new IgnoreSSLFactory()); 66 | ReactNativeFlipper.initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/rn_edit_text_material.xml: -------------------------------------------------------------------------------- 1 | 2 | 16 | 21 | 22 | 23 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | FrigateViewer 3 | 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | * 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | def enableProguardInReleaseBuilds = true 4 | 5 | buildscript { 6 | ext { 7 | RNNKotlinVersion = "1.9.22" 8 | buildToolsVersion = "34.0.0" 9 | minSdkVersion = 23 10 | compileSdkVersion = 34 11 | targetSdkVersion = 34 12 | 13 | ndkVersion = "26.1.10909125" 14 | kotlinVersion = "1.9.22" 15 | } 16 | repositories { 17 | google() 18 | mavenCentral() 19 | } 20 | dependencies { 21 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$RNNKotlinVersion" 22 | classpath("com.android.tools.build:gradle") 23 | classpath("com.facebook.react:react-native-gradle-plugin") 24 | classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") 25 | classpath("com.google.gms:google-services:4.4.2") 26 | classpath("com.google.firebase:firebase-crashlytics-gradle:3.0.2") 27 | } 28 | } 29 | 30 | apply plugin: "com.facebook.react.rootproject" 31 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m 13 | org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m 14 | 15 | # When configured, Gradle will run in incubating parallel mode. 16 | # This option should only be used with decoupled projects. More details, visit 17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 18 | # org.gradle.parallel=true 19 | 20 | # AndroidX package structure to make it clearer which packages are bundled with the 21 | # Android operating system, and which are packaged with your app's APK 22 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 23 | android.useAndroidX=true 24 | # Automatically convert third-party libraries to use AndroidX 25 | android.enableJetifier=true 26 | 27 | # Use this property to specify which architecture you want to build. 28 | # You can also override it from the CLI using 29 | # ./gradlew -PreactNativeArchitectures=x86_64 30 | reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 31 | 32 | # Use this property to enable support to the new architecture. 33 | # This will allow you to use TurboModules and the Fabric render in 34 | # your application. You should enable this flag either if you want 35 | # to write custom TurboModules/Fabric components OR use libraries that 36 | # are providing them. 37 | newArchEnabled=false 38 | 39 | # Use this property to enable or disable the Hermes JS engine. 40 | # If set to false, you will be using JSC instead. 41 | hermesEnabled=true 42 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /android/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /android/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 | -------------------------------------------------------------------------------- /android/screenshots/phone/1-cameras.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/phone/1-cameras.png -------------------------------------------------------------------------------- /android/screenshots/phone/2-cameras-labels.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/phone/2-cameras-labels.png -------------------------------------------------------------------------------- /android/screenshots/phone/3-events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/phone/3-events.png -------------------------------------------------------------------------------- /android/screenshots/phone/4-events-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/phone/4-events-actions.png -------------------------------------------------------------------------------- /android/screenshots/phone/5-events-filters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/phone/5-events-filters.png -------------------------------------------------------------------------------- /android/screenshots/phone/6-clip-play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/phone/6-clip-play.png -------------------------------------------------------------------------------- /android/screenshots/phone/7-main-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/phone/7-main-menu.png -------------------------------------------------------------------------------- /android/screenshots/phone/8-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/phone/8-settings.png -------------------------------------------------------------------------------- /android/screenshots/tablet-10/1-cameras.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-10/1-cameras.png -------------------------------------------------------------------------------- /android/screenshots/tablet-10/2-cameras-labels.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-10/2-cameras-labels.png -------------------------------------------------------------------------------- /android/screenshots/tablet-10/3-events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-10/3-events.png -------------------------------------------------------------------------------- /android/screenshots/tablet-10/4-events-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-10/4-events-actions.png -------------------------------------------------------------------------------- /android/screenshots/tablet-10/5-events-filters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-10/5-events-filters.png -------------------------------------------------------------------------------- /android/screenshots/tablet-10/6-clip-play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-10/6-clip-play.png -------------------------------------------------------------------------------- /android/screenshots/tablet-10/7-main-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-10/7-main-menu.png -------------------------------------------------------------------------------- /android/screenshots/tablet-10/8-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-10/8-settings.png -------------------------------------------------------------------------------- /android/screenshots/tablet-7/1-cameras.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-7/1-cameras.png -------------------------------------------------------------------------------- /android/screenshots/tablet-7/2-cameras-labels.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-7/2-cameras-labels.png -------------------------------------------------------------------------------- /android/screenshots/tablet-7/3-events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-7/3-events.png -------------------------------------------------------------------------------- /android/screenshots/tablet-7/4-events-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-7/4-events-actions.png -------------------------------------------------------------------------------- /android/screenshots/tablet-7/5-events-filters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-7/5-events-filters.png -------------------------------------------------------------------------------- /android/screenshots/tablet-7/6-clip-play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-7/6-clip-play.png -------------------------------------------------------------------------------- /android/screenshots/tablet-7/7-main-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-7/7-main-menu.png -------------------------------------------------------------------------------- /android/screenshots/tablet-7/8-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sp-engineering/frigate-viewer/b8680724dea08c33a1e9d23bcc749cbb3d865e4c/android/screenshots/tablet-7/8-settings.png -------------------------------------------------------------------------------- /android/screenshots/texts/en/description.txt: -------------------------------------------------------------------------------- 1 | Do you own Frigate NVR server to store your camera events? This unofficial app will help you to browse events with viewing clips and manage them. 2 | 3 | Features: 4 | * Live preview of Frigate NVR cameras 5 | * List of events with filtering with cameras, labels and zones 6 | * Preview of snapshots and clips with zoom 7 | * Removing and retaining events 8 | * Preview of last event from the cameras 9 | * Storage and system info 10 | * Logs of the server 11 | -------------------------------------------------------------------------------- /android/screenshots/texts/en/summary.txt: -------------------------------------------------------------------------------- 1 | Easily browse and manage your camera events of Frigate NVR. Not official app. 2 | -------------------------------------------------------------------------------- /android/screenshots/texts/pl/description.txt: -------------------------------------------------------------------------------- 1 | Czy posiadasz własny serwer Frigate NVR do przechowywania zdarzeń z Twoich kamer? Ta nieoficjalna aplikacja pomoże Ci przeglądać zdarzenia i oglądać nagrania oraz zadządzać nimi. 2 | 3 | Funkcjonalności: 4 | * Podgląd na żywo z kamer Frigate NVR 5 | * Lista zdarzeń z filtrowaniem po kamerach, etykietach i strefach 6 | * Przegląd migawek i nagrań z możliwością przybliżenia 7 | * Usuwanie i zachowywanie zdarzeń 8 | * Podgląd ostatniego zdarzenia z kamer 9 | * Informacje systemowe i o przechowywaniu plików 10 | * Dzienniki serwera 11 | -------------------------------------------------------------------------------- /android/screenshots/texts/pl/summary.txt: -------------------------------------------------------------------------------- 1 | Z łatwością przeglądaj zdarzenia z kamer Frigate NVR. Aplikacja nieoficjalna. 2 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'FrigateViewer' 2 | apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) 3 | include ':app' 4 | includeBuild('../node_modules/@react-native/gradle-plugin') 5 | include ':react-native-svg' 6 | project(':react-native-svg').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-svg/android') 7 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "FrigateViewer", 3 | "displayName": "FrigateViewer" 4 | } -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:@react-native/babel-preset'], 3 | plugins: ['react-native-reanimated/plugin'], 4 | }; 5 | -------------------------------------------------------------------------------- /components/Background.tsx: -------------------------------------------------------------------------------- 1 | import {FC} from 'react'; 2 | import {View, ViewProps} from 'react-native'; 3 | import {useStyles} from '../helpers/colors'; 4 | 5 | export const Background: FC = ({children}) => { 6 | const styles = useStyles(({theme}) => ({ 7 | wrapper: { 8 | width: '100%', 9 | height: '100%', 10 | backgroundColor: theme.background, 11 | }, 12 | })); 13 | 14 | return {children}; 15 | }; 16 | -------------------------------------------------------------------------------- /components/Refresh.tsx: -------------------------------------------------------------------------------- 1 | import {RefreshControl} from 'react-native-gesture-handler'; 2 | import {useTheme} from '../helpers/colors'; 3 | import {FC} from 'react'; 4 | import {RefreshControlProps} from 'react-native'; 5 | 6 | export const Refresh: FC = refreshControlProps => { 7 | const theme = useTheme(); 8 | 9 | return ( 10 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /components/ZoomableImage.tsx: -------------------------------------------------------------------------------- 1 | import React, {FC} from 'react'; 2 | import {ImageProps} from 'react-native'; 3 | import {Gesture, GestureDetector} from 'react-native-gesture-handler'; 4 | import Animated, { 5 | useAnimatedStyle, 6 | useSharedValue, 7 | withSpring, 8 | } from 'react-native-reanimated'; 9 | 10 | type IZoomableImageProps = ImageProps; 11 | 12 | export const ZoomableImage: FC = ({ 13 | style, 14 | ...imageProps 15 | }) => { 16 | const offset = useSharedValue({x: 0, y: 0}); 17 | const scale = useSharedValue(1); 18 | 19 | const animatedStyles = useAnimatedStyle(() => { 20 | return { 21 | transform: [ 22 | {translateX: scale.value * offset.value.x}, 23 | {translateY: scale.value * offset.value.y}, 24 | {scale: withSpring(Math.max(scale.value, 1))}, 25 | ], 26 | }; 27 | }); 28 | 29 | const nativeGesture = Gesture.Native(); 30 | 31 | const zoomGesture = Gesture.Pinch() 32 | .onUpdate(event => { 33 | scale.value = event.scale; 34 | }) 35 | .onEnd(() => { 36 | scale.value = 1; 37 | }); 38 | 39 | const dragGesture = Gesture.Pan() 40 | .averageTouches(true) 41 | .minPointers(2) 42 | .onUpdate(event => { 43 | offset.value = { 44 | x: event.translationX, 45 | y: event.translationY, 46 | }; 47 | }) 48 | .onEnd(() => { 49 | offset.value = {x: 0, y: 0}; 50 | }); 51 | 52 | const gestures = Gesture.Race( 53 | nativeGesture, 54 | Gesture.Simultaneous(zoomGesture, dragGesture), 55 | ); 56 | 57 | return ( 58 | 59 | 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /components/ZoomableView.tsx: -------------------------------------------------------------------------------- 1 | import React, {FC} from 'react'; 2 | import {ViewProps} from 'react-native'; 3 | import {Gesture, GestureDetector} from 'react-native-gesture-handler'; 4 | import Animated, { 5 | useAnimatedStyle, 6 | useSharedValue, 7 | withSpring, 8 | } from 'react-native-reanimated'; 9 | 10 | type IZoomableViewProps = ViewProps; 11 | 12 | export const ZoomableView: FC = ({style, ...viewProps}) => { 13 | const offset = useSharedValue({x: 0, y: 0}); 14 | const scale = useSharedValue(1); 15 | 16 | const animatedStyles = useAnimatedStyle(() => { 17 | return { 18 | transform: [ 19 | {translateX: scale.value * offset.value.x}, 20 | {translateY: scale.value * offset.value.y}, 21 | {scale: withSpring(Math.max(scale.value, 1))}, 22 | ], 23 | }; 24 | }); 25 | 26 | const nativeGesture = Gesture.Native(); 27 | 28 | const zoomGesture = Gesture.Pinch() 29 | .manualActivation(true) 30 | .onTouchesDown((event, manager) => { 31 | if (event.numberOfTouches > 1) { 32 | manager.activate(); 33 | } 34 | }) 35 | .onUpdate(event => { 36 | scale.value = event.scale; 37 | }) 38 | .onEnd(() => { 39 | scale.value = 1; 40 | }); 41 | 42 | const dragGesture = Gesture.Pan() 43 | .averageTouches(true) 44 | .manualActivation(true) 45 | .onTouchesDown((event, manager) => { 46 | if (event.numberOfTouches > 1) { 47 | manager.activate(); 48 | } 49 | }) 50 | .onUpdate(event => { 51 | if (event.numberOfPointers > 1) { 52 | offset.value = { 53 | x: event.translationX, 54 | y: event.translationY, 55 | }; 56 | } 57 | }) 58 | .onEnd(() => { 59 | offset.value = {x: 0, y: 0}; 60 | }); 61 | 62 | const gestures = Gesture.Race( 63 | nativeGesture, 64 | Gesture.Simultaneous(zoomGesture, dragGesture), 65 | ); 66 | 67 | return ( 68 | 69 | 70 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /components/charts/ProgressChart.tsx: -------------------------------------------------------------------------------- 1 | import {FC, useMemo} from 'react'; 2 | import {ProgressCircle} from 'react-native-svg-charts'; 3 | import {Text, View} from 'react-native-ui-lib'; 4 | import {useStyles} from '../../helpers/colors'; 5 | 6 | export interface ProgressChartData { 7 | label: string; 8 | value: number; 9 | color: string; 10 | } 11 | 12 | interface IProgressChartProps { 13 | chartData: ProgressChartData[]; 14 | height: number; 15 | angle?: number; 16 | offset?: number; 17 | } 18 | 19 | const percent = (value: number) => `${Math.floor(value * 100)}%`; 20 | 21 | export const ProgressChart: FC = ({ 22 | chartData, 23 | angle, 24 | height, 25 | offset, 26 | }) => { 27 | const styles = useStyles(({theme}) => ({ 28 | wrapper: { 29 | display: 'flex', 30 | justifyContent: 'center', 31 | alignItems: 'center', 32 | }, 33 | circle: { 34 | position: 'absolute', 35 | left: 0, 36 | width: '100%', 37 | }, 38 | legend: { 39 | flexDirection: 'row', 40 | justifyContent: 'center', 41 | alignItems: 'center', 42 | marginVertical: 2, 43 | }, 44 | legendMarker: { 45 | width: 12, 46 | height: 12, 47 | borderRadius: 4, 48 | marginRight: 4, 49 | }, 50 | legendLabel: { 51 | color: theme.text, 52 | fontSize: 12, 53 | fontWeight: '600', 54 | }, 55 | legendValue: { 56 | color: theme.text, 57 | marginLeft: 2, 58 | fontSize: 12, 59 | }, 60 | })); 61 | 62 | const startAngle = useMemo(() => Math.PI * angle!, []); 63 | 64 | return ( 65 | 66 | {chartData.map((point, index) => ( 67 | 78 | ))} 79 | 80 | {chartData.map((point, i) => ( 81 | 82 | 87 | {point.label}: 88 | {percent(point.value)} 89 | 90 | ))} 91 | 92 | 93 | ); 94 | }; 95 | ProgressChart.defaultProps = { 96 | angle: 0.8, 97 | offset: 12, 98 | }; 99 | -------------------------------------------------------------------------------- /components/charts/UsagePieChart.tsx: -------------------------------------------------------------------------------- 1 | import {FC, useMemo} from 'react'; 2 | import {StyleSheet} from 'react-native'; 3 | import {PieChart, PieChartData} from 'react-native-svg-charts'; 4 | import {View} from 'react-native-ui-lib'; 5 | import {Circle, G, Line, Text} from 'react-native-svg'; 6 | 7 | const styles = StyleSheet.create({ 8 | wrapper: { 9 | display: 'flex', 10 | justifyContent: 'center', 11 | alignItems: 'center', 12 | }, 13 | pie: { 14 | width: '100%', 15 | height: '100%', 16 | }, 17 | }); 18 | 19 | interface Slice { 20 | labelCentroid: [number, number]; 21 | pieCentroid: [number, number]; 22 | data: PieChartData; 23 | } 24 | 25 | export interface UsagePieChartData { 26 | label: string; 27 | value: number; 28 | color: string; 29 | } 30 | 31 | interface IUsagePieChartProps { 32 | chartData: UsagePieChartData[]; 33 | height: number; 34 | } 35 | 36 | export const UsagePieChart: FC = ({chartData, height}) => { 37 | const data: PieChartData[] = useMemo( 38 | () => [ 39 | ...chartData.map(({label, value, color}) => ({ 40 | key: label, 41 | value, 42 | svg: {fill: color}, 43 | arc: {outerRadius: '110%', cornerRadius: 5}, 44 | })), 45 | { 46 | key: '_free', 47 | value: 100 - chartData.reduce((sum, data) => sum + data.value, 0), 48 | svg: {fill: '#ddd'}, 49 | arc: {cornerRadius: 5}, 50 | }, 51 | ], 52 | [chartData], 53 | ); 54 | 55 | const Labels: FC<{slices?: Slice[]}> = ({slices}) => ( 56 | <> 57 | {slices && 58 | slices 59 | .filter(slice => slice.data.key !== '_free') 60 | .map((slice, index) => { 61 | const {labelCentroid, pieCentroid, data} = slice; 62 | const [centroidX, centroidY] = slice.labelCentroid; 63 | let alignment: 'start' | 'end' | 'middle' = 'middle'; 64 | let dx = 0; // Default no horizontal shift 65 | let dy = 0; // Default no vertical shift 66 | 67 | // Adjust dx and dy based on quadrant 68 | if (centroidX > 0 && centroidY < 0) { 69 | // Top-right quadrant 70 | alignment = 'start'; 71 | dx = 10; // Move text right 72 | } else if (centroidX < 0 && centroidY < 0) { 73 | // Top-left quadrant 74 | alignment = 'end'; 75 | dx = -10; // Move text left 76 | } else if (centroidX < 0 && centroidY > 0) { 77 | // Bottom-left quadrant 78 | alignment = 'middle'; 79 | dx = 0; 80 | dy = 10; // Move text down 81 | } else if (centroidX > 0 && centroidY > 0) { 82 | // Bottom-right quadrant 83 | alignment = 'start'; 84 | dx = 10; // Move text right 85 | } 86 | 87 | return ( 88 | 89 | 96 | 104 | {data.key} 105 | 106 | 112 | 113 | ); 114 | })} 115 | 116 | ); 117 | 118 | return ( 119 | 120 | 126 | 127 | 128 | 129 | ); 130 | }; 131 | -------------------------------------------------------------------------------- /components/forms/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, {FC, useCallback, useEffect, useState} from 'react'; 2 | import {FlatList, Modal, Pressable, StyleSheet, Text} from 'react-native'; 3 | import {useFormsStyles} from './styles'; 4 | import {useStyles} from '../../helpers/colors'; 5 | 6 | interface IDropdownOption { 7 | value: any; 8 | label?: string; 9 | } 10 | 11 | interface IDropdownProps { 12 | value?: any; 13 | options: IDropdownOption[]; 14 | onValueChange?: (value: any) => void; 15 | } 16 | 17 | export const Dropdown: FC = ({ 18 | value, 19 | options, 20 | onValueChange, 21 | }) => { 22 | const formsStyles = useFormsStyles(); 23 | const styles = useStyles(({theme}) => ({ 24 | overlay: { 25 | ...StyleSheet.absoluteFillObject, 26 | backgroundColor: theme.overlay, 27 | }, 28 | options: { 29 | flexGrow: 1, 30 | justifyContent: 'center', 31 | width: '100%', 32 | borderTopWidth: 1, 33 | borderColor: theme.border, 34 | }, 35 | item: { 36 | paddingHorizontal: 4, 37 | paddingVertical: 10, 38 | borderBottomWidth: 1, 39 | borderColor: theme.border, 40 | backgroundColor: theme.background, 41 | }, 42 | itemSelected: { 43 | backgroundColor: theme.highlighted, 44 | }, 45 | itemText: { 46 | color: theme.text, 47 | }, 48 | })); 49 | 50 | const [opened, setOpened] = useState(false); 51 | const [selected, setSelected] = useState(); 52 | 53 | const open = useCallback(() => { 54 | setOpened(true); 55 | }, []); 56 | 57 | const close = useCallback(() => { 58 | setOpened(false); 59 | }, []); 60 | 61 | const toggle = useCallback(() => { 62 | if (opened) { 63 | close(); 64 | } else { 65 | open(); 66 | } 67 | }, [opened, open, close]); 68 | 69 | const select = useCallback( 70 | (option: IDropdownOption) => () => { 71 | setSelected(option); 72 | if (onValueChange) { 73 | onValueChange(option.value); 74 | } 75 | close(); 76 | }, 77 | [close, onValueChange], 78 | ); 79 | 80 | useEffect(() => { 81 | const found = options.find(option => value === option.value); 82 | if (found) { 83 | setSelected(found); 84 | } 85 | }, [value, options]); 86 | 87 | return ( 88 | <> 89 | {opened && ( 90 | 91 | 92 | ( 96 | 102 | {item.label || item.value} 103 | 104 | )} 105 | /> 106 | 107 | )} 108 | 109 | 110 | {selected ? selected.label || selected.value : ''} 111 | 112 | 113 | 114 | ); 115 | }; 116 | -------------------------------------------------------------------------------- /components/forms/Input.tsx: -------------------------------------------------------------------------------- 1 | import React, {FC} from 'react'; 2 | import {TextInput, TextInputProps} from 'react-native'; 3 | import {useFormsStyles} from './styles'; 4 | 5 | type IInputProps = TextInputProps; 6 | 7 | export const Input: FC = inputProps => { 8 | const formsStyles = useFormsStyles(); 9 | 10 | return ( 11 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /components/forms/Label.tsx: -------------------------------------------------------------------------------- 1 | import React, {FC} from 'react'; 2 | import {Text, TextInputProps, View} from 'react-native'; 3 | import {useStyles} from '../../helpers/colors'; 4 | 5 | export interface ILabelProps extends TextInputProps { 6 | text: string; 7 | touched?: boolean; 8 | error?: string; 9 | required?: boolean; 10 | } 11 | 12 | export const Label: FC = ({ 13 | text, 14 | touched, 15 | error, 16 | required, 17 | children, 18 | }) => { 19 | const styles = useStyles(({theme}) => ({ 20 | wrapper: { 21 | marginVertical: 10, 22 | }, 23 | text: { 24 | color: theme.text, 25 | }, 26 | error: { 27 | color: theme.error, 28 | }, 29 | required: { 30 | color: theme.error, 31 | }, 32 | })); 33 | 34 | return ( 35 | 36 | 37 | {text} 38 | {required && *} 39 | 40 | {children} 41 | {touched && error && {error}} 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /components/forms/Section.tsx: -------------------------------------------------------------------------------- 1 | import React, {FC} from 'react'; 2 | import {StyleSheet, Text, TextInputProps, View} from 'react-native'; 3 | import {useStyles} from '../../helpers/colors'; 4 | 5 | export interface ISectionProps extends TextInputProps { 6 | header: string | JSX.Element; 7 | } 8 | 9 | export const Section: FC = ({header, children}) => { 10 | const styles = useStyles(({theme}) => ({ 11 | wrapper: { 12 | marginVertical: 10, 13 | }, 14 | header: { 15 | fontSize: 18, 16 | fontWeight: '600', 17 | color: theme.text, 18 | }, 19 | })); 20 | 21 | return ( 22 | 23 | {header} 24 | {children} 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /components/forms/styles.ts: -------------------------------------------------------------------------------- 1 | import {useStyles} from '../../helpers/colors'; 2 | 3 | export const useFormsStyles = () => 4 | useStyles(({theme}) => ({ 5 | input: { 6 | borderBottomWidth: 2, 7 | borderColor: theme.border, 8 | borderRadius: 5, 9 | backgroundColor: theme.background, 10 | paddingHorizontal: 4, 11 | paddingVertical: 8, 12 | }, 13 | inputText: { 14 | color: theme.text, 15 | }, 16 | })); 17 | -------------------------------------------------------------------------------- /components/icons/TopBarButton.tsx: -------------------------------------------------------------------------------- 1 | import {IconOutline, OutlineGlyphMapType} from '@ant-design/icons-react-native'; 2 | import React, {FC, useCallback} from 'react'; 3 | import {StyleSheet, Text, TouchableWithoutFeedback, View} from 'react-native'; 4 | 5 | const styles = StyleSheet.create({ 6 | button: { 7 | paddingHorizontal: 12, 8 | marginHorizontal: 4, 9 | paddingVertical: 14, 10 | backgroundColor: 'black', 11 | }, 12 | bullet: { 13 | position: 'absolute', 14 | right: 0, 15 | bottom: 0, 16 | minWidth: 14, 17 | height: 14, 18 | borderRadius: 7, 19 | backgroundColor: 'red', 20 | }, 21 | bulletText: { 22 | color: 'white', 23 | textAlign: 'center', 24 | lineHeight: 12, 25 | fontSize: 10, 26 | fontWeight: '700', 27 | }, 28 | }); 29 | 30 | interface ITopBarButtonProps { 31 | icon: OutlineGlyphMapType; 32 | count?: number; 33 | onPress: () => void; 34 | } 35 | 36 | export const TopBarButton: FC = ({ 37 | icon, 38 | count, 39 | onPress, 40 | }) => { 41 | const press = useCallback(() => { 42 | if (onPress) { 43 | onPress(); 44 | } 45 | }, [onPress]); 46 | 47 | return ( 48 | 49 | 50 | 51 | {count !== undefined && count !== 0 && ( 52 | 53 | {`${count}`} 54 | 55 | )} 56 | 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "react-native": { 3 | "crashlytics_debug_enabled": false 4 | } 5 | } -------------------------------------------------------------------------------- /helpers/buttonts.ts: -------------------------------------------------------------------------------- 1 | import { OptionsTopBarButton } from 'react-native-navigation'; 2 | 3 | export const refreshButton: (onPress?: () => void) => OptionsTopBarButton = onPress => ({ 4 | id: 'refresh', 5 | component: { 6 | id: 'FilterButton', 7 | name: 'TopBarButton', 8 | passProps: { 9 | icon: 'sync', 10 | onPress, 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /helpers/charts.ts: -------------------------------------------------------------------------------- 1 | function* randomColorGenerator() { 2 | yield '#4CB140'; 3 | yield '#F445CF'; 4 | yield '#06C'; 5 | yield '#F4C145'; 6 | while (true) { 7 | yield ( 8 | '#' + 9 | ((Math.random() * 0xffffff) << 0).toString(16) + 10 | '000000' 11 | ).slice(0, 7); 12 | } 13 | } 14 | 15 | const randomColor = randomColorGenerator(); 16 | 17 | const colors: string[] = []; 18 | 19 | export function getColor(index: number) { 20 | if (colors[index]) { 21 | return colors[index]; 22 | } 23 | const color = randomColor.next().value as string; 24 | colors.push(color); 25 | return color; 26 | } 27 | -------------------------------------------------------------------------------- /helpers/colors.ts: -------------------------------------------------------------------------------- 1 | import {StyleSheet, useColorScheme} from 'react-native'; 2 | import {selectAppColorScheme} from '../store/settings'; 3 | import {useAppSelector} from '../store/store'; 4 | import {useMemo} from 'react'; 5 | 6 | const defaultColorScheme = 'light'; 7 | 8 | type ColorName = 9 | | 'background' 10 | | 'text' 11 | | 'link' 12 | | 'border' 13 | | 'highlighted' 14 | | 'disabled' 15 | | 'overlay' 16 | | 'error' 17 | | 'tableColumnHeaderBg' 18 | | 'tableRowHeaderBg' 19 | | 'tableCellBg' 20 | | 'tableText'; 21 | 22 | type Theme = Record; 23 | 24 | export const palette = { 25 | white: 'white', 26 | black: 'black', 27 | darkgray: '#222', 28 | blue: 'blue', 29 | lightblue: 'lightblue', 30 | }; 31 | 32 | const lightTheme: Theme = { 33 | background: palette.white, 34 | text: palette.darkgray, 35 | link: palette.blue, 36 | border: '#00000088', 37 | highlighted: '#f5f5f5', 38 | disabled: '#888', 39 | overlay: '#ffffff88', 40 | error: 'red', 41 | tableColumnHeaderBg: '#ddd', 42 | tableRowHeaderBg: '#eee', 43 | tableCellBg: palette.white, 44 | tableText: palette.black, 45 | }; 46 | 47 | const darkTheme: Theme = { 48 | background: palette.darkgray, 49 | text: palette.white, 50 | link: palette.lightblue, 51 | border: '#ffffff88', 52 | highlighted: '#353535', 53 | disabled: '#888', 54 | overlay: '#00000088', 55 | error: 'red', 56 | tableColumnHeaderBg: '#111', 57 | tableRowHeaderBg: '#333', 58 | tableCellBg: '#222', 59 | tableText: palette.white, 60 | }; 61 | 62 | export const useAppColorScheme = () => { 63 | const colorScheme = useAppSelector(selectAppColorScheme); 64 | const systemColorScheme = useColorScheme(); 65 | 66 | const appColorScheme = useMemo( 67 | () => 68 | colorScheme === 'auto' 69 | ? systemColorScheme 70 | ? systemColorScheme 71 | : defaultColorScheme 72 | : colorScheme, 73 | [colorScheme, systemColorScheme], 74 | ); 75 | 76 | return appColorScheme; 77 | }; 78 | 79 | export const useTheme = () => { 80 | const colorScheme = useAppColorScheme(); 81 | const palette = useMemo( 82 | () => (colorScheme === 'light' ? lightTheme : darkTheme), 83 | [colorScheme], 84 | ); 85 | return palette; 86 | }; 87 | 88 | export const useStyles = ( 89 | styles: (helpers: {theme: Theme}) => StyleSheet.NamedStyles, 90 | ) => { 91 | const theme = useTheme(); 92 | const computedStyles = useMemo( 93 | () => StyleSheet.create(styles({theme})), 94 | [theme], 95 | ); 96 | return computedStyles; 97 | }; 98 | -------------------------------------------------------------------------------- /helpers/interfaces.ts: -------------------------------------------------------------------------------- 1 | interface Detector { 2 | inference_speed: number; 3 | detection_start: number; 4 | pid: number; 5 | } 6 | 7 | interface CpuUsage { 8 | cpu: string; // `${0.0-100.0}` 9 | mem: string; // `${0.0-100.0}` 10 | } 11 | 12 | interface GpuUsage { 13 | gpu: string; // `${0.0-100.0} %` 14 | mem: string; // `${0.0-100.0} %` 15 | } 16 | 17 | export type StoragePlace = 18 | | '/dev/shm' 19 | | '/media/frigate/clips' 20 | | '/media/frigate/recordings' 21 | | '/tmp/cache'; 22 | 23 | export type StorageShortPlace = 'clips' | 'recordings' | 'cache' | 'shm'; 24 | 25 | export interface StorageInfo { 26 | used: number; // MB 27 | free: number; // MB 28 | total: number; // MB 29 | mount_type: 'ext4' | 'zfs' | 'tmpfs' | 'overlay'; 30 | } 31 | 32 | export interface Service { 33 | last_updated?: number; // timestamp 34 | latest_version?: string; // latest frigate nvr version 35 | storage: Record; 36 | temperatures?: {}; 37 | uptime: number; // seconds 38 | version: string; // current frigate nvr version 39 | } 40 | 41 | interface CameraInfo { 42 | camera_fps: number; 43 | process_fps: number; 44 | skipped_fps: number; 45 | detection_fps: number; 46 | pid: number; 47 | capture_pid: number; 48 | ffmpeg_pid?: number; 49 | detection_enabled?: number; // 0 or 1 50 | } 51 | 52 | interface StatsInfo { 53 | cpu_usages?: Record; 54 | detectors: Record; 55 | gpu_usages?: Record; 56 | service: Service; 57 | } 58 | 59 | export type Stats = { 60 | cameras: Record; 61 | } & StatsInfo; 62 | 63 | interface CameraStorageInfo { 64 | bandwidth: number; // MB/h 65 | usage: number; // MB 66 | usage_percent: number; // 0-100 67 | } 68 | 69 | export type CamerasStorage = Record; 70 | -------------------------------------------------------------------------------- /helpers/locale.tsx: -------------------------------------------------------------------------------- 1 | import {enGB, enUS, pl, es, enAU, enCA, enIE, enNZ, fr, frCA, frCH, deAT, de, pt, ptBR, uk, it, itCH, sv} from 'date-fns/locale'; 2 | import React, {useEffect, useState} from 'react'; 3 | import {defineMessages, IntlProvider, MessageDescriptor} from 'react-intl'; 4 | import { 5 | NavigationFunctionComponent, 6 | NavigationProps, 7 | } from 'react-native-navigation'; 8 | import {Region, selectLocaleRegion} from '../store/settings'; 9 | import {useAppSelector} from '../store/store'; 10 | import deLang from '../i18n/de'; 11 | import enLang from '../i18n/en'; 12 | import esLang from '../i18n/es'; 13 | import frLang from '../i18n/fr'; 14 | import plLang from '../i18n/pl'; 15 | import ptLang from '../i18n/pt'; 16 | import ukLang from '../i18n/uk'; 17 | import itLang from '../i18n/it'; 18 | import svLang from '../i18n/sv'; 19 | 20 | export const useDateLocale = () => { 21 | const region = useAppSelector(selectLocaleRegion); 22 | const regionLocaleMap: Record = { 23 | de_AT: deAT, 24 | de_DE: de, 25 | de_LU: de, 26 | de_CH: de, 27 | en_AU: enAU, 28 | en_CA: enCA, 29 | en_GB: enGB, 30 | en_IE: enIE, 31 | en_NZ: enNZ, 32 | en_US: enUS, 33 | es_AR: es, 34 | es_BO: es, 35 | es_CL: es, 36 | es_CO: es, 37 | es_CR: es, 38 | es_DO: es, 39 | es_EC: es, 40 | es_ES: es, 41 | es_GT: es, 42 | es_HN: es, 43 | es_MX: es, 44 | es_NI: es, 45 | es_PA: es, 46 | es_PE: es, 47 | es_PY: es, 48 | es_SV: es, 49 | es_UY: es, 50 | es_VE: es, 51 | pl_PL: pl, 52 | fr_FR: fr, 53 | fr_CA: frCA, 54 | fr_CH: frCH, 55 | pt_PT: pt, 56 | pt_BR: ptBR, 57 | uk_UA: uk, 58 | it_CH: itCH, 59 | it_IT: it, 60 | sv_SE: sv, 61 | }; 62 | const fallbackLocale = enGB; 63 | return regionLocaleMap[region] || fallbackLocale; 64 | }; 65 | 66 | export const formatVideoTime = (t: number) => { 67 | const sign = t < 0 ? '-' : ''; 68 | const time = Math.abs(Math.round(t)); 69 | const minutes = Math.floor(time / 60); 70 | const seconds = time % 60; 71 | return `${sign}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; 72 | }; 73 | 74 | type Lang = Record; 75 | type LangCode = 'en' | 'pl' | 'es' | 'fr' | 'de' | 'pt' | 'uk' | 'it' | 'sv'; 76 | 77 | const regionTranslationsMap: Record = { 78 | de_AT: ['de', deLang], 79 | de_DE: ['de', deLang], 80 | de_LU: ['de', deLang], 81 | de_CH: ['de', deLang], 82 | en_AU: ['en', enLang], 83 | en_CA: ['en', enLang], 84 | en_GB: ['en', enLang], 85 | en_IE: ['en', enLang], 86 | en_NZ: ['en', enLang], 87 | en_US: ['en', enLang], 88 | pl_PL: ['pl', plLang], 89 | es_AR: ['es', esLang], 90 | es_BO: ['es', esLang], 91 | es_CL: ['es', esLang], 92 | es_CO: ['es', esLang], 93 | es_CR: ['es', esLang], 94 | es_DO: ['es', esLang], 95 | es_EC: ['es', esLang], 96 | es_ES: ['es', esLang], 97 | es_GT: ['es', esLang], 98 | es_HN: ['es', esLang], 99 | es_MX: ['es', esLang], 100 | es_NI: ['es', esLang], 101 | es_PA: ['es', esLang], 102 | es_PE: ['es', esLang], 103 | es_PY: ['es', esLang], 104 | es_SV: ['es', esLang], 105 | es_UY: ['es', esLang], 106 | es_VE: ['es', esLang], 107 | fr_FR: ['fr', frLang], 108 | fr_CA: ['fr', frLang], 109 | fr_CH: ['fr', frLang], 110 | pt_BR: ['pt', ptLang], 111 | pt_PT: ['pt', ptLang], 112 | uk_UA: ['uk', ukLang], 113 | it_CH: ['it', itLang], 114 | it_IT: ['it', itLang], 115 | sv_SE: ['sv', svLang], 116 | }; 117 | 118 | const fallbackLanguage = 'en'; 119 | 120 | const useTranslations = () => { 121 | const [langCode, setLangCode] = useState(fallbackLanguage); 122 | const [messages, setMessages] = useState({}); 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 |

137 | 138 | {progress}% 139 | 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 | --------------------------------------------------------------------------------