2044 lines
70 KiB
JavaScript
2044 lines
70 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,
|
|
openLinkedCameraMenuId: null,
|
|
activityFeed: [],
|
|
loading: false, // global loading spinner state if needed
|
|
});
|
|
|
|
const PAGE_PATHS = {
|
|
auth: '/sim/mobile-sim-auth.html',
|
|
onboarding: '/sim/mobile-sim-onboarding.html',
|
|
camera: '/sim/mobile-sim-camera.html',
|
|
client: '/sim/mobile-sim-client.html',
|
|
activity: '/sim/mobile-sim-activity.html',
|
|
settings: '/sim/mobile-sim-settings.html',
|
|
};
|
|
|
|
const currentPageKey = document.body?.dataset?.page || '';
|
|
const multiPageMode = Boolean(currentPageKey);
|
|
|
|
const getHomePageKeyForRole = (role) => (role === 'camera' ? 'camera' : 'client');
|
|
|
|
const getPathForScreen = (screen, role) => {
|
|
if (screen === 'home') {
|
|
return PAGE_PATHS[getHomePageKeyForRole(role)];
|
|
}
|
|
return PAGE_PATHS[screen] || null;
|
|
};
|
|
|
|
const navigateToScreen = (screen, options = {}) => {
|
|
const { replace = false, role = store.get().device?.role } = options;
|
|
const targetPath = getPathForScreen(screen, role);
|
|
|
|
if (multiPageMode && targetPath && window.location.pathname !== targetPath) {
|
|
if (replace) {
|
|
window.location.replace(targetPath);
|
|
} else {
|
|
window.location.assign(targetPath);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
store.update({ screen });
|
|
return false;
|
|
};
|
|
|
|
const getScreenForCurrentPage = () => {
|
|
if (currentPageKey === 'activity') return 'activity';
|
|
if (currentPageKey === 'settings') return 'settings';
|
|
if (currentPageKey === 'onboarding') return 'onboarding';
|
|
if (currentPageKey === 'camera' || currentPageKey === 'client') return 'home';
|
|
return 'auth';
|
|
};
|
|
|
|
// --- 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 escapeHtml = (value = '') =>
|
|
String(value)
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
|
|
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) }),
|
|
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: {
|
|
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 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<ICE candidates>
|
|
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 hasWebrtcEverConnected = false;
|
|
let webrtcConnected = false;
|
|
let frameRelayTimer = null;
|
|
let frameRelayStartTimer = null;
|
|
let frameCanvas = null;
|
|
let frameContext = null;
|
|
|
|
const requestedStreams = new Set(); // cameraDeviceIds that have been requested
|
|
|
|
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) {
|
|
const role = store.get().device?.role;
|
|
if (multiPageMode && (currentPageKey === 'auth' || currentPageKey === 'onboarding')) {
|
|
if (navigateToScreen('home', { replace: true, role })) return;
|
|
}
|
|
if (multiPageMode && (currentPageKey === 'camera' || currentPageKey === 'client')) {
|
|
const expectedHome = getHomePageKeyForRole(role);
|
|
if (expectedHome !== currentPageKey) {
|
|
if (navigateToScreen('home', { replace: true, role })) return;
|
|
}
|
|
}
|
|
|
|
if (multiPageMode) {
|
|
store.update({ screen: getScreenForCurrentPage() });
|
|
} else {
|
|
navigateBasedOnRole();
|
|
}
|
|
connectSocket();
|
|
startPolling();
|
|
} else {
|
|
if (multiPageMode) {
|
|
if (currentPageKey !== 'onboarding') {
|
|
if (navigateToScreen('onboarding', { replace: true })) return;
|
|
} else {
|
|
store.update({ screen: 'onboarding' });
|
|
}
|
|
} else {
|
|
store.update({ screen: 'onboarding' });
|
|
}
|
|
}
|
|
} else {
|
|
if (multiPageMode) {
|
|
if (currentPageKey !== 'auth') {
|
|
if (navigateToScreen('auth', { replace: true })) return;
|
|
} else {
|
|
store.update({ screen: 'auth' });
|
|
}
|
|
} else {
|
|
store.update({ screen: 'auth' });
|
|
}
|
|
}
|
|
} catch {
|
|
if (multiPageMode) {
|
|
if (currentPageKey !== 'auth') {
|
|
if (navigateToScreen('auth', { replace: true })) return;
|
|
} else {
|
|
store.update({ screen: 'auth' });
|
|
}
|
|
} else {
|
|
store.update({ screen: 'auth' });
|
|
}
|
|
}
|
|
};
|
|
|
|
const navigateBasedOnRole = () => {
|
|
const { device } = store.get();
|
|
if (!device) {
|
|
navigateToScreen('onboarding');
|
|
return;
|
|
}
|
|
|
|
// Default home screen based on role
|
|
navigateToScreen('home', { role: device.role });
|
|
};
|
|
|
|
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 = () => {
|
|
const { activeStreamSessionId } = store.get();
|
|
if (activeStreamSessionId && streamTimers.has(activeStreamSessionId)) {
|
|
clearTimeout(streamTimers.get(activeStreamSessionId));
|
|
streamTimers.delete(activeStreamSessionId);
|
|
}
|
|
const videoEl = $('clientStreamVideo');
|
|
const imageEl = $('clientStreamImage');
|
|
if (activeStreamSessionId && remoteStreams.has(activeStreamSessionId)) {
|
|
remoteStreams.get(activeStreamSessionId).getTracks().forEach((track) => track.stop());
|
|
remoteStreams.delete(activeStreamSessionId);
|
|
}
|
|
if (videoEl) {
|
|
videoEl.srcObject = null;
|
|
}
|
|
if (imageEl) {
|
|
imageEl.src = '';
|
|
}
|
|
setClientStreamMode('none');
|
|
};
|
|
|
|
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;
|
|
|
|
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();
|
|
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');
|
|
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 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;
|
|
}
|
|
|
|
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();
|
|
}
|
|
};
|
|
|
|
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);
|
|
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 (peerConnections.has(streamSessionId)) {
|
|
return peerConnections.get(streamSessionId);
|
|
}
|
|
|
|
const connection = new RTCPeerConnection(rtcConfig);
|
|
peerConnections.set(streamSessionId, connection);
|
|
|
|
if (asCamera) {
|
|
peerSessionId = streamSessionId;
|
|
peerTargetDeviceId = targetDeviceId;
|
|
}
|
|
|
|
connection.onicecandidate = (event) => {
|
|
if (!socket || !event.candidate) return;
|
|
socket.emit('webrtc:signal', {
|
|
toDeviceId: targetDeviceId,
|
|
streamSessionId: streamSessionId,
|
|
signalType: 'candidate',
|
|
data: event.candidate.toJSON(),
|
|
});
|
|
};
|
|
|
|
connection.onconnectionstatechange = () => {
|
|
if (connection.connectionState === 'connected') {
|
|
addActivity('WebRTC', `Peer connected for ${streamSessionId}`);
|
|
connectedPeers.add(streamSessionId);
|
|
if (asCamera) {
|
|
webrtcConnected = true;
|
|
hasWebrtcEverConnected = true;
|
|
stopFrameRelay();
|
|
}
|
|
} 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 (streamTimers.has(streamSessionId)) {
|
|
clearTimeout(streamTimers.get(streamSessionId));
|
|
streamTimers.delete(streamSessionId);
|
|
}
|
|
const [stream] = event.streams;
|
|
if (!stream) return;
|
|
|
|
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
|
|
}
|
|
};
|
|
|
|
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 compressedBlob = await compressRecordingBlob(captureResult.blob);
|
|
|
|
const uploadMeta = await API.request('/videos/upload-url', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
fileName: `stream-${streamSessionId}.webm`,
|
|
deviceId: currentDevice.id,
|
|
prefix: 'recordings',
|
|
recordingId: recording.id,
|
|
}),
|
|
});
|
|
|
|
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}`);
|
|
}
|
|
|
|
await API.events.finalizeRecording(recording.id, {
|
|
objectKey: uploadMeta.objectKey,
|
|
bucket: uploadMeta.bucket,
|
|
durationSeconds: captureResult.durationSeconds,
|
|
sizeBytes: compressedBlob.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 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',
|
|
eventId: lastMotionEventId,
|
|
}),
|
|
});
|
|
|
|
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}`);
|
|
}
|
|
|
|
await API.events.finalizeRecording(uploadMeta.video.id, {
|
|
objectKey: uploadMeta.objectKey,
|
|
bucket: uploadMeta.bucket,
|
|
durationSeconds: captureResult.durationSeconds,
|
|
sizeBytes: compressedBlob.size,
|
|
});
|
|
|
|
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;
|
|
|
|
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');
|
|
}
|
|
activeRecordingStreamSessionId = streamId;
|
|
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');
|
|
}
|
|
|
|
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);
|
|
|
|
// 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...');
|
|
|
|
// 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({
|
|
cameraSessions: {
|
|
...(store.get().cameraSessions || {}),
|
|
[payload.cameraDeviceId]: payload.streamSessionId
|
|
}
|
|
});
|
|
|
|
try {
|
|
await API.streams.getSubscribeCreds(payload.streamSessionId);
|
|
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));
|
|
} catch (e) {
|
|
console.error('Stream connect failed', e);
|
|
}
|
|
});
|
|
|
|
socket.on('stream:frame', (payload) => {
|
|
if (connectedPeers.has(payload.streamSessionId)) return;
|
|
if (!payload?.frame) return;
|
|
|
|
if (streamTimers.has(payload.streamSessionId)) {
|
|
clearTimeout(streamTimers.get(payload.streamSessionId));
|
|
streamTimers.delete(payload.streamSessionId);
|
|
}
|
|
|
|
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');
|
|
}
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
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') {
|
|
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 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;
|
|
const conn = peerConnections.get(payload.streamSessionId);
|
|
if (!conn) {
|
|
queueRemoteCandidate(payload);
|
|
return;
|
|
}
|
|
if (!conn.remoteDescription) {
|
|
queueRemoteCandidate(payload);
|
|
return;
|
|
}
|
|
await conn.addIceCandidate(new RTCIceCandidate(payload.data));
|
|
return;
|
|
}
|
|
|
|
if (payload.signalType === 'hangup') {
|
|
teardownPeerConnection(payload.streamSessionId);
|
|
if (store.get().activeStreamSessionId === payload.streamSessionId) {
|
|
store.update({ 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 } = store.get();
|
|
if (!device) return;
|
|
|
|
if (screen === 'home' && device.role === 'client') {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
const role = store.get().device?.role;
|
|
if (multiPageMode && currentPageKey === 'auth') {
|
|
if (navigateToScreen('home', { replace: true, role })) return;
|
|
} else {
|
|
navigateBasedOnRole();
|
|
}
|
|
connectSocket();
|
|
startPolling();
|
|
} else {
|
|
if (navigateToScreen('onboarding')) return;
|
|
}
|
|
} 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');
|
|
if (navigateToScreen('auth', { replace: true })) return;
|
|
Toast.show('Signed Out', 'info');
|
|
},
|
|
|
|
// Camera 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);
|
|
} catch (e) { }
|
|
},
|
|
|
|
endMotion: async () => {
|
|
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');
|
|
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) { }
|
|
},
|
|
|
|
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 {
|
|
console.log(`Requesting Stream from ${camId}...`);
|
|
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;
|
|
}
|
|
|
|
if (navigateToScreen('home')) return;
|
|
await Actions.requestStream(cameraDeviceId);
|
|
},
|
|
};
|
|
|
|
// --- 5. Rendering ---
|
|
|
|
const render = (state) => {
|
|
// 1. Screen Visibility
|
|
$$('section[id^="screen-"]').forEach(el => el.classList.add('hidden'));
|
|
|
|
const showSectionById = (id) => {
|
|
const element = $(id);
|
|
if (!element) return false;
|
|
element.classList.remove('hidden');
|
|
return true;
|
|
};
|
|
|
|
if (state.screen === 'home') {
|
|
const preferredHomeId = state.device?.role === 'camera' ? 'screen-home-camera' : 'screen-home-client';
|
|
if (!showSectionById(preferredHomeId)) {
|
|
const fallbackHomeId = preferredHomeId === 'screen-home-camera' ? 'screen-home-client' : 'screen-home-camera';
|
|
showSectionById(fallbackHomeId);
|
|
}
|
|
} else {
|
|
showSectionById(`screen-${state.screen}`);
|
|
}
|
|
|
|
// 2. Top Bar Status
|
|
const statusDot = $('#connectionStatus .status-dot');
|
|
const statusText = $('#connectionStatus span:last-child');
|
|
if (statusDot && statusText) {
|
|
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 (authBadge) {
|
|
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 (nav) {
|
|
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' && state.screen === 'home') {
|
|
const preview = $('cameraPreview');
|
|
const offlineOverlay = $('cameraOfflineOverlay');
|
|
const startMotionBtn = $('startMotionBtn');
|
|
const endMotionBtn = $('endMotionBtn');
|
|
|
|
if (!preview || !offlineOverlay || !startMotionBtn || !endMotionBtn) return;
|
|
|
|
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') {
|
|
const list = $('linkedCamerasList');
|
|
if (list) {
|
|
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 => {
|
|
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 `
|
|
<div class="min-w-[240px] max-w-[240px] bg-gray-900/60 rounded-xl border border-white/5 overflow-hidden flex-shrink-0 cursor-pointer hover:border-blue-500/50 transition-colors camera-card" data-camera-id="${link.cameraDeviceId}">
|
|
<div class="relative overflow-hidden bg-black/40 border-b border-white/5 aspect-video">
|
|
${state.activeCameraDeviceId === link.cameraDeviceId
|
|
? `
|
|
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-blue-400 bg-blue-500/10">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
|
</svg>
|
|
<p class="text-[10px] font-medium uppercase tracking-wider">Viewing</p>
|
|
</div>
|
|
`
|
|
: `
|
|
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-500 hover:text-gray-300 transition-colors">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" 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]">${Array.from(connectedPeers).includes(link.cameraDeviceId) ? 'Live Stream Active' : (requestedStreams.has(link.cameraDeviceId) ? 'Connecting...' : 'Click to view')}</p>
|
|
</div>
|
|
`
|
|
}
|
|
</div>
|
|
<div class="px-3 py-3">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<div class="w-2 h-2 rounded-full shrink-0 ${statusDotClass}"></div>
|
|
<div class="min-w-0">
|
|
<p class="text-xs font-bold text-gray-200 truncate" title="${escapedCameraName}">${escapedCameraName}</p>
|
|
<p class="text-[10px] text-gray-400 capitalize">${cameraStatus}</p>
|
|
</div>
|
|
</div>
|
|
<div class="relative linked-camera-menu">
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-xs btn-circle text-gray-400 hover:text-white linked-camera-menu-toggle"
|
|
data-link-id="${link.id}"
|
|
aria-haspopup="menu"
|
|
aria-expanded="${state.openLinkedCameraMenuId === link.id ? 'true' : 'false'}"
|
|
aria-label="Camera options"
|
|
title="Camera options">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
<circle cx="10" cy="4" r="1.6" />
|
|
<circle cx="10" cy="10" r="1.6" />
|
|
<circle cx="10" cy="16" r="1.6" />
|
|
</svg>
|
|
</button>
|
|
<div
|
|
class="${state.openLinkedCameraMenuId === link.id ? '' : 'hidden '}absolute right-0 bottom-8 z-20 w-28 overflow-hidden rounded-lg border border-white/10 bg-gray-900/95 shadow-xl backdrop-blur linked-camera-menu-dropdown"
|
|
role="menu"
|
|
aria-label="Linked camera actions">
|
|
<button
|
|
type="button"
|
|
class="w-full px-3 py-2 text-left text-xs text-gray-200 hover:bg-white/10 linked-camera-rename-btn"
|
|
data-camera-id="${link.cameraDeviceId}"
|
|
role="menuitem">
|
|
Rename
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="w-full px-3 py-2 text-left text-xs text-red-300 hover:bg-red-500/10 linked-camera-delete-btn"
|
|
data-link-id="${link.id}"
|
|
role="menuitem">
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
// Show/hide main wrapper
|
|
const viewerWrapper = $('clientStreamViewerWrapper');
|
|
if (viewerWrapper) {
|
|
if (state.activeCameraDeviceId) {
|
|
viewerWrapper.classList.remove('hidden');
|
|
const title = $('clientStreamViewerTitle');
|
|
if (title) title.textContent = `Live Feed: ${getCameraLabel(state.activeCameraDeviceId)}`;
|
|
} else {
|
|
viewerWrapper.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
if (state.activeCameraDeviceId) {
|
|
// Find session ID for active camera if known
|
|
let foundSessionId = state.activeStreamSessionId;
|
|
const sessions = state.cameraSessions || {};
|
|
if (!foundSessionId && sessions[state.activeCameraDeviceId]) {
|
|
foundSessionId = sessions[state.activeCameraDeviceId];
|
|
}
|
|
|
|
const currentStream = foundSessionId ? remoteStreams.get(foundSessionId) : null;
|
|
if (currentStream) {
|
|
const videoEl = $('clientStreamVideo');
|
|
if (videoEl && videoEl.srcObject !== currentStream) {
|
|
videoEl.srcObject = currentStream;
|
|
setClientStreamMode('video');
|
|
$('clientLiveDot')?.classList.remove('hidden');
|
|
// Only play if it's not already playing to prevent interruptions
|
|
if (videoEl.paused) {
|
|
void videoEl.play().catch(() => { });
|
|
}
|
|
}
|
|
} else {
|
|
$('clientLiveDot')?.classList.add('hidden');
|
|
}
|
|
} else {
|
|
$('clientLiveDot')?.classList.add('hidden');
|
|
}
|
|
|
|
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 (recList) {
|
|
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 (activityFeed) {
|
|
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') {
|
|
const profileName = $('profileName');
|
|
const profileEmail = $('profileEmail');
|
|
const profileInitials = $('profileInitials');
|
|
if (profileName) profileName.textContent = state.session.user.name;
|
|
if (profileEmail) profileEmail.textContent = state.session.user.email;
|
|
if (profileInitials) profileInitials.textContent = state.session.user.name.charAt(0).toUpperCase();
|
|
}
|
|
};
|
|
|
|
const addActivity = (type, msg) => {
|
|
const list = $('activityFeedList');
|
|
if (list) {
|
|
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 (!dot) return;
|
|
if (show) dot.classList.remove('hidden');
|
|
else dot.classList.add('hidden');
|
|
};
|
|
|
|
// --- 6. Event Listeners ---
|
|
|
|
const bind = (id, eventName, handler) => {
|
|
const element = $(id);
|
|
if (!element) return;
|
|
element.addEventListener(eventName, handler);
|
|
};
|
|
|
|
bind('toggleAuthModeBtn', 'click', Actions.toggleAuthMode);
|
|
bind('signInBtn', 'click', Actions.submitAuth);
|
|
bind('registerBtn', 'click', Actions.registerDevice);
|
|
bind('loadSavedBtn', 'click', () => { /* Handle legacy loading if needed */ });
|
|
$$('#screen-onboarding [data-role]').forEach((btn) => {
|
|
btn.addEventListener('click', () => Actions.selectRole(btn.dataset.role));
|
|
});
|
|
bind('recordingsList', '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);
|
|
});
|
|
bind('activityFeedList', '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);
|
|
});
|
|
|
|
$('linkedCamerasList')?.addEventListener('click', (event) => {
|
|
const menuToggleBtn = event.target.closest('.linked-camera-menu-toggle');
|
|
if (menuToggleBtn) {
|
|
event.stopPropagation();
|
|
const linkId = menuToggleBtn.dataset.linkId;
|
|
if (!linkId) return;
|
|
|
|
const { openLinkedCameraMenuId } = store.get();
|
|
store.update({
|
|
openLinkedCameraMenuId: openLinkedCameraMenuId === linkId ? null : linkId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const renameBtn = event.target.closest('.linked-camera-rename-btn');
|
|
if (renameBtn) {
|
|
event.stopPropagation();
|
|
const cameraId = renameBtn.dataset.cameraId;
|
|
if (cameraId) {
|
|
store.update({ openLinkedCameraMenuId: null });
|
|
void Actions.renameLinkedCamera(cameraId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const deleteBtn = event.target.closest('.linked-camera-delete-btn');
|
|
if (deleteBtn) {
|
|
event.stopPropagation();
|
|
const linkId = deleteBtn.dataset.linkId;
|
|
if (linkId) {
|
|
store.update({ openLinkedCameraMenuId: null });
|
|
void Actions.deleteLinkedCamera(linkId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const card = event.target.closest('.camera-card');
|
|
if (!card) return;
|
|
const camId = card.dataset.cameraId;
|
|
if (camId) {
|
|
const sessions = store.get().cameraSessions || {};
|
|
store.update({
|
|
activeCameraDeviceId: camId,
|
|
activeStreamSessionId: sessions[camId] || null,
|
|
openLinkedCameraMenuId: null,
|
|
});
|
|
// If not currently streamed or requested, kick off request
|
|
if (!requestedStreams.has(camId)) {
|
|
requestedStreams.add(camId);
|
|
Actions.requestStream(camId);
|
|
} else {
|
|
// Just re-render
|
|
store.notify();
|
|
}
|
|
}
|
|
});
|
|
|
|
document.addEventListener('click', (event) => {
|
|
if (!event.target.closest('.linked-camera-menu')) {
|
|
const { openLinkedCameraMenuId } = store.get();
|
|
if (openLinkedCameraMenuId) {
|
|
store.update({ openLinkedCameraMenuId: null });
|
|
}
|
|
}
|
|
});
|
|
|
|
// Navbar
|
|
$$('.nav-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
if (btn.dataset.target === 'activity') {
|
|
markAllNotificationsRead();
|
|
}
|
|
if (navigateToScreen(btn.dataset.target)) return;
|
|
});
|
|
});
|
|
|
|
// Camera Controls
|
|
bind('cameraGoOnlineBtn', 'click', async () => {
|
|
if (store.get().device?.role === 'camera') {
|
|
await startCameraPreview();
|
|
}
|
|
connectSocket();
|
|
});
|
|
bind('startMotionBtn', 'click', Actions.startMotion);
|
|
bind('endMotionBtn', 'click', Actions.endMotion);
|
|
|
|
// Client Controls
|
|
bind('linkCameraBtn', 'click', Actions.linkCamera);
|
|
bind('refreshClientBtn', 'click', startPolling);
|
|
|
|
// Settings
|
|
bind('signOutBtn', 'click', Actions.signOut);
|
|
bind('clearActivityBtn', 'click', () => {
|
|
store.update({ motionNotifications: [] });
|
|
});
|
|
bind('recordingModalCloseBtn', 'click', Actions.closeRecordingModal);
|
|
bind('recordingModal', 'click', (event) => {
|
|
if (event.target === $('recordingModal')) {
|
|
Actions.closeRecordingModal();
|
|
}
|
|
});
|
|
$('closeStreamViewerBtn')?.addEventListener('click', () => {
|
|
store.update({ activeCameraDeviceId: null, activeStreamSessionId: null });
|
|
});
|
|
|
|
// Init
|
|
store.subscribe(render);
|
|
init();
|
|
|
|
window.addEventListener('beforeunload', () => {
|
|
stopFrameRelay();
|
|
void stopLocalRecording();
|
|
teardownPeerConnection();
|
|
stopCameraPreview();
|
|
});
|
|
|
|
window.Actions = Actions;
|