Refactor web app controller shared and media modules
This commit is contained in:
472
WebApp/src/lib/app/controller-shared.js
Normal file
472
WebApp/src/lib/app/controller-shared.js
Normal file
@@ -0,0 +1,472 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
// @ts-nocheck
|
||||
|
||||
const PAGE_PATHS = {
|
||||
auth: '/',
|
||||
onboarding: '/onboarding',
|
||||
camera: '/camera',
|
||||
client: '/client',
|
||||
activity: '/activity',
|
||||
settings: '/settings'
|
||||
};
|
||||
|
||||
const DEVICE_STORAGE_KEY = 'mobileSimDevice';
|
||||
const MOTION_DETECTION_SETTINGS_STORAGE_KEY = 'securecam-motion-detection-settings';
|
||||
|
||||
export const INVALID_DEVICE_TOKEN_ERRORS = new Set([
|
||||
'Missing device token',
|
||||
'Invalid device token',
|
||||
'Device not found',
|
||||
'Token role does not match device role'
|
||||
]);
|
||||
|
||||
export 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: 6000
|
||||
},
|
||||
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: 6000
|
||||
},
|
||||
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: 6000
|
||||
}
|
||||
};
|
||||
|
||||
export const MAX_STREAM_DIAGNOSTIC_SESSIONS = 12;
|
||||
export const MAX_STREAM_DIAGNOSTIC_ENTRIES = 24;
|
||||
|
||||
const normalizePath = (path) => path.replace(/\/+$/, '') || '/';
|
||||
|
||||
export 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';
|
||||
}
|
||||
};
|
||||
|
||||
export const getHomePageKeyForRole = (role) => (role === 'camera' ? 'camera' : 'client');
|
||||
|
||||
export const getMotionDetectionProfile = (profile) =>
|
||||
MOTION_DETECTION_PROFILES[profile] ?? MOTION_DETECTION_PROFILES.balanced;
|
||||
|
||||
const getDefaultMotionDetectionState = () => ({
|
||||
enabled: false,
|
||||
profile: MOTION_DETECTION_PROFILES.balanced.profile,
|
||||
state: 'idle',
|
||||
score: 0,
|
||||
debug: false,
|
||||
lastTriggeredAt: null
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
export const createControllerShared = ({ api, getAppState, setAppState, patchAppState }) => {
|
||||
const makeId = () => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
};
|
||||
|
||||
const getCurrentPath = () => normalizePath(window.location.pathname);
|
||||
|
||||
const getPathForScreen = (screen, role) => {
|
||||
if (screen === 'home') {
|
||||
return PAGE_PATHS[getHomePageKeyForRole(role)];
|
||||
}
|
||||
return PAGE_PATHS[screen] || null;
|
||||
};
|
||||
|
||||
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 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 pruneStreamDiagnostics = (diagnostics) => {
|
||||
const entries = Object.entries(diagnostics || {});
|
||||
if (entries.length <= MAX_STREAM_DIAGNOSTIC_SESSIONS) {
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
entries
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right[1]?.updatedAt || 0).getTime() - new Date(left[1]?.updatedAt || 0).getTime()
|
||||
)
|
||||
.slice(0, MAX_STREAM_DIAGNOSTIC_SESSIONS)
|
||||
);
|
||||
};
|
||||
|
||||
const pushStreamDiagnostic = (streamSessionId, stage, message, level = 'info', meta = {}) => {
|
||||
if (!streamSessionId || !stage || !message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const createdAt = new Date().toISOString();
|
||||
patchAppState((state) => {
|
||||
const current = state.streamDiagnostics?.[streamSessionId] ?? {
|
||||
streamSessionId,
|
||||
cameraDeviceId: null,
|
||||
updatedAt: createdAt,
|
||||
entries: []
|
||||
};
|
||||
const next = {
|
||||
...current,
|
||||
...meta,
|
||||
streamSessionId,
|
||||
updatedAt: createdAt,
|
||||
entries: [
|
||||
{
|
||||
id: makeId(),
|
||||
stage,
|
||||
message,
|
||||
level,
|
||||
createdAt
|
||||
},
|
||||
...(current.entries || [])
|
||||
].slice(0, MAX_STREAM_DIAGNOSTIC_ENTRIES)
|
||||
};
|
||||
|
||||
return {
|
||||
streamDiagnostics: pruneStreamDiagnostics({
|
||||
...(state.streamDiagnostics || {}),
|
||||
[streamSessionId]: next
|
||||
})
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
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 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;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
makeId,
|
||||
pushToast,
|
||||
removeToast,
|
||||
addActivity,
|
||||
pushStreamDiagnostic,
|
||||
loadMotionDetectionSettings,
|
||||
updateMotionDetectionState,
|
||||
navigateToScreen,
|
||||
persistSavedDeviceRecord,
|
||||
clearSavedDeviceRecord,
|
||||
applySavedDeviceState,
|
||||
clearDeviceState,
|
||||
restoreSavedDeviceForSession
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user