feat(recordings): enhance recording management with improved error handling, finalize recording logic, and add motion notification support
This commit is contained in:
@@ -208,17 +208,6 @@
|
||||
|
||||
<!-- CLIENT DASHBOARD -->
|
||||
<section id="screen-home-client" class="hidden space-y-5">
|
||||
<div class="relative overflow-hidden rounded-2xl bg-gray-900 border border-white/5 aspect-video">
|
||||
<video id="clientStreamVideo" class="absolute inset-0 w-full h-full object-cover hidden" autoplay playsinline></video>
|
||||
<img id="clientStreamImage" class="absolute inset-0 w-full h-full object-cover hidden" alt="Live stream preview" />
|
||||
<div id="clientStreamPlaceholder" class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p class="text-xs">No active live stream</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
<button id="linkCameraBtn" class="btn btn-sm btn-outline border-white/10 text-gray-300 gap-2 shrink-0">
|
||||
@@ -233,7 +222,7 @@
|
||||
<!-- Live Feeds Section -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-xs font-bold text-gray-500 uppercase tracking-wider pl-1">Linked Cameras</h3>
|
||||
<div id="linkedCamerasList" class="space-y-3">
|
||||
<div id="linkedCamerasList" class="flex gap-3 overflow-x-auto pb-2 scrollbar-hide">
|
||||
<!-- Populated by JS -->
|
||||
<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>
|
||||
@@ -309,5 +298,15 @@
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="/sim/mobile-sim.js" defer></script>
|
||||
|
||||
<div id="recordingModal" class="fixed inset-0 bg-black/70 backdrop-blur-sm z-50 hidden items-center justify-center p-4">
|
||||
<div class="w-full max-w-lg bg-gray-900 border border-white/10 rounded-2xl p-4 space-y-3 shadow-2xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="recordingModalTitle" class="text-sm font-semibold text-white">Recording Playback</h3>
|
||||
<button id="recordingModalCloseBtn" class="btn btn-xs btn-ghost text-gray-400">Close</button>
|
||||
</div>
|
||||
<video id="recordingModalVideo" class="w-full rounded-lg bg-black max-h-[60vh]" controls playsinline></video>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -39,6 +39,9 @@ const store = new Store({
|
||||
cameraStatus: 'idle', // idle, recording, streaming
|
||||
linkedCameras: [],
|
||||
recordings: [],
|
||||
motionNotifications: [],
|
||||
activeCameraDeviceId: null,
|
||||
activeStreamSessionId: null,
|
||||
activityFeed: [],
|
||||
loading: false, // global loading spinner state if needed
|
||||
});
|
||||
@@ -131,7 +134,7 @@ const API = {
|
||||
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 }) }),
|
||||
finalizeRecording: (id, payload) => API.request(`/recordings/${id}/finalize`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||
},
|
||||
|
||||
ops: {
|
||||
@@ -154,6 +157,10 @@ let remoteStreamWaitTimer = null;
|
||||
let frameRelayTimer = null;
|
||||
let frameCanvas = null;
|
||||
let frameContext = null;
|
||||
let activeMediaRecorder = null;
|
||||
let activeRecordingChunks = [];
|
||||
let activeRecordingStartedAt = null;
|
||||
let recordingModalUrl = null;
|
||||
const rtcConfig = {
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||
};
|
||||
@@ -269,6 +276,70 @@ const clearClientStream = () => {
|
||||
setClientStreamVisibility(false);
|
||||
};
|
||||
|
||||
const getCameraLabel = (cameraDeviceId) => `Camera ${cameraDeviceId?.substring(0, 6) ?? 'Unknown'}`;
|
||||
|
||||
const pushMotionNotification = (cameraDeviceId) => {
|
||||
if (!cameraDeviceId) return;
|
||||
|
||||
const notification = {
|
||||
id: crypto.randomUUID(),
|
||||
cameraDeviceId,
|
||||
message: `${getCameraLabel(cameraDeviceId)} has detected movement`,
|
||||
createdAt: new Date().toISOString(),
|
||||
isRead: false,
|
||||
};
|
||||
|
||||
store.update({
|
||||
motionNotifications: [notification, ...store.get().motionNotifications].slice(0, 50),
|
||||
});
|
||||
};
|
||||
|
||||
const markMotionNotificationRead = (notificationId) => {
|
||||
store.update({
|
||||
motionNotifications: store
|
||||
.get()
|
||||
.motionNotifications.map((notification) =>
|
||||
notification.id === notificationId ? { ...notification, isRead: true } : notification,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const markAllNotificationsRead = () => {
|
||||
store.update({
|
||||
motionNotifications: store
|
||||
.get()
|
||||
.motionNotifications.map((notification) => (notification.isRead ? notification : { ...notification, isRead: true })),
|
||||
});
|
||||
};
|
||||
|
||||
const openRecordingModal = (downloadUrl, title) => {
|
||||
const modal = $('recordingModal');
|
||||
const videoEl = $('recordingModalVideo');
|
||||
const titleEl = $('recordingModalTitle');
|
||||
|
||||
if (!modal || !videoEl || !titleEl) return;
|
||||
|
||||
recordingModalUrl = downloadUrl;
|
||||
titleEl.textContent = title || 'Recording Playback';
|
||||
videoEl.src = downloadUrl;
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
void videoEl.play().catch(() => {});
|
||||
};
|
||||
|
||||
const closeRecordingModal = () => {
|
||||
const modal = $('recordingModal');
|
||||
const videoEl = $('recordingModalVideo');
|
||||
if (!modal || !videoEl) return;
|
||||
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
videoEl.pause();
|
||||
videoEl.removeAttribute('src');
|
||||
videoEl.load();
|
||||
recordingModalUrl = null;
|
||||
};
|
||||
|
||||
const stopFrameRelay = () => {
|
||||
if (frameRelayTimer) {
|
||||
clearInterval(frameRelayTimer);
|
||||
@@ -311,6 +382,82 @@ const startFrameRelay = async (streamSessionId, toDeviceId) => {
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const getPreferredRecordingMimeType = () => {
|
||||
if (typeof MediaRecorder === 'undefined') return '';
|
||||
const preferredTypes = [
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm',
|
||||
];
|
||||
return preferredTypes.find((type) => MediaRecorder.isTypeSupported(type)) ?? '';
|
||||
};
|
||||
|
||||
const startLocalRecording = async () => {
|
||||
if (!localCameraStream || typeof MediaRecorder === 'undefined') {
|
||||
addActivity('Recording', 'MediaRecorder unavailable');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeMediaRecorder?.state === 'recording') {
|
||||
return true;
|
||||
}
|
||||
|
||||
activeRecordingChunks = [];
|
||||
activeRecordingStartedAt = Date.now();
|
||||
|
||||
try {
|
||||
const mimeType = getPreferredRecordingMimeType();
|
||||
activeMediaRecorder = mimeType ? new MediaRecorder(localCameraStream, { mimeType }) : new MediaRecorder(localCameraStream);
|
||||
} catch (error) {
|
||||
console.error('Failed to create MediaRecorder', error);
|
||||
addActivity('Recording', 'Failed to start recorder');
|
||||
activeMediaRecorder = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
activeMediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
activeRecordingChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
activeMediaRecorder.start(1000);
|
||||
addActivity('Recording', 'Local recording started');
|
||||
return true;
|
||||
};
|
||||
|
||||
const stopLocalRecording = async () => {
|
||||
if (!activeMediaRecorder || activeMediaRecorder.state === 'inactive') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const recorder = activeMediaRecorder;
|
||||
const startedAt = activeRecordingStartedAt ?? Date.now();
|
||||
|
||||
recorder.onstop = () => {
|
||||
const mimeType = recorder.mimeType || 'video/webm';
|
||||
const blob = activeRecordingChunks.length > 0 ? new Blob(activeRecordingChunks, { type: mimeType }) : null;
|
||||
const durationSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
||||
|
||||
activeMediaRecorder = null;
|
||||
activeRecordingChunks = [];
|
||||
activeRecordingStartedAt = null;
|
||||
|
||||
resolve(blob ? { blob, durationSeconds } : null);
|
||||
};
|
||||
|
||||
recorder.onerror = () => {
|
||||
activeMediaRecorder = null;
|
||||
activeRecordingChunks = [];
|
||||
activeRecordingStartedAt = null;
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
recorder.stop();
|
||||
});
|
||||
};
|
||||
|
||||
const teardownPeerConnection = () => {
|
||||
if (peerConnection) {
|
||||
peerConnection.onicecandidate = null;
|
||||
@@ -408,6 +555,73 @@ const startOfferToClient = async (streamSessionId, requesterDeviceId) => {
|
||||
});
|
||||
};
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const finalizeRecordingForStream = async (streamSessionId, captureResult) => {
|
||||
const currentDevice = store.get().device;
|
||||
if (!currentDevice?.id) {
|
||||
addActivity('Recording', 'No device identity for finalize');
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const recs = await API.ops.listRecordings().catch(() => ({ recordings: [] }));
|
||||
const recording = (recs.recordings || []).find((rec) => rec.streamSessionId === streamSessionId && rec.status === 'awaiting_upload');
|
||||
|
||||
if (recording?.id) {
|
||||
try {
|
||||
if (!captureResult?.blob || captureResult.blob.size === 0) {
|
||||
throw new Error('No captured video blob to upload');
|
||||
}
|
||||
|
||||
const uploadMeta = await API.request('/videos/upload-url', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: `stream-${streamSessionId}.webm`,
|
||||
deviceId: currentDevice.id,
|
||||
prefix: 'recordings',
|
||||
}),
|
||||
});
|
||||
|
||||
const uploadResponse = await fetch(uploadMeta.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': captureResult.blob.type || 'video/webm' },
|
||||
body: captureResult.blob,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||
}
|
||||
|
||||
await API.events.finalizeRecording(recording.id, {
|
||||
objectKey: uploadMeta.objectKey,
|
||||
bucket: uploadMeta.bucket,
|
||||
durationSeconds: captureResult.durationSeconds,
|
||||
sizeBytes: captureResult.blob.size,
|
||||
});
|
||||
|
||||
addActivity('Recording', 'Recording uploaded and finalized');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Recording upload failed, falling back to simulated key', error);
|
||||
const fallbackObjectKey = `sim/${streamSessionId}/${Date.now()}.webm`;
|
||||
await API.events.finalizeRecording(recording.id, {
|
||||
objectKey: fallbackObjectKey,
|
||||
durationSeconds: captureResult?.durationSeconds ?? 15,
|
||||
sizeBytes: captureResult?.blob?.size ?? 5000000,
|
||||
});
|
||||
addActivity('Recording', 'Upload failed; finalized with simulator fallback');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(350);
|
||||
}
|
||||
|
||||
addActivity('Recording', 'No recording row found to finalize');
|
||||
return false;
|
||||
};
|
||||
|
||||
const connectSocket = () => {
|
||||
const { deviceToken } = store.get();
|
||||
if (!deviceToken) return;
|
||||
@@ -427,7 +641,9 @@ const connectSocket = () => {
|
||||
socket.on('disconnect', () => {
|
||||
store.update({ socketConnected: false });
|
||||
stopFrameRelay();
|
||||
void stopLocalRecording();
|
||||
teardownPeerConnection();
|
||||
store.update({ activeCameraDeviceId: null, activeStreamSessionId: null });
|
||||
});
|
||||
|
||||
// Handle commands (as Camera)
|
||||
@@ -443,6 +659,7 @@ const connectSocket = () => {
|
||||
}
|
||||
await API.streams.accept(streamId);
|
||||
await API.streams.getPublishCreds(streamId);
|
||||
await startLocalRecording();
|
||||
if (payload.sourceDeviceId) {
|
||||
await startOfferToClient(streamId, payload.sourceDeviceId);
|
||||
await startFrameRelay(streamId, payload.sourceDeviceId);
|
||||
@@ -450,7 +667,9 @@ const connectSocket = () => {
|
||||
addActivity('Stream', 'Accepted & Published');
|
||||
// Auto-stop after 15s for simulation
|
||||
setTimeout(async () => {
|
||||
const captureResult = await stopLocalRecording();
|
||||
await API.streams.end(streamId);
|
||||
await finalizeRecordingForStream(streamId, captureResult);
|
||||
stopFrameRelay();
|
||||
if (socket && payload.sourceDeviceId) {
|
||||
socket.emit('webrtc:signal', {
|
||||
@@ -460,6 +679,7 @@ const connectSocket = () => {
|
||||
});
|
||||
}
|
||||
teardownPeerConnection();
|
||||
store.update({ activeCameraDeviceId: null, activeStreamSessionId: null });
|
||||
addActivity('Stream', 'Ended auto-simulation');
|
||||
}, 15000);
|
||||
}
|
||||
@@ -472,14 +692,19 @@ const connectSocket = () => {
|
||||
|
||||
// Handle Events (as Client)
|
||||
socket.on('motion:detected', (payload) => {
|
||||
addActivity('Motion', `Detected on camera ${payload.deviceId?.split('-')[0]}...`);
|
||||
const cameraDeviceId = payload.cameraDeviceId || payload.deviceId;
|
||||
addActivity('Motion', `${getCameraLabel(cameraDeviceId)} has detected movement`);
|
||||
Toast.show('Motion Detected!', 'info');
|
||||
updateNotificationDot(true);
|
||||
pushMotionNotification(cameraDeviceId);
|
||||
});
|
||||
|
||||
socket.on('stream:started', async (payload) => {
|
||||
addActivity('Stream', 'Stream is live, connecting...');
|
||||
clearClientStream();
|
||||
store.update({
|
||||
activeCameraDeviceId: payload.cameraDeviceId ?? store.get().activeCameraDeviceId,
|
||||
activeStreamSessionId: payload.streamSessionId ?? null,
|
||||
});
|
||||
try {
|
||||
await API.streams.getSubscribeCreds(payload.streamSessionId);
|
||||
Toast.show('Connected to Stream', 'success');
|
||||
@@ -511,6 +736,13 @@ const connectSocket = () => {
|
||||
setClientStreamVisibility(true);
|
||||
});
|
||||
|
||||
socket.on('stream:ended', (payload) => {
|
||||
if (payload?.streamSessionId && payload.streamSessionId === store.get().activeStreamSessionId) {
|
||||
clearClientStream();
|
||||
store.update({ activeCameraDeviceId: null, activeStreamSessionId: null });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('webrtc:signal', async (payload) => {
|
||||
const device = store.get().device;
|
||||
if (!device || !payload?.streamSessionId || !payload?.signalType || !payload?.fromDeviceId) return;
|
||||
@@ -553,6 +785,7 @@ const connectSocket = () => {
|
||||
|
||||
if (payload.signalType === 'hangup') {
|
||||
teardownPeerConnection();
|
||||
store.update({ activeCameraDeviceId: null, activeStreamSessionId: null });
|
||||
addActivity('Stream', 'Remote stream ended');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -688,6 +921,7 @@ const Actions = {
|
||||
store.update({ session: null, screen: 'auth', device: null, deviceToken: null, socketConnected: false });
|
||||
if (socket) socket.disconnect();
|
||||
stopFrameRelay();
|
||||
await stopLocalRecording();
|
||||
teardownPeerConnection();
|
||||
stopCameraPreview();
|
||||
localStorage.removeItem('mobileSimDevice');
|
||||
@@ -728,6 +962,7 @@ const Actions = {
|
||||
|
||||
requestStream: async (camId) => {
|
||||
try {
|
||||
store.update({ activeCameraDeviceId: camId });
|
||||
Toast.show('Requesting Stream...', 'info');
|
||||
await API.streams.request(camId);
|
||||
// Socket will handle the rest ('stream:started')
|
||||
@@ -741,11 +976,35 @@ const Actions = {
|
||||
Toast.show('Recording URL unavailable', 'error');
|
||||
return;
|
||||
}
|
||||
window.open(result.downloadUrl, '_blank', 'noopener,noreferrer');
|
||||
const recording = store.get().recordings.find((entry) => entry.id === recordingId);
|
||||
const title = recording ? `${new Date(recording.createdAt).toLocaleString()} recording` : 'Recording Playback';
|
||||
openRecordingModal(result.downloadUrl, title);
|
||||
} catch (e) {
|
||||
// handled by API wrapper
|
||||
}
|
||||
},
|
||||
|
||||
closeRecordingModal: () => {
|
||||
closeRecordingModal();
|
||||
},
|
||||
|
||||
openMotionNotificationTarget: async (notificationId, cameraDeviceId) => {
|
||||
markMotionNotificationRead(notificationId);
|
||||
if (!cameraDeviceId) return;
|
||||
|
||||
const recs = await API.ops.listRecordings().catch(() => ({ recordings: [] }));
|
||||
const readyRecording = (recs.recordings || [])
|
||||
.filter((recording) => recording.cameraDeviceId === cameraDeviceId && recording.status === 'ready')
|
||||
.sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime())[0];
|
||||
|
||||
if (readyRecording?.id) {
|
||||
await Actions.openRecording(readyRecording.id);
|
||||
return;
|
||||
}
|
||||
|
||||
store.update({ screen: 'home' });
|
||||
await Actions.requestStream(cameraDeviceId);
|
||||
},
|
||||
};
|
||||
|
||||
// --- 5. Rendering ---
|
||||
@@ -783,6 +1042,9 @@ const render = (state) => {
|
||||
|
||||
// 3. Bottom Nav Visibility & State
|
||||
const nav = $('bottomNav');
|
||||
const unreadNotifications = state.motionNotifications.filter((notification) => !notification.isRead).length;
|
||||
updateNotificationDot(unreadNotifications > 0);
|
||||
|
||||
if (state.session && state.device) {
|
||||
nav.classList.remove('hidden');
|
||||
$$('.nav-btn').forEach(btn => {
|
||||
@@ -820,24 +1082,56 @@ const render = (state) => {
|
||||
|
||||
// 5. Client Mode Lists
|
||||
if (state.device?.role === 'client' && state.screen === 'home') {
|
||||
if (!state.activeCameraDeviceId && state.linkedCameras.length > 0) {
|
||||
void Actions.requestStream(state.linkedCameras[0].cameraDeviceId);
|
||||
}
|
||||
|
||||
const list = $('linkedCamerasList');
|
||||
if (state.linkedCameras.length === 0) {
|
||||
list.innerHTML = `<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>`;
|
||||
list.innerHTML = `<div class="min-w-full text-center py-8 bg-gray-900/30 rounded-xl border border-dashed border-gray-800"><p class="text-gray-600 text-xs">No cameras linked yet</p></div>`;
|
||||
} else {
|
||||
list.innerHTML = state.linkedCameras.map(link => `
|
||||
<div class="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 class="min-w-[240px] max-w-[240px] bg-gray-900/60 rounded-xl border border-white/5 overflow-hidden">
|
||||
<div class="relative overflow-hidden bg-black/40 border-b border-white/5 aspect-video">
|
||||
${
|
||||
state.activeCameraDeviceId === link.cameraDeviceId
|
||||
? `
|
||||
<video id="clientStreamVideo" class="absolute inset-0 w-full h-full object-cover hidden" autoplay playsinline></video>
|
||||
<img id="clientStreamImage" class="absolute inset-0 w-full h-full object-cover hidden" alt="Live stream preview" />
|
||||
<div id="clientStreamPlaceholder" class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p class="text-[10px]">${state.activeStreamSessionId ? 'Connecting stream...' : 'Waiting for stream'}</p>
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p class="text-[10px]">Stand by</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<div class="px-3 py-2">
|
||||
<div>
|
||||
<p class="text-xs font-bold text-gray-300">${getCameraLabel(link.cameraDeviceId)}</p>
|
||||
<p class="text-[10px] text-gray-500">${link.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-xs btn-primary request-stream-btn" data-camera-device-id="${link.cameraDeviceId}">Live</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
if (state.activeCameraDeviceId && remoteClientStream) {
|
||||
const videoEl = $('clientStreamVideo');
|
||||
if (videoEl && videoEl.srcObject !== remoteClientStream) {
|
||||
videoEl.srcObject = remoteClientStream;
|
||||
setClientStreamVisibility(true);
|
||||
void videoEl.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const recList = $('recordingsList');
|
||||
@@ -858,6 +1152,27 @@ const render = (state) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (state.screen === 'activity') {
|
||||
const activityFeed = $('activityFeedList');
|
||||
if (state.motionNotifications.length === 0) {
|
||||
activityFeed.innerHTML = `
|
||||
<div class="text-center py-10 opacity-50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mx-auto mb-2 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
<p class="text-sm text-gray-500">No notifications yet</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
activityFeed.innerHTML = state.motionNotifications.map((notification) => `
|
||||
<button class="w-full text-left p-3 rounded-lg border border-white/5 ${notification.isRead ? 'bg-gray-900/30' : 'bg-blue-900/20'} motion-notification-btn" data-notification-id="${notification.id}" data-camera-device-id="${notification.cameraDeviceId}">
|
||||
<p class="text-xs font-medium text-gray-200">${notification.message}</p>
|
||||
<p class="text-[10px] text-gray-500 mt-1">${new Date(notification.createdAt).toLocaleString()}</p>
|
||||
</button>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Settings Screen
|
||||
if (state.session?.user && state.screen === 'settings') {
|
||||
$('profileName').textContent = state.session.user.name;
|
||||
@@ -902,13 +1217,6 @@ $('loadSavedBtn').addEventListener('click', () => { /* Handle legacy loading if
|
||||
$$('#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;
|
||||
@@ -916,12 +1224,22 @@ $('recordingsList').addEventListener('click', (event) => {
|
||||
if (!recordingId) return;
|
||||
Actions.openRecording(recordingId);
|
||||
});
|
||||
$('activityFeedList').addEventListener('click', (event) => {
|
||||
const target = event.target.closest('.motion-notification-btn');
|
||||
if (!target) return;
|
||||
const notificationId = target.dataset.notificationId;
|
||||
const cameraDeviceId = target.dataset.cameraDeviceId;
|
||||
if (!notificationId || !cameraDeviceId) return;
|
||||
Actions.openMotionNotificationTarget(notificationId, cameraDeviceId);
|
||||
});
|
||||
|
||||
// Navbar
|
||||
$$('.nav-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.dataset.target === 'activity') {
|
||||
markAllNotificationsRead();
|
||||
}
|
||||
store.update({ screen: btn.dataset.target });
|
||||
if (btn.dataset.target === 'activity') updateNotificationDot(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -941,6 +1259,15 @@ $('refreshClientBtn').addEventListener('click', startPolling);
|
||||
|
||||
// Settings
|
||||
$('signOutBtn').addEventListener('click', Actions.signOut);
|
||||
$('clearActivityBtn').addEventListener('click', () => {
|
||||
store.update({ motionNotifications: [] });
|
||||
});
|
||||
$('recordingModalCloseBtn').addEventListener('click', Actions.closeRecordingModal);
|
||||
$('recordingModal').addEventListener('click', (event) => {
|
||||
if (event.target === $('recordingModal')) {
|
||||
Actions.closeRecordingModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Init
|
||||
store.subscribe(render);
|
||||
@@ -948,6 +1275,7 @@ init();
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
stopFrameRelay();
|
||||
void stopLocalRecording();
|
||||
teardownPeerConnection();
|
||||
stopCameraPreview();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user