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

1354 lines
39 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 {
INVALID_DEVICE_TOKEN_ERRORS,
MAX_STREAM_DIAGNOSTIC_ENTRIES,
createControllerShared,
getHomePageKeyForRole,
getMotionDetectionProfile,
pageFromPath
} from './controller-shared';
import { createControllerClientModule } from './controller-client';
import { createControllerMediaModule } from './controller-media';
import { getAppState, patchAppState, resetAppState, setAppState } from './store';
const SOCKET_HEARTBEAT_INTERVAL_MS = 10_000;
const parseRtcUrls = (value = '') =>
value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
const buildIceServers = () => {
const stunUrls = parseRtcUrls(import.meta.env.VITE_STUN_URLS ?? 'stun:stun.l.google.com:19302');
const turnUrls = parseRtcUrls(import.meta.env.VITE_TURN_URLS ?? '');
const turnUsername = (import.meta.env.VITE_TURN_USERNAME ?? '').trim();
const turnCredential = (import.meta.env.VITE_TURN_CREDENTIAL ?? '').trim();
const iceServers = [];
if (stunUrls.length > 0) {
iceServers.push({ urls: stunUrls });
}
if (turnUrls.length > 0) {
iceServers.push({
urls: turnUrls,
username: turnUsername,
credential: turnCredential
});
}
return iceServers.length > 0 ? iceServers : [{ urls: 'stun:stun.l.google.com:19302' }];
};
const rtcConfig = {
iceServers: buildIceServers()
};
let initialized = false;
let initPromise = null;
let socket = null;
let pollInterval = null;
let socketHeartbeatInterval = null;
const handleBeforeUnload = () => {
void cleanupConnectionState();
};
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,
pushToast,
removeToast,
addActivity,
pushStreamDiagnostic,
loadMotionDetectionSettings,
updateMotionDetectionState,
navigateToScreen,
persistSavedDeviceRecord,
clearSavedDeviceRecord,
applySavedDeviceState,
clearDeviceState,
restoreSavedDeviceForSession
} = createControllerShared({
api,
getAppState,
setAppState,
patchAppState
});
const clientController = createControllerClientModule({
api,
getAppState,
setAppState,
patchAppState,
makeId
});
const mediaController = createControllerMediaModule({
api,
createMotionDetector,
getAppState,
setAppState,
patchAppState,
pushToast,
addActivity,
getMotionDetectionProfile,
updateMotionDetectionState
});
const {
getLinkedCamera,
getCameraLabel,
markMotionNotificationRead,
markAllNotificationsRead,
pushMotionNotification,
openRecordingModal,
closeRecordingModal,
pollClientData
} = clientController;
const {
refreshCameraInputDevices,
startCameraPreview,
applyMotionDetectionReadiness,
stopLocalRecording,
finalizeRecordingForStream,
startMotionEvent,
endMotionEvent,
handleCameraStreamRequest,
onMediaDeviceChange,
onVisibilityChange,
setCameraVideoElement: bindCameraVideoElement,
getLocalCameraStream,
getActiveRecordingStreamSessionId,
clearActiveRecordingStreamSession,
isRecordingActive,
cleanupMediaState
} = mediaController;
const setConnectedStreamSessionIds = () => {
setAppState({ connectedStreamSessionIds: Array.from(connectedPeers) });
};
const bindClientStreamSession = (cameraDeviceId, streamSessionId, reason = '') => {
if (!cameraDeviceId || !streamSessionId) {
return;
}
const currentState = getAppState();
const nextCameraSessions = {
...(currentState.cameraSessions || {}),
[cameraDeviceId]: streamSessionId
};
const nextState = {
cameraSessions: nextCameraSessions
};
if (currentState.activeCameraDeviceId === cameraDeviceId) {
nextState.activeStreamSessionId = streamSessionId;
}
setAppState(nextState);
if (reason) {
pushStreamDiagnostic(streamSessionId, 'session', reason, 'info', { cameraDeviceId });
}
if (currentState.activeCameraDeviceId === cameraDeviceId) {
if (remoteStreams.has(streamSessionId)) {
attachClientStreamToElement();
setClientStreamMode('video');
} else if (getAppState().clientStreamMode !== 'video') {
setClientStreamMode('connecting');
}
}
};
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 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;
pushStreamDiagnostic(activeStreamSessionId, 'viewer', 'Attached remote stream to client video element');
void clientVideoElement.play().catch((error) => {
pushStreamDiagnostic(
activeStreamSessionId,
'viewer',
`Autoplay blocked: ${error?.name || 'play_failed'}`,
'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;
}
pushStreamDiagnostic(streamSessionId, 'viewer', 'Primed remote stream in hidden video element');
void hiddenVideoElement.play().catch(() => {});
};
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;
}
if (activeStreamSessionId) {
pushStreamDiagnostic(activeStreamSessionId, 'viewer', 'Cleared active client stream viewer');
}
setClientStreamMode('none');
};
const endClientStreamSession = async (streamSessionId, { teardown = true } = {}) => {
if (!streamSessionId) return;
pushStreamDiagnostic(streamSessionId, 'session', 'Ending client stream session');
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 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;
}
pushStreamDiagnostic(streamSessionId, 'peer', 'Tearing down peer connection');
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);
if (queued.length > 0) {
pushStreamDiagnostic(streamSessionId, 'signal', `Applying ${queued.length} queued ICE candidate(s)`);
}
for (const candidate of queued) {
try {
await connection.addIceCandidate(new RTCIceCandidate(candidate.data));
} catch (error) {
pushStreamDiagnostic(streamSessionId, 'error', 'Dropped queued ICE candidate', '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);
pushStreamDiagnostic(
streamSessionId,
'peer',
asCamera ? 'Created camera-side RTCPeerConnection' : 'Created client-side RTCPeerConnection'
);
connection.onicecandidate = (event) => {
if (!socket || !event.candidate) return;
pushStreamDiagnostic(streamSessionId, 'signal', 'Sent ICE candidate');
socket.emit('webrtc:signal', {
toDeviceId: targetDeviceId,
streamSessionId,
signalType: 'candidate',
data: event.candidate.toJSON()
});
};
connection.onconnectionstatechange = () => {
pushStreamDiagnostic(
streamSessionId,
'peer',
`Connection state changed to ${connection.connectionState}`,
connection.connectionState === 'failed' ? 'error' : 'info'
);
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.oniceconnectionstatechange = () => {
pushStreamDiagnostic(
streamSessionId,
'peer',
`ICE connection state changed to ${connection.iceConnectionState}`,
connection.iceConnectionState === 'failed' ? 'error' : 'info'
);
};
connection.ontrack = (event) => {
if (streamTimers.has(streamSessionId)) {
clearTimeout(streamTimers.get(streamSessionId));
streamTimers.delete(streamSessionId);
}
const [stream] = event.streams;
if (!stream) return;
pushStreamDiagnostic(streamSessionId, 'media', 'Received remote media track');
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();
const localCameraStream = getLocalCameraStream();
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);
pushStreamDiagnostic(streamSessionId, 'signal', 'Created and sent WebRTC offer');
socket.emit('webrtc:signal', {
toDeviceId: requesterDeviceId,
streamSessionId,
signalType: 'offer',
data: offer
});
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
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();
clearActiveRecordingStreamSession();
teardownPeerConnection();
setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null });
patchAppState((state) => ({
streamDiagnostics: Object.fromEntries(
Object.entries(state.streamDiagnostics || {}).map(([streamSessionId, diagnostic]) => [
streamSessionId,
{
...diagnostic,
updatedAt: new Date().toISOString(),
entries: [
{
id: makeId(),
stage: 'realtime',
message: 'Realtime socket disconnected',
level: 'error',
createdAt: new Date().toISOString()
},
...(diagnostic.entries || [])
].slice(0, MAX_STREAM_DIAGNOSTIC_ENTRIES)
}
])
)
}));
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,
startOfferToClient
});
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 === 'client') {
bindClientStreamSession(
payload.cameraDeviceId,
payload.streamSessionId,
'Requester received stream session creation event'
);
return;
}
if (getAppState().device?.role !== 'camera') return;
try {
await handleCameraStreamRequest({
streamId: payload.streamSessionId,
requesterDeviceId: payload.requesterDeviceId,
startOfferToClient
});
} 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...');
pushStreamDiagnostic(payload.streamSessionId, 'session', 'Received stream:started from realtime gateway');
bindClientStreamSession(payload.cameraDeviceId, payload.streamSessionId);
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;
pushStreamDiagnostic(streamSessionId, 'session', 'Received stream:ended event');
teardownPeerConnection(streamSessionId);
if (streamSessionId === getAppState().activeStreamSessionId) {
setAppState({ activeStreamSessionId: null });
}
if (getAppState().device?.role === 'camera') {
const shouldFinalize =
getActiveRecordingStreamSessionId() === streamSessionId || isRecordingActive();
if (shouldFinalize) {
const captureResult = await stopLocalRecording();
await finalizeRecordingForStream(streamSessionId, captureResult);
}
clearActiveRecordingStreamSession(streamSessionId);
}
});
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;
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Received WebRTC offer');
addActivity('WebRTC', 'Offer received');
const connection = await ensurePeerConnection({
streamSessionId: payload.streamSessionId,
targetDeviceId: payload.fromDeviceId,
asCamera: false
});
await connection.setRemoteDescription(new RTCSessionDescription(payload.data));
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Applied remote offer');
await applyQueuedCandidates(connection, payload.streamSessionId, payload.fromDeviceId);
const answer = await connection.createAnswer();
await connection.setLocalDescription(answer);
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Created local answer');
socket.emit('webrtc:signal', {
toDeviceId: payload.fromDeviceId,
streamSessionId: payload.streamSessionId,
signalType: 'answer',
data: answer
});
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Sent WebRTC answer');
addActivity('WebRTC', 'Answer sent');
return;
}
if (payload.signalType === 'answer') {
const connection = peerConnections.get(payload.streamSessionId);
if (device.role !== 'camera' || !connection) return;
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Received WebRTC answer');
if (connection.signalingState !== 'have-local-offer') {
if (connection.signalingState === 'stable' && connection.remoteDescription?.type === 'answer') {
return;
}
return;
}
await connection.setRemoteDescription(new RTCSessionDescription(payload.data));
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Applied remote answer');
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) {
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Queued remote ICE candidate');
queueRemoteCandidate(payload);
return;
}
await connection.addIceCandidate(new RTCIceCandidate(payload.data));
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Applied remote ICE candidate');
return;
}
if (payload.signalType === 'hangup') {
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Received remote hangup');
teardownPeerConnection(payload.streamSessionId);
if (getAppState().activeStreamSessionId === payload.streamSessionId) {
setAppState({ activeStreamSessionId: null });
}
addActivity('Stream', 'Remote stream ended');
}
} catch (error) {
pushStreamDiagnostic(
payload.streamSessionId,
'error',
error?.message || 'Failed handling WebRTC signal',
'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';
if (payload?.streamSessionId) {
pushStreamDiagnostic(payload.streamSessionId, 'error', message, 'error');
}
addActivity('WebRTC', message);
pushToast(message, 'error');
});
};
const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
};
const startPolling = () => {
stopPolling();
void pollClientData();
pollInterval = setInterval(() => {
void pollClientData();
}, 5000);
};
const cleanupConnectionState = async () => {
stopPolling();
stopSocketHeartbeat();
await cleanupMediaState();
teardownPeerConnection();
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);
const initialPage =
page === 'app'
? state.deviceToken
? getHomePageKeyForRole(state.device?.role)
: 'onboarding'
: page;
setAppState({ page: initialPage });
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' || page === 'app') && 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', handleBeforeUnload);
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);
}
window.removeEventListener('beforeunload', handleBeforeUnload);
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 }));
},
setAuthMode(isRegistering) {
setAppState({ 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() {
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(getLocalCameraStream());
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);
const result = await api.streams.request(cameraDeviceId);
requestedStreams.delete(cameraDeviceId);
const streamSessionId = result?.streamSession?.id;
if (streamSessionId) {
bindClientStreamSession(
cameraDeviceId,
streamSessionId,
'Bound requested stream session from HTTP response'
);
pushStreamDiagnostic(streamSessionId, 'request', 'Backend accepted stream request');
}
} 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) {
pushStreamDiagnostic(reusableSessionId, 'viewer', 'Reusing existing stream session for selected camera');
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) {
bindCameraVideoElement(element);
},
setClientVideoElement(element) {
clientVideoElement = element;
attachClientStreamToElement();
}
};
export const appController = {
init,
destroy,
...actions
};