881 lines
30 KiB
JavaScript
881 lines
30 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: [],
|
|
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, objectKey) => API.request(`/recordings/${id}/finalize`, { method: 'POST', body: JSON.stringify({ objectKey, bucket: 'videos', durationSeconds: 15, sizeBytes: 5000000 }) }),
|
|
},
|
|
|
|
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;
|
|
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: true, 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 setClientStreamVisibility = (isVisible) => {
|
|
const videoEl = $('clientStreamVideo');
|
|
const placeholderEl = $('clientStreamPlaceholder');
|
|
if (videoEl) {
|
|
videoEl.classList.toggle('hidden', !isVisible);
|
|
}
|
|
if (placeholderEl) {
|
|
placeholderEl.classList.toggle('hidden', isVisible);
|
|
}
|
|
};
|
|
|
|
const clearClientStream = () => {
|
|
if (remoteStreamWaitTimer) {
|
|
clearTimeout(remoteStreamWaitTimer);
|
|
remoteStreamWaitTimer = null;
|
|
}
|
|
const videoEl = $('clientStreamVideo');
|
|
if (remoteClientStream) {
|
|
remoteClientStream.getTracks().forEach((track) => track.stop());
|
|
remoteClientStream = null;
|
|
}
|
|
if (videoEl) {
|
|
videoEl.srcObject = null;
|
|
}
|
|
setClientStreamVisibility(false);
|
|
};
|
|
|
|
const teardownPeerConnection = () => {
|
|
if (peerConnection) {
|
|
peerConnection.onicecandidate = null;
|
|
peerConnection.ontrack = null;
|
|
peerConnection.onconnectionstatechange = null;
|
|
peerConnection.close();
|
|
}
|
|
|
|
peerConnection = null;
|
|
peerSessionId = null;
|
|
peerTargetDeviceId = null;
|
|
clearClientStream();
|
|
};
|
|
|
|
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 = () => {
|
|
addActivity('WebRTC', `Peer ${connection.connectionState}`);
|
|
if (connection.connectionState === 'failed' || connection.connectionState === 'disconnected' || connection.connectionState === 'closed') {
|
|
if (store.get().device?.role === 'client') {
|
|
clearClientStream();
|
|
}
|
|
}
|
|
};
|
|
|
|
connection.ontrack = (event) => {
|
|
if (remoteStreamWaitTimer) {
|
|
clearTimeout(remoteStreamWaitTimer);
|
|
remoteStreamWaitTimer = null;
|
|
}
|
|
const [stream] = event.streams;
|
|
if (!stream) return;
|
|
remoteClientStream = stream;
|
|
const videoEl = $('clientStreamVideo');
|
|
if (videoEl) {
|
|
videoEl.srcObject = stream;
|
|
setClientStreamVisibility(true);
|
|
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 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 });
|
|
teardownPeerConnection();
|
|
});
|
|
|
|
// 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);
|
|
if (payload.sourceDeviceId) {
|
|
await startOfferToClient(streamId, payload.sourceDeviceId);
|
|
}
|
|
addActivity('Stream', 'Accepted & Published');
|
|
// Auto-stop after 15s for simulation
|
|
setTimeout(async () => {
|
|
await API.streams.end(streamId);
|
|
if (socket && payload.sourceDeviceId) {
|
|
socket.emit('webrtc:signal', {
|
|
toDeviceId: payload.sourceDeviceId,
|
|
streamSessionId: streamId,
|
|
signalType: 'hangup',
|
|
});
|
|
}
|
|
teardownPeerConnection();
|
|
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) => {
|
|
addActivity('Motion', `Detected on camera ${payload.deviceId?.split('-')[0]}...`);
|
|
Toast.show('Motion Detected!', 'info');
|
|
updateNotificationDot(true);
|
|
});
|
|
|
|
socket.on('stream:started', async (payload) => {
|
|
addActivity('Stream', 'Stream is live, connecting...');
|
|
clearClientStream();
|
|
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('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));
|
|
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;
|
|
await peerConnection.setRemoteDescription(new RTCSessionDescription(payload.data));
|
|
addActivity('WebRTC', 'Answer received');
|
|
return;
|
|
}
|
|
|
|
if (payload.signalType === 'candidate') {
|
|
if (!peerConnection || !payload.data) return;
|
|
await peerConnection.addIceCandidate(new RTCIceCandidate(payload.data));
|
|
addActivity('WebRTC', 'ICE candidate added');
|
|
return;
|
|
}
|
|
|
|
if (payload.signalType === 'hangup') {
|
|
teardownPeerConnection();
|
|
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 = 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();
|
|
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 {
|
|
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;
|
|
}
|
|
window.open(result.downloadUrl, '_blank', 'noopener,noreferrer');
|
|
} catch (e) {
|
|
// handled by API wrapper
|
|
}
|
|
},
|
|
};
|
|
|
|
// --- 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');
|
|
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') {
|
|
const list = $('linkedCamerasList');
|
|
if (state.linkedCameras.length === 0) {
|
|
list.innerHTML = `<div class="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="flex items-center justify-between p-3 bg-gray-900/60 rounded-xl border border-white/5">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-8 h-8 rounded-full bg-blue-900/30 flex items-center justify-center text-blue-500">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" 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>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-bold text-gray-300">Camera ${link.cameraDeviceId.substring(0, 6)}</p>
|
|
<p class="text-[10px] text-gray-500">${link.status}</p>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-xs btn-primary request-stream-btn" data-camera-device-id="${link.cameraDeviceId}">Live</button>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
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('');
|
|
}
|
|
}
|
|
|
|
// 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));
|
|
});
|
|
$('linkedCamerasList').addEventListener('click', (event) => {
|
|
const target = event.target.closest('.request-stream-btn');
|
|
if (!target) return;
|
|
const cameraDeviceId = target.dataset.cameraDeviceId;
|
|
if (!cameraDeviceId) return;
|
|
Actions.requestStream(cameraDeviceId);
|
|
});
|
|
$('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);
|
|
});
|
|
|
|
// Navbar
|
|
$$('.nav-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
store.update({ screen: btn.dataset.target });
|
|
if (btn.dataset.target === 'activity') updateNotificationDot(false);
|
|
});
|
|
});
|
|
|
|
// 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);
|
|
|
|
// Init
|
|
store.subscribe(render);
|
|
init();
|
|
|
|
window.addEventListener('beforeunload', () => {
|
|
teardownPeerConnection();
|
|
stopCameraPreview();
|
|
});
|
|
|
|
window.Actions = Actions;
|