Extract web app client controller helpers

This commit is contained in:
2026-04-16 10:15:00 +01:00
parent 78d14cb73f
commit 9a8603e5cd
2 changed files with 200 additions and 124 deletions

View 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
};
};

View File

@@ -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()) {