feat(android): Add Capacitor for Android APK build
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

- Add Capacitor core, CLI, and Android platform
- Install plugins: camera, push-notifications, splash-screen, status-bar
- Configure capacitor.config.ts with app ID run.runfoo.veridian
- Update vite.config.ts with base: './' for Capacitor compatibility
- Update api.ts and SessionTimeoutWarning.tsx to detect Capacitor and use production API URL
- Generate Android project structure with Gradle build files
This commit is contained in:
fullsizemalt 2026-01-06 21:56:28 -08:00
parent 469286deac
commit 57c70b91db
69 changed files with 2922 additions and 13 deletions

1
.gitignore vendored
View file

@ -36,3 +36,4 @@ backend/prisma/dev.db
# Docker
docker-compose.env
_SECRETS_BACKUP

View file

@ -600,6 +600,7 @@ model FacilityRoom {
rotation Int @default(0)
color String? // Custom color override
sections FacilitySection[]
cameras Camera[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -1041,6 +1042,49 @@ model EnvironmentProfile {
@@map("environment_profiles")
}
// ---------------------- Security Cameras ----------------------
enum CameraStatus {
ONLINE
OFFLINE
IDLE // Showing idle image, waiting for motion
STREAMING // Actively streaming
ERROR
}
model Camera {
id String @id @default(uuid())
name String // "Grow Room Entry"
slug String @unique
streamKey String // go2rtc stream name, e.g., "arlo_grow_room"
location String? // "North wall entrance"
roomId String?
room FacilityRoom? @relation(fields: [roomId], references: [id])
// Camera metadata
manufacturer String? // "Arlo", "Wyze", etc.
model String? // "Pro 4"
protocol String @default("RTSP") // RTSP, ONVIF, HLS
// Status
isActive Boolean @default(true)
status CameraStatus @default(OFFLINE)
lastSeen DateTime?
// Positioning on floor plan (optional)
posX Int?
posY Int?
rotation Int @default(0) // 0-360 degrees
fov Int @default(90) // Field of view degrees
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([roomId])
@@index([streamKey])
@@map("cameras")
}
// ---------------------- Financial Tracking ----------------------
enum TransactionType {

View file

@ -0,0 +1,254 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { CameraStatus } from '@prisma/client';
// Utility to generate slug from name
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
// GET /api/cameras - List all cameras
export const getCameras = async (request: FastifyRequest, reply: FastifyReply) => {
const cameras = await request.server.prisma.camera.findMany({
orderBy: { name: 'asc' },
include: {
room: {
select: {
id: true,
name: true,
code: true,
type: true
}
}
}
});
return cameras;
};
// GET /api/cameras/:id - Get single camera
export const getCameraById = async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as { id: string };
const camera = await request.server.prisma.camera.findUnique({
where: { id },
include: {
room: {
select: {
id: true,
name: true,
code: true,
type: true
}
}
}
});
if (!camera) {
return reply.status(404).send({ message: 'Camera not found' });
}
return camera;
};
// GET /api/cameras/:id/stream - Get stream URL for go2rtc
export const getCameraStream = async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as { id: string };
const camera = await request.server.prisma.camera.findUnique({
where: { id }
});
if (!camera) {
return reply.status(404).send({ message: 'Camera not found' });
}
if (!camera.isActive) {
return reply.status(503).send({ message: 'Camera is disabled' });
}
// Return go2rtc stream endpoints
const go2rtcBase = process.env.GO2RTC_URL || 'http://go2rtc:1984';
return {
camera: {
id: camera.id,
name: camera.name,
streamKey: camera.streamKey,
status: camera.status
},
streams: {
// go2rtc provides these endpoints automatically
webrtc: `${go2rtcBase}/api/ws?src=${camera.streamKey}`,
mse: `${go2rtcBase}/api/stream.mp4?src=${camera.streamKey}`,
hls: `${go2rtcBase}/api/stream.m3u8?src=${camera.streamKey}`,
snapshot: `${go2rtcBase}/api/frame.jpeg?src=${camera.streamKey}`
}
};
};
// POST /api/cameras - Create new camera
export const createCamera = async (request: FastifyRequest, reply: FastifyReply) => {
const {
name,
streamKey,
location,
roomId,
manufacturer,
model,
protocol,
posX,
posY,
rotation,
fov
} = request.body as any;
if (!name || !streamKey) {
return reply.status(400).send({ message: 'Name and streamKey are required' });
}
const slug = slugify(name);
// Check for duplicate slug
const existing = await request.server.prisma.camera.findUnique({
where: { slug }
});
if (existing) {
return reply.status(409).send({ message: 'Camera with this name already exists' });
}
const camera = await request.server.prisma.camera.create({
data: {
name,
slug,
streamKey,
location,
roomId: roomId || null,
manufacturer,
model,
protocol: protocol || 'RTSP',
posX: posX ? parseInt(posX) : null,
posY: posY ? parseInt(posY) : null,
rotation: rotation ? parseInt(rotation) : 0,
fov: fov ? parseInt(fov) : 90,
status: CameraStatus.OFFLINE
},
include: {
room: {
select: {
id: true,
name: true,
code: true,
type: true
}
}
}
});
return reply.status(201).send(camera);
};
// PUT /api/cameras/:id - Update camera
export const updateCamera = async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as { id: string };
const {
name,
streamKey,
location,
roomId,
manufacturer,
model,
protocol,
isActive,
posX,
posY,
rotation,
fov
} = request.body as any;
const existing = await request.server.prisma.camera.findUnique({
where: { id }
});
if (!existing) {
return reply.status(404).send({ message: 'Camera not found' });
}
const data: any = {};
if (name !== undefined) {
data.name = name;
data.slug = slugify(name);
}
if (streamKey !== undefined) data.streamKey = streamKey;
if (location !== undefined) data.location = location;
if (roomId !== undefined) data.roomId = roomId || null;
if (manufacturer !== undefined) data.manufacturer = manufacturer;
if (model !== undefined) data.model = model;
if (protocol !== undefined) data.protocol = protocol;
if (isActive !== undefined) data.isActive = isActive;
if (posX !== undefined) data.posX = parseInt(posX);
if (posY !== undefined) data.posY = parseInt(posY);
if (rotation !== undefined) data.rotation = parseInt(rotation);
if (fov !== undefined) data.fov = parseInt(fov);
const camera = await request.server.prisma.camera.update({
where: { id },
data,
include: {
room: {
select: {
id: true,
name: true,
code: true,
type: true
}
}
}
});
return camera;
};
// DELETE /api/cameras/:id - Delete camera
export const deleteCamera = async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as { id: string };
const existing = await request.server.prisma.camera.findUnique({
where: { id }
});
if (!existing) {
return reply.status(404).send({ message: 'Camera not found' });
}
await request.server.prisma.camera.delete({
where: { id }
});
return reply.status(204).send();
};
// PATCH /api/cameras/:id/status - Update camera status (called by system/integrations)
export const updateCameraStatus = async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as { id: string };
const { status } = request.body as { status: CameraStatus };
if (!status || !Object.values(CameraStatus).includes(status)) {
return reply.status(400).send({
message: 'Invalid status. Must be one of: ONLINE, OFFLINE, IDLE, STREAMING, ERROR'
});
}
const camera = await request.server.prisma.camera.update({
where: { id },
data: {
status,
lastSeen: new Date()
}
});
return camera;
};

View file

@ -0,0 +1,30 @@
import { FastifyInstance } from 'fastify';
import {
getCameras,
getCameraById,
getCameraStream,
createCamera,
updateCamera,
deleteCamera,
updateCameraStatus
} from '../controllers/cameras.controller';
export async function cameraRoutes(server: FastifyInstance) {
// Auth required for all routes
server.addHook('onRequest', async (request) => {
try {
await request.jwtVerify();
} catch (err) {
throw err;
}
});
// CRUD Routes
server.get('/', getCameras);
server.get('/:id', getCameraById);
server.get('/:id/stream', getCameraStream);
server.post('/', createCamera);
server.put('/:id', updateCamera);
server.delete('/:id', deleteCamera);
server.patch('/:id/status', updateCameraStatus);
}

View file

@ -82,6 +82,10 @@ server.register(uploadRoutes, { prefix: '/api/upload' });
// Pulse sensor integration
server.register(pulseRoutes, { prefix: '/api/pulse' });
// Camera/Security monitoring
import { cameraRoutes } from './routes/cameras.routes';
server.register(cameraRoutes, { prefix: '/api/cameras' });
// WebSocket for real-time alerts
server.register(websocketPlugin);

View file

@ -73,6 +73,25 @@ services:
volumes:
- ./go2rtc.yaml:/config/go2rtc.yaml
# Arlo camera bridge - converts Arlo Cloud streams to RTSP
arlo-streamer:
image: kaffetorsk/arlo-streamer:latest
restart: unless-stopped
networks:
- internal
environment:
- ARLO_USER=${ARLO_USER}
- ARLO_PASS=${ARLO_PASS}
- IMAP_HOST=${ARLO_IMAP_HOST}
- IMAP_USER=${ARLO_IMAP_USER}
- IMAP_PASS=${ARLO_IMAP_PASS}
# Output to go2rtc's RTSP server - {name} is replaced by camera name
- FFMPEG_OUT=-c:v copy -c:a copy -f rtsp rtsp://go2rtc:8554/{name}
- MOTION_TIMEOUT=60
- PYAARLO_RECONNECT_EVERY=110
depends_on:
- go2rtc
frontend:
build:
context: ./frontend

101
frontend/android/.gitignore vendored Normal file
View file

@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

2
frontend/android/app/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

View file

@ -0,0 +1,54 @@
apply plugin: 'com.android.application'
android {
namespace = "run.runfoo.veridian"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "run.runfoo.veridian"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View file

@ -0,0 +1,22 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-camera')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-splash-screen')
implementation project(':capacitor-status-bar')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

21
frontend/android/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View file

@ -0,0 +1,5 @@
package run.runfoo.veridian;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View file

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Veridian</string>
<string name="title_activity_main">Veridian</string>
<string name="package_name">run.runfoo.veridian</string>
<string name="custom_url_scheme">run.runfoo.veridian</string>
</resources>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View file

@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

View file

@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View file

@ -0,0 +1,15 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-camera'
project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/camera/android')
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
include ':capacitor-splash-screen'
project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')

View file

@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
frontend/android/gradlew vendored Executable file
View file

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
frontend/android/gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'

View file

@ -0,0 +1,16 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}

View file

@ -0,0 +1,34 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'run.runfoo.veridian',
appName: 'Veridian',
webDir: 'dist',
server: {
// For development, you can use localhost
// For production APK, the app will use the built-in web assets
androidScheme: 'https',
},
plugins: {
SplashScreen: {
launchShowDuration: 2000,
backgroundColor: '#09090b',
showSpinner: false,
androidSplashResourceName: 'splash',
androidScaleType: 'CENTER_CROP',
},
StatusBar: {
backgroundColor: '#09090b',
style: 'DARK',
},
PushNotifications: {
presentationOptions: ['badge', 'sound', 'alert'],
},
},
android: {
allowMixedContent: false,
backgroundColor: '#09090b',
},
};
export default config;

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,13 @@
"test": "vitest"
},
"dependencies": {
"@capacitor/android": "^8.0.0",
"@capacitor/camera": "^8.0.0",
"@capacitor/cli": "^8.0.0",
"@capacitor/core": "^8.0.0",
"@capacitor/push-notifications": "^8.0.0",
"@capacitor/splash-screen": "^8.0.0",
"@capacitor/status-bar": "^8.0.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",

View file

@ -0,0 +1,246 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Camera as CameraIcon, Video, VideoOff, ChevronRight, Eye, Settings, AlertCircle } from 'lucide-react';
import { cn } from '../../lib/utils';
import { VideoPlayer } from './VideoPlayer';
import { useNavigate } from 'react-router-dom';
interface Camera {
id: string;
name: string;
slug: string;
streamKey: string;
location?: string;
status: 'ONLINE' | 'OFFLINE' | 'IDLE' | 'STREAMING' | 'ERROR';
manufacturer?: string;
room?: {
id: string;
name: string;
code: string;
type: string;
};
}
interface CameraWidgetProps {
cameras?: Camera[];
maxVisible?: number;
onCameraClick?: (camera: Camera) => void;
}
/**
* Dashboard widget showing camera grid with live previews
*/
export function CameraWidget({ cameras = [], maxVisible = 4, onCameraClick }: CameraWidgetProps) {
const navigate = useNavigate();
const [selectedCamera, setSelectedCamera] = useState<Camera | null>(null);
const [showModal, setShowModal] = useState(false);
const visibleCameras = cameras.slice(0, maxVisible);
const remainingCount = cameras.length - maxVisible;
const handleCameraClick = (camera: Camera) => {
if (onCameraClick) {
onCameraClick(camera);
} else {
setSelectedCamera(camera);
setShowModal(true);
}
};
const getStatusColor = (status: Camera['status']) => {
switch (status) {
case 'ONLINE':
case 'STREAMING':
return 'bg-emerald-500';
case 'IDLE':
return 'bg-amber-500';
case 'OFFLINE':
return 'bg-gray-400';
case 'ERROR':
return 'bg-red-500';
default:
return 'bg-gray-400';
}
};
const getStatusText = (status: Camera['status']) => {
switch (status) {
case 'STREAMING':
return 'Live';
case 'IDLE':
return 'Ready';
default:
return status.charAt(0) + status.slice(1).toLowerCase();
}
};
return (
<>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
"overflow-hidden rounded-2xl",
"bg-white dark:bg-zinc-900/80",
"border border-gray-200 dark:border-zinc-700/50",
"shadow-sm dark:shadow-xl"
)}
>
{/* Header */}
<div className="p-5 pb-4 flex items-center justify-between border-b border-gray-100 dark:border-zinc-800">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400">
<Video size={20} />
</div>
<div>
<h3 className="text-sm font-bold text-gray-900 dark:text-white tracking-wide">
Security Cameras
</h3>
<p className="text-xs text-gray-500 dark:text-zinc-500">
{cameras.filter(c => c.status !== 'OFFLINE').length} of {cameras.length} online
</p>
</div>
</div>
<button
onClick={() => navigate('/cameras')}
className="flex items-center gap-1 text-xs text-gray-400 hover:text-emerald-500 dark:text-zinc-500 dark:hover:text-emerald-400 transition-colors"
>
View All
<ChevronRight size={14} />
</button>
</div>
{/* Camera Grid */}
{cameras.length === 0 ? (
<div className="p-8 text-center">
<CameraIcon className="w-12 h-12 text-gray-300 dark:text-zinc-700 mx-auto mb-3" />
<p className="text-sm text-gray-500 dark:text-zinc-500">No cameras configured</p>
<button
onClick={() => navigate('/settings/cameras')}
className="mt-3 text-xs text-emerald-600 dark:text-emerald-400 hover:underline"
>
Add Camera
</button>
</div>
) : (
<div className="p-4 grid grid-cols-2 gap-3">
{visibleCameras.map((camera) => (
<motion.button
key={camera.id}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => handleCameraClick(camera)}
className={cn(
"relative aspect-video rounded-xl overflow-hidden cursor-pointer transition-all",
"bg-gray-100 dark:bg-zinc-800",
"border border-gray-200 dark:border-zinc-700",
"hover:border-emerald-500/50 hover:shadow-lg hover:shadow-emerald-500/10"
)}
>
{/* Placeholder/Thumbnail */}
<div className="absolute inset-0 flex items-center justify-center">
{camera.status === 'OFFLINE' || camera.status === 'ERROR' ? (
<VideoOff className="w-8 h-8 text-gray-400 dark:text-zinc-600" />
) : (
<CameraIcon className="w-8 h-8 text-gray-400 dark:text-zinc-600" />
)}
</div>
{/* Status Badge */}
<div className="absolute top-2 left-2 flex items-center gap-1.5 px-2 py-0.5 bg-black/60 rounded-full">
<div className={cn("w-1.5 h-1.5 rounded-full", getStatusColor(camera.status))} />
<span className="text-[10px] font-medium text-white">
{getStatusText(camera.status)}
</span>
</div>
{/* Camera Name */}
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/80 to-transparent">
<p className="text-xs font-medium text-white truncate">{camera.name}</p>
{camera.room && (
<p className="text-[10px] text-gray-300 truncate">{camera.room.name}</p>
)}
</div>
{/* View Overlay */}
<div className="absolute inset-0 bg-emerald-500/0 hover:bg-emerald-500/10 transition-colors flex items-center justify-center opacity-0 hover:opacity-100">
<div className="p-2 bg-white/20 rounded-full backdrop-blur-sm">
<Eye size={16} className="text-white" />
</div>
</div>
</motion.button>
))}
</div>
)}
{/* More cameras indicator */}
{remainingCount > 0 && (
<div className="px-4 pb-4">
<button
onClick={() => navigate('/cameras')}
className="w-full py-2 text-center text-xs text-gray-500 dark:text-zinc-500 hover:text-emerald-600 dark:hover:text-emerald-400 border border-dashed border-gray-200 dark:border-zinc-700 rounded-lg transition-colors"
>
+{remainingCount} more camera{remainingCount > 1 ? 's' : ''}
</button>
</div>
)}
</motion.div>
{/* Video Modal */}
{showModal && selectedCamera && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={() => setShowModal(false)}
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="relative w-full max-w-4xl mx-4"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-lg font-bold text-white">{selectedCamera.name}</h2>
{selectedCamera.location && (
<p className="text-sm text-gray-400">{selectedCamera.location}</p>
)}
</div>
<button
onClick={() => setShowModal(false)}
className="p-2 text-gray-400 hover:text-white transition-colors"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Video Player */}
<VideoPlayer
streamKey={selectedCamera.streamKey}
className="aspect-video"
showControls
autoPlay
/>
{/* Camera Info */}
<div className="mt-3 flex items-center justify-between text-sm text-gray-400">
<div className="flex items-center gap-4">
{selectedCamera.manufacturer && (
<span>{selectedCamera.manufacturer}</span>
)}
{selectedCamera.room && (
<span>{selectedCamera.room.name}</span>
)}
</div>
<span className="text-xs">Stream: {selectedCamera.streamKey}</span>
</div>
</motion.div>
</div>
)}
</>
);
}
export default CameraWidget;

View file

@ -0,0 +1,300 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { motion } from 'framer-motion';
import { Video, VideoOff, Volume2, VolumeX, Maximize2, RefreshCw, Wifi, WifiOff } from 'lucide-react';
import { cn } from '../../lib/utils';
interface VideoPlayerProps {
streamKey: string;
className?: string;
showControls?: boolean;
autoPlay?: boolean;
muted?: boolean;
onError?: (error: Error) => void;
onStatusChange?: (status: 'connecting' | 'connected' | 'disconnected' | 'error') => void;
}
/**
* WebRTC video player for go2rtc streams
* Connects to go2rtc's WebSocket API for low-latency video playback
*/
export function VideoPlayer({
streamKey,
className,
showControls = true,
autoPlay = true,
muted = true,
onError,
onStatusChange
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('disconnected');
const [isMuted, setIsMuted] = useState(muted);
const [isFullscreen, setIsFullscreen] = useState(false);
const [reconnectCount, setReconnectCount] = useState(0);
// go2rtc base URL - uses relative path since it's behind the same domain via Traefik
const go2rtcBase = '/monitor';
const updateStatus = useCallback((newStatus: typeof status) => {
setStatus(newStatus);
onStatusChange?.(newStatus);
}, [onStatusChange]);
const cleanup = useCallback(() => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
if (pcRef.current) {
pcRef.current.close();
pcRef.current = null;
}
}, []);
const connect = useCallback(async () => {
cleanup();
updateStatus('connecting');
try {
// Create RTCPeerConnection
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
});
pcRef.current = pc;
// Handle incoming tracks
pc.ontrack = (event) => {
if (videoRef.current && event.streams[0]) {
videoRef.current.srcObject = event.streams[0];
updateStatus('connected');
}
};
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
updateStatus('disconnected');
}
};
// Open WebSocket to go2rtc
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${go2rtcBase}/api/ws?src=${encodeURIComponent(streamKey)}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = async () => {
// Add transceivers for receiving video/audio
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
// Create and send offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({
type: 'webrtc/offer',
value: pc.localDescription?.sdp
}));
};
ws.onmessage = async (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'webrtc/answer') {
await pc.setRemoteDescription({
type: 'answer',
sdp: msg.value
});
} else if (msg.type === 'webrtc/candidate') {
if (msg.value) {
await pc.addIceCandidate({
candidate: msg.value,
sdpMid: '0'
});
}
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
updateStatus('error');
onError?.(new Error('WebSocket connection failed'));
};
ws.onclose = () => {
if (status !== 'error') {
updateStatus('disconnected');
}
};
// Send ICE candidates
pc.onicecandidate = (event) => {
if (event.candidate && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'webrtc/candidate',
value: event.candidate.candidate
}));
}
};
} catch (error) {
console.error('Failed to connect:', error);
updateStatus('error');
onError?.(error as Error);
}
}, [streamKey, cleanup, updateStatus, onError, status]);
// Initial connection
useEffect(() => {
if (autoPlay) {
connect();
}
return cleanup;
}, [streamKey, autoPlay, connect, cleanup]);
// Reconnect logic
const handleReconnect = useCallback(() => {
setReconnectCount(c => c + 1);
connect();
}, [connect]);
const toggleMute = useCallback(() => {
if (videoRef.current) {
videoRef.current.muted = !videoRef.current.muted;
setIsMuted(!isMuted);
}
}, [isMuted]);
const toggleFullscreen = useCallback(() => {
if (!videoRef.current) return;
if (!document.fullscreenElement) {
videoRef.current.requestFullscreen();
setIsFullscreen(true);
} else {
document.exitFullscreen();
setIsFullscreen(false);
}
}, []);
return (
<div className={cn(
"relative overflow-hidden rounded-xl bg-black",
className
)}>
{/* Video Element */}
<video
ref={videoRef}
autoPlay
playsInline
muted={isMuted}
className="w-full h-full object-contain"
/>
{/* Status Overlay */}
{status !== 'connected' && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
<div className="text-center">
{status === 'connecting' && (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
>
<RefreshCw className="w-8 h-8 text-emerald-400 mx-auto mb-2" />
</motion.div>
)}
{status === 'disconnected' && (
<WifiOff className="w-8 h-8 text-gray-400 mx-auto mb-2" />
)}
{status === 'error' && (
<VideoOff className="w-8 h-8 text-red-400 mx-auto mb-2" />
)}
<p className="text-sm text-gray-400 capitalize">{status}</p>
{(status === 'disconnected' || status === 'error') && (
<button
onClick={handleReconnect}
className="mt-3 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-sm rounded-lg transition-colors"
>
Reconnect
</button>
)}
</div>
</div>
)}
{/* Controls Overlay */}
{showControls && status === 'connected' && (
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent opacity-0 hover:opacity-100 transition-opacity">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-xs text-emerald-400">
<Wifi size={12} />
<span className="font-medium">LIVE</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={toggleMute}
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
>
{isMuted ? (
<VolumeX size={18} className="text-white" />
) : (
<Volume2 size={18} className="text-white" />
)}
</button>
<button
onClick={toggleFullscreen}
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
>
<Maximize2 size={18} className="text-white" />
</button>
</div>
</div>
</div>
)}
{/* Live indicator */}
{status === 'connected' && (
<div className="absolute top-3 left-3 flex items-center gap-1.5 px-2 py-1 bg-red-600 rounded text-xs text-white font-bold">
<div className="w-2 h-2 bg-white rounded-full animate-pulse" />
LIVE
</div>
)}
</div>
);
}
/**
* Fallback MSE player for browsers without WebRTC support
*/
export function VideoPlayerMSE({
streamKey,
className,
showControls = true
}: Omit<VideoPlayerProps, 'autoPlay' | 'muted' | 'onError' | 'onStatusChange'>) {
const go2rtcBase = '/monitor';
const mseUrl = `${go2rtcBase}/api/stream.mp4?src=${encodeURIComponent(streamKey)}`;
return (
<div className={cn(
"relative overflow-hidden rounded-xl bg-black",
className
)}>
<video
src={mseUrl}
autoPlay
playsInline
muted
controls={showControls}
className="w-full h-full object-contain"
/>
</div>
);
}
export default VideoPlayer;

View file

@ -0,0 +1,2 @@
export { VideoPlayer, VideoPlayerMSE } from './VideoPlayer';
export { CameraWidget } from './CameraWidget';

View file

@ -2,6 +2,12 @@ import { useState, useEffect, useCallback } from 'react';
import { Clock, RefreshCw } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
// Detect Capacitor environment for API URL
const isCapacitor = typeof window !== 'undefined' && !!(window as any).Capacitor?.isNativePlatform?.();
const API_BASE_URL = isCapacitor
? 'https://api.veridian.runfoo.run/api'
: (import.meta.env.VITE_API_URL || '/api');
const SESSION_TIMEOUT = 60 * 60 * 1000; // 1 hour
const WARNING_THRESHOLD = 5 * 60 * 1000; // 5 minutes before expiry
const CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
@ -28,7 +34,7 @@ export function SessionTimeoutWarning() {
try {
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
const response = await fetch(`${import.meta.env.VITE_API_URL || '/api'}/auth/refresh`, {
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })

View file

@ -1,5 +1,13 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
// Detect if running in Capacitor native environment
const isCapacitor = typeof window !== 'undefined' && !!(window as any).Capacitor?.isNativePlatform?.();
// Production API URL for native app, relative URL for web
const API_BASE_URL = isCapacitor
? 'https://api.veridian.runfoo.run/api'
: (import.meta.env.VITE_API_URL || '/api');
// Retry configuration
const MAX_RETRIES = 3;
const RETRY_DELAY_BASE = 1000; // 1 second
@ -23,7 +31,7 @@ const processQueue = (error: unknown = null) => {
};
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
@ -65,7 +73,7 @@ api.interceptors.response.use(
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
const { data } = await axios.post(
`${import.meta.env.VITE_API_URL || '/api'}/auth/refresh`,
`${API_BASE_URL}/auth/refresh`,
{ refreshToken }
);

View file

@ -8,6 +8,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: './', // Required for Capacitor - relative asset paths
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),

View file

@ -6,12 +6,22 @@ streams:
demo:
- ffmpeg:https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4#video=h264#audio=aac
# Arlo cameras - arlo-streamer pushes to these dynamically via RTSP
# Streams will appear automatically when arlo-streamer connects
# Example manual entries (replace with actual camera names from Arlo):
# arlo_grow_room:
# - rtsp://localhost:8554/grow_room
# arlo_entry:
# - rtsp://localhost:8554/entry
api:
listen: ":1984"
base_path: "/monitor"
rtsp:
listen: ":8554"
# Allow arlo-streamer to push streams without auth (internal network only)
default_query: "mp4"
srtp:
listen: ":8443"
@ -21,3 +31,8 @@ webrtc:
candidates:
- 777wolfpack.runfoo.run:8555
- stun:stun.l.google.com:19302
# Enable publishing of dynamic streams from arlo-streamer
publish:
# Streams published to rtsp://go2rtc:8554/{name} will auto-register
default: true