Extract web app client controller helpers
This commit is contained in:
180
WebApp/src/lib/app/controller-client.js
Normal file
180
WebApp/src/lib/app/controller-client.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
export const createControllerClientModule = ({
|
||||||
|
api,
|
||||||
|
getAppState,
|
||||||
|
setAppState,
|
||||||
|
patchAppState,
|
||||||
|
makeId
|
||||||
|
}) => {
|
||||||
|
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 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getLinkedCamera,
|
||||||
|
getCameraLabel,
|
||||||
|
markMotionNotificationRead,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
pushMotionNotification,
|
||||||
|
openRecordingModal,
|
||||||
|
closeRecordingModal,
|
||||||
|
pollClientData
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getMotionDetectionProfile,
|
getMotionDetectionProfile,
|
||||||
pageFromPath
|
pageFromPath
|
||||||
} from './controller-shared';
|
} from './controller-shared';
|
||||||
|
import { createControllerClientModule } from './controller-client';
|
||||||
import { createControllerMediaModule } from './controller-media';
|
import { createControllerMediaModule } from './controller-media';
|
||||||
import { getAppState, patchAppState, resetAppState, setAppState } from './store';
|
import { getAppState, patchAppState, resetAppState, setAppState } from './store';
|
||||||
|
|
||||||
@@ -86,6 +87,14 @@ const {
|
|||||||
patchAppState
|
patchAppState
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const clientController = createControllerClientModule({
|
||||||
|
api,
|
||||||
|
getAppState,
|
||||||
|
setAppState,
|
||||||
|
patchAppState,
|
||||||
|
makeId
|
||||||
|
});
|
||||||
|
|
||||||
const mediaController = createControllerMediaModule({
|
const mediaController = createControllerMediaModule({
|
||||||
api,
|
api,
|
||||||
createMotionDetector,
|
createMotionDetector,
|
||||||
@@ -98,6 +107,17 @@ const mediaController = createControllerMediaModule({
|
|||||||
updateMotionDetectionState
|
updateMotionDetectionState
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
getLinkedCamera,
|
||||||
|
getCameraLabel,
|
||||||
|
markMotionNotificationRead,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
pushMotionNotification,
|
||||||
|
openRecordingModal,
|
||||||
|
closeRecordingModal,
|
||||||
|
pollClientData
|
||||||
|
} = clientController;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
refreshCameraInputDevices,
|
refreshCameraInputDevices,
|
||||||
startCameraPreview,
|
startCameraPreview,
|
||||||
@@ -275,130 +295,6 @@ const removeCameraSessionMapping = (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 teardownPeerConnection = (streamSessionId) => {
|
const teardownPeerConnection = (streamSessionId) => {
|
||||||
if (!streamSessionId) {
|
if (!streamSessionId) {
|
||||||
for (const connection of peerConnections.values()) {
|
for (const connection of peerConnections.values()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user