diff --git a/WebApp/src/lib/app/api.js b/WebApp/src/lib/app/api.js index fdd9311..9c7b023 100644 --- a/WebApp/src/lib/app/api.js +++ b/WebApp/src/lib/app/api.js @@ -53,10 +53,10 @@ export const api = { end: (id) => request(`/streams/${id}/end`, { method: 'POST', body: JSON.stringify({ reason: 'completed' }) }) }, events: { - startMotion: () => + startMotion: (payload = {}) => request('/events/motion/start', { method: 'POST', - body: JSON.stringify({ title: 'Simulated Motion', triggeredBy: 'motion' }) + body: JSON.stringify({ title: 'Simulated Motion', triggeredBy: 'motion', ...payload }) }), endMotion: (id) => request(`/events/${id}/motion/end`, { method: 'POST', body: JSON.stringify({ status: 'completed' }) }), finalizeRecording: (id, payload) => request(`/recordings/${id}/finalize`, { method: 'POST', body: JSON.stringify(payload) }) diff --git a/WebApp/src/lib/app/controller.js b/WebApp/src/lib/app/controller.js index 9a2a53a..9e7ca6a 100644 --- a/WebApp/src/lib/app/controller.js +++ b/WebApp/src/lib/app/controller.js @@ -81,6 +81,8 @@ let activeRecordingStartedAt = null; let activeRecordingStreamSessionId = null; let lastMotionEventId = null; let motionDetector = null; +let detectorMotionActive = false; +let autoMotionTransitionInFlight = false; let cameraVideoElement = null; let clientVideoElement = null; @@ -481,6 +483,38 @@ const updateMotionDetectionRuntime = (updates) => { })); }; +const isAutoMotionEventActive = () => { + const state = getAppState(); + return Boolean(state.isMotionActive && state.activeMotionSource === 'auto' && lastMotionEventId); +}; + +const getMotionDetectionPauseReason = () => { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + return 'page hidden'; + } + + const state = getAppState(); + if (state.device?.role !== 'camera') { + return 'device is not a camera'; + } + if (!state.motionDetection?.enabled) { + return 'detector disarmed'; + } + if (!state.socketConnected) { + return 'realtime connection offline'; + } + if (!state.cameraPreviewReady) { + return 'camera preview unavailable'; + } + if (!localCameraStream) { + return 'camera stream unavailable'; + } + if (!cameraVideoElement) { + return 'preview element unavailable'; + } + return 'idle'; +}; + const ensureMotionDetector = () => { if (motionDetector) { return motionDetector; @@ -492,13 +526,22 @@ const ensureMotionDetector = () => { onUpdate: ({ state, score, activeMotion, activeSince }) => { const current = getAppState().motionDetection; const nextState = state === 'cooldown' && !activeMotion ? 'monitoring' : state; + const motionBecameActive = activeMotion && !detectorMotionActive; + const motionBecameInactive = !activeMotion && detectorMotionActive; + detectorMotionActive = activeMotion; updateMotionDetectionRuntime({ state: nextState, score, - lastTriggeredAt: activeMotion - ? current.lastTriggeredAt ?? (activeSince ? new Date(activeSince).toISOString() : new Date().toISOString()) + lastTriggeredAt: motionBecameActive + ? activeSince + ? new Date(activeSince).toISOString() + : new Date().toISOString() : current.lastTriggeredAt }); + + if (motionBecameActive || motionBecameInactive) { + void syncAutoMotionLifecycle({ activeMotion }); + } } }); @@ -514,6 +557,7 @@ const shouldRunMotionDetector = () => { return Boolean( state.device?.role === 'camera' && state.motionDetection?.enabled && + state.socketConnected && state.cameraPreviewReady && localCameraStream && cameraVideoElement @@ -530,9 +574,15 @@ const applyMotionDetectionReadiness = () => { return; } + const pauseReason = getMotionDetectionPauseReason(); + detectorMotionActive = false; + if (isAutoMotionEventActive()) { + void syncAutoMotionLifecycle({ activeMotion: false }); + } + if (detector.isRunning()) { detector.stop('idle'); - addActivity('Motion Detection', 'Detector monitoring paused'); + addActivity('Motion Detection', `Detector monitoring paused (${pauseReason})`); return; } @@ -1094,6 +1144,107 @@ const uploadStandaloneMotionRecording = async (captureResult) => { } }; +const getMotionStartPayload = (source = 'manual') => + source === 'auto' + ? { title: 'Automatic Motion', triggeredBy: 'auto_motion' } + : { title: 'Simulated Motion', triggeredBy: 'motion' }; + +const startMotionEvent = async ({ source = 'manual' } = {}) => { + if (getAppState().isMotionActive || lastMotionEventId) { + return false; + } + + const response = await api.events.startMotion(getMotionStartPayload(source)); + await startCameraPreview(); + await startLocalRecording(); + lastMotionEventId = response.event.id; + + const startedAt = new Date().toISOString(); + setAppState({ + isMotionActive: true, + activeMotionSource: source + }); + + if (source === 'auto') { + updateMotionDetectionRuntime({ lastTriggeredAt: startedAt }); + } + + pushToast(source === 'auto' ? 'Automatic motion event started' : 'Motion Event Started', 'success'); + addActivity( + 'Motion', + source === 'auto' ? `Automatic motion event started (${response.event.id})` : `Started event ${response.event.id}` + ); + return true; +}; + +const endMotionEvent = async ({ source = 'manual' } = {}) => { + if (!lastMotionEventId) { + return false; + } + + const eventId = lastMotionEventId; + 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(eventId); + lastMotionEventId = null; + setAppState({ + isMotionActive: false, + activeMotionSource: null + }); + + pushToast(source === 'auto' ? 'Automatic motion event ended' : 'Motion Ended', 'success'); + addActivity( + 'Motion', + source === 'auto' ? `Automatic motion event ended (${eventId})` : `Ended event ${eventId}` + ); + return true; +}; + +const syncAutoMotionLifecycle = async ({ activeMotion }) => { + if (autoMotionTransitionInFlight) { + return; + } + + if (activeMotion) { + if (getAppState().isMotionActive || lastMotionEventId) { + return; + } + + autoMotionTransitionInFlight = true; + try { + await startMotionEvent({ source: 'auto' }); + } catch (error) { + console.error('Failed to auto-start motion event', error); + pushToast(error.message || 'Failed to start automatic motion event', 'error'); + } finally { + autoMotionTransitionInFlight = false; + } + return; + } + + if (!isAutoMotionEventActive()) { + return; + } + + autoMotionTransitionInFlight = true; + try { + await endMotionEvent({ source: 'auto' }); + } catch (error) { + console.error('Failed to auto-end motion event', error); + pushToast(error.message || 'Failed to end automatic motion event', 'error'); + } finally { + autoMotionTransitionInFlight = false; + } +}; + const handleCameraStreamRequest = async ({ streamId, requesterDeviceId }) => { if (!streamId || !requesterDeviceId) { throw new Error('Missing stream request context'); @@ -1606,13 +1757,7 @@ const actions = { async startMotion() { try { - const response = await api.events.startMotion(); - await startCameraPreview(); - await startLocalRecording(); - lastMotionEventId = response.event.id; - setAppState({ isMotionActive: true }); - pushToast('Motion Event Started', 'success'); - addActivity('Motion', `Started event ${response.event.id}`); + await startMotionEvent({ source: 'manual' }); } catch (error) { pushToast(error.message || 'Failed to start motion', 'error'); } @@ -1621,19 +1766,7 @@ const actions = { async endMotion() { 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); - lastMotionEventId = null; - setAppState({ isMotionActive: false }); - pushToast('Motion Ended', 'success'); - addActivity('Motion', 'Ended event'); + await endMotionEvent({ source: 'manual' }); } catch (error) { pushToast(error.message || 'Failed to end motion', 'error'); } diff --git a/WebApp/src/lib/app/store.js b/WebApp/src/lib/app/store.js index 4efb157..ed22d7d 100644 --- a/WebApp/src/lib/app/store.js +++ b/WebApp/src/lib/app/store.js @@ -9,6 +9,7 @@ export const createInitialState = () => ({ deviceToken: null, socketConnected: false, isMotionActive: false, + activeMotionSource: null, cameraStatus: 'idle', cameraPreviewReady: false, cameraInputDevices: [],