1354 lines
39 KiB
JavaScript
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
|
|
};
|