diff --git a/Backend/public/mobile-sim.js b/Backend/public/mobile-sim.js
index d8311e5..522ecc9 100644
--- a/Backend/public/mobile-sim.js
+++ b/Backend/public/mobile-sim.js
@@ -42,6 +42,7 @@ const store = new Store({
motionNotifications: [],
activeCameraDeviceId: null,
activeStreamSessionId: null,
+ openLinkedCameraMenuId: null,
activityFeed: [],
loading: false, // global loading spinner state if needed
});
@@ -56,6 +57,13 @@ const $ = (selector) => {
return document.querySelector(selector);
};
const $$ = (selector) => document.querySelectorAll(selector);
+const escapeHtml = (value = '') =>
+ String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
const Toast = {
show(message, type = 'info') {
@@ -119,8 +127,11 @@ const API = {
devices: {
register: (data) => API.request('/devices/register', { method: 'POST', body: JSON.stringify(data) }),
+ list: () => API.request('/devices'),
+ update: (deviceId, data) => API.request(`/devices/${deviceId}`, { method: 'PATCH', body: JSON.stringify(data) }),
listLinks: () => API.request('/device-links'),
link: (cameraDeviceId, clientDeviceId) => API.request('/device-links', { method: 'POST', body: JSON.stringify({ cameraDeviceId, clientDeviceId }) }),
+ unlink: (linkId) => API.request(`/device-links/${linkId}`, { method: 'DELETE' }),
},
streams: {
@@ -149,23 +160,36 @@ const API = {
let socket = null;
let pollInterval = null;
let localCameraStream = null;
-let remoteClientStream = null;
-let peerConnection = null;
+let activeMediaRecorder = null;
+let activeRecordingChunks = [];
+let activeRecordingStartedAt = null;
+let activeRecordingStreamSessionId = null;
+let recordingModalUrl = null;
+const RECORDING_VIDEO_BITS_PER_SECOND = 850_000;
+const COMPRESSED_UPLOAD_MAX_WIDTH = 640;
+const COMPRESSED_UPLOAD_MAX_HEIGHT = 360;
+const COMPRESSED_UPLOAD_FRAME_RATE = 12;
+const COMPRESSED_UPLOAD_BITS_PER_SECOND = 450_000;
+
+// Multi-stream state (for Client)
+const peerConnections = new Map(); // streamSessionId -> RTCPeerConnection
+const remoteStreams = new Map(); // streamSessionId -> MediaStream
+const pendingCandidatesMap = new Map(); // streamSessionId -> Array
+const streamTimers = new Map(); // streamSessionId -> frameRelay/wait timers
+const connectedPeers = new Set(); // streamSessionId
+
+// Legacy fallback for camera single stream
let peerSessionId = null;
let peerTargetDeviceId = null;
-let remoteStreamWaitTimer = null;
+let hasWebrtcEverConnected = false;
+let webrtcConnected = false;
let frameRelayTimer = null;
let frameRelayStartTimer = null;
let frameCanvas = null;
let frameContext = null;
-let activeMediaRecorder = null;
-let activeRecordingChunks = [];
-let activeRecordingStartedAt = null;
-let recordingModalUrl = null;
-let webrtcConnected = false;
-let hasWebrtcEverConnected = false;
-let lastPeerConnectionState = null;
-let pendingRemoteCandidates = [];
+
+const requestedStreams = new Set(); // cameraDeviceIds that have been requested
+
const rtcConfig = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
};
@@ -288,15 +312,16 @@ const setClientStreamMode = (mode) => {
};
const clearClientStream = () => {
- if (remoteStreamWaitTimer) {
- clearTimeout(remoteStreamWaitTimer);
- remoteStreamWaitTimer = null;
+ const { activeStreamSessionId } = store.get();
+ if (activeStreamSessionId && streamTimers.has(activeStreamSessionId)) {
+ clearTimeout(streamTimers.get(activeStreamSessionId));
+ streamTimers.delete(activeStreamSessionId);
}
const videoEl = $('clientStreamVideo');
const imageEl = $('clientStreamImage');
- if (remoteClientStream) {
- remoteClientStream.getTracks().forEach((track) => track.stop());
- remoteClientStream = null;
+ if (activeStreamSessionId && remoteStreams.has(activeStreamSessionId)) {
+ remoteStreams.get(activeStreamSessionId).getTracks().forEach((track) => track.stop());
+ remoteStreams.delete(activeStreamSessionId);
}
if (videoEl) {
videoEl.srcObject = null;
@@ -307,7 +332,20 @@ const clearClientStream = () => {
setClientStreamMode('none');
};
-const getCameraLabel = (cameraDeviceId) => `Camera ${cameraDeviceId?.substring(0, 6) ?? 'Unknown'}`;
+const getLinkedCamera = (cameraDeviceId) =>
+ store.get().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 pushMotionNotification = (cameraDeviceId) => {
if (!cameraDeviceId) return;
@@ -444,7 +482,13 @@ const startLocalRecording = async () => {
try {
const mimeType = getPreferredRecordingMimeType();
- activeMediaRecorder = mimeType ? new MediaRecorder(localCameraStream, { mimeType }) : new MediaRecorder(localCameraStream);
+ const recorderOptions = {
+ videoBitsPerSecond: RECORDING_VIDEO_BITS_PER_SECOND,
+ };
+ if (mimeType) {
+ recorderOptions.mimeType = mimeType;
+ }
+ activeMediaRecorder = new MediaRecorder(localCameraStream, recorderOptions);
} catch (error) {
console.error('Failed to create MediaRecorder', error);
addActivity('Recording', 'Failed to start recorder');
@@ -495,43 +539,168 @@ const stopLocalRecording = async () => {
});
};
-const teardownPeerConnection = () => {
- const previousSessionId = peerSessionId;
- if (peerConnection) {
- peerConnection.onicecandidate = null;
- peerConnection.ontrack = null;
- peerConnection.onconnectionstatechange = null;
- peerConnection.close();
+const toEvenDimension = (value) => {
+ const rounded = Math.max(2, Math.floor(value));
+ return rounded % 2 === 0 ? rounded : rounded - 1;
+};
+
+const compressRecordingBlob = async (sourceBlob) => {
+ if (!sourceBlob || sourceBlob.size === 0) return sourceBlob;
+ if (typeof document === 'undefined' || typeof MediaRecorder === 'undefined') return sourceBlob;
+
+ const mimeType = getPreferredRecordingMimeType();
+ if (!mimeType) return sourceBlob;
+
+ const sourceUrl = URL.createObjectURL(sourceBlob);
+ const videoEl = document.createElement('video');
+ videoEl.muted = true;
+ videoEl.playsInline = true;
+ videoEl.preload = 'auto';
+
+ let rafId = null;
+ let captureStream = null;
+
+ try {
+ await new Promise((resolve, reject) => {
+ videoEl.onloadedmetadata = resolve;
+ videoEl.onerror = () => reject(new Error('Failed loading recorded clip'));
+ videoEl.src = sourceUrl;
+ });
+
+ const sourceWidth = videoEl.videoWidth || COMPRESSED_UPLOAD_MAX_WIDTH;
+ const sourceHeight = videoEl.videoHeight || COMPRESSED_UPLOAD_MAX_HEIGHT;
+ const scale = Math.min(1, COMPRESSED_UPLOAD_MAX_WIDTH / sourceWidth, COMPRESSED_UPLOAD_MAX_HEIGHT / sourceHeight);
+ const width = toEvenDimension(sourceWidth * scale);
+ const height = toEvenDimension(sourceHeight * scale);
+
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const context = canvas.getContext('2d');
+ if (!context || typeof canvas.captureStream !== 'function') {
+ return sourceBlob;
+ }
+
+ captureStream = canvas.captureStream(COMPRESSED_UPLOAD_FRAME_RATE);
+ const compressedChunks = [];
+ const recorder = new MediaRecorder(captureStream, {
+ mimeType,
+ videoBitsPerSecond: COMPRESSED_UPLOAD_BITS_PER_SECOND,
+ });
+
+ const recorderStopped = new Promise((resolve, reject) => {
+ recorder.ondataavailable = (event) => {
+ if (event.data?.size > 0) {
+ compressedChunks.push(event.data);
+ }
+ };
+ recorder.onerror = (event) => {
+ const message = event?.error?.message || 'Compression recorder failed';
+ reject(new Error(message));
+ };
+ recorder.onstop = () => {
+ resolve(new Blob(compressedChunks, { type: recorder.mimeType || mimeType }));
+ };
+ });
+
+ const drawFrame = () => {
+ if (videoEl.paused || videoEl.ended) return;
+ context.drawImage(videoEl, 0, 0, width, height);
+ rafId = requestAnimationFrame(drawFrame);
+ };
+
+ recorder.start(300);
+ await videoEl.play();
+ drawFrame();
+
+ await new Promise((resolve, reject) => {
+ videoEl.onended = resolve;
+ videoEl.onerror = () => reject(new Error('Failed during compression playback'));
+ });
+
+ if (rafId !== null) {
+ cancelAnimationFrame(rafId);
+ rafId = null;
+ }
+ recorder.stop();
+ const compressedBlob = await recorderStopped;
+ if (!compressedBlob || compressedBlob.size === 0 || compressedBlob.size >= sourceBlob.size) {
+ return sourceBlob;
+ }
+
+ const reductionPct = Math.round(((sourceBlob.size - compressedBlob.size) / sourceBlob.size) * 100);
+ addActivity('Recording', `Compressed clip by ${reductionPct}% before upload`);
+ return compressedBlob;
+ } catch (error) {
+ console.warn('Recording compression failed, uploading original clip', error);
+ return sourceBlob;
+ } finally {
+ if (rafId !== null) {
+ cancelAnimationFrame(rafId);
+ }
+ if (captureStream) {
+ captureStream.getTracks().forEach((track) => track.stop());
+ }
+ videoEl.pause();
+ videoEl.removeAttribute('src');
+ videoEl.load();
+ URL.revokeObjectURL(sourceUrl);
+ }
+};
+
+const teardownPeerConnection = (streamSessionId) => {
+ if (!streamSessionId) {
+ // Teardown all
+ for (const [sid, conn] of peerConnections.entries()) {
+ conn.close();
+ }
+ peerConnections.clear();
+ remoteStreams.clear();
+ pendingCandidatesMap.clear();
+ connectedPeers.clear();
+ webrtcConnected = false;
+ hasWebrtcEverConnected = false;
+ clearClientStream();
+ return;
}
- peerConnection = null;
- peerSessionId = null;
- peerTargetDeviceId = null;
- lastPeerConnectionState = null;
- webrtcConnected = false;
- hasWebrtcEverConnected = false;
- if (previousSessionId) {
- pendingRemoteCandidates = pendingRemoteCandidates.filter((item) => item.streamSessionId !== previousSessionId);
+ if (peerConnections.has(streamSessionId)) {
+ const conn = peerConnections.get(streamSessionId);
+ conn.close();
+ peerConnections.delete(streamSessionId);
+ }
+ remoteStreams.delete(streamSessionId);
+ pendingCandidatesMap.delete(streamSessionId);
+ connectedPeers.delete(streamSessionId);
+
+ if (peerSessionId === streamSessionId) {
+ peerSessionId = null;
+ peerTargetDeviceId = null;
+ webrtcConnected = false;
+ hasWebrtcEverConnected = false;
+ }
+
+ if (store.get().activeStreamSessionId === streamSessionId) {
+ clearClientStream();
}
- clearClientStream();
};
const queueRemoteCandidate = ({ streamSessionId, fromDeviceId, data }) => {
if (!streamSessionId || !fromDeviceId || !data) return;
- pendingRemoteCandidates.push({ streamSessionId, fromDeviceId, data, createdAt: Date.now() });
+ 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;
- pendingRemoteCandidates = pendingRemoteCandidates
- .filter((item) => item.createdAt >= cutoff)
- .slice(-200);
+ pendingCandidatesMap.set(streamSessionId, queue.filter((item) => item.createdAt >= cutoff).slice(-200));
};
const takeQueuedCandidates = (streamSessionId, fromDeviceId) => {
- const queued = pendingRemoteCandidates.filter(
- (item) => item.streamSessionId === streamSessionId && item.fromDeviceId === fromDeviceId
- );
- pendingRemoteCandidates = pendingRemoteCandidates.filter(
- (item) => !(item.streamSessionId === streamSessionId && item.fromDeviceId === 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;
};
@@ -552,79 +721,85 @@ const ensurePeerConnection = async ({
targetDeviceId,
asCamera,
}) => {
- if (peerConnection && peerSessionId === streamSessionId && peerTargetDeviceId === targetDeviceId) {
- return peerConnection;
+ if (peerConnections.has(streamSessionId)) {
+ return peerConnections.get(streamSessionId);
}
- teardownPeerConnection();
-
const connection = new RTCPeerConnection(rtcConfig);
- peerConnection = connection;
- peerSessionId = streamSessionId;
- peerTargetDeviceId = targetDeviceId;
+ peerConnections.set(streamSessionId, connection);
+
+ if (asCamera) {
+ peerSessionId = streamSessionId;
+ peerTargetDeviceId = targetDeviceId;
+ }
connection.onicecandidate = (event) => {
- if (!socket || !event.candidate || !peerSessionId || !peerTargetDeviceId) return;
+ if (!socket || !event.candidate) return;
socket.emit('webrtc:signal', {
- toDeviceId: peerTargetDeviceId,
- streamSessionId: peerSessionId,
+ toDeviceId: targetDeviceId,
+ streamSessionId: streamSessionId,
signalType: 'candidate',
data: event.candidate.toJSON(),
});
};
connection.onconnectionstatechange = () => {
- if (connection.connectionState !== lastPeerConnectionState) {
- if (connection.connectionState === 'connected') {
- addActivity('WebRTC', 'Peer connected');
- } else if (
- connection.connectionState === 'failed' ||
- connection.connectionState === 'disconnected' ||
- connection.connectionState === 'closed'
- ) {
- addActivity('WebRTC', `Peer ${connection.connectionState}`);
- }
- lastPeerConnectionState = connection.connectionState;
- }
-
if (connection.connectionState === 'connected') {
- webrtcConnected = true;
- hasWebrtcEverConnected = true;
- stopFrameRelay();
- }
-
- if (connection.connectionState === 'disconnected') {
- if (!hasWebrtcEverConnected) {
- webrtcConnected = false;
+ addActivity('WebRTC', `Peer connected for ${streamSessionId}`);
+ connectedPeers.add(streamSessionId);
+ if (asCamera) {
+ webrtcConnected = true;
+ hasWebrtcEverConnected = true;
+ stopFrameRelay();
}
- return;
- }
-
- if (connection.connectionState === 'failed' || connection.connectionState === 'closed') {
- webrtcConnected = false;
- hasWebrtcEverConnected = false;
- if (store.get().device?.role === 'client') {
- clearClientStream();
+ } else if (
+ connection.connectionState === 'failed' ||
+ connection.connectionState === 'disconnected' ||
+ connection.connectionState === 'closed'
+ ) {
+ addActivity('WebRTC', `Peer ${connection.connectionState} for ${streamSessionId}`);
+ connectedPeers.delete(streamSessionId);
+ if (asCamera) {
+ if (!hasWebrtcEverConnected) webrtcConnected = false;
+ if (connection.connectionState === 'failed' || connection.connectionState === 'closed') {
+ hasWebrtcEverConnected = false;
+ }
+ }
+ if (store.get().device?.role === 'client' && store.get().activeStreamSessionId === streamSessionId) {
+ if (connection.connectionState === 'failed' || connection.connectionState === 'closed') {
+ clearClientStream();
+ }
}
}
};
connection.ontrack = (event) => {
- if (remoteStreamWaitTimer) {
- clearTimeout(remoteStreamWaitTimer);
- remoteStreamWaitTimer = null;
+ if (streamTimers.has(streamSessionId)) {
+ clearTimeout(streamTimers.get(streamSessionId));
+ streamTimers.delete(streamSessionId);
}
const [stream] = event.streams;
if (!stream) return;
- webrtcConnected = true;
- hasWebrtcEverConnected = true;
- stopFrameRelay();
- remoteClientStream = stream;
- const videoEl = $('clientStreamVideo');
- if (videoEl) {
- videoEl.srcObject = stream;
- setClientStreamMode('video');
- void videoEl.play().catch(() => { });
+
+ connectedPeers.add(streamSessionId);
+ remoteStreams.set(streamSessionId, stream);
+
+ if (store.get().activeStreamSessionId === streamSessionId) {
+ const videoEl = $('clientStreamVideo');
+ if (videoEl) {
+ videoEl.srcObject = stream;
+ setClientStreamMode('video');
+ void videoEl.play().catch(() => { });
+ store.notify(); // Re-render to show active feed
+ }
+ } else {
+ // If not active, play it hidden anyway so it connects properly
+ const tempVideo = document.createElement('video');
+ tempVideo.srcObject = stream;
+ tempVideo.muted = true;
+ tempVideo.playsInline = true;
+ void tempVideo.play().catch(() => { });
+ store.notify(); // Re-render to show stream active in list
}
};
@@ -678,6 +853,7 @@ const finalizeRecordingForStream = async (streamSessionId, captureResult) => {
if (!captureResult?.blob || captureResult.blob.size === 0) {
throw new Error('No captured video blob to upload');
}
+ const compressedBlob = await compressRecordingBlob(captureResult.blob);
const uploadMeta = await API.request('/videos/upload-url', {
method: 'POST',
@@ -690,8 +866,8 @@ const finalizeRecordingForStream = async (streamSessionId, captureResult) => {
const uploadResponse = await fetch(uploadMeta.uploadUrl, {
method: 'PUT',
- headers: { 'Content-Type': captureResult.blob.type || 'video/webm' },
- body: captureResult.blob,
+ headers: { 'Content-Type': compressedBlob.type || 'video/webm' },
+ body: compressedBlob,
});
if (!uploadResponse.ok) {
@@ -702,7 +878,7 @@ const finalizeRecordingForStream = async (streamSessionId, captureResult) => {
objectKey: uploadMeta.objectKey,
bucket: uploadMeta.bucket,
durationSeconds: captureResult.durationSeconds,
- sizeBytes: captureResult.blob.size,
+ sizeBytes: compressedBlob.size,
});
addActivity('Recording', 'Recording uploaded and finalized');
@@ -727,6 +903,48 @@ const finalizeRecordingForStream = async (streamSessionId, captureResult) => {
return false;
};
+const uploadStandaloneMotionRecording = async (captureResult) => {
+ const currentDevice = store.get().device;
+ if (!currentDevice?.id) {
+ addActivity('Recording', 'Cannot upload motion clip without device identity');
+ return false;
+ }
+
+ if (!captureResult?.blob || captureResult.blob.size === 0) {
+ addActivity('Recording', 'No motion clip captured for upload');
+ return false;
+ }
+
+ try {
+ const compressedBlob = await compressRecordingBlob(captureResult.blob);
+ const uploadMeta = await API.request('/videos/upload-url', {
+ method: 'POST',
+ body: JSON.stringify({
+ fileName: `motion-${Date.now()}.webm`,
+ deviceId: currentDevice.id,
+ prefix: 'recordings',
+ }),
+ });
+
+ const uploadResponse = await fetch(uploadMeta.uploadUrl, {
+ method: 'PUT',
+ headers: { 'Content-Type': compressedBlob.type || 'video/webm' },
+ body: compressedBlob,
+ });
+
+ if (!uploadResponse.ok) {
+ throw new Error(`Upload failed with status ${uploadResponse.status}`);
+ }
+
+ addActivity('Recording', `Motion clip uploaded (${uploadMeta.objectKey})`);
+ return true;
+ } catch (error) {
+ console.error('Standalone motion upload failed', error);
+ addActivity('Recording', 'Standalone motion upload failed');
+ return false;
+ }
+};
+
const connectSocket = () => {
const { deviceToken } = store.get();
if (!deviceToken) return;
@@ -762,6 +980,7 @@ const connectSocket = () => {
if (!ready) {
throw new Error('Camera permission is required before streaming');
}
+ activeRecordingStreamSessionId = streamId;
await API.streams.accept(streamId);
await API.streams.getPublishCreds(streamId);
await startLocalRecording();
@@ -772,28 +991,11 @@ const connectSocket = () => {
void startFrameRelay(streamId, payload.sourceDeviceId);
}
}, 2500);
+ addActivity('Stream', 'Accepted & Published');
}
- addActivity('Stream', 'Accepted & Published');
- // Auto-stop after 15s for simulation
- setTimeout(async () => {
- const captureResult = await stopLocalRecording();
- await API.streams.end(streamId);
- await finalizeRecordingForStream(streamId, captureResult);
- stopFrameRelay();
- if (socket && payload.sourceDeviceId) {
- socket.emit('webrtc:signal', {
- toDeviceId: payload.sourceDeviceId,
- streamSessionId: streamId,
- signalType: 'hangup',
- });
- }
- teardownPeerConnection();
- store.update({ activeCameraDeviceId: null, activeStreamSessionId: null });
- addActivity('Stream', 'Ended auto-simulation');
- }, 15000);
- }
- socket.emit('command:ack', { commandId: payload.commandId, status: 'acknowledged' });
+ socket.emit('command:ack', { commandId: payload.commandId, status: 'acknowledged' });
+ }
} catch (e) {
socket.emit('command:ack', { commandId: payload.commandId, status: 'rejected', error: e.message });
}
@@ -805,51 +1007,91 @@ const connectSocket = () => {
addActivity('Motion', `${getCameraLabel(cameraDeviceId)} has detected movement`);
Toast.show('Motion Detected!', 'info');
pushMotionNotification(cameraDeviceId);
+
+ // Auto display this camera's active stream on motion
+ if (cameraDeviceId) {
+ store.update({ activeCameraDeviceId: cameraDeviceId });
+ const existingSession = store.get().activeStreamSessionId;
+ // If we don't know the exact session ID associated, requestStream will fetch a new one or join
+ // For simplicity, directly requesting stream again is fine (idempotent setup).
+ Actions.requestStream(cameraDeviceId);
+ }
});
socket.on('stream:started', async (payload) => {
addActivity('Stream', 'Stream is live, connecting...');
- clearClientStream();
+
+ // Always store latest session ID for the camera
+ if (payload.cameraDeviceId === store.get().activeCameraDeviceId) {
+ store.update({ activeStreamSessionId: payload.streamSessionId });
+ }
+
+ // Track camera to stream session map
store.update({
- activeCameraDeviceId: payload.cameraDeviceId ?? store.get().activeCameraDeviceId,
- activeStreamSessionId: payload.streamSessionId ?? null,
+ cameraSessions: {
+ ...(store.get().cameraSessions || {}),
+ [payload.cameraDeviceId]: payload.streamSessionId
+ }
});
+
try {
await API.streams.getSubscribeCreds(payload.streamSessionId);
- Toast.show('Connected to Stream', 'success');
- remoteStreamWaitTimer = setTimeout(() => {
- if (!remoteClientStream) {
- Toast.show('Stream connected but no video received', 'error');
- addActivity('Stream', 'No remote video track received');
+ console.log(`Connected to Stream ${payload.streamSessionId}`);
+
+ streamTimers.set(payload.streamSessionId, setTimeout(() => {
+ if (!remoteStreams.has(payload.streamSessionId)) {
+ console.log(`Stream connected but no video received for ${payload.streamSessionId}`);
+ addActivity('Stream', `No remote video track received for ${payload.streamSessionId}`);
}
- }, 6000);
+ }, 6000));
} catch (e) {
- Toast.show('Stream connect failed', 'error');
+ console.error('Stream connect failed', e);
}
});
socket.on('stream:frame', (payload) => {
- if (webrtcConnected) return;
+ if (connectedPeers.has(payload.streamSessionId)) return;
if (!payload?.frame) return;
- if (remoteStreamWaitTimer) {
- clearTimeout(remoteStreamWaitTimer);
- remoteStreamWaitTimer = null;
+
+ if (streamTimers.has(payload.streamSessionId)) {
+ clearTimeout(streamTimers.get(payload.streamSessionId));
+ streamTimers.delete(payload.streamSessionId);
}
- const imageEl = $('clientStreamImage');
- if (!imageEl) return;
- imageEl.src = payload.frame;
- imageEl.classList.remove('hidden');
- const videoEl = $('clientStreamVideo');
- if (videoEl) {
- videoEl.classList.add('hidden');
+
+ if (payload.streamSessionId === store.get().activeStreamSessionId) {
+ const imageEl = $('clientStreamImage');
+ if (!imageEl) return;
+ imageEl.src = payload.frame;
+ imageEl.classList.remove('hidden');
+ const videoEl = $('clientStreamVideo');
+ if (videoEl) {
+ videoEl.classList.add('hidden');
+ }
+ setClientStreamMode('image');
}
- setClientStreamMode('image');
});
- socket.on('stream:ended', (payload) => {
- if (payload?.streamSessionId && payload.streamSessionId === store.get().activeStreamSessionId) {
- clearClientStream();
- store.update({ activeCameraDeviceId: null, activeStreamSessionId: null });
+ socket.on('stream:ended', async (payload) => {
+ if (payload?.streamSessionId) {
+ const streamSessionId = payload.streamSessionId;
+ teardownPeerConnection(payload.streamSessionId);
+ if (streamSessionId === store.get().activeStreamSessionId) {
+ store.update({ activeStreamSessionId: null });
+ }
+
+ if (store.get().device?.role === 'camera') {
+ const shouldFinalize =
+ activeRecordingStreamSessionId === streamSessionId || activeMediaRecorder?.state === 'recording';
+
+ if (shouldFinalize) {
+ const captureResult = await stopLocalRecording();
+ await finalizeRecordingForStream(streamSessionId, captureResult);
+ }
+
+ if (activeRecordingStreamSessionId === streamSessionId) {
+ activeRecordingStreamSessionId = null;
+ }
+ }
}
});
@@ -881,39 +1123,41 @@ const connectSocket = () => {
}
if (payload.signalType === 'answer') {
- if (device.role !== 'camera' || !peerConnection) return;
- if (peerSessionId !== payload.streamSessionId || peerTargetDeviceId !== payload.fromDeviceId) {
- return;
- }
- if (peerConnection.signalingState !== 'have-local-offer') {
- if (peerConnection.signalingState === 'stable' && peerConnection.remoteDescription?.type === 'answer') {
+ const conn = peerConnections.get(payload.streamSessionId);
+ if (device.role !== 'camera' || !conn) return;
+
+ if (conn.signalingState !== 'have-local-offer') {
+ if (conn.signalingState === 'stable' && conn.remoteDescription?.type === 'answer') {
return;
}
return;
}
- await peerConnection.setRemoteDescription(new RTCSessionDescription(payload.data));
- await applyQueuedCandidates(peerConnection, payload.streamSessionId, payload.fromDeviceId);
+ await conn.setRemoteDescription(new RTCSessionDescription(payload.data));
+ await applyQueuedCandidates(conn, payload.streamSessionId, payload.fromDeviceId);
addActivity('WebRTC', 'Answer received and applied');
return;
}
if (payload.signalType === 'candidate') {
if (!payload.data) return;
- if (!peerConnection || peerSessionId !== payload.streamSessionId || peerTargetDeviceId !== payload.fromDeviceId) {
+ const conn = peerConnections.get(payload.streamSessionId);
+ if (!conn) {
queueRemoteCandidate(payload);
return;
}
- if (!peerConnection.remoteDescription) {
+ if (!conn.remoteDescription) {
queueRemoteCandidate(payload);
return;
}
- await peerConnection.addIceCandidate(new RTCIceCandidate(payload.data));
+ await conn.addIceCandidate(new RTCIceCandidate(payload.data));
return;
}
if (payload.signalType === 'hangup') {
- teardownPeerConnection();
- store.update({ activeCameraDeviceId: null, activeStreamSessionId: null });
+ teardownPeerConnection(payload.streamSessionId);
+ if (store.get().activeStreamSessionId === payload.streamSessionId) {
+ store.update({ activeStreamSessionId: null });
+ }
addActivity('Stream', 'Remote stream ended');
}
} catch (error) {
@@ -933,19 +1177,43 @@ const startPolling = () => {
if (pollInterval) clearInterval(pollInterval);
const poller = async () => {
- const { device, screen, activeStreamSessionId } = store.get();
+ const { device, screen } = store.get();
if (!device) return;
if (screen === 'home' && device.role === 'client') {
- if (activeStreamSessionId) {
- return;
+ const [recs, links, deviceList] = await Promise.all([
+ API.ops.listRecordings().catch(() => ({ recordings: [] })),
+ API.devices.listLinks().catch(() => ({ links: [] })),
+ API.devices.list().catch(() => ({ devices: [] })),
+ ]);
+
+ 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',
+ };
+ });
+
+ store.update({
+ recordings: recs.recordings || [],
+ linkedCameras,
+ });
+
+ // Request streams for all linked cameras if not already requested
+ for (const link of linkedCameras) {
+ if (!requestedStreams.has(link.cameraDeviceId)) {
+ requestedStreams.add(link.cameraDeviceId);
+ void Actions.requestStream(link.cameraDeviceId);
+ }
}
-
- const recs = await API.ops.listRecordings().catch(() => ({ recordings: [] }));
- store.update({ recordings: recs.recordings || [] });
-
- const links = await API.devices.listLinks().catch(() => ({ links: [] }));
- store.update({ linkedCameras: links.links || [] });
}
if (screen === 'activity') {
@@ -1064,6 +1332,8 @@ const Actions = {
startMotion: async () => {
try {
const res = await API.events.startMotion();
+ await startCameraPreview();
+ await startLocalRecording();
store.update({ isMotionActive: true, lastMotionEventId: res.event.id });
Toast.show('Motion Event Started', 'success');
addActivity('Motion', 'Started event ' + res.event.id);
@@ -1074,6 +1344,14 @@ const Actions = {
const { lastMotionEventId } = store.get();
if (!lastMotionEventId) return;
try {
+ const streamSessionId = activeRecordingStreamSessionId;
+ if (streamSessionId) {
+ await API.streams.end(streamSessionId);
+ addActivity('Stream', `Ended stream ${streamSessionId}`);
+ } else if (activeMediaRecorder?.state === 'recording') {
+ const captureResult = await stopLocalRecording();
+ await uploadStandaloneMotionRecording(captureResult);
+ }
await API.events.endMotion(lastMotionEventId);
store.update({ isMotionActive: false });
Toast.show('Motion Ended', 'success');
@@ -1092,10 +1370,68 @@ const Actions = {
} catch (e) { }
},
+ renameLinkedCamera: async (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) {
+ Toast.show('Camera name cannot be empty', 'error');
+ return;
+ }
+
+ if (trimmedName === currentName) return;
+
+ try {
+ await API.devices.update(linked.cameraDeviceId, { name: trimmedName });
+ store.update({
+ linkedCameras: store.get().linkedCameras.map((entry) =>
+ entry.cameraDeviceId === linked.cameraDeviceId ? { ...entry, cameraName: trimmedName } : entry,
+ ),
+ });
+ Toast.show('Camera Renamed', 'success');
+ } catch (e) { }
+ },
+
+ deleteLinkedCamera: async (linkId) => {
+ const link = store.get().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 = store.get().linkedCameras.filter((entry) => entry.id !== linkId);
+ const isDeletedCameraActive = store.get().activeCameraDeviceId === link.cameraDeviceId;
+
+ if (isDeletedCameraActive) {
+ clearClientStream();
+ }
+
+ requestedStreams.delete(link.cameraDeviceId);
+
+ store.update({
+ linkedCameras: remaining,
+ activeCameraDeviceId: isDeletedCameraActive ? null : store.get().activeCameraDeviceId,
+ activeStreamSessionId: isDeletedCameraActive ? null : store.get().activeStreamSessionId,
+ openLinkedCameraMenuId: null,
+ });
+
+ Toast.show('Camera Link Removed', 'success');
+ } catch (e) { }
+ },
+
requestStream: async (camId) => {
try {
- store.update({ activeCameraDeviceId: camId });
- Toast.show('Requesting Stream...', 'info');
+ console.log(`Requesting Stream from ${camId}...`);
await API.streams.request(camId);
// Socket will handle the rest ('stream:started')
} catch (e) { }
@@ -1215,54 +1551,123 @@ const render = (state) => {
// 5. Client Mode Lists
if (state.device?.role === 'client' && state.screen === 'home') {
- if (!state.activeCameraDeviceId && state.linkedCameras.length > 0) {
- void Actions.requestStream(state.linkedCameras[0].cameraDeviceId);
- }
-
const list = $('linkedCamerasList');
if (state.linkedCameras.length === 0) {
list.innerHTML = ``;
} else {
- list.innerHTML = state.linkedCameras.map(link => `
-
+ list.innerHTML = state.linkedCameras.map(link => {
+ const cameraName = getCameraLabel(link.cameraDeviceId, link.cameraName);
+ const escapedCameraName = escapeHtml(cameraName);
+ const cameraStatus = (link.cameraStatus || '').toLowerCase() === 'online' ? 'Online' : 'Offline';
+ const statusDotClass = cameraStatus === 'Online' ? 'bg-green-500' : 'bg-gray-600';
+
+ return `
+
${state.activeCameraDeviceId === link.cameraDeviceId
? `
-
-
![Live stream preview]()
-
-