import { useFocusEffect } from '@react-navigation/native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useKeepAwake } from 'expo-keep-awake';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Image,
Modal,
Pressable,
SafeAreaView,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useApp } from '@/src/app-context';
function PromptModal({
visible,
title,
placeholder,
value,
onChange,
onCancel,
onConfirm,
}: {
visible: boolean;
title: string;
placeholder: string;
value: string;
onChange: (value: string) => void;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
{title}
Cancel
Save
);
}
function CameraDashboard() {
const { state, actions } = useApp();
const cameraViewRef = useRef(null);
const [permission, requestPermission] = useCameraPermissions();
// Prevent auto-lock while the camera dashboard is open in the foreground.
useKeepAwake('securecam-camera-dashboard');
useEffect(() => {
actions.setCameraPermissionGranted(Boolean(permission?.granted));
}, [actions, permission?.granted]);
useEffect(() => {
return () => {
actions.setCameraRef(null);
actions.setCameraPreviewReady(false);
};
}, [actions]);
return (
Camera Preview
{!permission?.granted ? (
Camera permission is required to publish live frames.
void requestPermission()}>
Grant Camera Permission
) : (
{
cameraViewRef.current = ref;
actions.setCameraRef(ref);
}}
style={styles.previewCamera}
facing="back"
onCameraReady={() => actions.setCameraPreviewReady(true)}
/>
)}
Camera Status
Realtime
{state.socketConnected ? 'ONLINE' : 'OFFLINE'}
Motion
{state.isMotionActive ? 'ACTIVE' : 'IDLE'}
Preview
{state.cameraPreviewReady ? 'READY' : 'NOT READY'}
Publishing
{state.cameraStatus === 'recording' ? 'ACTIVE' : 'IDLE'}
Actions
{!state.isMotionActive ? (
void actions.startMotion()}>
Simulate Motion Event
) : (
void actions.endMotion()}>
Stop Motion Event
)}
void actions.goOnline()}>
Reconnect Realtime
This mobile build remains on the legacy frame-relay path. Add a native WebRTC stack before enabling
SIMPLE_STREAMING by default.
Logs
{state.activityLog.length === 0 ? (
Awaiting events...
) : (
state.activityLog.map((item) => (
[{new Date(item.createdAt).toLocaleTimeString()}] {item.type}: {item.message}
))
)}
);
}
function ClientDashboard() {
const { state, actions } = useApp();
const [linkModalOpen, setLinkModalOpen] = useState(false);
const [cameraIdInput, setCameraIdInput] = useState('');
const [renameTargetId, setRenameTargetId] = useState(null);
const [renameInput, setRenameInput] = useState('');
const activeCameraLabel = useMemo(() => {
const linked = state.linkedCameras.find((camera) => camera.cameraDeviceId === state.activeCameraDeviceId);
return linked?.cameraName || linked?.cameraDeviceId || 'Live Feed Viewer';
}, [state.activeCameraDeviceId, state.linkedCameras]);
const isCameraLive = (cameraDeviceId: string) => {
const sessionId = state.cameraSessions?.[cameraDeviceId];
if (!sessionId) return false;
return state.connectedStreamSessionIds.includes(sessionId);
};
const openRenameModal = (cameraDeviceId: string, currentName?: string | null) => {
setRenameTargetId(cameraDeviceId);
setRenameInput(currentName || '');
};
const submitRename = async () => {
if (!renameTargetId) return;
await actions.renameLinkedCamera(renameTargetId, renameInput);
setRenameTargetId(null);
setRenameInput('');
};
return (
<>
Client Dashboard
setLinkModalOpen(true)}>
Link Camera
void actions.refreshClientData()}>
R
Your Cameras
{state.linkedCameras.length === 0 ? (
No cameras linked yet
) : (
state.linkedCameras.map((link) => {
const selected = state.activeCameraDeviceId === link.cameraDeviceId;
const live = isCameraLive(link.cameraDeviceId);
return (
{link.cameraName || link.cameraDeviceId}
{(link.cameraStatus || 'offline').toUpperCase()}
{live ? 'Live stream active' : 'Tap view to request stream'}
void actions.selectCamera(link.cameraDeviceId)}>
View
openRenameModal(link.cameraDeviceId, link.cameraName)}>
Rename
{
Alert.alert(
'Remove Camera',
`Remove \"${link.cameraName || link.cameraDeviceId}\" from linked cameras?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => {
void actions.deleteLinkedCamera(link.id);
},
},
],
);
}}>
Delete
);
})
)}
{state.activeCameraDeviceId ? (
Live Feed: {activeCameraLabel}
X
{state.clientStreamMode === 'image' && state.clientFallbackFrame ? (
) : (
{state.clientPlaceholderText}
)}
) : null}
Recent Recordings
{state.recordings.length === 0 ? (
No recordings found
) : (
state.recordings.slice(0, 5).map((recording) => (
{new Date(recording.createdAt).toLocaleString()}
{recording.durationSeconds != null ? `${recording.durationSeconds}s` : 'Duration pending'} -{' '}
{recording.status ?? 'unknown'}
void actions.openRecording(recording.id)}>
Open
))
)}
{
setLinkModalOpen(false);
setCameraIdInput('');
}}
onConfirm={() => {
void actions.linkCamera(cameraIdInput);
setLinkModalOpen(false);
setCameraIdInput('');
}}
/>
{
setRenameTargetId(null);
setRenameInput('');
}}
onConfirm={() => {
void submitRename();
}}
/>
>
);
}
export default function DashboardScreen() {
const { state, actions } = useApp();
useFocusEffect(
React.useCallback(() => {
actions.setPage(state.device?.role === 'camera' ? 'camera' : 'client');
return undefined;
}, [actions, state.device?.role]),
);
return {state.device?.role === 'camera' ? : };
}
const styles = StyleSheet.create({
safe: {
flex: 1,
backgroundColor: '#0a0a0c',
},
scrollContent: {
padding: 14,
gap: 12,
},
screenTitle: {
color: '#f9fafb',
fontSize: 22,
fontWeight: '700',
},
rowBetween: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 10,
},
rowGap: {
flexDirection: 'row',
gap: 8,
alignItems: 'center',
},
card: {
borderRadius: 16,
padding: 14,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
backgroundColor: '#111218',
gap: 10,
},
cardTitle: {
color: '#f9fafb',
fontSize: 15,
fontWeight: '600',
},
previewBox: {
height: 220,
borderRadius: 12,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
backgroundColor: '#08090d',
},
previewCamera: {
flex: 1,
},
permissionBox: {
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
backgroundColor: '#08090d',
padding: 12,
gap: 10,
},
permissionText: {
color: '#9ca3af',
fontSize: 12,
lineHeight: 18,
},
label: {
color: '#9ca3af',
fontSize: 12,
},
status: {
fontSize: 12,
fontWeight: '700',
},
online: {
color: '#34d399',
},
offline: {
color: '#f87171',
},
primaryButton: {
height: 44,
borderRadius: 10,
backgroundColor: '#2563eb',
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonText: {
color: '#f9fafb',
fontWeight: '700',
fontSize: 14,
},
secondaryButton: {
height: 44,
borderRadius: 10,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.12)',
alignItems: 'center',
justifyContent: 'center',
},
secondaryButtonText: {
color: '#d1d5db',
fontWeight: '600',
fontSize: 13,
},
outlineButton: {
height: 36,
borderRadius: 9,
paddingHorizontal: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.18)',
alignItems: 'center',
justifyContent: 'center',
},
outlineButtonText: {
color: '#d1d5db',
fontSize: 12,
fontWeight: '600',
},
iconButton: {
height: 36,
width: 36,
borderRadius: 9,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.18)',
alignItems: 'center',
justifyContent: 'center',
},
iconButtonText: {
color: '#d1d5db',
fontSize: 12,
fontWeight: '700',
},
infoText: {
color: '#9ca3af',
fontSize: 12,
lineHeight: 18,
},
emptyText: {
color: '#6b7280',
fontSize: 12,
},
logLine: {
color: '#9ca3af',
fontSize: 12,
lineHeight: 18,
},
cameraRow: {
gap: 10,
},
emptyCameraCard: {
borderRadius: 12,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: 'rgba(255,255,255,0.2)',
padding: 16,
minWidth: 240,
},
cameraCard: {
minWidth: 260,
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
padding: 12,
backgroundColor: '#0d0f15',
gap: 6,
},
cameraCardSelected: {
borderColor: '#3b82f6',
backgroundColor: '#111b2f',
},
cameraName: {
color: '#f3f4f6',
fontWeight: '600',
fontSize: 13,
},
cameraStatus: {
fontSize: 11,
fontWeight: '700',
},
cameraSubtext: {
color: '#9ca3af',
fontSize: 11,
marginBottom: 4,
},
cameraActionsRow: {
flexDirection: 'row',
gap: 6,
},
smallButton: {
borderRadius: 8,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.16)',
paddingHorizontal: 10,
height: 30,
alignItems: 'center',
justifyContent: 'center',
},
smallButtonText: {
color: '#d1d5db',
fontSize: 11,
fontWeight: '600',
},
smallDangerButton: {
borderColor: 'rgba(248,113,113,0.4)',
},
smallDangerText: {
color: '#fca5a5',
},
streamBox: {
height: 220,
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
backgroundColor: '#08090d',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
streamImage: {
width: '100%',
height: '100%',
},
streamPlaceholder: {
color: '#6b7280',
fontWeight: '600',
},
recordingRow: {
borderRadius: 10,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
backgroundColor: '#0d0f15',
padding: 10,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
recordingTitle: {
color: '#e5e7eb',
fontSize: 12,
fontWeight: '500',
},
recordingMeta: {
color: '#6b7280',
fontSize: 11,
marginTop: 2,
},
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.7)',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
modalCard: {
width: '100%',
maxWidth: 360,
borderRadius: 14,
padding: 14,
backgroundColor: '#111218',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
gap: 10,
},
modalTitle: {
color: '#f3f4f6',
fontSize: 16,
fontWeight: '700',
},
modalInput: {
height: 46,
borderRadius: 10,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.14)',
backgroundColor: '#0a0a0e',
color: '#f3f4f6',
paddingHorizontal: 10,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 8,
},
modalButton: {
height: 36,
borderRadius: 8,
minWidth: 76,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 10,
},
modalCancel: {
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.16)',
},
modalConfirm: {
backgroundColor: '#2563eb',
},
modalCancelText: {
color: '#d1d5db',
fontWeight: '600',
},
modalConfirmText: {
color: '#f9fafb',
fontWeight: '700',
},
});