Files
Final-Year-Project/Backend/public/mobile-sim.js
2026-02-20 14:00:00 +00:00

1428 lines
49 KiB
JavaScript

/**
* SecureCam Mobile Simulator Logic
* Refactored for modern UI/UX and stability.
*/
// --- 1. State Management ---
class Store {
constructor(initialState) {
this.state = initialState;
this.listeners = new Set();
}
get() {
return this.state;
}
update(partialState) {
this.state = { ...this.state, ...partialState };
this.notify();
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
notify() {
this.listeners.forEach((listener) => listener(this.state));
}
}
const store = new Store({
screen: 'auth', // auth, onboarding, home, activity, settings
session: null,
device: null,
deviceToken: null,
socketConnected: false,
isMotionActive: false,
cameraStatus: 'idle', // idle, recording, streaming
linkedCameras: [],
recordings: [],
motionNotifications: [],
activeCameraDeviceId: null,
activeStreamSessionId: null,
activityFeed: [],
loading: false, // global loading spinner state if needed
});
// --- 2. UI Utilities ---
const $ = (selector) => {
// If it looks like a simple ID (no spaces, dots, hash), use getElementById
if (/^[a-zA-Z0-9_\-]+$/.test(selector)) {
return document.getElementById(selector);
}
// Otherwise use querySelector (handles #id, .class, complex selectors)
return document.querySelector(selector);
};
const $$ = (selector) => document.querySelectorAll(selector);
const Toast = {
show(message, type = 'info') {
const container = $('toast-container');
const toast = document.createElement('div');
let alertClass = 'alert-info';
let icon = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>';
if (type === 'success') {
alertClass = 'alert-success';
icon = '<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>';
} else if (type === 'error') {
alertClass = 'alert-error';
icon = '<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>';
}
toast.className = `alert ${alertClass} text-white shadow-lg text-xs py-2 px-3 flex flex-row gap-2 toast-enter`;
toast.innerHTML = `${icon}<span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
toast.style.opacity = '0';
toast.style.transform = 'translateY(100%)';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
};
// --- 3. API Client ---
const API = {
async request(path, options = {}) {
const { deviceToken } = store.get();
const headers = { 'Content-Type': 'application/json' };
if (deviceToken) {
headers['Authorization'] = `Bearer ${deviceToken}`;
}
try {
const res = await fetch(path, { ...options, headers: { ...headers, ...options.headers } });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.message || data.error || res.statusText);
}
return data;
} catch (e) {
Toast.show(e.message, 'error');
throw e;
}
},
auth: {
signUp: (data) => API.request('/api/auth/sign-up/email', { method: 'POST', body: JSON.stringify(data) }),
signIn: (data) => API.request('/api/auth/sign-in/email', { method: 'POST', body: JSON.stringify(data) }),
getSession: () => API.request('/api/auth/get-session'),
signOut: () => API.request('/api/auth/sign-out', { method: 'POST', body: JSON.stringify({}) }),
},
devices: {
register: (data) => API.request('/devices/register', { method: 'POST', body: JSON.stringify(data) }),
listLinks: () => API.request('/device-links'),
link: (cameraDeviceId, clientDeviceId) => API.request('/device-links', { method: 'POST', body: JSON.stringify({ cameraDeviceId, clientDeviceId }) }),
},
streams: {
request: (cameraDeviceId) => API.request('/streams/request', { method: 'POST', body: JSON.stringify({ cameraDeviceId, reason: 'on_demand' }) }),
accept: (id) => API.request(`/streams/${id}/accept`, { method: 'POST', body: JSON.stringify({}) }),
end: (id) => API.request(`/streams/${id}/end`, { method: 'POST', body: JSON.stringify({ reason: 'completed' }) }),
getPublishCreds: (id) => API.request(`/streams/${id}/publish-credentials`),
getSubscribeCreds: (id) => API.request(`/streams/${id}/subscribe-credentials`),
},
events: {
startMotion: () => API.request('/events/motion/start', { method: 'POST', body: JSON.stringify({ title: 'Simulated Motion', triggeredBy: 'motion' }) }),
endMotion: (id) => API.request(`/events/${id}/motion/end`, { method: 'POST', body: JSON.stringify({ status: 'completed' }) }),
finalizeRecording: (id, payload) => API.request(`/recordings/${id}/finalize`, { method: 'POST', body: JSON.stringify(payload) }),
},
ops: {
listRecordings: () => API.request('/recordings/me/list'),
getRecordingDownloadUrl: (recordingId) => API.request(`/recordings/${recordingId}/download-url`),
listNotifications: () => API.request('/push-notifications/me'),
}
};
// --- 4. Logic & Controllers ---
let socket = null;
let pollInterval = null;
let localCameraStream = null;
let remoteClientStream = null;
let peerConnection = null;
let peerSessionId = null;
let peerTargetDeviceId = null;
let remoteStreamWaitTimer = null;
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 rtcConfig = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
};
const init = async () => {
// Load local storage
const saved = localStorage.getItem('mobileSimDevice');
if (saved) {
try {
const parsed = JSON.parse(saved);
store.update({ device: parsed.device, deviceToken: parsed.deviceToken });
} catch (e) { console.error('Failed to load saved device', e); }
}
try {
const session = await API.auth.getSession();
if (session && session.session) {
store.update({ session });
if (store.get().deviceToken) {
// If we have a token, skip onboarding
navigateBasedOnRole();
connectSocket();
startPolling();
} else {
store.update({ screen: 'onboarding' });
}
} else {
store.update({ screen: 'auth' });
}
} catch {
store.update({ screen: 'auth' });
}
};
const navigateBasedOnRole = () => {
const { device } = store.get();
if (!device) return store.update({ screen: 'onboarding' });
// Default home screen based on role
store.update({ screen: 'home' });
};
const startCameraPreview = async () => {
const videoEl = $('cameraVideo');
if (!videoEl || !navigator.mediaDevices?.getUserMedia) {
Toast.show('Camera API is not available in this browser', 'error');
return false;
}
if (localCameraStream) {
videoEl.srcObject = localCameraStream;
videoEl.classList.remove('hidden');
return true;
}
try {
localCameraStream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 640, max: 960 },
height: { ideal: 360, max: 540 },
frameRate: { ideal: 15, max: 24 },
},
audio: false,
});
videoEl.srcObject = localCameraStream;
videoEl.classList.remove('hidden');
addActivity('Camera', 'Camera access granted');
return true;
} catch (error) {
Toast.show('Camera permission denied or unavailable', 'error');
addActivity('Camera', 'Camera access failed');
return false;
}
};
const stopCameraPreview = () => {
const videoEl = $('cameraVideo');
if (localCameraStream) {
localCameraStream.getTracks().forEach((track) => track.stop());
localCameraStream = null;
}
if (videoEl) {
videoEl.srcObject = null;
videoEl.classList.add('hidden');
}
};
const setClientStreamPlaceholderText = (text) => {
const placeholderEl = $('clientStreamPlaceholder');
if (!placeholderEl) return;
const label = placeholderEl.querySelector('p');
if (label) {
label.textContent = text;
}
};
const setClientStreamMode = (mode) => {
const videoEl = $('clientStreamVideo');
const imageEl = $('clientStreamImage');
const placeholderEl = $('clientStreamPlaceholder');
if (videoEl) videoEl.classList.toggle('hidden', mode !== 'video');
if (imageEl) imageEl.classList.toggle('hidden', mode !== 'image');
if (!placeholderEl) return;
if (mode === 'video' || mode === 'image') {
placeholderEl.classList.add('hidden');
return;
}
if (mode === 'unavailable') {
setClientStreamPlaceholderText('Stream unavailable');
} else if (mode === 'connecting') {
setClientStreamPlaceholderText('Connecting stream...');
} else {
setClientStreamPlaceholderText('Waiting for stream');
}
placeholderEl.classList.remove('hidden');
};
const clearClientStream = () => {
if (remoteStreamWaitTimer) {
clearTimeout(remoteStreamWaitTimer);
remoteStreamWaitTimer = null;
}
const videoEl = $('clientStreamVideo');
const imageEl = $('clientStreamImage');
if (remoteClientStream) {
remoteClientStream.getTracks().forEach((track) => track.stop());
remoteClientStream = null;
}
if (videoEl) {
videoEl.srcObject = null;
}
if (imageEl) {
imageEl.src = '';
}
setClientStreamMode('none');
};
const getCameraLabel = (cameraDeviceId) => `Camera ${cameraDeviceId?.substring(0, 6) ?? 'Unknown'}`;
const pushMotionNotification = (cameraDeviceId) => {
if (!cameraDeviceId) return;
const notification = {
id: crypto.randomUUID(),
cameraDeviceId,
message: `${getCameraLabel(cameraDeviceId)} has detected movement`,
createdAt: new Date().toISOString(),
isRead: false,
};
store.update({
motionNotifications: [notification, ...store.get().motionNotifications].slice(0, 50),
});
};
const markMotionNotificationRead = (notificationId) => {
store.update({
motionNotifications: store
.get()
.motionNotifications.map((notification) =>
notification.id === notificationId ? { ...notification, isRead: true } : notification,
),
});
};
const markAllNotificationsRead = () => {
store.update({
motionNotifications: store
.get()
.motionNotifications.map((notification) => (notification.isRead ? notification : { ...notification, isRead: true })),
});
};
const openRecordingModal = (downloadUrl, title) => {
const modal = $('recordingModal');
const videoEl = $('recordingModalVideo');
const titleEl = $('recordingModalTitle');
if (!modal || !videoEl || !titleEl) return;
recordingModalUrl = downloadUrl;
titleEl.textContent = title || 'Recording Playback';
videoEl.src = downloadUrl;
modal.classList.remove('hidden');
modal.classList.add('flex');
void videoEl.play().catch(() => {});
};
const closeRecordingModal = () => {
const modal = $('recordingModal');
const videoEl = $('recordingModalVideo');
if (!modal || !videoEl) return;
modal.classList.add('hidden');
modal.classList.remove('flex');
videoEl.pause();
videoEl.removeAttribute('src');
videoEl.load();
recordingModalUrl = null;
};
const stopFrameRelay = () => {
if (frameRelayStartTimer) {
clearTimeout(frameRelayStartTimer);
frameRelayStartTimer = null;
}
if (frameRelayTimer) {
clearInterval(frameRelayTimer);
frameRelayTimer = null;
}
};
const startFrameRelay = async (streamSessionId, toDeviceId) => {
if (!socket || !streamSessionId || !toDeviceId) return;
if (hasWebrtcEverConnected) return;
const ready = await startCameraPreview();
if (!ready) {
throw new Error('Camera permission is required before streaming');
}
const cameraVideoEl = $('cameraVideo');
if (!cameraVideoEl) return;
stopFrameRelay();
frameRelayTimer = setInterval(() => {
if (webrtcConnected || hasWebrtcEverConnected) return;
if (!socket || cameraVideoEl.readyState < 2 || !cameraVideoEl.videoWidth || !cameraVideoEl.videoHeight) return;
if (!frameCanvas) {
frameCanvas = document.createElement('canvas');
frameContext = frameCanvas.getContext('2d');
}
if (!frameCanvas || !frameContext) return;
frameCanvas.width = cameraVideoEl.videoWidth;
frameCanvas.height = cameraVideoEl.videoHeight;
frameContext.drawImage(cameraVideoEl, 0, 0, frameCanvas.width, frameCanvas.height);
const frame = frameCanvas.toDataURL('image/jpeg', 0.6);
socket.emit('stream:frame', {
toDeviceId,
streamSessionId,
frame,
capturedAt: new Date().toISOString(),
});
}, 600);
};
const getPreferredRecordingMimeType = () => {
if (typeof MediaRecorder === 'undefined') return '';
const preferredTypes = [
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
];
return preferredTypes.find((type) => MediaRecorder.isTypeSupported(type)) ?? '';
};
const startLocalRecording = async () => {
if (!localCameraStream || typeof MediaRecorder === 'undefined') {
addActivity('Recording', 'MediaRecorder unavailable');
return false;
}
if (activeMediaRecorder?.state === 'recording') {
return true;
}
activeRecordingChunks = [];
activeRecordingStartedAt = Date.now();
try {
const mimeType = getPreferredRecordingMimeType();
activeMediaRecorder = mimeType ? new MediaRecorder(localCameraStream, { mimeType }) : new MediaRecorder(localCameraStream);
} catch (error) {
console.error('Failed to create MediaRecorder', error);
addActivity('Recording', 'Failed to start recorder');
activeMediaRecorder = null;
return false;
}
activeMediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
activeRecordingChunks.push(event.data);
}
};
activeMediaRecorder.start(1000);
addActivity('Recording', 'Local recording started');
return true;
};
const stopLocalRecording = async () => {
if (!activeMediaRecorder || activeMediaRecorder.state === 'inactive') {
return null;
}
return await new Promise((resolve) => {
const recorder = activeMediaRecorder;
const startedAt = activeRecordingStartedAt ?? Date.now();
recorder.onstop = () => {
const mimeType = recorder.mimeType || 'video/webm';
const blob = activeRecordingChunks.length > 0 ? new Blob(activeRecordingChunks, { type: mimeType }) : null;
const durationSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
activeMediaRecorder = null;
activeRecordingChunks = [];
activeRecordingStartedAt = null;
resolve(blob ? { blob, durationSeconds } : null);
};
recorder.onerror = () => {
activeMediaRecorder = null;
activeRecordingChunks = [];
activeRecordingStartedAt = null;
resolve(null);
};
recorder.stop();
});
};
const teardownPeerConnection = () => {
const previousSessionId = peerSessionId;
if (peerConnection) {
peerConnection.onicecandidate = null;
peerConnection.ontrack = null;
peerConnection.onconnectionstatechange = null;
peerConnection.close();
}
peerConnection = null;
peerSessionId = null;
peerTargetDeviceId = null;
lastPeerConnectionState = null;
webrtcConnected = false;
hasWebrtcEverConnected = false;
if (previousSessionId) {
pendingRemoteCandidates = pendingRemoteCandidates.filter((item) => item.streamSessionId !== previousSessionId);
}
clearClientStream();
};
const queueRemoteCandidate = ({ streamSessionId, fromDeviceId, data }) => {
if (!streamSessionId || !fromDeviceId || !data) return;
pendingRemoteCandidates.push({ streamSessionId, fromDeviceId, data, createdAt: Date.now() });
const cutoff = Date.now() - 120000;
pendingRemoteCandidates = pendingRemoteCandidates
.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)
);
return queued;
};
const applyQueuedCandidates = async (connection, streamSessionId, fromDeviceId) => {
if (!connection?.remoteDescription) return;
const queued = takeQueuedCandidates(streamSessionId, fromDeviceId);
for (const candidate of queued) {
try {
await connection.addIceCandidate(new RTCIceCandidate(candidate.data));
} catch (error) {
console.warn('Dropping queued ICE candidate', error);
}
}
};
const ensurePeerConnection = async ({
streamSessionId,
targetDeviceId,
asCamera,
}) => {
if (peerConnection && peerSessionId === streamSessionId && peerTargetDeviceId === targetDeviceId) {
return peerConnection;
}
teardownPeerConnection();
const connection = new RTCPeerConnection(rtcConfig);
peerConnection = connection;
peerSessionId = streamSessionId;
peerTargetDeviceId = targetDeviceId;
connection.onicecandidate = (event) => {
if (!socket || !event.candidate || !peerSessionId || !peerTargetDeviceId) return;
socket.emit('webrtc:signal', {
toDeviceId: peerTargetDeviceId,
streamSessionId: peerSessionId,
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;
}
return;
}
if (connection.connectionState === 'failed' || connection.connectionState === 'closed') {
webrtcConnected = false;
hasWebrtcEverConnected = false;
if (store.get().device?.role === 'client') {
clearClientStream();
}
}
};
connection.ontrack = (event) => {
if (remoteStreamWaitTimer) {
clearTimeout(remoteStreamWaitTimer);
remoteStreamWaitTimer = null;
}
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(() => {});
}
};
if (asCamera) {
const ready = await startCameraPreview();
if (!ready || !localCameraStream || localCameraStream.getVideoTracks().length === 0) {
throw new Error('Camera stream unavailable for WebRTC publish');
}
if (localCameraStream) {
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);
socket.emit('webrtc:signal', {
toDeviceId: requesterDeviceId,
streamSessionId,
signalType: 'offer',
data: offer,
});
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const finalizeRecordingForStream = async (streamSessionId, captureResult) => {
const currentDevice = store.get().device;
if (!currentDevice?.id) {
addActivity('Recording', 'No device identity for finalize');
return false;
}
for (let attempt = 0; attempt < 8; attempt += 1) {
const recs = await API.ops.listRecordings().catch(() => ({ recordings: [] }));
const recording = (recs.recordings || []).find((rec) => rec.streamSessionId === streamSessionId && rec.status === 'awaiting_upload');
if (recording?.id) {
try {
if (!captureResult?.blob || captureResult.blob.size === 0) {
throw new Error('No captured video blob to upload');
}
const uploadMeta = await API.request('/videos/upload-url', {
method: 'POST',
body: JSON.stringify({
fileName: `stream-${streamSessionId}.webm`,
deviceId: currentDevice.id,
prefix: 'recordings',
}),
});
const uploadResponse = await fetch(uploadMeta.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': captureResult.blob.type || 'video/webm' },
body: captureResult.blob,
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
await API.events.finalizeRecording(recording.id, {
objectKey: uploadMeta.objectKey,
bucket: uploadMeta.bucket,
durationSeconds: captureResult.durationSeconds,
sizeBytes: captureResult.blob.size,
});
addActivity('Recording', 'Recording uploaded and finalized');
return true;
} catch (error) {
console.error('Recording upload failed, falling back to simulated key', error);
const fallbackObjectKey = `sim/${streamSessionId}/${Date.now()}.webm`;
await API.events.finalizeRecording(recording.id, {
objectKey: fallbackObjectKey,
durationSeconds: captureResult?.durationSeconds ?? 15,
sizeBytes: captureResult?.blob?.size ?? 5000000,
});
addActivity('Recording', 'Upload failed; finalized with simulator fallback');
return true;
}
}
await sleep(350);
}
addActivity('Recording', 'No recording row found to finalize');
return false;
};
const connectSocket = () => {
const { deviceToken } = store.get();
if (!deviceToken) return;
if (socket) socket.disconnect();
socket = io({ auth: { token: deviceToken } });
socket.on('connect', () => {
store.update({ socketConnected: true });
addActivity('System', 'Connected to realtime server');
if (store.get().device?.role === 'camera') {
startCameraPreview();
}
});
socket.on('disconnect', () => {
store.update({ socketConnected: false });
stopFrameRelay();
void stopLocalRecording();
teardownPeerConnection();
store.update({ activeCameraDeviceId: null, activeStreamSessionId: null });
});
// Handle commands (as Camera)
socket.on('command:received', async (payload) => {
addActivity('Command', `Received ${payload.commandType}`);
try {
if (payload.commandType === 'start_stream') {
const streamId = payload.payload.streamSessionId;
const ready = await startCameraPreview();
if (!ready) {
throw new Error('Camera permission is required before streaming');
}
await API.streams.accept(streamId);
await API.streams.getPublishCreds(streamId);
await startLocalRecording();
if (payload.sourceDeviceId) {
await startOfferToClient(streamId, payload.sourceDeviceId);
frameRelayStartTimer = setTimeout(() => {
if (!webrtcConnected && !hasWebrtcEverConnected) {
void startFrameRelay(streamId, payload.sourceDeviceId);
}
}, 2500);
}
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' });
} catch (e) {
socket.emit('command:ack', { commandId: payload.commandId, status: 'rejected', error: e.message });
}
});
// Handle Events (as Client)
socket.on('motion:detected', (payload) => {
const cameraDeviceId = payload.cameraDeviceId || payload.deviceId;
addActivity('Motion', `${getCameraLabel(cameraDeviceId)} has detected movement`);
Toast.show('Motion Detected!', 'info');
pushMotionNotification(cameraDeviceId);
});
socket.on('stream:started', async (payload) => {
addActivity('Stream', 'Stream is live, connecting...');
clearClientStream();
store.update({
activeCameraDeviceId: payload.cameraDeviceId ?? store.get().activeCameraDeviceId,
activeStreamSessionId: payload.streamSessionId ?? null,
});
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');
}
}, 6000);
} catch (e) {
Toast.show('Stream connect failed', 'error');
}
});
socket.on('stream:frame', (payload) => {
if (webrtcConnected) return;
if (!payload?.frame) return;
if (remoteStreamWaitTimer) {
clearTimeout(remoteStreamWaitTimer);
remoteStreamWaitTimer = null;
}
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');
});
socket.on('stream:ended', (payload) => {
if (payload?.streamSessionId && payload.streamSessionId === store.get().activeStreamSessionId) {
clearClientStream();
store.update({ activeCameraDeviceId: null, activeStreamSessionId: null });
}
});
socket.on('webrtc:signal', async (payload) => {
const device = store.get().device;
if (!device || !payload?.streamSessionId || !payload?.signalType || !payload?.fromDeviceId) return;
try {
if (payload.signalType === 'offer') {
if (device.role !== 'client') return;
addActivity('WebRTC', 'Offer received');
const connection = await ensurePeerConnection({
streamSessionId: payload.streamSessionId,
targetDeviceId: payload.fromDeviceId,
asCamera: false,
});
await connection.setRemoteDescription(new RTCSessionDescription(payload.data));
await applyQueuedCandidates(connection, payload.streamSessionId, payload.fromDeviceId);
const answer = await connection.createAnswer();
await connection.setLocalDescription(answer);
socket.emit('webrtc:signal', {
toDeviceId: payload.fromDeviceId,
streamSessionId: payload.streamSessionId,
signalType: 'answer',
data: answer,
});
addActivity('WebRTC', 'Answer sent');
return;
}
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') {
return;
}
return;
}
await peerConnection.setRemoteDescription(new RTCSessionDescription(payload.data));
await applyQueuedCandidates(peerConnection, payload.streamSessionId, payload.fromDeviceId);
addActivity('WebRTC', 'Answer received');
return;
}
if (payload.signalType === 'candidate') {
if (!payload.data) return;
if (!peerConnection || peerSessionId !== payload.streamSessionId || peerTargetDeviceId !== payload.fromDeviceId) {
queueRemoteCandidate(payload);
return;
}
if (!peerConnection.remoteDescription) {
queueRemoteCandidate(payload);
return;
}
await peerConnection.addIceCandidate(new RTCIceCandidate(payload.data));
return;
}
if (payload.signalType === 'hangup') {
teardownPeerConnection();
store.update({ activeCameraDeviceId: null, activeStreamSessionId: null });
addActivity('Stream', 'Remote stream ended');
}
} catch (error) {
console.error('Failed handling WebRTC signal', error);
Toast.show('WebRTC negotiation failed', 'error');
}
});
socket.on('error:webrtc_signal', (payload) => {
const message = payload?.message || 'WebRTC signaling error';
addActivity('WebRTC', message);
Toast.show(message, 'error');
});
};
const startPolling = () => {
if (pollInterval) clearInterval(pollInterval);
const poller = async () => {
const { device, screen, activeStreamSessionId } = store.get();
if (!device) return;
if (screen === 'home' && device.role === 'client') {
if (activeStreamSessionId) {
return;
}
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') {
// maybe poll notifications
}
};
poller();
pollInterval = setInterval(poller, 5000);
};
// --- Actions ---
const Actions = {
toggleAuthMode: () => {
const isRegistering = !$('authNameField').classList.contains('hidden');
if (isRegistering) {
$('authNameField').classList.add('hidden');
$('signInBtn').textContent = 'Sign In';
$('toggleAuthModeBtn').textContent = 'Create an account';
} else {
$('authNameField').classList.remove('hidden');
$('signInBtn').textContent = 'Create Account';
$('toggleAuthModeBtn').textContent = 'I already have an account';
}
},
submitAuth: async () => {
const email = $('authEmail').value;
const password = $('authPassword').value;
const name = $('authName').value || email.split('@')[0];
const isRegistering = !$('authNameField').classList.contains('hidden');
try {
if (isRegistering) {
await API.auth.signUp({ email, password, name });
}
await API.auth.signIn({ email, password });
const session = await API.auth.getSession();
store.update({ session });
Toast.show(`Welcome, ${session.user.name}`, 'success');
// Proceed
if (store.get().deviceToken) {
navigateBasedOnRole();
connectSocket();
startPolling();
} else {
store.update({ screen: 'onboarding' });
}
} catch (e) {
// handled by API wrapper toast
}
},
selectRole: (role) => {
$('role').value = role;
const btnCamera = $('btn-role-camera');
const btnClient = $('btn-role-client');
btnCamera.setAttribute('data-active', role === 'camera');
btnClient.setAttribute('data-active', role === 'client');
// DaisyUI/Tailwind manual toggle logic for visual feedback
if (role === 'camera') {
btnCamera.classList.add('bg-blue-600', 'text-white');
btnCamera.classList.remove('text-gray-400');
btnClient.classList.remove('bg-blue-600', 'text-white');
btnClient.classList.add('text-gray-400');
} else {
btnClient.classList.add('bg-blue-600', 'text-white');
btnClient.classList.remove('text-gray-400');
btnCamera.classList.remove('bg-blue-600', 'text-white');
btnCamera.classList.add('text-gray-400');
}
},
registerDevice: async () => {
const name = $('deviceName').value || 'Web Simulator';
const role = $('role').value;
const pushToken = $('pushToken').value;
try {
const payload = { name, role, platform: 'web', appVersion: 'sim-2.0' };
if (pushToken && pushToken.trim().length > 0) {
payload.pushToken = pushToken.trim();
}
const res = await API.devices.register(payload);
store.update({ device: res.device, deviceToken: res.deviceToken });
localStorage.setItem('mobileSimDevice', JSON.stringify({ device: res.device, deviceToken: res.deviceToken }));
Toast.show('Device Registered', 'success');
navigateBasedOnRole();
connectSocket();
startPolling();
} catch (e) {
// handled
}
},
signOut: async () => {
await API.auth.signOut();
store.update({ session: null, screen: 'auth', device: null, deviceToken: null, socketConnected: false });
if (socket) socket.disconnect();
stopFrameRelay();
await stopLocalRecording();
teardownPeerConnection();
stopCameraPreview();
localStorage.removeItem('mobileSimDevice');
Toast.show('Signed Out', 'info');
},
// Camera Actions
startMotion: async () => {
try {
const res = await API.events.startMotion();
store.update({ isMotionActive: true, lastMotionEventId: res.event.id });
Toast.show('Motion Event Started', 'success');
addActivity('Motion', 'Started event ' + res.event.id);
} catch (e) { }
},
endMotion: async () => {
const { lastMotionEventId } = store.get();
if (!lastMotionEventId) return;
try {
await API.events.endMotion(lastMotionEventId);
store.update({ isMotionActive: false });
Toast.show('Motion Ended', 'success');
addActivity('Motion', 'Ended event');
} catch (e) { }
},
// Client Actions
linkCamera: async () => {
const id = prompt('Enter Camera Device ID:'); // Simple prompt for now, could be better UI
if (!id) return;
try {
await API.devices.link(id, store.get().device.id);
Toast.show('Camera Linked', 'success');
startPolling(); // refresh list
} catch (e) { }
},
requestStream: async (camId) => {
try {
store.update({ activeCameraDeviceId: camId });
Toast.show('Requesting Stream...', 'info');
await API.streams.request(camId);
// Socket will handle the rest ('stream:started')
} catch (e) { }
},
openRecording: async (recordingId) => {
try {
const result = await API.ops.getRecordingDownloadUrl(recordingId);
if (!result?.downloadUrl) {
Toast.show('Recording URL unavailable', 'error');
return;
}
const recording = store.get().recordings.find((entry) => entry.id === recordingId);
const title = recording ? `${new Date(recording.createdAt).toLocaleString()} recording` : 'Recording Playback';
openRecordingModal(result.downloadUrl, title);
} catch (e) {
// handled by API wrapper
}
},
closeRecordingModal: () => {
closeRecordingModal();
},
openMotionNotificationTarget: async (notificationId, cameraDeviceId) => {
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;
}
store.update({ screen: 'home' });
await Actions.requestStream(cameraDeviceId);
},
};
// --- 5. Rendering ---
const render = (state) => {
// 1. Screen Visibility
$$('section[id^="screen-"]').forEach(el => el.classList.add('hidden'));
if (state.screen === 'home') {
const homeId = state.device?.role === 'camera' ? 'screen-home-camera' : 'screen-home-client';
$(homeId).classList.remove('hidden');
} else {
$(`screen-${state.screen}`).classList.remove('hidden');
}
// 2. Top Bar Status
const statusDot = $('#connectionStatus .status-dot');
const statusText = $('#connectionStatus span:last-child');
if (state.socketConnected) {
statusDot.className = 'status-dot status-online transition-colors duration-300';
statusText.textContent = 'ONLINE';
} else {
statusDot.className = 'status-dot status-offline transition-colors duration-300';
statusText.textContent = 'OFFLINE';
}
const authBadge = $('authStatusBadge');
if (state.session?.user) {
authBadge.textContent = state.session.user.email;
authBadge.classList.add('text-blue-400');
} else {
authBadge.textContent = 'Signed Out';
authBadge.classList.remove('text-blue-400');
}
// 3. Bottom Nav Visibility & State
const nav = $('bottomNav');
const unreadNotifications = state.motionNotifications.filter((notification) => !notification.isRead).length;
updateNotificationDot(unreadNotifications > 0);
if (state.session && state.device) {
nav.classList.remove('hidden');
$$('.nav-btn').forEach(btn => {
const target = btn.dataset.target;
const isActive = target === state.screen || (target === 'home' && (state.screen === 'home-camera' || state.screen === 'home-client'));
btn.setAttribute('data-active', isActive);
});
} else {
nav.classList.add('hidden');
}
// 4. Camera Mode specifics
if (state.device?.role === 'camera') {
const preview = $('cameraPreview');
const offlineOverlay = $('cameraOfflineOverlay');
if (state.socketConnected) {
offlineOverlay.classList.add('hidden');
if (state.isMotionActive) {
preview.classList.remove('bg-black/50');
preview.classList.add('bg-red-900/20');
$('startMotionBtn').classList.add('hidden');
$('endMotionBtn').classList.remove('hidden');
$('endMotionBtn').disabled = false;
} else {
preview.classList.add('bg-black/50');
preview.classList.remove('bg-red-900/20');
$('startMotionBtn').classList.remove('hidden');
$('endMotionBtn').classList.add('hidden');
}
} else {
offlineOverlay.classList.remove('hidden');
}
}
// 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 = `<div class="min-w-full text-center py-8 bg-gray-900/30 rounded-xl border border-dashed border-gray-800"><p class="text-gray-600 text-xs">No cameras linked yet</p></div>`;
} else {
list.innerHTML = state.linkedCameras.map(link => `
<div class="min-w-[240px] max-w-[240px] bg-gray-900/60 rounded-xl border border-white/5 overflow-hidden">
<div class="relative overflow-hidden bg-black/40 border-b border-white/5 aspect-video">
${
state.activeCameraDeviceId === link.cameraDeviceId
? `
<video id="clientStreamVideo" class="absolute inset-0 w-full h-full object-cover hidden" autoplay playsinline></video>
<img id="clientStreamImage" class="absolute inset-0 w-full h-full object-cover hidden" alt="Live stream preview" />
<div id="clientStreamPlaceholder" class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<p class="text-[10px]">${state.activeStreamSessionId ? 'Connecting stream...' : 'Waiting for stream'}</p>
</div>
`
: `
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<p class="text-[10px]">Stand by</p>
</div>
`
}
</div>
<div class="px-3 py-2">
<div>
<p class="text-xs font-bold text-gray-300">${getCameraLabel(link.cameraDeviceId)}</p>
<p class="text-[10px] text-gray-500">${link.status}</p>
</div>
</div>
</div>
`).join('');
if (state.activeCameraDeviceId && remoteClientStream) {
const videoEl = $('clientStreamVideo');
if (videoEl && videoEl.srcObject !== remoteClientStream) {
videoEl.srcObject = remoteClientStream;
setClientStreamMode('video');
void videoEl.play().catch(() => {});
}
}
const imageEl = $('clientStreamImage');
if (imageEl && !imageEl.dataset.errorBound) {
imageEl.dataset.errorBound = '1';
imageEl.addEventListener('error', () => {
const videoEl = $('clientStreamVideo');
if (videoEl) {
videoEl.classList.add('hidden');
}
setClientStreamMode('unavailable');
});
}
}
const recList = $('recordingsList');
if (state.recordings.length === 0) {
recList.innerHTML = `<div class="text-center py-4 bg-gray-900/30 rounded-xl"><p class="text-gray-600 text-xs text-center">No recordings found</p></div>`;
} else {
recList.innerHTML = state.recordings.slice(0, 5).map(rec => `
<div class="flex items-center justify-between p-3 bg-gray-900/40 rounded-lg border border-white/5 hover:bg-gray-800 transition-colors">
<div class="flex flex-col">
<span class="text-xs font-medium text-gray-300">${new Date(rec.createdAt).toLocaleString()}</span>
<span class="text-[10px] text-gray-500">${rec.durationSeconds != null ? `${rec.durationSeconds}s duration` : 'Duration pending'} · ${rec.status ?? 'unknown'}</span>
</div>
<button class="btn btn-xs btn-outline border-white/10 text-gray-400 download-recording-btn" data-recording-id="${rec.id}" ${rec.status === 'ready' ? '' : 'disabled'}>
View
</button>
</div>
`).join('');
}
}
if (state.screen === 'activity') {
const activityFeed = $('activityFeedList');
if (state.motionNotifications.length === 0) {
activityFeed.innerHTML = `
<div class="text-center py-10 opacity-50">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mx-auto mb-2 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<p class="text-sm text-gray-500">No notifications yet</p>
</div>
`;
} else {
activityFeed.innerHTML = state.motionNotifications.map((notification) => `
<button class="w-full text-left p-3 rounded-lg border border-white/5 ${notification.isRead ? 'bg-gray-900/30' : 'bg-blue-900/20'} motion-notification-btn" data-notification-id="${notification.id}" data-camera-device-id="${notification.cameraDeviceId}">
<p class="text-xs font-medium text-gray-200">${notification.message}</p>
<p class="text-[10px] text-gray-500 mt-1">${new Date(notification.createdAt).toLocaleString()}</p>
</button>
`).join('');
}
}
// 6. Settings Screen
if (state.session?.user && state.screen === 'settings') {
$('profileName').textContent = state.session.user.name;
$('profileEmail').textContent = state.session.user.email;
$('profileInitials').textContent = state.session.user.name.charAt(0).toUpperCase();
}
};
const addActivity = (type, msg) => {
const list = $('activityFeedList');
const item = document.createElement('div');
item.className = 'p-3 rounded-lg bg-gray-900/40 border border-white/5 flex flex-col gap-1';
item.innerHTML = `
<div class="flex justify-between items-start">
<span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">${type}</span>
<span class="text-[10px] text-gray-600">${new Date().toLocaleTimeString()}</span>
</div>
<p class="text-xs text-gray-300">${msg}</p>
`;
list.prepend(item);
// Also update camera logs if applicable
if ($('cameraLogs')) {
const logLine = document.createElement('div');
logLine.textContent = `[${new Date().toLocaleTimeString()}] ${type}: ${msg}`;
$('cameraLogs').prepend(logLine);
}
};
const updateNotificationDot = (show) => {
const dot = $('notificationDot');
if (show) dot.classList.remove('hidden');
else dot.classList.add('hidden');
};
// --- 6. Event Listeners ---
$('toggleAuthModeBtn').addEventListener('click', Actions.toggleAuthMode);
$('signInBtn').addEventListener('click', Actions.submitAuth);
$('registerBtn').addEventListener('click', Actions.registerDevice);
$('loadSavedBtn').addEventListener('click', () => { /* Handle legacy loading if needed */ });
$$('#screen-onboarding [data-role]').forEach((btn) => {
btn.addEventListener('click', () => Actions.selectRole(btn.dataset.role));
});
$('recordingsList').addEventListener('click', (event) => {
const target = event.target.closest('.download-recording-btn');
if (!target || target.disabled) return;
const recordingId = target.dataset.recordingId;
if (!recordingId) return;
Actions.openRecording(recordingId);
});
$('activityFeedList').addEventListener('click', (event) => {
const target = event.target.closest('.motion-notification-btn');
if (!target) return;
const notificationId = target.dataset.notificationId;
const cameraDeviceId = target.dataset.cameraDeviceId;
if (!notificationId || !cameraDeviceId) return;
Actions.openMotionNotificationTarget(notificationId, cameraDeviceId);
});
// Navbar
$$('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.target === 'activity') {
markAllNotificationsRead();
}
store.update({ screen: btn.dataset.target });
});
});
// Camera Controls
$('cameraGoOnlineBtn').addEventListener('click', async () => {
if (store.get().device?.role === 'camera') {
await startCameraPreview();
}
connectSocket();
});
$('startMotionBtn').addEventListener('click', Actions.startMotion);
$('endMotionBtn').addEventListener('click', Actions.endMotion);
// Client Controls
$('linkCameraBtn').addEventListener('click', Actions.linkCamera);
$('refreshClientBtn').addEventListener('click', startPolling);
// Settings
$('signOutBtn').addEventListener('click', Actions.signOut);
$('clearActivityBtn').addEventListener('click', () => {
store.update({ motionNotifications: [] });
});
$('recordingModalCloseBtn').addEventListener('click', Actions.closeRecordingModal);
$('recordingModal').addEventListener('click', (event) => {
if (event.target === $('recordingModal')) {
Actions.closeRecordingModal();
}
});
// Init
store.subscribe(render);
init();
window.addEventListener('beforeunload', () => {
stopFrameRelay();
void stopLocalRecording();
teardownPeerConnection();
stopCameraPreview();
});
window.Actions = Actions;