Files
Final-Year-Project/WebApp/src/lib/app/controller.js

2387 lines
67 KiB
JavaScript

/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { io } from 'socket.io-client';
import { api, getBackendUrl } from './api';
import { createMotionDetector } from './motion-detector';
import { getAppState, patchAppState, resetAppState, setAppState } from './store';
const PAGE_PATHS = {
auth: '/',
onboarding: '/onboarding',
camera: '/camera',
client: '/client',
activity: '/activity',
settings: '/settings'
};
const DEVICE_STORAGE_KEY = 'mobileSimDevice';
const INVALID_DEVICE_TOKEN_ERRORS = new Set([
'Missing device token',
'Invalid device token',
'Device not found',
'Token role does not match device role'
]);
const MOTION_DETECTION_SETTINGS_STORAGE_KEY = 'securecam-motion-detection-settings';
const MOTION_DETECTION_PROFILES = {
low_power: {
profile: 'low_power',
label: 'Low Power',
description: 'Least heat and battery usage, slower trigger response.',
sampleIntervalMs: 1400,
burstIntervalMs: 500,
triggerThreshold: 0.15,
releaseThreshold: 0.05,
consecutiveTriggerFrames: 3,
cooldownMs: 12000,
minimumEventMs: 9000
},
balanced: {
profile: 'balanced',
label: 'Balanced',
description: 'Recommended default for a plugged-in foreground browser.',
sampleIntervalMs: 1000,
burstIntervalMs: 300,
triggerThreshold: 0.12,
releaseThreshold: 0.04,
consecutiveTriggerFrames: 3,
cooldownMs: 9000,
minimumEventMs: 8000
},
responsive: {
profile: 'responsive',
label: 'Responsive',
description: 'Faster trigger response with higher CPU and thermal cost.',
sampleIntervalMs: 700,
burstIntervalMs: 220,
triggerThreshold: 0.1,
releaseThreshold: 0.035,
consecutiveTriggerFrames: 2,
cooldownMs: 7000,
minimumEventMs: 7000
}
};
const RECORDING_VIDEO_BITS_PER_SECOND = 850_000;
const COMPRESSED_UPLOAD_MAX_WIDTH = 640;
const COMPRESSED_UPLOAD_MAX_HEIGHT = 360;
const COMPRESSED_UPLOAD_FRAME_RATE = 12;
const COMPRESSED_UPLOAD_BITS_PER_SECOND = 450_000;
const DEFAULT_CAMERA_CONSTRAINTS = {
width: { ideal: 640, max: 960 },
height: { ideal: 360, max: 540 },
frameRate: { ideal: 15, max: 24 }
};
const SOCKET_HEARTBEAT_INTERVAL_MS = 10_000;
const rtcConfig = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
};
let initialized = false;
let initPromise = null;
let socket = null;
let pollInterval = null;
let socketHeartbeatInterval = null;
let localCameraStream = null;
let activeMediaRecorder = null;
let activeRecordingChunks = [];
let activeRecordingStartedAt = null;
let activeRecordingStreamSessionId = null;
let lastMotionEventId = null;
let motionDetector = null;
let detectorMotionActive = false;
let autoMotionTransitionInFlight = false;
let cameraVideoElement = null;
let clientVideoElement = null;
const peerConnections = new Map();
const remoteStreams = new Map();
const hiddenClientStreamElements = new Map();
const pendingCandidatesMap = new Map();
const streamTimers = new Map();
const connectedPeers = new Set();
const requestedStreams = new Set();
const makeId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const getDefaultMotionDetectionState = () => ({
enabled: false,
profile: MOTION_DETECTION_PROFILES.balanced.profile,
state: 'idle',
score: 0,
debug: false,
lastTriggeredAt: null
});
const getMotionDetectionProfile = (profile) =>
MOTION_DETECTION_PROFILES[profile] ?? MOTION_DETECTION_PROFILES.balanced;
const buildMotionDetectionState = (overrides = {}) => {
const defaults = getDefaultMotionDetectionState();
const nextProfile = getMotionDetectionProfile(overrides.profile ?? defaults.profile);
return {
...defaults,
...nextProfile,
...overrides,
profile: nextProfile.profile
};
};
const sanitizeMotionDetectionSettings = (value) => {
if (!value || typeof value !== 'object') {
return buildMotionDetectionState();
}
return buildMotionDetectionState({
enabled: Boolean(value.enabled),
profile: typeof value.profile === 'string' ? value.profile : undefined,
debug: Boolean(value.debug)
});
};
const normalizeMotionDetectionState = (value) => {
if (!value || typeof value !== 'object') {
return buildMotionDetectionState();
}
return buildMotionDetectionState({
enabled: Boolean(value.enabled),
profile: typeof value.profile === 'string' ? value.profile : undefined,
state: typeof value.state === 'string' ? value.state : undefined,
score: typeof value.score === 'number' ? value.score : undefined,
debug: Boolean(value.debug),
lastTriggeredAt: typeof value.lastTriggeredAt === 'string' ? value.lastTriggeredAt : null
});
};
const loadMotionDetectionSettings = () => {
if (typeof localStorage === 'undefined') {
return buildMotionDetectionState();
}
try {
const saved = localStorage.getItem(MOTION_DETECTION_SETTINGS_STORAGE_KEY);
if (!saved) {
return buildMotionDetectionState();
}
return sanitizeMotionDetectionSettings(JSON.parse(saved));
} catch (error) {
console.error('Failed to load motion detection settings', error);
return buildMotionDetectionState();
}
};
const persistMotionDetectionSettings = (motionDetection) => {
if (typeof localStorage === 'undefined') {
return;
}
try {
localStorage.setItem(
MOTION_DETECTION_SETTINGS_STORAGE_KEY,
JSON.stringify({
enabled: Boolean(motionDetection?.enabled),
profile: motionDetection?.profile ?? MOTION_DETECTION_PROFILES.balanced.profile,
debug: Boolean(motionDetection?.debug)
})
);
} catch (error) {
console.error('Failed to save motion detection settings', error);
}
};
const updateMotionDetectionState = (updates) => {
const current = normalizeMotionDetectionState(getAppState().motionDetection);
const next =
typeof updates === 'function'
? normalizeMotionDetectionState({
...current,
...updates(current)
})
: normalizeMotionDetectionState({
...current,
...updates
});
setAppState({ motionDetection: next });
persistMotionDetectionSettings(next);
return next;
};
const normalizePath = (path) => path.replace(/\/+$/, '') || '/';
const getCurrentPath = () => normalizePath(window.location.pathname);
const getHomePageKeyForRole = (role) => (role === 'camera' ? 'camera' : 'client');
const getPathForScreen = (screen, role) => {
if (screen === 'home') {
return PAGE_PATHS[getHomePageKeyForRole(role)];
}
return PAGE_PATHS[screen] || null;
};
const pageFromPath = (path) => {
switch (normalizePath(path)) {
case '/onboarding':
return 'onboarding';
case '/camera':
return 'camera';
case '/client':
return 'client';
case '/activity':
return 'activity';
case '/settings':
return 'settings';
default:
return 'auth';
}
};
const navigateToScreen = (screen, options = {}) => {
const { replace = false, role = getAppState().device?.role } = options;
const targetPath = getPathForScreen(screen, role);
if (!targetPath) return false;
if (getCurrentPath() !== normalizePath(targetPath)) {
if (replace) {
window.location.replace(targetPath);
} else {
window.location.assign(targetPath);
}
return true;
}
setAppState({ page: pageFromPath(targetPath) });
return false;
};
const readSavedDeviceRecord = () => {
if (typeof localStorage === 'undefined') {
return null;
}
const saved = localStorage.getItem(DEVICE_STORAGE_KEY);
if (!saved) {
return null;
}
try {
return JSON.parse(saved);
} catch (error) {
console.error('Failed to parse saved device', error);
localStorage.removeItem(DEVICE_STORAGE_KEY);
return null;
}
};
const persistSavedDeviceRecord = ({ device, deviceToken, userId }) => {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(
DEVICE_STORAGE_KEY,
JSON.stringify({
device,
deviceToken,
userId
})
);
};
const clearSavedDeviceRecord = () => {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.removeItem(DEVICE_STORAGE_KEY);
};
const applySavedDeviceState = (device, deviceToken) => {
setAppState({
device,
deviceToken,
onboardingForm: {
...getAppState().onboardingForm,
name: device?.name ?? '',
role: device?.role ?? 'client',
pushToken: ''
}
});
};
const clearDeviceState = () => {
setAppState({
device: null,
deviceToken: null,
socketConnected: false,
isMotionActive: false,
activeMotionSource: null,
cameraStatus: 'idle',
cameraPreviewReady: false,
linkedCameras: [],
recordings: [],
activeCameraDeviceId: null,
activeStreamSessionId: null,
openLinkedCameraMenuId: null,
cameraSessions: {},
connectedStreamSessionIds: [],
clientStreamMode: 'none',
clientPlaceholderText: 'Select a camera to view',
onboardingForm: {
...getAppState().onboardingForm,
name: '',
role: 'client',
pushToken: ''
}
});
};
const restoreSavedDeviceForSession = async (session, options = {}) => {
const { showMissingToast = false, showInvalidToast = false } = options;
const saved = readSavedDeviceRecord();
if (!saved) {
if (showMissingToast) {
pushToast('No saved device found', 'info');
}
return false;
}
const sessionUserId = session?.user?.id;
const savedUserId = typeof saved.userId === 'string' ? saved.userId : null;
const savedDeviceId = saved?.device?.id;
const savedDeviceToken = typeof saved?.deviceToken === 'string' ? saved.deviceToken : '';
if (!sessionUserId || !savedDeviceId || !savedDeviceToken) {
clearSavedDeviceRecord();
clearDeviceState();
if (showInvalidToast) {
pushToast('Saved device is incomplete. Please register again.', 'error');
}
return false;
}
if (savedUserId && savedUserId !== sessionUserId) {
clearSavedDeviceRecord();
clearDeviceState();
if (showInvalidToast) {
pushToast('Saved device belongs to a different account.', 'info');
}
return false;
}
try {
const result = await api.devices.list();
const matchingDevice = result.devices?.find((device) => device.id === savedDeviceId);
if (!matchingDevice) {
clearSavedDeviceRecord();
clearDeviceState();
if (showInvalidToast) {
pushToast('Saved device was not found for this account.', 'info');
}
return false;
}
applySavedDeviceState(matchingDevice, savedDeviceToken);
persistSavedDeviceRecord({
device: matchingDevice,
deviceToken: savedDeviceToken,
userId: sessionUserId
});
return true;
} catch (error) {
console.error('Failed to restore saved device', error);
if (showInvalidToast) {
pushToast('Unable to restore saved device right now.', 'error');
}
return false;
}
};
const setConnectedStreamSessionIds = () => {
setAppState({ connectedStreamSessionIds: Array.from(connectedPeers) });
};
const pushToast = (message, type = 'info') => {
const id = makeId();
patchAppState((state) => ({
toasts: [...state.toasts, { id, message, type }].slice(-6)
}));
setTimeout(() => {
patchAppState((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id)
}));
}, 3200);
};
const removeToast = (id) => {
patchAppState((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id)
}));
};
const addActivity = (type, message) => {
const item = {
id: makeId(),
type,
message,
createdAt: new Date().toISOString()
};
patchAppState((state) => ({
activityLog: [item, ...state.activityLog].slice(0, 200)
}));
};
const setClientStreamMode = (mode) => {
let clientPlaceholderText = 'Select a camera to view';
if (mode === 'connecting') clientPlaceholderText = 'Connecting stream...';
if (mode === 'unavailable') clientPlaceholderText = 'Stream unavailable';
if (mode === 'none') clientPlaceholderText = 'Select a camera to view';
patchAppState(() => ({
clientStreamMode: mode,
clientPlaceholderText
}));
};
const getCameraDeviceIdFromStream = (stream) => {
if (!stream) return '';
const videoTrack = stream.getVideoTracks?.()[0];
if (!videoTrack) return '';
const settings = videoTrack.getSettings?.() || {};
return typeof settings.deviceId === 'string' ? settings.deviceId : '';
};
const buildCameraConstraints = (cameraDeviceId = '') => {
if (!cameraDeviceId) return { ...DEFAULT_CAMERA_CONSTRAINTS };
return {
...DEFAULT_CAMERA_CONSTRAINTS,
deviceId: { exact: cameraDeviceId }
};
};
const refreshCameraInputDevices = async () => {
if (!navigator.mediaDevices?.enumerateDevices) {
setAppState({ cameraInputDevices: [], selectedCameraInputId: '' });
return [];
}
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const cameraInputDevices = devices
.filter((device) => device.kind === 'videoinput' && device.deviceId)
.map((device, index) => ({
id: device.deviceId,
label: device.label || `Camera ${index + 1}`
}));
const selectedCameraInputId = getAppState().selectedCameraInputId;
const streamCameraInputId = getCameraDeviceIdFromStream(localCameraStream);
const candidateCameraInputId = selectedCameraInputId || streamCameraInputId || '';
const nextSelectedCameraInputId = cameraInputDevices.some((device) => device.id === candidateCameraInputId)
? candidateCameraInputId
: (cameraInputDevices[0]?.id ?? '');
setAppState({
cameraInputDevices,
selectedCameraInputId: nextSelectedCameraInputId
});
return cameraInputDevices;
} catch (error) {
console.error('Failed to enumerate cameras', error);
addActivity('Camera', 'Failed to enumerate camera inputs');
return [];
}
};
const onMediaDeviceChange = () => {
void refreshCameraInputDevices();
};
const attachCameraStreamToElement = () => {
if (!cameraVideoElement) return;
cameraVideoElement.srcObject = localCameraStream;
if (localCameraStream) {
void cameraVideoElement.play().catch(() => {});
}
applyMotionDetectionReadiness();
};
const attachClientStreamToElement = () => {
if (!clientVideoElement) return;
const { activeStreamSessionId } = getAppState();
if (!activeStreamSessionId) {
clientVideoElement.srcObject = null;
return;
}
const stream = remoteStreams.get(activeStreamSessionId);
if (!stream) return;
if (clientVideoElement.srcObject !== stream) {
clientVideoElement.srcObject = stream;
}
clientVideoElement.muted = true;
void clientVideoElement.play().catch((error) => {
addActivity('Stream', `Autoplay blocked for ${activeStreamSessionId}: ${error?.name || 'play_failed'}`);
});
};
const primeClientStreamPlayback = (streamSessionId, stream) => {
if (!streamSessionId || !stream) return;
let hiddenVideoElement = hiddenClientStreamElements.get(streamSessionId);
if (!hiddenVideoElement && typeof document !== 'undefined') {
hiddenVideoElement = document.createElement('video');
hiddenVideoElement.muted = true;
hiddenVideoElement.autoplay = true;
hiddenVideoElement.playsInline = true;
hiddenVideoElement.style.position = 'fixed';
hiddenVideoElement.style.width = '1px';
hiddenVideoElement.style.height = '1px';
hiddenVideoElement.style.opacity = '0';
hiddenVideoElement.style.pointerEvents = 'none';
hiddenVideoElement.style.left = '-9999px';
hiddenVideoElement.style.top = '-9999px';
document.body.appendChild(hiddenVideoElement);
hiddenClientStreamElements.set(streamSessionId, hiddenVideoElement);
}
if (!hiddenVideoElement) return;
if (hiddenVideoElement.srcObject !== stream) {
hiddenVideoElement.srcObject = stream;
}
void hiddenVideoElement.play().catch(() => {});
};
const startCameraPreview = async (cameraInputId = getAppState().selectedCameraInputId) => {
if (!navigator.mediaDevices?.getUserMedia) {
pushToast('Camera API is not available in this browser', 'error');
return false;
}
const requestedCameraInputId = typeof cameraInputId === 'string' ? cameraInputId.trim() : '';
const activeCameraInputId = getCameraDeviceIdFromStream(localCameraStream);
if (localCameraStream) {
if (!requestedCameraInputId || requestedCameraInputId === activeCameraInputId) {
attachCameraStreamToElement();
setAppState({ cameraPreviewReady: true });
void refreshCameraInputDevices();
return true;
}
localCameraStream.getTracks().forEach((track) => track.stop());
localCameraStream = null;
}
const constraintCandidates = [];
if (requestedCameraInputId) {
constraintCandidates.push(
{
video: buildCameraConstraints(requestedCameraInputId),
audio: false
},
{
video: {
deviceId: { exact: requestedCameraInputId }
},
audio: false
}
);
}
constraintCandidates.push(
{
video: buildCameraConstraints(),
audio: false
},
{
video: true,
audio: false
}
);
let lastError = null;
for (const constraints of constraintCandidates) {
try {
localCameraStream = await navigator.mediaDevices.getUserMedia(constraints);
break;
} catch (error) {
lastError = error;
}
}
if (!localCameraStream) {
pushToast('Camera permission denied or unavailable', 'error');
addActivity('Camera', 'Camera access failed');
setAppState({ cameraPreviewReady: false });
return false;
}
const nextSelectedCameraInputId = getCameraDeviceIdFromStream(localCameraStream) || requestedCameraInputId || '';
attachCameraStreamToElement();
setAppState({
cameraPreviewReady: true,
selectedCameraInputId: nextSelectedCameraInputId
});
await refreshCameraInputDevices();
if (requestedCameraInputId && nextSelectedCameraInputId && nextSelectedCameraInputId !== requestedCameraInputId) {
pushToast('Selected camera unavailable, using another camera', 'info');
}
if (lastError && !requestedCameraInputId) {
addActivity('Camera', `Applied fallback camera constraints (${lastError.name || 'unknown_error'})`);
}
addActivity('Camera', 'Camera access granted');
return true;
};
const stopCameraPreview = () => {
if (localCameraStream) {
localCameraStream.getTracks().forEach((track) => track.stop());
localCameraStream = null;
}
if (cameraVideoElement) {
cameraVideoElement.srcObject = null;
}
setAppState({ cameraPreviewReady: false });
applyMotionDetectionReadiness();
};
const updateMotionDetectionRuntime = (updates) => {
patchAppState((state) => ({
motionDetection: {
...state.motionDetection,
...updates
}
}));
};
const isAutoMotionEventActive = () => {
const state = getAppState();
return Boolean(state.isMotionActive && state.activeMotionSource === 'auto' && lastMotionEventId);
};
const getMotionDetectionPauseReason = () => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
return 'page hidden';
}
const state = getAppState();
if (state.device?.role !== 'camera') {
return 'device is not a camera';
}
if (!state.motionDetection?.enabled) {
return 'detector disarmed';
}
if (!state.socketConnected) {
return 'realtime connection offline';
}
if (!state.cameraPreviewReady) {
return 'camera preview unavailable';
}
if (!localCameraStream) {
return 'camera stream unavailable';
}
if (!cameraVideoElement) {
return 'preview element unavailable';
}
return 'idle';
};
const ensureMotionDetector = () => {
if (motionDetector) {
return motionDetector;
}
motionDetector = createMotionDetector({
getSourceElement: () => cameraVideoElement,
getConfig: () => getAppState().motionDetection,
onUpdate: ({ state, score, activeMotion, activeSince }) => {
const current = getAppState().motionDetection;
const nextState = state === 'cooldown' && !activeMotion ? 'monitoring' : state;
const motionBecameActive = activeMotion && !detectorMotionActive;
const motionBecameInactive = !activeMotion && detectorMotionActive;
detectorMotionActive = activeMotion;
updateMotionDetectionRuntime({
state: nextState,
score,
lastTriggeredAt: motionBecameActive
? activeSince
? new Date(activeSince).toISOString()
: new Date().toISOString()
: current.lastTriggeredAt
});
if (motionBecameActive || motionBecameInactive) {
void syncAutoMotionLifecycle({ activeMotion });
}
}
});
return motionDetector;
};
const shouldRunMotionDetector = () => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
return false;
}
const state = getAppState();
return Boolean(
state.device?.role === 'camera' &&
state.motionDetection?.enabled &&
state.socketConnected &&
state.cameraPreviewReady &&
localCameraStream &&
cameraVideoElement
);
};
const applyMotionDetectionReadiness = () => {
const detector = ensureMotionDetector();
if (shouldRunMotionDetector()) {
if (!detector.isRunning()) {
detector.start();
addActivity('Motion Detection', 'Detector monitoring started');
}
return;
}
const pauseReason = getMotionDetectionPauseReason();
detectorMotionActive = false;
if (isAutoMotionEventActive()) {
void syncAutoMotionLifecycle({ activeMotion: false });
}
if (detector.isRunning()) {
detector.stop('idle');
addActivity('Motion Detection', `Detector monitoring paused (${pauseReason})`);
return;
}
updateMotionDetectionRuntime({ state: 'idle', score: 0 });
};
const onVisibilityChange = () => {
applyMotionDetectionReadiness();
};
const clearClientStream = () => {
const { activeStreamSessionId } = getAppState();
if (activeStreamSessionId && streamTimers.has(activeStreamSessionId)) {
clearTimeout(streamTimers.get(activeStreamSessionId));
streamTimers.delete(activeStreamSessionId);
}
if (activeStreamSessionId && remoteStreams.has(activeStreamSessionId)) {
remoteStreams.get(activeStreamSessionId)?.getTracks().forEach((track) => track.stop());
remoteStreams.delete(activeStreamSessionId);
}
if (clientVideoElement) {
clientVideoElement.srcObject = null;
}
setClientStreamMode('none');
};
const endClientStreamSession = async (streamSessionId, { teardown = true } = {}) => {
if (!streamSessionId) return;
try {
await api.streams.end(streamSessionId);
} catch (error) {
const message = error?.message || '';
if (!/cannot be ended|not found/i.test(message)) {
console.warn('Failed ending client stream session', error);
}
}
if (teardown) {
teardownPeerConnection(streamSessionId);
}
};
const hasReusableClientStreamSession = (streamSessionId) =>
Boolean(streamSessionId && (remoteStreams.has(streamSessionId) || streamTimers.has(streamSessionId)));
const removeCameraSessionMapping = (streamSessionId) => {
if (!streamSessionId) return;
patchAppState((state) => ({
cameraSessions: Object.fromEntries(
Object.entries(state.cameraSessions || {}).filter(([, sessionId]) => sessionId !== streamSessionId)
)
}));
};
const getLinkedCamera = (cameraDeviceId) =>
getAppState().linkedCameras.find((camera) => camera.cameraDeviceId === cameraDeviceId);
const getCameraLabel = (cameraDeviceId, cameraName) => {
const explicitName = typeof cameraName === 'string' ? cameraName.trim() : '';
if (explicitName) return explicitName;
const linkedName = getLinkedCamera(cameraDeviceId)?.cameraName;
if (typeof linkedName === 'string' && linkedName.trim()) {
return linkedName.trim();
}
return `Camera ${cameraDeviceId?.substring(0, 6) ?? 'Unknown'}`;
};
const mapNotificationDeliveryToMotionNotification = (delivery) => {
const payload = delivery?.payload ?? {};
const cameraDeviceId = typeof payload.cameraDeviceId === 'string' ? payload.cameraDeviceId : '';
const deliveryType = typeof delivery?.type === 'string' ? delivery.type : '';
if (deliveryType !== 'motion_detected') {
return null;
}
return {
id: delivery.id,
eventId: typeof payload.eventId === 'string' ? payload.eventId : null,
cameraDeviceId,
message: `${getCameraLabel(cameraDeviceId)} has detected movement`,
createdAt: delivery.sentAt || delivery.createdAt || new Date().toISOString(),
isRead: delivery.status === 'read'
};
};
const syncMotionNotificationsFromDeliveries = (deliveries) => {
const motionNotifications = (deliveries || [])
.map(mapNotificationDeliveryToMotionNotification)
.filter(Boolean)
.sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime());
setAppState({ motionNotifications });
};
const refreshMotionNotifications = async () => {
const { device } = getAppState();
if (!device || device.role !== 'client') {
setAppState({ motionNotifications: [] });
return [];
}
const result = await api.ops.listNotifications().catch(() => ({ notifications: [] }));
syncMotionNotificationsFromDeliveries(result.notifications);
return result.notifications || [];
};
const pushMotionNotification = (cameraDeviceId) => {
if (!cameraDeviceId) return;
const notification = {
id: makeId(),
eventId: null,
cameraDeviceId,
message: `${getCameraLabel(cameraDeviceId)} has detected movement`,
createdAt: new Date().toISOString(),
isRead: false
};
patchAppState((state) => ({
motionNotifications: [notification, ...state.motionNotifications].slice(0, 50)
}));
void refreshMotionNotifications();
};
const markMotionNotificationRead = async (notificationId) => {
patchAppState((state) => ({
motionNotifications: state.motionNotifications.map((notification) =>
notification.id === notificationId ? { ...notification, isRead: true } : notification
)
}));
try {
await api.pushNotifications.markRead(notificationId);
} catch (error) {
console.error('Failed to persist notification read state', error);
}
};
const markAllNotificationsRead = async () => {
const unreadIds = getAppState().motionNotifications.filter((notification) => !notification.isRead).map((notification) => notification.id);
if (unreadIds.length === 0) {
return;
}
patchAppState((state) => ({
motionNotifications: state.motionNotifications.map((notification) =>
notification.isRead ? notification : { ...notification, isRead: true }
)
}));
await Promise.allSettled(unreadIds.map((notificationId) => api.pushNotifications.markRead(notificationId)));
};
const openRecordingModal = (downloadUrl, title) => {
setAppState({
recordingModal: {
open: true,
title: title || 'Recording Playback',
url: downloadUrl
}
});
};
const closeRecordingModal = () => {
setAppState({
recordingModal: {
open: false,
title: 'Recording Playback',
url: ''
}
});
};
const getPreferredRecordingMimeType = () => {
if (typeof MediaRecorder === 'undefined') return '';
const preferredTypes = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'];
return preferredTypes.find((type) => MediaRecorder.isTypeSupported(type)) ?? '';
};
const startLocalRecording = async () => {
if (!localCameraStream || typeof MediaRecorder === 'undefined') {
addActivity('Recording', 'MediaRecorder unavailable');
return false;
}
if (activeMediaRecorder?.state === 'recording') {
return true;
}
activeRecordingChunks = [];
activeRecordingStartedAt = Date.now();
try {
const mimeType = getPreferredRecordingMimeType();
const recorderOptions = {
videoBitsPerSecond: RECORDING_VIDEO_BITS_PER_SECOND
};
if (mimeType) {
recorderOptions.mimeType = mimeType;
}
activeMediaRecorder = new MediaRecorder(localCameraStream, recorderOptions);
} catch (error) {
console.error('Failed to create MediaRecorder', error);
addActivity('Recording', 'Failed to start recorder');
activeMediaRecorder = null;
return false;
}
activeMediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
activeRecordingChunks.push(event.data);
}
};
activeMediaRecorder.start(1000);
setAppState({ cameraStatus: 'recording' });
addActivity('Recording', 'Local recording started');
return true;
};
const stopLocalRecording = async () => {
if (!activeMediaRecorder || activeMediaRecorder.state === 'inactive') {
setAppState({ cameraStatus: 'idle' });
return null;
}
return await new Promise((resolve) => {
const recorder = activeMediaRecorder;
const startedAt = activeRecordingStartedAt ?? Date.now();
recorder.onstop = () => {
const mimeType = recorder.mimeType || 'video/webm';
const blob = activeRecordingChunks.length > 0 ? new Blob(activeRecordingChunks, { type: mimeType }) : null;
const durationSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
activeMediaRecorder = null;
activeRecordingChunks = [];
activeRecordingStartedAt = null;
setAppState({ cameraStatus: 'idle' });
resolve(blob ? { blob, durationSeconds } : null);
};
recorder.onerror = () => {
activeMediaRecorder = null;
activeRecordingChunks = [];
activeRecordingStartedAt = null;
setAppState({ cameraStatus: 'idle' });
resolve(null);
};
recorder.stop();
});
};
const toEvenDimension = (value) => {
const rounded = Math.max(2, Math.floor(value));
return rounded % 2 === 0 ? rounded : rounded - 1;
};
const compressRecordingBlob = async (sourceBlob) => {
if (!sourceBlob || sourceBlob.size === 0) return sourceBlob;
if (typeof document === 'undefined' || typeof MediaRecorder === 'undefined') return sourceBlob;
const mimeType = getPreferredRecordingMimeType();
if (!mimeType) return sourceBlob;
const sourceUrl = URL.createObjectURL(sourceBlob);
const videoEl = document.createElement('video');
videoEl.muted = true;
videoEl.playsInline = true;
videoEl.preload = 'auto';
let rafId = null;
let captureStream = null;
try {
await new Promise((resolve, reject) => {
videoEl.onloadedmetadata = resolve;
videoEl.onerror = () => reject(new Error('Failed loading recorded clip'));
videoEl.src = sourceUrl;
});
const sourceWidth = videoEl.videoWidth || COMPRESSED_UPLOAD_MAX_WIDTH;
const sourceHeight = videoEl.videoHeight || COMPRESSED_UPLOAD_MAX_HEIGHT;
const scale = Math.min(1, COMPRESSED_UPLOAD_MAX_WIDTH / sourceWidth, COMPRESSED_UPLOAD_MAX_HEIGHT / sourceHeight);
const width = toEvenDimension(sourceWidth * scale);
const height = toEvenDimension(sourceHeight * scale);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context || typeof canvas.captureStream !== 'function') {
return sourceBlob;
}
captureStream = canvas.captureStream(COMPRESSED_UPLOAD_FRAME_RATE);
const compressedChunks = [];
const recorder = new MediaRecorder(captureStream, {
mimeType,
videoBitsPerSecond: COMPRESSED_UPLOAD_BITS_PER_SECOND
});
const recorderStopped = new Promise((resolve, reject) => {
recorder.ondataavailable = (event) => {
if (event.data?.size > 0) {
compressedChunks.push(event.data);
}
};
recorder.onerror = (event) => {
const message = event?.error?.message || 'Compression recorder failed';
reject(new Error(message));
};
recorder.onstop = () => {
resolve(new Blob(compressedChunks, { type: recorder.mimeType || mimeType }));
};
});
const drawFrame = () => {
if (videoEl.paused || videoEl.ended) return;
context.drawImage(videoEl, 0, 0, width, height);
rafId = requestAnimationFrame(drawFrame);
};
recorder.start(300);
await videoEl.play();
drawFrame();
await new Promise((resolve, reject) => {
videoEl.onended = resolve;
videoEl.onerror = () => reject(new Error('Failed during compression playback'));
});
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
recorder.stop();
const compressedBlob = await recorderStopped;
if (!compressedBlob || compressedBlob.size === 0 || compressedBlob.size >= sourceBlob.size) {
return sourceBlob;
}
const reductionPct = Math.round(((sourceBlob.size - compressedBlob.size) / sourceBlob.size) * 100);
addActivity('Recording', `Compressed clip by ${reductionPct}% before upload`);
return compressedBlob;
} catch (error) {
console.warn('Recording compression failed, uploading original clip', error);
return sourceBlob;
} finally {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
if (captureStream) {
captureStream.getTracks().forEach((track) => track.stop());
}
videoEl.pause();
videoEl.removeAttribute('src');
videoEl.load();
URL.revokeObjectURL(sourceUrl);
}
};
const teardownPeerConnection = (streamSessionId) => {
if (!streamSessionId) {
for (const connection of peerConnections.values()) {
connection.close();
}
peerConnections.clear();
remoteStreams.clear();
for (const hiddenVideoElement of hiddenClientStreamElements.values()) {
hiddenVideoElement.pause();
hiddenVideoElement.srcObject = null;
hiddenVideoElement.remove();
}
hiddenClientStreamElements.clear();
pendingCandidatesMap.clear();
connectedPeers.clear();
setConnectedStreamSessionIds();
setAppState({ cameraSessions: {} });
clearClientStream();
return;
}
if (peerConnections.has(streamSessionId)) {
peerConnections.get(streamSessionId)?.close();
peerConnections.delete(streamSessionId);
}
remoteStreams.delete(streamSessionId);
if (hiddenClientStreamElements.has(streamSessionId)) {
const hiddenVideoElement = hiddenClientStreamElements.get(streamSessionId);
hiddenVideoElement?.pause();
if (hiddenVideoElement) {
hiddenVideoElement.srcObject = null;
hiddenVideoElement.remove();
}
hiddenClientStreamElements.delete(streamSessionId);
}
pendingCandidatesMap.delete(streamSessionId);
connectedPeers.delete(streamSessionId);
setConnectedStreamSessionIds();
removeCameraSessionMapping(streamSessionId);
if (getAppState().activeStreamSessionId === streamSessionId) {
clearClientStream();
}
};
const queueRemoteCandidate = ({ streamSessionId, fromDeviceId, data }) => {
if (!streamSessionId || !fromDeviceId || !data) return;
if (!pendingCandidatesMap.has(streamSessionId)) {
pendingCandidatesMap.set(streamSessionId, []);
}
const queue = pendingCandidatesMap.get(streamSessionId);
queue.push({ streamSessionId, fromDeviceId, data, createdAt: Date.now() });
const cutoff = Date.now() - 120000;
pendingCandidatesMap.set(
streamSessionId,
queue.filter((item) => item.createdAt >= cutoff).slice(-200)
);
};
const takeQueuedCandidates = (streamSessionId, fromDeviceId) => {
if (!pendingCandidatesMap.has(streamSessionId)) return [];
const queue = pendingCandidatesMap.get(streamSessionId);
const queued = queue.filter((item) => item.fromDeviceId === fromDeviceId);
pendingCandidatesMap.set(
streamSessionId,
queue.filter((item) => item.fromDeviceId !== fromDeviceId)
);
return queued;
};
const applyQueuedCandidates = async (connection, streamSessionId, fromDeviceId) => {
if (!connection?.remoteDescription) return;
const queued = takeQueuedCandidates(streamSessionId, fromDeviceId);
for (const candidate of queued) {
try {
await connection.addIceCandidate(new RTCIceCandidate(candidate.data));
} catch (error) {
console.warn('Dropping queued ICE candidate', error);
}
}
};
const ensurePeerConnection = async ({ streamSessionId, targetDeviceId, asCamera }) => {
if (peerConnections.has(streamSessionId)) {
return peerConnections.get(streamSessionId);
}
const connection = new RTCPeerConnection(rtcConfig);
peerConnections.set(streamSessionId, connection);
connection.onicecandidate = (event) => {
if (!socket || !event.candidate) return;
socket.emit('webrtc:signal', {
toDeviceId: targetDeviceId,
streamSessionId,
signalType: 'candidate',
data: event.candidate.toJSON()
});
};
connection.onconnectionstatechange = () => {
if (connection.connectionState === 'connected') {
addActivity('WebRTC', `Peer connected for ${streamSessionId}`);
connectedPeers.add(streamSessionId);
setConnectedStreamSessionIds();
} else if (
connection.connectionState === 'failed' ||
connection.connectionState === 'disconnected' ||
connection.connectionState === 'closed'
) {
addActivity('WebRTC', `Peer ${connection.connectionState} for ${streamSessionId}`);
connectedPeers.delete(streamSessionId);
setConnectedStreamSessionIds();
if (getAppState().device?.role === 'client' && getAppState().activeStreamSessionId === streamSessionId) {
if (connection.connectionState === 'failed' || connection.connectionState === 'closed') {
setClientStreamMode('unavailable');
}
}
}
};
connection.ontrack = (event) => {
if (streamTimers.has(streamSessionId)) {
clearTimeout(streamTimers.get(streamSessionId));
streamTimers.delete(streamSessionId);
}
const [stream] = event.streams;
if (!stream) return;
connectedPeers.add(streamSessionId);
setConnectedStreamSessionIds();
remoteStreams.set(streamSessionId, stream);
if (getAppState().activeStreamSessionId === streamSessionId) {
attachClientStreamToElement();
setClientStreamMode('video');
return;
}
primeClientStreamPlayback(streamSessionId, stream);
};
if (asCamera) {
const ready = await startCameraPreview();
if (!ready || !localCameraStream || localCameraStream.getVideoTracks().length === 0) {
throw new Error('Camera stream unavailable for WebRTC publish');
}
localCameraStream.getTracks().forEach((track) => connection.addTrack(track, localCameraStream));
}
return connection;
};
const startOfferToClient = async (streamSessionId, requesterDeviceId) => {
if (!socket) return;
const connection = await ensurePeerConnection({
streamSessionId,
targetDeviceId: requesterDeviceId,
asCamera: true
});
const offer = await connection.createOffer();
await connection.setLocalDescription(offer);
socket.emit('webrtc:signal', {
toDeviceId: requesterDeviceId,
streamSessionId,
signalType: 'offer',
data: offer
});
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const finalizeRecordingForStream = async (streamSessionId, captureResult) => {
const currentDevice = getAppState().device;
if (!currentDevice?.id) {
addActivity('Recording', 'No device identity for finalize');
return false;
}
for (let attempt = 0; attempt < 8; attempt += 1) {
const recs = await api.ops.listRecordings().catch(() => ({ recordings: [] }));
const recording = (recs.recordings || []).find(
(rec) => rec.streamSessionId === streamSessionId && rec.status === 'awaiting_upload'
);
if (recording?.id) {
try {
if (!captureResult?.blob || captureResult.blob.size === 0) {
throw new Error('No captured video blob to upload');
}
const compressedBlob = await compressRecordingBlob(captureResult.blob);
const uploadMeta = await api.request('/videos/upload-url', {
method: 'POST',
body: JSON.stringify({
fileName: `stream-${streamSessionId}.webm`,
deviceId: currentDevice.id,
prefix: 'recordings',
recordingId: recording.id
})
});
const uploadResponse = await fetch(uploadMeta.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': compressedBlob.type || 'video/webm' },
body: compressedBlob
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
await api.events.finalizeRecording(recording.id, {
objectKey: uploadMeta.objectKey,
bucket: uploadMeta.bucket,
durationSeconds: captureResult.durationSeconds,
sizeBytes: compressedBlob.size
});
addActivity('Recording', 'Recording uploaded and finalized');
return true;
} catch (error) {
console.error('Recording upload failed, falling back to simulated key', error);
const fallbackObjectKey = `sim/${streamSessionId}/${Date.now()}.webm`;
await api.events.finalizeRecording(recording.id, {
objectKey: fallbackObjectKey,
durationSeconds: captureResult?.durationSeconds ?? 15,
sizeBytes: captureResult?.blob?.size ?? 5000000
});
addActivity('Recording', 'Upload failed; finalized with simulator fallback');
return true;
}
}
await sleep(350);
}
addActivity('Recording', 'No recording row found to finalize');
return false;
};
const uploadStandaloneMotionRecording = async (captureResult) => {
const currentDevice = getAppState().device;
if (!currentDevice?.id) {
addActivity('Recording', 'Cannot upload motion clip without device identity');
return false;
}
if (!captureResult?.blob || captureResult.blob.size === 0) {
addActivity('Recording', 'No motion clip captured for upload');
return false;
}
try {
const compressedBlob = await compressRecordingBlob(captureResult.blob);
const uploadMeta = await api.request('/videos/upload-url', {
method: 'POST',
body: JSON.stringify({
fileName: `motion-${Date.now()}.webm`,
deviceId: currentDevice.id,
prefix: 'recordings',
eventId: lastMotionEventId
})
});
const uploadResponse = await fetch(uploadMeta.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': compressedBlob.type || 'video/webm' },
body: compressedBlob
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
await api.events.finalizeRecording(uploadMeta.video.id, {
objectKey: uploadMeta.objectKey,
bucket: uploadMeta.bucket,
durationSeconds: captureResult.durationSeconds,
sizeBytes: compressedBlob.size
});
addActivity('Recording', `Motion clip uploaded (${uploadMeta.objectKey})`);
return true;
} catch (error) {
console.error('Standalone motion upload failed', error);
addActivity('Recording', 'Standalone motion upload failed');
return false;
}
};
const getMotionStartPayload = (source = 'manual') =>
source === 'auto'
? { title: 'Automatic Motion', triggeredBy: 'auto_motion' }
: { title: 'Simulated Motion', triggeredBy: 'motion' };
const startMotionEvent = async ({ source = 'manual' } = {}) => {
if (getAppState().isMotionActive || lastMotionEventId) {
return false;
}
const response = await api.events.startMotion(getMotionStartPayload(source));
await startCameraPreview();
await startLocalRecording();
lastMotionEventId = response.event.id;
const startedAt = new Date().toISOString();
setAppState({
isMotionActive: true,
activeMotionSource: source
});
if (source === 'auto') {
updateMotionDetectionRuntime({ lastTriggeredAt: startedAt });
}
pushToast(source === 'auto' ? 'Automatic motion event started' : 'Motion Event Started', 'success');
addActivity(
'Motion',
source === 'auto' ? `Automatic motion event started (${response.event.id})` : `Started event ${response.event.id}`
);
return true;
};
const endMotionEvent = async ({ source = 'manual' } = {}) => {
if (!lastMotionEventId) {
return false;
}
const eventId = lastMotionEventId;
const streamSessionId = activeRecordingStreamSessionId;
if (streamSessionId) {
await api.streams.end(streamSessionId);
addActivity('Stream', `Ended stream ${streamSessionId}`);
} else if (activeMediaRecorder?.state === 'recording') {
const captureResult = await stopLocalRecording();
await uploadStandaloneMotionRecording(captureResult);
}
await api.events.endMotion(eventId);
lastMotionEventId = null;
setAppState({
isMotionActive: false,
activeMotionSource: null
});
pushToast(source === 'auto' ? 'Automatic motion event ended' : 'Motion Ended', 'success');
addActivity(
'Motion',
source === 'auto' ? `Automatic motion event ended (${eventId})` : `Ended event ${eventId}`
);
return true;
};
const syncAutoMotionLifecycle = async ({ activeMotion }) => {
if (autoMotionTransitionInFlight) {
return;
}
if (activeMotion) {
if (getAppState().isMotionActive || lastMotionEventId) {
return;
}
autoMotionTransitionInFlight = true;
try {
await startMotionEvent({ source: 'auto' });
} catch (error) {
console.error('Failed to auto-start motion event', error);
pushToast(error.message || 'Failed to start automatic motion event', 'error');
} finally {
autoMotionTransitionInFlight = false;
}
return;
}
if (!isAutoMotionEventActive()) {
return;
}
autoMotionTransitionInFlight = true;
try {
await endMotionEvent({ source: 'auto' });
} catch (error) {
console.error('Failed to auto-end motion event', error);
pushToast(error.message || 'Failed to end automatic motion event', 'error');
} finally {
autoMotionTransitionInFlight = false;
}
};
const handleCameraStreamRequest = async ({ streamId, requesterDeviceId }) => {
if (!streamId || !requesterDeviceId) {
throw new Error('Missing stream request context');
}
const ready = await startCameraPreview();
if (!ready) {
throw new Error('Camera permission is required before streaming');
}
activeRecordingStreamSessionId = streamId;
await api.streams.accept(streamId);
await startLocalRecording();
await startOfferToClient(streamId, requesterDeviceId);
addActivity('Stream', 'Accepted stream request and started WebRTC offer');
};
const stopSocketHeartbeat = () => {
if (socketHeartbeatInterval) {
clearInterval(socketHeartbeatInterval);
socketHeartbeatInterval = null;
}
};
const emitSocketHeartbeat = () => {
if (!socket?.connected) {
return;
}
socket.emit('heartbeat');
};
const startSocketHeartbeat = () => {
stopSocketHeartbeat();
emitSocketHeartbeat();
socketHeartbeatInterval = setInterval(() => {
emitSocketHeartbeat();
}, SOCKET_HEARTBEAT_INTERVAL_MS);
};
const ensureRealtimeConnection = async ({ timeoutMs = 4000 } = {}) => {
if (socket?.connected || getAppState().socketConnected) {
return true;
}
if (!getAppState().deviceToken) {
return false;
}
connectSocket();
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (socket?.connected || getAppState().socketConnected) {
return true;
}
await sleep(100);
}
return Boolean(socket?.connected || getAppState().socketConnected);
};
const connectSocket = () => {
const { deviceToken } = getAppState();
if (!deviceToken) return;
stopSocketHeartbeat();
if (socket) socket.disconnect();
socket = io(getBackendUrl(), {
auth: { token: deviceToken },
withCredentials: true
});
socket.on('connect', () => {
startSocketHeartbeat();
setAppState({ socketConnected: true });
addActivity('System', 'Connected to realtime server');
if (getAppState().device?.role === 'camera') {
void startCameraPreview();
}
applyMotionDetectionReadiness();
});
socket.on('disconnect', () => {
stopSocketHeartbeat();
setAppState({ socketConnected: false });
void stopLocalRecording();
teardownPeerConnection();
setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null });
applyMotionDetectionReadiness();
});
socket.on('connect_error', (error) => {
const message = error?.message || 'Realtime connection failed';
stopSocketHeartbeat();
setAppState({ socketConnected: false });
addActivity('System', `Realtime connection failed: ${message}`);
if (INVALID_DEVICE_TOKEN_ERRORS.has(message)) {
void invalidateSavedDevice('Saved device is invalid for this account. Please register this browser again.');
return;
}
pushToast(message, 'error');
applyMotionDetectionReadiness();
});
socket.on('command:received', async (payload) => {
addActivity('Command', `Received ${payload.commandType}`);
try {
if (payload.commandType === 'start_stream') {
await handleCameraStreamRequest({
streamId: payload.payload.streamSessionId,
requesterDeviceId: payload.sourceDeviceId
});
socket.emit('command:ack', { commandId: payload.commandId, status: 'acknowledged' });
}
} catch (error) {
socket.emit('command:ack', { commandId: payload.commandId, status: 'rejected', error: error.message });
}
});
socket.on('stream:requested', async (payload) => {
if (getAppState().device?.role !== 'camera') return;
try {
await handleCameraStreamRequest({
streamId: payload.streamSessionId,
requesterDeviceId: payload.requesterDeviceId
});
} catch (error) {
console.error('Failed handling direct stream request', error);
pushToast(error.message || 'Failed to accept stream request', 'error');
}
});
socket.on('motion:detected', (payload) => {
const cameraDeviceId = payload.cameraDeviceId || payload.deviceId;
addActivity('Motion', `${getCameraLabel(cameraDeviceId)} has detected movement`);
pushToast('Motion Detected!', 'info');
pushMotionNotification(cameraDeviceId);
if (cameraDeviceId) {
setAppState({ activeCameraDeviceId: cameraDeviceId });
void actions.requestStream(cameraDeviceId);
}
});
socket.on('stream:started', async (payload) => {
addActivity('Stream', 'Stream is live, connecting...');
const currentState = getAppState();
const cameraSessions = { ...currentState.cameraSessions, [payload.cameraDeviceId]: payload.streamSessionId };
setAppState({ cameraSessions });
if (payload.cameraDeviceId === currentState.activeCameraDeviceId) {
setAppState({ activeStreamSessionId: payload.streamSessionId });
if (remoteStreams.has(payload.streamSessionId)) {
attachClientStreamToElement();
setClientStreamMode('video');
} else {
setClientStreamMode('connecting');
}
}
streamTimers.set(
payload.streamSessionId,
setTimeout(() => {
if (!remoteStreams.has(payload.streamSessionId)) {
addActivity('Stream', `No remote video track received for ${payload.streamSessionId}`);
if (getAppState().activeStreamSessionId === payload.streamSessionId) {
setClientStreamMode('unavailable');
}
}
}, 6000)
);
});
socket.on('stream:ended', async (payload) => {
if (!payload?.streamSessionId) return;
const streamSessionId = payload.streamSessionId;
teardownPeerConnection(streamSessionId);
if (streamSessionId === getAppState().activeStreamSessionId) {
setAppState({ activeStreamSessionId: null });
}
if (getAppState().device?.role === 'camera') {
const shouldFinalize =
activeRecordingStreamSessionId === streamSessionId || activeMediaRecorder?.state === 'recording';
if (shouldFinalize) {
const captureResult = await stopLocalRecording();
await finalizeRecordingForStream(streamSessionId, captureResult);
}
if (activeRecordingStreamSessionId === streamSessionId) {
activeRecordingStreamSessionId = null;
}
}
});
socket.on('webrtc:signal', async (payload) => {
const device = getAppState().device;
if (!device || !payload?.streamSessionId || !payload?.signalType || !payload?.fromDeviceId) return;
try {
if (payload.signalType === 'offer') {
if (device.role !== 'client') return;
addActivity('WebRTC', 'Offer received');
const connection = await ensurePeerConnection({
streamSessionId: payload.streamSessionId,
targetDeviceId: payload.fromDeviceId,
asCamera: false
});
await connection.setRemoteDescription(new RTCSessionDescription(payload.data));
await applyQueuedCandidates(connection, payload.streamSessionId, payload.fromDeviceId);
const answer = await connection.createAnswer();
await connection.setLocalDescription(answer);
socket.emit('webrtc:signal', {
toDeviceId: payload.fromDeviceId,
streamSessionId: payload.streamSessionId,
signalType: 'answer',
data: answer
});
addActivity('WebRTC', 'Answer sent');
return;
}
if (payload.signalType === 'answer') {
const connection = peerConnections.get(payload.streamSessionId);
if (device.role !== 'camera' || !connection) return;
if (connection.signalingState !== 'have-local-offer') {
if (connection.signalingState === 'stable' && connection.remoteDescription?.type === 'answer') {
return;
}
return;
}
await connection.setRemoteDescription(new RTCSessionDescription(payload.data));
await applyQueuedCandidates(connection, payload.streamSessionId, payload.fromDeviceId);
addActivity('WebRTC', 'Answer received and applied');
return;
}
if (payload.signalType === 'candidate') {
if (!payload.data) return;
const connection = peerConnections.get(payload.streamSessionId);
if (!connection || !connection.remoteDescription) {
queueRemoteCandidate(payload);
return;
}
await connection.addIceCandidate(new RTCIceCandidate(payload.data));
return;
}
if (payload.signalType === 'hangup') {
teardownPeerConnection(payload.streamSessionId);
if (getAppState().activeStreamSessionId === payload.streamSessionId) {
setAppState({ activeStreamSessionId: null });
}
addActivity('Stream', 'Remote stream ended');
}
} catch (error) {
console.error('Failed handling WebRTC signal', error);
pushToast('WebRTC negotiation failed', 'error');
}
});
socket.on('error:webrtc_signal', (payload) => {
const message = payload?.message || 'WebRTC signaling error';
addActivity('WebRTC', message);
pushToast(message, 'error');
});
};
const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
};
const pollClientData = async () => {
const { device } = getAppState();
if (!device || device.role !== 'client') return;
const [recs, links, deviceList, notifications] = await Promise.all([
api.ops.listRecordings().catch(() => ({ recordings: [] })),
api.devices.listLinks().catch(() => ({ links: [] })),
api.devices.list().catch(() => ({ devices: [] })),
api.ops.listNotifications().catch(() => ({ notifications: [] }))
]);
const cameraById = new Map(
(deviceList.devices || [])
.filter((entry) => entry.role === 'camera')
.map((entry) => [entry.id, entry])
);
const linkedCameras = (links.links || []).map((link) => {
const camera = cameraById.get(link.cameraDeviceId);
return {
...link,
cameraName: camera?.name ?? null,
cameraStatus: camera?.status ?? 'offline'
};
});
setAppState({
recordings: recs.recordings || [],
linkedCameras
});
syncMotionNotificationsFromDeliveries(notifications.notifications);
};
const startPolling = () => {
stopPolling();
void pollClientData();
pollInterval = setInterval(() => {
void pollClientData();
}, 5000);
};
const cleanupConnectionState = async () => {
stopPolling();
stopSocketHeartbeat();
await stopLocalRecording();
teardownPeerConnection();
stopCameraPreview();
if (motionDetector?.isRunning()) {
motionDetector.stop('idle');
}
if (socket) {
socket.disconnect();
socket = null;
}
requestedStreams.clear();
};
const invalidateSavedDevice = async (message, options = {}) => {
const { showToast = true } = options;
clearSavedDeviceRecord();
await cleanupConnectionState();
clearDeviceState();
if (showToast) {
pushToast(message || 'Saved device is no longer valid. Please register this browser again.', 'error');
}
navigateToScreen('onboarding', { replace: true });
};
const enforceRouteForSession = () => {
const state = getAppState();
const page = pageFromPath(window.location.pathname);
setAppState({ page });
if (!state.session) {
if (page !== 'auth') {
navigateToScreen('auth', { replace: true });
}
return;
}
if (!state.deviceToken) {
if (page !== 'onboarding') {
navigateToScreen('onboarding', { replace: true });
}
return;
}
const expectedHome = getHomePageKeyForRole(state.device?.role);
if ((page === 'auth' || page === 'onboarding') && expectedHome) {
navigateToScreen('home', { replace: true, role: state.device?.role });
return;
}
if ((page === 'camera' || page === 'client') && page !== expectedHome) {
navigateToScreen('home', { replace: true, role: state.device?.role });
}
};
const init = async () => {
if (initialized) return;
if (initPromise) return initPromise;
initPromise = (async () => {
setAppState({ page: pageFromPath(window.location.pathname), loading: true });
if (navigator.mediaDevices?.addEventListener) {
navigator.mediaDevices.addEventListener('devicechange', onMediaDeviceChange);
}
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', onVisibilityChange);
}
setAppState({ motionDetection: loadMotionDetectionSettings() });
try {
const session = await api.auth.getSession();
if (session?.session) {
setAppState({ session });
const restoredSavedDevice = await restoreSavedDeviceForSession(session);
if (restoredSavedDevice) {
connectSocket();
startPolling();
}
} else {
setAppState({ session: null });
clearDeviceState();
}
} catch {
setAppState({ session: null });
clearDeviceState();
}
enforceRouteForSession();
void refreshCameraInputDevices();
applyMotionDetectionReadiness();
window.addEventListener('beforeunload', () => {
void cleanupConnectionState();
});
initialized = true;
})().finally(() => {
setAppState({ loading: false });
initPromise = null;
});
return initPromise;
};
const destroy = async () => {
if (navigator.mediaDevices?.removeEventListener) {
navigator.mediaDevices.removeEventListener('devicechange', onMediaDeviceChange);
}
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', onVisibilityChange);
}
initialized = false;
await cleanupConnectionState();
};
const actions = {
setPage(page) {
setAppState({ page });
if (page === 'activity') {
markAllNotificationsRead();
}
if (page === 'client') {
void pollClientData();
}
},
navigate(target) {
if (target === 'activity') {
markAllNotificationsRead();
}
navigateToScreen(target);
},
setAuthField(field, value) {
patchAppState((state) => ({
authForm: { ...state.authForm, [field]: value }
}));
},
toggleAuthMode() {
patchAppState((state) => ({ isRegistering: !state.isRegistering }));
},
async submitAuth() {
const state = getAppState();
const { email, password, name } = state.authForm;
const normalizedName = name || email.split('@')[0];
try {
if (state.isRegistering) {
await api.auth.signUp({ email, password, name: normalizedName });
}
await api.auth.signIn({ email, password });
const session = await api.auth.getSession();
setAppState({ session, authForm: { ...state.authForm, password: '' } });
pushToast(`Welcome, ${session.user.name}`, 'success');
const restoredSavedDevice = await restoreSavedDeviceForSession(session);
if (restoredSavedDevice) {
connectSocket();
startPolling();
navigateToScreen('home', { role: getAppState().device?.role });
} else {
navigateToScreen('onboarding');
}
} catch (error) {
pushToast(error.message || 'Authentication failed', 'error');
}
},
setOnboardingField(field, value) {
patchAppState((state) => ({
onboardingForm: { ...state.onboardingForm, [field]: value }
}));
},
selectRole(role) {
patchAppState((state) => ({
onboardingForm: { ...state.onboardingForm, role }
}));
},
async registerDevice() {
const { onboardingForm } = getAppState();
const name = onboardingForm.name || 'Web Dashboard';
const role = onboardingForm.role;
const pushToken = onboardingForm.pushToken;
try {
const payload = { name, role, platform: 'web', appVersion: 'webapp-1.0' };
if (pushToken?.trim()) {
payload.pushToken = pushToken.trim();
}
const result = await api.devices.register(payload);
applySavedDeviceState(result.device, result.deviceToken);
persistSavedDeviceRecord({
device: result.device,
deviceToken: result.deviceToken,
userId: getAppState().session?.user?.id ?? null
});
pushToast('Device Registered', 'success');
connectSocket();
startPolling();
navigateToScreen('home', { role: result.device.role });
} catch (error) {
pushToast(error.message || 'Device registration failed', 'error');
}
},
loadSavedDevice() {
const session = getAppState().session;
if (!session) {
pushToast('Please sign in before loading a saved device', 'error');
return;
}
void restoreSavedDeviceForSession(session, { showMissingToast: true, showInvalidToast: true }).then((restored) => {
if (restored) {
pushToast('Loaded saved device', 'success');
}
});
},
async signOut() {
try {
await api.auth.signOut();
} catch {
// ignore
}
await cleanupConnectionState();
clearSavedDeviceRecord();
const keep = { page: 'auth', toasts: [] };
resetAppState(keep);
pushToast('Signed Out', 'info');
navigateToScreen('auth', { replace: true });
},
async startMotion() {
try {
await startMotionEvent({ source: 'manual' });
} catch (error) {
pushToast(error.message || 'Failed to start motion', 'error');
}
},
async endMotion() {
if (!lastMotionEventId) return;
try {
await endMotionEvent({ source: 'manual' });
} catch (error) {
pushToast(error.message || 'Failed to end motion', 'error');
}
},
async goOnline() {
await startCameraPreview();
connectSocket();
},
async refreshCameraInputs(showToast = true) {
const inputs = await refreshCameraInputDevices();
if (!showToast) return;
if (inputs.length === 0) {
pushToast('No camera inputs detected', 'info');
return;
}
pushToast('Camera list refreshed', 'success');
},
async selectCameraInput(cameraInputId) {
const nextCameraInputId = typeof cameraInputId === 'string' ? cameraInputId.trim() : '';
if (!nextCameraInputId) return;
setAppState({ selectedCameraInputId: nextCameraInputId });
const isPreviewRunning = Boolean(localCameraStream);
if (!isPreviewRunning) return;
const ready = await startCameraPreview(nextCameraInputId);
if (!ready) {
pushToast('Failed to switch camera', 'error');
}
},
setMotionDetectionEnabled(enabled) {
const motionDetection = updateMotionDetectionState({ enabled: Boolean(enabled) });
pushToast(motionDetection.enabled ? 'Automatic detection armed' : 'Automatic detection paused', 'info');
addActivity('Motion Detection', motionDetection.enabled ? 'Detector armed' : 'Detector paused');
applyMotionDetectionReadiness();
},
setMotionDetectionProfile(profile) {
const nextProfile = getMotionDetectionProfile(profile);
const motionDetection = updateMotionDetectionState({ profile: nextProfile.profile });
pushToast(`${nextProfile.label} profile selected`, 'success');
addActivity('Motion Detection', `Profile set to ${motionDetection.profile}`);
applyMotionDetectionReadiness();
},
setMotionDetectionDebug(debug) {
const motionDetection = updateMotionDetectionState({ debug: Boolean(debug) });
pushToast(motionDetection.debug ? 'Motion debug enabled' : 'Motion debug hidden', 'info');
},
async linkCamera() {
const id = prompt('Enter Camera Device ID:');
if (!id) return;
try {
await api.devices.link(id, getAppState().device.id);
pushToast('Camera Linked', 'success');
await pollClientData();
} catch (error) {
pushToast(error.message || 'Failed to link camera', 'error');
}
},
toggleLinkedCameraMenu(linkId) {
patchAppState((state) => ({
openLinkedCameraMenuId: state.openLinkedCameraMenuId === linkId ? null : linkId
}));
},
closeLinkedCameraMenu() {
setAppState({ openLinkedCameraMenuId: null });
},
async renameLinkedCamera(cameraDeviceId) {
const linked = getLinkedCamera(cameraDeviceId);
if (!linked?.cameraDeviceId) return;
const currentName = linked.cameraName?.trim() || '';
const nextName = prompt('Enter a new camera name:', currentName || getCameraLabel(linked.cameraDeviceId));
if (nextName == null) return;
const trimmedName = nextName.trim();
if (!trimmedName) {
pushToast('Camera name cannot be empty', 'error');
return;
}
if (trimmedName === currentName) return;
try {
await api.devices.update(linked.cameraDeviceId, { name: trimmedName });
patchAppState((state) => ({
linkedCameras: state.linkedCameras.map((entry) =>
entry.cameraDeviceId === linked.cameraDeviceId ? { ...entry, cameraName: trimmedName } : entry
),
openLinkedCameraMenuId: null
}));
pushToast('Camera Renamed', 'success');
} catch (error) {
pushToast(error.message || 'Failed to rename camera', 'error');
}
},
async deleteLinkedCamera(linkId) {
const link = getAppState().linkedCameras.find((entry) => entry.id === linkId);
if (!link) return;
const cameraLabel = getCameraLabel(link.cameraDeviceId, link.cameraName);
const confirmed = window.confirm(`Remove "${cameraLabel}" from linked cameras?`);
if (!confirmed) return;
try {
await api.devices.unlink(linkId);
const remaining = getAppState().linkedCameras.filter((entry) => entry.id !== linkId);
const isDeletedCameraActive = getAppState().activeCameraDeviceId === link.cameraDeviceId;
if (isDeletedCameraActive) {
clearClientStream();
}
requestedStreams.delete(link.cameraDeviceId);
setAppState({
linkedCameras: remaining,
activeCameraDeviceId: isDeletedCameraActive ? null : getAppState().activeCameraDeviceId,
activeStreamSessionId: isDeletedCameraActive ? null : getAppState().activeStreamSessionId,
openLinkedCameraMenuId: null
});
pushToast('Camera Link Removed', 'success');
} catch (error) {
pushToast(error.message || 'Failed to remove camera link', 'error');
}
},
async requestStream(cameraDeviceId) {
const realtimeReady = await ensureRealtimeConnection();
if (!realtimeReady) {
pushToast('Realtime connection unavailable. Reconnect and try again.', 'error');
return;
}
try {
requestedStreams.add(cameraDeviceId);
await api.streams.request(cameraDeviceId);
} catch (error) {
requestedStreams.delete(cameraDeviceId);
pushToast(error.message || 'Failed to request stream', 'error');
}
},
async selectCamera(cameraDeviceId) {
const currentState = getAppState();
const sessions = currentState.cameraSessions || {};
const existingSessionId = sessions[cameraDeviceId] || null;
const reusableSessionId = hasReusableClientStreamSession(existingSessionId) ? existingSessionId : null;
const previousActiveStreamSessionId = currentState.activeStreamSessionId;
const isSwitchingStreams =
Boolean(previousActiveStreamSessionId) && previousActiveStreamSessionId !== reusableSessionId;
if (currentState.activeCameraDeviceId !== cameraDeviceId || currentState.activeStreamSessionId !== reusableSessionId) {
clearClientStream();
}
setAppState({
activeCameraDeviceId: cameraDeviceId,
activeStreamSessionId: reusableSessionId,
openLinkedCameraMenuId: null
});
if (isSwitchingStreams) {
void endClientStreamSession(previousActiveStreamSessionId, { teardown: false });
}
if (reusableSessionId) {
attachClientStreamToElement();
setClientStreamMode(remoteStreams.has(reusableSessionId) ? 'video' : 'connecting');
return;
}
setClientStreamMode('connecting');
await actions.requestStream(cameraDeviceId);
},
closeStreamViewer() {
const streamSessionId = getAppState().activeStreamSessionId;
setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null });
clearClientStream();
void endClientStreamSession(streamSessionId, { teardown: false });
},
async openRecording(recordingId) {
try {
const result = await api.ops.getRecordingDownloadUrl(recordingId);
if (!result?.downloadUrl) {
pushToast('Recording URL unavailable', 'error');
return;
}
const recording = getAppState().recordings.find((entry) => entry.id === recordingId);
const title = recording
? `${new Date(recording.createdAt).toLocaleString()} recording`
: 'Recording Playback';
openRecordingModal(result.downloadUrl, title);
} catch (error) {
pushToast(error.message || 'Failed to load recording', 'error');
}
},
closeRecordingModal() {
closeRecordingModal();
},
async openMotionNotificationTarget(notificationId, cameraDeviceId) {
await markMotionNotificationRead(notificationId);
if (!cameraDeviceId) return;
const recs = await api.ops.listRecordings().catch(() => ({ recordings: [] }));
const readyRecording = (recs.recordings || [])
.filter((recording) => recording.cameraDeviceId === cameraDeviceId && recording.status === 'ready')
.sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime())[0];
if (readyRecording?.id) {
await actions.openRecording(readyRecording.id);
return;
}
navigateToScreen('home', { role: getAppState().device?.role });
await actions.requestStream(cameraDeviceId);
},
markAllNotificationsRead() {
void markAllNotificationsRead();
},
clearNotifications() {
patchAppState((state) => ({
motionNotifications: state.motionNotifications.filter((notification) => !notification.isRead)
}));
},
refreshClientData() {
void pollClientData();
},
runDiagnostics() {
pushToast('Diagnostics complete: realtime connected', 'success');
},
removeToast,
setCameraVideoElement(element) {
cameraVideoElement = element;
attachCameraStreamToElement();
void refreshCameraInputDevices();
},
setClientVideoElement(element) {
clientVideoElement = element;
attachClientStreamToElement();
}
};
export const appController = {
init,
destroy,
...actions
};