From 78d14cb73f11878a7d88c1fd5a62d6b8a4623c83 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Wed, 15 Apr 2026 19:00:00 +0100 Subject: [PATCH] Refactor web app controller shared and media modules --- WebApp/src/lib/app/controller-media.js | 898 +++++++++++++++ WebApp/src/lib/app/controller-shared.js | 472 ++++++++ WebApp/src/lib/app/controller.js | 1360 ++--------------------- 3 files changed, 1438 insertions(+), 1292 deletions(-) create mode 100644 WebApp/src/lib/app/controller-media.js create mode 100644 WebApp/src/lib/app/controller-shared.js diff --git a/WebApp/src/lib/app/controller-media.js b/WebApp/src/lib/app/controller-media.js new file mode 100644 index 0000000..af4695f --- /dev/null +++ b/WebApp/src/lib/app/controller-media.js @@ -0,0 +1,898 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck + +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; +const DEFAULT_CAMERA_CONSTRAINTS = { + width: { ideal: 640, max: 960 }, + height: { ideal: 360, max: 540 }, + frameRate: { ideal: 15, max: 24 } +}; + +export const createControllerMediaModule = ({ + api, + createMotionDetector, + getAppState, + setAppState, + patchAppState, + pushToast, + addActivity, + getMotionDetectionProfile, + updateMotionDetectionState +}) => { + let localCameraStream = null; + let activeMediaRecorder = null; + let activeRecordingChunks = []; + let activeRecordingStartedAt = null; + let activeRecordingStreamSessionId = null; + let lastMotionEventId = null; + let motionDetector = null; + let detectorMotionActive = false; + let autoMotionTransitionInFlight = false; + + let cameraVideoElement = null; + + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + const updateMotionDetectionRuntime = (updates) => { + patchAppState((state) => ({ + motionDetection: { + ...state.motionDetection, + ...updates + } + })); + }; + + const getCameraDeviceIdFromStream = (stream) => { + if (!stream) return ''; + const videoTrack = stream.getVideoTracks?.()[0]; + if (!videoTrack) return ''; + const settings = videoTrack.getSettings?.() || {}; + return typeof settings.deviceId === 'string' ? settings.deviceId : ''; + }; + + const buildCameraConstraints = (cameraDeviceId = '') => { + if (!cameraDeviceId) return { ...DEFAULT_CAMERA_CONSTRAINTS }; + return { + ...DEFAULT_CAMERA_CONSTRAINTS, + deviceId: { exact: cameraDeviceId } + }; + }; + + 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 shouldRunMotionDetector = () => { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + return false; + } + + const state = getAppState(); + return Boolean( + state.device?.role === 'camera' && + state.motionDetection?.enabled && + state.socketConnected && + state.cameraPreviewReady && + localCameraStream && + cameraVideoElement + ); + }; + + const attachCameraStreamToElement = () => { + if (!cameraVideoElement) return; + cameraVideoElement.srcObject = localCameraStream; + if (localCameraStream) { + void cameraVideoElement.play().catch(() => {}); + } + applyMotionDetectionReadiness(); + }; + + 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 toEvenDimension = (value) => { + const rounded = Math.max(2, Math.floor(value)); + return rounded % 2 === 0 ? rounded : rounded - 1; + }; + + const summarizeFetchFailure = async (response) => { + if (!response) { + return 'no response returned'; + } + + const parts = [`status ${response.status}`]; + try { + const bodyText = (await response.text()).trim(); + if (bodyText) { + parts.push(bodyText.slice(0, 240)); + } + } catch { + // Ignore body parsing failures for diagnostics. + } + + return parts.join(' · '); + }; + + const refreshCameraInputDevices = async () => { + if (!navigator.mediaDevices?.enumerateDevices) { + setAppState({ cameraInputDevices: [], selectedCameraInputId: '' }); + return []; + } + + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const cameraInputDevices = devices + .filter((device) => device.kind === 'videoinput' && device.deviceId) + .map((device, index) => ({ + id: device.deviceId, + label: device.label || `Camera ${index + 1}` + })); + + const selectedCameraInputId = getAppState().selectedCameraInputId; + const streamCameraInputId = getCameraDeviceIdFromStream(localCameraStream); + const candidateCameraInputId = selectedCameraInputId || streamCameraInputId || ''; + const nextSelectedCameraInputId = cameraInputDevices.some((device) => device.id === candidateCameraInputId) + ? candidateCameraInputId + : (cameraInputDevices[0]?.id ?? ''); + + setAppState({ + cameraInputDevices, + selectedCameraInputId: nextSelectedCameraInputId + }); + return cameraInputDevices; + } catch (error) { + console.error('Failed to enumerate cameras', error); + addActivity('Camera', 'Failed to enumerate camera inputs'); + return []; + } + }; + + const startCameraPreview = async (cameraInputId = getAppState().selectedCameraInputId) => { + if (!navigator.mediaDevices?.getUserMedia) { + pushToast('Camera API is not available in this browser', 'error'); + return false; + } + + const requestedCameraInputId = typeof cameraInputId === 'string' ? cameraInputId.trim() : ''; + const activeCameraInputId = getCameraDeviceIdFromStream(localCameraStream); + + if (localCameraStream) { + if (!requestedCameraInputId || requestedCameraInputId === activeCameraInputId) { + attachCameraStreamToElement(); + setAppState({ cameraPreviewReady: true }); + void refreshCameraInputDevices(); + return true; + } + + localCameraStream.getTracks().forEach((track) => track.stop()); + localCameraStream = null; + } + + const constraintCandidates = []; + if (requestedCameraInputId) { + constraintCandidates.push( + { + video: buildCameraConstraints(requestedCameraInputId), + audio: false + }, + { + video: { + deviceId: { exact: requestedCameraInputId } + }, + audio: false + } + ); + } + + constraintCandidates.push( + { + video: buildCameraConstraints(), + audio: false + }, + { + video: true, + audio: false + } + ); + + let lastError = null; + for (const constraints of constraintCandidates) { + try { + localCameraStream = await navigator.mediaDevices.getUserMedia(constraints); + break; + } catch (error) { + lastError = error; + } + } + + if (!localCameraStream) { + pushToast('Camera permission denied or unavailable', 'error'); + addActivity('Camera', 'Camera access failed'); + setAppState({ cameraPreviewReady: false }); + return false; + } + + const nextSelectedCameraInputId = getCameraDeviceIdFromStream(localCameraStream) || requestedCameraInputId || ''; + attachCameraStreamToElement(); + setAppState({ + cameraPreviewReady: true, + selectedCameraInputId: nextSelectedCameraInputId + }); + await refreshCameraInputDevices(); + + if (requestedCameraInputId && nextSelectedCameraInputId && nextSelectedCameraInputId !== requestedCameraInputId) { + pushToast('Selected camera unavailable, using another camera', 'info'); + } + if (lastError && !requestedCameraInputId) { + addActivity('Camera', `Applied fallback camera constraints (${lastError.name || 'unknown_error'})`); + } + addActivity('Camera', 'Camera access granted'); + return true; + }; + + const stopCameraPreview = () => { + if (localCameraStream) { + localCameraStream.getTracks().forEach((track) => track.stop()); + localCameraStream = null; + } + if (cameraVideoElement) { + cameraVideoElement.srcObject = null; + } + setAppState({ cameraPreviewReady: false }); + applyMotionDetectionReadiness(); + }; + + const ensureMotionDetector = () => { + if (motionDetector) { + return motionDetector; + } + + motionDetector = createMotionDetector({ + getSourceElement: () => cameraVideoElement, + getConfig: () => getAppState().motionDetection, + 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: motionBecameActive + ? activeSince + ? new Date(activeSince).toISOString() + : new Date().toISOString() + : current.lastTriggeredAt + }); + + if (motionBecameActive || motionBecameInactive) { + void syncAutoMotionLifecycle({ activeMotion }); + } + } + }); + + return motionDetector; + }; + + const applyMotionDetectionReadiness = () => { + const detector = ensureMotionDetector(); + if (shouldRunMotionDetector()) { + if (!detector.isRunning()) { + detector.start(); + addActivity('Motion Detection', 'Detector monitoring started'); + } + return; + } + + const pauseReason = getMotionDetectionPauseReason(); + detectorMotionActive = false; + if (isAutoMotionEventActive()) { + void syncAutoMotionLifecycle({ activeMotion: false }); + } + + if (detector.isRunning()) { + detector.stop('idle'); + addActivity('Motion Detection', `Detector monitoring paused (${pauseReason})`); + return; + } + + updateMotionDetectionRuntime({ state: 'idle', score: 0 }); + }; + + 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); + setAppState({ cameraStatus: 'recording' }); + addActivity('Recording', 'Local recording started'); + return true; + }; + + const stopLocalRecording = async () => { + if (!activeMediaRecorder || activeMediaRecorder.state === 'inactive') { + setAppState({ cameraStatus: 'idle' }); + 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; + setAppState({ cameraStatus: 'idle' }); + + resolve(blob ? { blob, durationSeconds } : null); + }; + + recorder.onerror = () => { + activeMediaRecorder = null; + activeRecordingChunks = []; + activeRecordingStartedAt = null; + setAppState({ cameraStatus: 'idle' }); + resolve(null); + }; + + recorder.stop(); + }); + }; + + 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 finalizeRecordingForStream = async (streamSessionId, captureResult) => { + const currentDevice = getAppState().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'); + } + + addActivity('Recording', `Preparing upload for stream ${streamSessionId}`); + 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 uploadOrigin = (() => { + try { + return new URL(uploadMeta.uploadUrl).origin; + } catch { + return 'invalid upload URL'; + } + })(); + console.info('[recording.upload] upload URL issued', { + recordingId: recording.id, + streamSessionId, + objectKey: uploadMeta.objectKey, + bucket: uploadMeta.bucket, + uploadOrigin, + blobSize: compressedBlob.size, + blobType: compressedBlob.type || 'video/webm' + }); + addActivity( + 'Recording', + `Upload URL ready for ${uploadMeta.objectKey} via ${uploadOrigin}` + ); + + 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: ${await summarizeFetchFailure(uploadResponse)}`); + } + + console.info('[recording.upload] object uploaded', { + recordingId: recording.id, + streamSessionId, + objectKey: uploadMeta.objectKey, + bucket: uploadMeta.bucket, + status: uploadResponse.status, + sizeBytes: compressedBlob.size + }); + addActivity('Recording', `Upload completed for ${uploadMeta.objectKey}`); + + await api.events.finalizeRecording(recording.id, { + objectKey: uploadMeta.objectKey, + bucket: uploadMeta.bucket, + durationSeconds: captureResult.durationSeconds, + sizeBytes: compressedBlob.size + }); + + console.info('[recording.upload] recording finalized', { + recordingId: recording.id, + streamSessionId, + objectKey: uploadMeta.objectKey, + durationSeconds: captureResult.durationSeconds, + sizeBytes: compressedBlob.size + }); + addActivity('Recording', 'Recording uploaded and finalized'); + return true; + } catch (error) { + console.error('[recording.upload] stream upload failed, falling back to simulated key', { + recordingId: recording.id, + streamSessionId, + error: error instanceof Error ? error.message : String(error) + }); + addActivity( + 'Recording', + `Upload failed for stream ${streamSessionId}: ${error instanceof Error ? error.message : 'unknown error'}` + ); + const fallbackObjectKey = `sim/${streamSessionId}/${Date.now()}.webm`; + await api.events.finalizeRecording(recording.id, { + objectKey: fallbackObjectKey, + durationSeconds: captureResult?.durationSeconds ?? 6, + 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 = getAppState().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 { + addActivity('Recording', 'Preparing standalone motion clip upload'); + 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 uploadOrigin = (() => { + try { + return new URL(uploadMeta.uploadUrl).origin; + } catch { + return 'invalid upload URL'; + } + })(); + console.info('[recording.upload] standalone upload URL issued', { + eventId: lastMotionEventId, + recordingId: uploadMeta.video?.id, + objectKey: uploadMeta.objectKey, + bucket: uploadMeta.bucket, + uploadOrigin, + blobSize: compressedBlob.size, + blobType: compressedBlob.type || 'video/webm' + }); + addActivity('Recording', `Standalone upload URL ready via ${uploadOrigin}`); + + 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: ${await summarizeFetchFailure(uploadResponse)}`); + } + + console.info('[recording.upload] standalone object uploaded', { + eventId: lastMotionEventId, + recordingId: uploadMeta.video?.id, + objectKey: uploadMeta.objectKey, + bucket: uploadMeta.bucket, + status: uploadResponse.status, + sizeBytes: compressedBlob.size + }); + addActivity('Recording', `Standalone upload completed for ${uploadMeta.objectKey}`); + + await api.events.finalizeRecording(uploadMeta.video.id, { + objectKey: uploadMeta.objectKey, + bucket: uploadMeta.bucket, + durationSeconds: captureResult.durationSeconds, + sizeBytes: compressedBlob.size + }); + + console.info('[recording.upload] standalone recording finalized', { + eventId: lastMotionEventId, + recordingId: uploadMeta.video?.id, + objectKey: uploadMeta.objectKey, + durationSeconds: captureResult.durationSeconds, + sizeBytes: compressedBlob.size + }); + addActivity('Recording', `Motion clip uploaded (${uploadMeta.objectKey})`); + return true; + } catch (error) { + console.error('[recording.upload] standalone motion upload failed', { + eventId: lastMotionEventId, + error: error instanceof Error ? error.message : String(error) + }); + addActivity( + 'Recording', + `Standalone motion upload failed: ${error instanceof Error ? error.message : 'unknown error'}` + ); + return false; + } + }; + + 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, startOfferToClient }) => { + if (!streamId || !requesterDeviceId) { + throw new Error('Missing stream request context'); + } + + const ready = await startCameraPreview(); + if (!ready) { + throw new Error('Camera permission is required before streaming'); + } + + activeRecordingStreamSessionId = streamId; + await api.streams.accept(streamId); + await startLocalRecording(); + await startOfferToClient(streamId, requesterDeviceId); + addActivity('Stream', 'Accepted stream request and started WebRTC offer'); + }; + + const cleanupMediaState = async () => { + await stopLocalRecording(); + stopCameraPreview(); + if (motionDetector?.isRunning()) { + motionDetector.stop('idle'); + } + activeRecordingStreamSessionId = null; + lastMotionEventId = null; + detectorMotionActive = false; + autoMotionTransitionInFlight = false; + updateMotionDetectionRuntime({ state: 'idle', score: 0 }); + }; + + return { + refreshCameraInputDevices, + startCameraPreview, + stopCameraPreview, + applyMotionDetectionReadiness, + startLocalRecording, + stopLocalRecording, + finalizeRecordingForStream, + startMotionEvent, + endMotionEvent, + syncAutoMotionLifecycle, + handleCameraStreamRequest, + onMediaDeviceChange() { + void refreshCameraInputDevices(); + }, + onVisibilityChange() { + applyMotionDetectionReadiness(); + }, + setCameraVideoElement(element) { + cameraVideoElement = element; + attachCameraStreamToElement(); + void refreshCameraInputDevices(); + }, + getLocalCameraStream() { + return localCameraStream; + }, + setActiveRecordingStreamSessionId(streamSessionId) { + activeRecordingStreamSessionId = streamSessionId; + }, + getActiveRecordingStreamSessionId() { + return activeRecordingStreamSessionId; + }, + clearActiveRecordingStreamSession(streamSessionId = null) { + if (!streamSessionId || activeRecordingStreamSessionId === streamSessionId) { + activeRecordingStreamSessionId = null; + } + }, + isRecordingActive() { + return activeMediaRecorder?.state === 'recording'; + }, + cleanupMediaState + }; +}; diff --git a/WebApp/src/lib/app/controller-shared.js b/WebApp/src/lib/app/controller-shared.js new file mode 100644 index 0000000..db7e0ea --- /dev/null +++ b/WebApp/src/lib/app/controller-shared.js @@ -0,0 +1,472 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck + +const PAGE_PATHS = { + auth: '/', + onboarding: '/onboarding', + camera: '/camera', + client: '/client', + activity: '/activity', + settings: '/settings' +}; + +const DEVICE_STORAGE_KEY = 'mobileSimDevice'; +const MOTION_DETECTION_SETTINGS_STORAGE_KEY = 'securecam-motion-detection-settings'; + +export const INVALID_DEVICE_TOKEN_ERRORS = new Set([ + 'Missing device token', + 'Invalid device token', + 'Device not found', + 'Token role does not match device role' +]); + +export const MOTION_DETECTION_PROFILES = { + low_power: { + profile: 'low_power', + label: 'Low Power', + description: 'Least heat and battery usage, slower trigger response.', + sampleIntervalMs: 1400, + burstIntervalMs: 500, + triggerThreshold: 0.15, + releaseThreshold: 0.05, + consecutiveTriggerFrames: 3, + cooldownMs: 12000, + minimumEventMs: 6000 + }, + balanced: { + profile: 'balanced', + label: 'Balanced', + description: 'Recommended default for a plugged-in foreground browser.', + sampleIntervalMs: 1000, + burstIntervalMs: 300, + triggerThreshold: 0.12, + releaseThreshold: 0.04, + consecutiveTriggerFrames: 3, + cooldownMs: 9000, + minimumEventMs: 6000 + }, + responsive: { + profile: 'responsive', + label: 'Responsive', + description: 'Faster trigger response with higher CPU and thermal cost.', + sampleIntervalMs: 700, + burstIntervalMs: 220, + triggerThreshold: 0.1, + releaseThreshold: 0.035, + consecutiveTriggerFrames: 2, + cooldownMs: 7000, + minimumEventMs: 6000 + } +}; + +export const MAX_STREAM_DIAGNOSTIC_SESSIONS = 12; +export const MAX_STREAM_DIAGNOSTIC_ENTRIES = 24; + +const normalizePath = (path) => path.replace(/\/+$/, '') || '/'; + +export const pageFromPath = (path) => { + switch (normalizePath(path)) { + case '/onboarding': + return 'onboarding'; + case '/camera': + return 'camera'; + case '/client': + return 'client'; + case '/activity': + return 'activity'; + case '/settings': + return 'settings'; + default: + return 'auth'; + } +}; + +export const getHomePageKeyForRole = (role) => (role === 'camera' ? 'camera' : 'client'); + +export const getMotionDetectionProfile = (profile) => + MOTION_DETECTION_PROFILES[profile] ?? MOTION_DETECTION_PROFILES.balanced; + +const getDefaultMotionDetectionState = () => ({ + enabled: false, + profile: MOTION_DETECTION_PROFILES.balanced.profile, + state: 'idle', + score: 0, + debug: false, + lastTriggeredAt: null +}); + +const buildMotionDetectionState = (overrides = {}) => { + const defaults = getDefaultMotionDetectionState(); + const nextProfile = getMotionDetectionProfile(overrides.profile ?? defaults.profile); + return { + ...defaults, + ...nextProfile, + ...overrides, + profile: nextProfile.profile + }; +}; + +const sanitizeMotionDetectionSettings = (value) => { + if (!value || typeof value !== 'object') { + return buildMotionDetectionState(); + } + + return buildMotionDetectionState({ + enabled: Boolean(value.enabled), + profile: typeof value.profile === 'string' ? value.profile : undefined, + debug: Boolean(value.debug) + }); +}; + +const normalizeMotionDetectionState = (value) => { + if (!value || typeof value !== 'object') { + return buildMotionDetectionState(); + } + + return buildMotionDetectionState({ + enabled: Boolean(value.enabled), + profile: typeof value.profile === 'string' ? value.profile : undefined, + state: typeof value.state === 'string' ? value.state : undefined, + score: typeof value.score === 'number' ? value.score : undefined, + debug: Boolean(value.debug), + lastTriggeredAt: typeof value.lastTriggeredAt === 'string' ? value.lastTriggeredAt : null + }); +}; + +export const createControllerShared = ({ api, getAppState, setAppState, patchAppState }) => { + const makeId = () => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; + }; + + const getCurrentPath = () => normalizePath(window.location.pathname); + + 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 = getAppState().device?.role } = options; + const targetPath = getPathForScreen(screen, role); + if (!targetPath) return false; + + if (getCurrentPath() !== normalizePath(targetPath)) { + if (replace) { + window.location.replace(targetPath); + } else { + window.location.assign(targetPath); + } + return true; + } + + setAppState({ page: pageFromPath(targetPath) }); + return false; + }; + + const pushToast = (message, type = 'info') => { + const id = makeId(); + patchAppState((state) => ({ + toasts: [...state.toasts, { id, message, type }].slice(-6) + })); + + setTimeout(() => { + patchAppState((state) => ({ + toasts: state.toasts.filter((toast) => toast.id !== id) + })); + }, 3200); + }; + + const removeToast = (id) => { + patchAppState((state) => ({ + toasts: state.toasts.filter((toast) => toast.id !== id) + })); + }; + + const addActivity = (type, message) => { + const item = { + id: makeId(), + type, + message, + createdAt: new Date().toISOString() + }; + + patchAppState((state) => ({ + activityLog: [item, ...state.activityLog].slice(0, 200) + })); + }; + + const pruneStreamDiagnostics = (diagnostics) => { + const entries = Object.entries(diagnostics || {}); + if (entries.length <= MAX_STREAM_DIAGNOSTIC_SESSIONS) { + return diagnostics; + } + + return Object.fromEntries( + entries + .sort( + (left, right) => + new Date(right[1]?.updatedAt || 0).getTime() - new Date(left[1]?.updatedAt || 0).getTime() + ) + .slice(0, MAX_STREAM_DIAGNOSTIC_SESSIONS) + ); + }; + + const pushStreamDiagnostic = (streamSessionId, stage, message, level = 'info', meta = {}) => { + if (!streamSessionId || !stage || !message) { + return; + } + + const createdAt = new Date().toISOString(); + patchAppState((state) => { + const current = state.streamDiagnostics?.[streamSessionId] ?? { + streamSessionId, + cameraDeviceId: null, + updatedAt: createdAt, + entries: [] + }; + const next = { + ...current, + ...meta, + streamSessionId, + updatedAt: createdAt, + entries: [ + { + id: makeId(), + stage, + message, + level, + createdAt + }, + ...(current.entries || []) + ].slice(0, MAX_STREAM_DIAGNOSTIC_ENTRIES) + }; + + return { + streamDiagnostics: pruneStreamDiagnostics({ + ...(state.streamDiagnostics || {}), + [streamSessionId]: next + }) + }; + }); + }; + + const loadMotionDetectionSettings = () => { + if (typeof localStorage === 'undefined') { + return buildMotionDetectionState(); + } + + try { + const saved = localStorage.getItem(MOTION_DETECTION_SETTINGS_STORAGE_KEY); + if (!saved) { + return buildMotionDetectionState(); + } + return sanitizeMotionDetectionSettings(JSON.parse(saved)); + } catch (error) { + console.error('Failed to load motion detection settings', error); + return buildMotionDetectionState(); + } + }; + + const persistMotionDetectionSettings = (motionDetection) => { + if (typeof localStorage === 'undefined') { + return; + } + + try { + localStorage.setItem( + MOTION_DETECTION_SETTINGS_STORAGE_KEY, + JSON.stringify({ + enabled: Boolean(motionDetection?.enabled), + profile: motionDetection?.profile ?? MOTION_DETECTION_PROFILES.balanced.profile, + debug: Boolean(motionDetection?.debug) + }) + ); + } catch (error) { + console.error('Failed to save motion detection settings', error); + } + }; + + const updateMotionDetectionState = (updates) => { + const current = normalizeMotionDetectionState(getAppState().motionDetection); + const next = + typeof updates === 'function' + ? normalizeMotionDetectionState({ + ...current, + ...updates(current) + }) + : normalizeMotionDetectionState({ + ...current, + ...updates + }); + + setAppState({ motionDetection: next }); + persistMotionDetectionSettings(next); + return next; + }; + + const readSavedDeviceRecord = () => { + if (typeof localStorage === 'undefined') { + return null; + } + + const saved = localStorage.getItem(DEVICE_STORAGE_KEY); + if (!saved) { + return null; + } + + try { + return JSON.parse(saved); + } catch (error) { + console.error('Failed to parse saved device', error); + localStorage.removeItem(DEVICE_STORAGE_KEY); + return null; + } + }; + + const persistSavedDeviceRecord = ({ device, deviceToken, userId }) => { + if (typeof localStorage === 'undefined') { + return; + } + + localStorage.setItem( + DEVICE_STORAGE_KEY, + JSON.stringify({ + device, + deviceToken, + userId + }) + ); + }; + + const clearSavedDeviceRecord = () => { + if (typeof localStorage === 'undefined') { + return; + } + + localStorage.removeItem(DEVICE_STORAGE_KEY); + }; + + const applySavedDeviceState = (device, deviceToken) => { + setAppState({ + device, + deviceToken, + onboardingForm: { + ...getAppState().onboardingForm, + name: device?.name ?? '', + role: device?.role ?? 'client', + pushToken: '' + } + }); + }; + + const clearDeviceState = () => { + setAppState({ + device: null, + deviceToken: null, + socketConnected: false, + isMotionActive: false, + activeMotionSource: null, + cameraStatus: 'idle', + cameraPreviewReady: false, + linkedCameras: [], + recordings: [], + activeCameraDeviceId: null, + activeStreamSessionId: null, + openLinkedCameraMenuId: null, + cameraSessions: {}, + connectedStreamSessionIds: [], + clientStreamMode: 'none', + clientPlaceholderText: 'Select a camera to view', + onboardingForm: { + ...getAppState().onboardingForm, + name: '', + role: 'client', + pushToken: '' + } + }); + }; + + const restoreSavedDeviceForSession = async (session, options = {}) => { + const { showMissingToast = false, showInvalidToast = false } = options; + const saved = readSavedDeviceRecord(); + + if (!saved) { + if (showMissingToast) { + pushToast('No saved device found', 'info'); + } + return false; + } + + const sessionUserId = session?.user?.id; + const savedUserId = typeof saved.userId === 'string' ? saved.userId : null; + const savedDeviceId = saved?.device?.id; + const savedDeviceToken = typeof saved?.deviceToken === 'string' ? saved.deviceToken : ''; + + if (!sessionUserId || !savedDeviceId || !savedDeviceToken) { + clearSavedDeviceRecord(); + clearDeviceState(); + if (showInvalidToast) { + pushToast('Saved device is incomplete. Please register again.', 'error'); + } + return false; + } + + if (savedUserId && savedUserId !== sessionUserId) { + clearSavedDeviceRecord(); + clearDeviceState(); + if (showInvalidToast) { + pushToast('Saved device belongs to a different account.', 'info'); + } + return false; + } + + try { + const result = await api.devices.list(); + const matchingDevice = result.devices?.find((device) => device.id === savedDeviceId); + + if (!matchingDevice) { + clearSavedDeviceRecord(); + clearDeviceState(); + if (showInvalidToast) { + pushToast('Saved device was not found for this account.', 'info'); + } + return false; + } + + applySavedDeviceState(matchingDevice, savedDeviceToken); + persistSavedDeviceRecord({ + device: matchingDevice, + deviceToken: savedDeviceToken, + userId: sessionUserId + }); + return true; + } catch (error) { + console.error('Failed to restore saved device', error); + if (showInvalidToast) { + pushToast('Unable to restore saved device right now.', 'error'); + } + return false; + } + }; + + return { + makeId, + pushToast, + removeToast, + addActivity, + pushStreamDiagnostic, + loadMotionDetectionSettings, + updateMotionDetectionState, + navigateToScreen, + persistSavedDeviceRecord, + clearSavedDeviceRecord, + applySavedDeviceState, + clearDeviceState, + restoreSavedDeviceForSession + }; +}; diff --git a/WebApp/src/lib/app/controller.js b/WebApp/src/lib/app/controller.js index 837db1b..9709687 100644 --- a/WebApp/src/lib/app/controller.js +++ b/WebApp/src/lib/app/controller.js @@ -4,77 +4,18 @@ import { io } from 'socket.io-client'; import { api, getBackendUrl } from './api'; import { createMotionDetector } from './motion-detector'; +import { + INVALID_DEVICE_TOKEN_ERRORS, + MAX_STREAM_DIAGNOSTIC_ENTRIES, + createControllerShared, + getHomePageKeyForRole, + getMotionDetectionProfile, + pageFromPath +} from './controller-shared'; +import { createControllerMediaModule } from './controller-media'; import { getAppState, patchAppState, resetAppState, setAppState } from './store'; -const PAGE_PATHS = { - auth: '/', - onboarding: '/onboarding', - camera: '/camera', - client: '/client', - activity: '/activity', - settings: '/settings' -}; -const DEVICE_STORAGE_KEY = 'mobileSimDevice'; -const INVALID_DEVICE_TOKEN_ERRORS = new Set([ - 'Missing device token', - 'Invalid device token', - 'Device not found', - 'Token role does not match device role' -]); - -const MOTION_DETECTION_SETTINGS_STORAGE_KEY = 'securecam-motion-detection-settings'; -const MOTION_DETECTION_PROFILES = { - low_power: { - profile: 'low_power', - label: 'Low Power', - description: 'Least heat and battery usage, slower trigger response.', - sampleIntervalMs: 1400, - burstIntervalMs: 500, - triggerThreshold: 0.15, - releaseThreshold: 0.05, - consecutiveTriggerFrames: 3, - cooldownMs: 12000, - minimumEventMs: 6000 - }, - balanced: { - profile: 'balanced', - label: 'Balanced', - description: 'Recommended default for a plugged-in foreground browser.', - sampleIntervalMs: 1000, - burstIntervalMs: 300, - triggerThreshold: 0.12, - releaseThreshold: 0.04, - consecutiveTriggerFrames: 3, - cooldownMs: 9000, - minimumEventMs: 6000 - }, - responsive: { - profile: 'responsive', - label: 'Responsive', - description: 'Faster trigger response with higher CPU and thermal cost.', - sampleIntervalMs: 700, - burstIntervalMs: 220, - triggerThreshold: 0.1, - releaseThreshold: 0.035, - consecutiveTriggerFrames: 2, - cooldownMs: 7000, - minimumEventMs: 6000 - } -}; - -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; -const DEFAULT_CAMERA_CONSTRAINTS = { - width: { ideal: 640, max: 960 }, - height: { ideal: 360, max: 540 }, - frameRate: { ideal: 15, max: 24 } -}; const SOCKET_HEARTBEAT_INTERVAL_MS = 10_000; -const MAX_STREAM_DIAGNOSTIC_SESSIONS = 12; -const MAX_STREAM_DIAGNOSTIC_ENTRIES = 24; const parseRtcUrls = (value = '') => value @@ -113,17 +54,7 @@ let initPromise = null; let socket = null; let pollInterval = null; let socketHeartbeatInterval = null; -let localCameraStream = null; -let activeMediaRecorder = null; -let activeRecordingChunks = []; -let activeRecordingStartedAt = null; -let activeRecordingStreamSessionId = null; -let lastMotionEventId = null; -let motionDetector = null; -let detectorMotionActive = false; -let autoMotionTransitionInFlight = false; -let cameraVideoElement = null; let clientVideoElement = null; const peerConnections = new Map(); @@ -134,401 +65,62 @@ const streamTimers = new Map(); const connectedPeers = new Set(); const requestedStreams = new Set(); -const makeId = () => { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID(); - } - return `${Date.now()}-${Math.random().toString(16).slice(2)}`; -}; - -const getDefaultMotionDetectionState = () => ({ - enabled: false, - profile: MOTION_DETECTION_PROFILES.balanced.profile, - state: 'idle', - score: 0, - debug: false, - lastTriggeredAt: null +const { + makeId, + pushToast, + removeToast, + addActivity, + pushStreamDiagnostic, + loadMotionDetectionSettings, + updateMotionDetectionState, + navigateToScreen, + persistSavedDeviceRecord, + clearSavedDeviceRecord, + applySavedDeviceState, + clearDeviceState, + restoreSavedDeviceForSession +} = createControllerShared({ + api, + getAppState, + setAppState, + patchAppState }); -const getMotionDetectionProfile = (profile) => - MOTION_DETECTION_PROFILES[profile] ?? MOTION_DETECTION_PROFILES.balanced; +const mediaController = createControllerMediaModule({ + api, + createMotionDetector, + getAppState, + setAppState, + patchAppState, + pushToast, + addActivity, + getMotionDetectionProfile, + updateMotionDetectionState +}); -const buildMotionDetectionState = (overrides = {}) => { - const defaults = getDefaultMotionDetectionState(); - const nextProfile = getMotionDetectionProfile(overrides.profile ?? defaults.profile); - return { - ...defaults, - ...nextProfile, - ...overrides, - profile: nextProfile.profile - }; -}; - -const sanitizeMotionDetectionSettings = (value) => { - if (!value || typeof value !== 'object') { - return buildMotionDetectionState(); - } - - return buildMotionDetectionState({ - enabled: Boolean(value.enabled), - profile: typeof value.profile === 'string' ? value.profile : undefined, - debug: Boolean(value.debug) - }); -}; - -const normalizeMotionDetectionState = (value) => { - if (!value || typeof value !== 'object') { - return buildMotionDetectionState(); - } - - return buildMotionDetectionState({ - enabled: Boolean(value.enabled), - profile: typeof value.profile === 'string' ? value.profile : undefined, - state: typeof value.state === 'string' ? value.state : undefined, - score: typeof value.score === 'number' ? value.score : undefined, - debug: Boolean(value.debug), - lastTriggeredAt: typeof value.lastTriggeredAt === 'string' ? value.lastTriggeredAt : null - }); -}; - -const loadMotionDetectionSettings = () => { - if (typeof localStorage === 'undefined') { - return buildMotionDetectionState(); - } - - try { - const saved = localStorage.getItem(MOTION_DETECTION_SETTINGS_STORAGE_KEY); - if (!saved) { - return buildMotionDetectionState(); - } - return sanitizeMotionDetectionSettings(JSON.parse(saved)); - } catch (error) { - console.error('Failed to load motion detection settings', error); - return buildMotionDetectionState(); - } -}; - -const persistMotionDetectionSettings = (motionDetection) => { - if (typeof localStorage === 'undefined') { - return; - } - - try { - localStorage.setItem( - MOTION_DETECTION_SETTINGS_STORAGE_KEY, - JSON.stringify({ - enabled: Boolean(motionDetection?.enabled), - profile: motionDetection?.profile ?? MOTION_DETECTION_PROFILES.balanced.profile, - debug: Boolean(motionDetection?.debug) - }) - ); - } catch (error) { - console.error('Failed to save motion detection settings', error); - } -}; - -const updateMotionDetectionState = (updates) => { - const current = normalizeMotionDetectionState(getAppState().motionDetection); - const next = - typeof updates === 'function' - ? normalizeMotionDetectionState({ - ...current, - ...updates(current) - }) - : normalizeMotionDetectionState({ - ...current, - ...updates - }); - - setAppState({ motionDetection: next }); - persistMotionDetectionSettings(next); - return next; -}; - -const normalizePath = (path) => path.replace(/\/+$/, '') || '/'; - -const getCurrentPath = () => normalizePath(window.location.pathname); - -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 pageFromPath = (path) => { - switch (normalizePath(path)) { - case '/onboarding': - return 'onboarding'; - case '/camera': - return 'camera'; - case '/client': - return 'client'; - case '/activity': - return 'activity'; - case '/settings': - return 'settings'; - default: - return 'auth'; - } -}; - -const navigateToScreen = (screen, options = {}) => { - const { replace = false, role = getAppState().device?.role } = options; - const targetPath = getPathForScreen(screen, role); - if (!targetPath) return false; - - if (getCurrentPath() !== normalizePath(targetPath)) { - if (replace) { - window.location.replace(targetPath); - } else { - window.location.assign(targetPath); - } - return true; - } - - setAppState({ page: pageFromPath(targetPath) }); - return false; -}; - -const readSavedDeviceRecord = () => { - if (typeof localStorage === 'undefined') { - return null; - } - - const saved = localStorage.getItem(DEVICE_STORAGE_KEY); - if (!saved) { - return null; - } - - try { - return JSON.parse(saved); - } catch (error) { - console.error('Failed to parse saved device', error); - localStorage.removeItem(DEVICE_STORAGE_KEY); - return null; - } -}; - -const persistSavedDeviceRecord = ({ device, deviceToken, userId }) => { - if (typeof localStorage === 'undefined') { - return; - } - - localStorage.setItem( - DEVICE_STORAGE_KEY, - JSON.stringify({ - device, - deviceToken, - userId - }) - ); -}; - -const clearSavedDeviceRecord = () => { - if (typeof localStorage === 'undefined') { - return; - } - - localStorage.removeItem(DEVICE_STORAGE_KEY); -}; - -const applySavedDeviceState = (device, deviceToken) => { - setAppState({ - device, - deviceToken, - onboardingForm: { - ...getAppState().onboardingForm, - name: device?.name ?? '', - role: device?.role ?? 'client', - pushToken: '' - } - }); -}; - -const clearDeviceState = () => { - setAppState({ - device: null, - deviceToken: null, - socketConnected: false, - isMotionActive: false, - activeMotionSource: null, - cameraStatus: 'idle', - cameraPreviewReady: false, - linkedCameras: [], - recordings: [], - activeCameraDeviceId: null, - activeStreamSessionId: null, - openLinkedCameraMenuId: null, - cameraSessions: {}, - connectedStreamSessionIds: [], - clientStreamMode: 'none', - clientPlaceholderText: 'Select a camera to view', - onboardingForm: { - ...getAppState().onboardingForm, - name: '', - role: 'client', - pushToken: '' - } - }); -}; - -const restoreSavedDeviceForSession = async (session, options = {}) => { - const { showMissingToast = false, showInvalidToast = false } = options; - const saved = readSavedDeviceRecord(); - - if (!saved) { - if (showMissingToast) { - pushToast('No saved device found', 'info'); - } - return false; - } - - const sessionUserId = session?.user?.id; - const savedUserId = typeof saved.userId === 'string' ? saved.userId : null; - const savedDeviceId = saved?.device?.id; - const savedDeviceToken = typeof saved?.deviceToken === 'string' ? saved.deviceToken : ''; - - if (!sessionUserId || !savedDeviceId || !savedDeviceToken) { - clearSavedDeviceRecord(); - clearDeviceState(); - if (showInvalidToast) { - pushToast('Saved device is incomplete. Please register again.', 'error'); - } - return false; - } - - if (savedUserId && savedUserId !== sessionUserId) { - clearSavedDeviceRecord(); - clearDeviceState(); - if (showInvalidToast) { - pushToast('Saved device belongs to a different account.', 'info'); - } - return false; - } - - try { - const result = await api.devices.list(); - const matchingDevice = result.devices?.find((device) => device.id === savedDeviceId); - - if (!matchingDevice) { - clearSavedDeviceRecord(); - clearDeviceState(); - if (showInvalidToast) { - pushToast('Saved device was not found for this account.', 'info'); - } - return false; - } - - applySavedDeviceState(matchingDevice, savedDeviceToken); - persistSavedDeviceRecord({ - device: matchingDevice, - deviceToken: savedDeviceToken, - userId: sessionUserId - }); - return true; - } catch (error) { - console.error('Failed to restore saved device', error); - if (showInvalidToast) { - pushToast('Unable to restore saved device right now.', 'error'); - } - return false; - } -}; +const { + refreshCameraInputDevices, + startCameraPreview, + applyMotionDetectionReadiness, + stopLocalRecording, + finalizeRecordingForStream, + startMotionEvent, + endMotionEvent, + handleCameraStreamRequest, + onMediaDeviceChange, + onVisibilityChange, + setCameraVideoElement: bindCameraVideoElement, + getLocalCameraStream, + getActiveRecordingStreamSessionId, + clearActiveRecordingStreamSession, + isRecordingActive, + cleanupMediaState +} = mediaController; const setConnectedStreamSessionIds = () => { setAppState({ connectedStreamSessionIds: Array.from(connectedPeers) }); }; -const pushToast = (message, type = 'info') => { - const id = makeId(); - patchAppState((state) => ({ - toasts: [...state.toasts, { id, message, type }].slice(-6) - })); - - setTimeout(() => { - patchAppState((state) => ({ - toasts: state.toasts.filter((toast) => toast.id !== id) - })); - }, 3200); -}; - -const removeToast = (id) => { - patchAppState((state) => ({ - toasts: state.toasts.filter((toast) => toast.id !== id) - })); -}; - -const addActivity = (type, message) => { - const item = { - id: makeId(), - type, - message, - createdAt: new Date().toISOString() - }; - - patchAppState((state) => ({ - activityLog: [item, ...state.activityLog].slice(0, 200) - })); -}; - -const pruneStreamDiagnostics = (diagnostics) => { - const entries = Object.entries(diagnostics || {}); - if (entries.length <= MAX_STREAM_DIAGNOSTIC_SESSIONS) { - return diagnostics; - } - - return Object.fromEntries( - entries - .sort( - (left, right) => - new Date(right[1]?.updatedAt || 0).getTime() - new Date(left[1]?.updatedAt || 0).getTime() - ) - .slice(0, MAX_STREAM_DIAGNOSTIC_SESSIONS) - ); -}; - -const pushStreamDiagnostic = (streamSessionId, stage, message, level = 'info', meta = {}) => { - if (!streamSessionId || !stage || !message) { - return; - } - - const createdAt = new Date().toISOString(); - patchAppState((state) => { - const current = state.streamDiagnostics?.[streamSessionId] ?? { - streamSessionId, - cameraDeviceId: null, - updatedAt: createdAt, - entries: [] - }; - const next = { - ...current, - ...meta, - streamSessionId, - updatedAt: createdAt, - entries: [ - { - id: makeId(), - stage, - message, - level, - createdAt - }, - ...(current.entries || []) - ].slice(0, MAX_STREAM_DIAGNOSTIC_ENTRIES) - }; - - return { - streamDiagnostics: pruneStreamDiagnostics({ - ...(state.streamDiagnostics || {}), - [streamSessionId]: next - }) - }; - }); -}; - const bindClientStreamSession = (cameraDeviceId, streamSessionId, reason = '') => { if (!cameraDeviceId || !streamSessionId) { return; @@ -576,69 +168,6 @@ const setClientStreamMode = (mode) => { })); }; -const getCameraDeviceIdFromStream = (stream) => { - if (!stream) return ''; - const videoTrack = stream.getVideoTracks?.()[0]; - if (!videoTrack) return ''; - const settings = videoTrack.getSettings?.() || {}; - return typeof settings.deviceId === 'string' ? settings.deviceId : ''; -}; - -const buildCameraConstraints = (cameraDeviceId = '') => { - if (!cameraDeviceId) return { ...DEFAULT_CAMERA_CONSTRAINTS }; - return { - ...DEFAULT_CAMERA_CONSTRAINTS, - deviceId: { exact: cameraDeviceId } - }; -}; - -const refreshCameraInputDevices = async () => { - if (!navigator.mediaDevices?.enumerateDevices) { - setAppState({ cameraInputDevices: [], selectedCameraInputId: '' }); - return []; - } - - try { - const devices = await navigator.mediaDevices.enumerateDevices(); - const cameraInputDevices = devices - .filter((device) => device.kind === 'videoinput' && device.deviceId) - .map((device, index) => ({ - id: device.deviceId, - label: device.label || `Camera ${index + 1}` - })); - - const selectedCameraInputId = getAppState().selectedCameraInputId; - const streamCameraInputId = getCameraDeviceIdFromStream(localCameraStream); - const candidateCameraInputId = selectedCameraInputId || streamCameraInputId || ''; - const nextSelectedCameraInputId = cameraInputDevices.some((device) => device.id === candidateCameraInputId) - ? candidateCameraInputId - : (cameraInputDevices[0]?.id ?? ''); - - setAppState({ - cameraInputDevices, - selectedCameraInputId: nextSelectedCameraInputId - }); - return cameraInputDevices; - } catch (error) { - console.error('Failed to enumerate cameras', error); - addActivity('Camera', 'Failed to enumerate camera inputs'); - return []; - } -}; - -const onMediaDeviceChange = () => { - void refreshCameraInputDevices(); -}; - -const attachCameraStreamToElement = () => { - if (!cameraVideoElement) return; - cameraVideoElement.srcObject = localCameraStream; - if (localCameraStream) { - void cameraVideoElement.play().catch(() => {}); - } - applyMotionDetectionReadiness(); -}; - const attachClientStreamToElement = () => { if (!clientVideoElement) return; const { activeStreamSessionId } = getAppState(); @@ -692,220 +221,6 @@ const primeClientStreamPlayback = (streamSessionId, stream) => { void hiddenVideoElement.play().catch(() => {}); }; -const startCameraPreview = async (cameraInputId = getAppState().selectedCameraInputId) => { - if (!navigator.mediaDevices?.getUserMedia) { - pushToast('Camera API is not available in this browser', 'error'); - return false; - } - - const requestedCameraInputId = typeof cameraInputId === 'string' ? cameraInputId.trim() : ''; - const activeCameraInputId = getCameraDeviceIdFromStream(localCameraStream); - - if (localCameraStream) { - if (!requestedCameraInputId || requestedCameraInputId === activeCameraInputId) { - attachCameraStreamToElement(); - setAppState({ cameraPreviewReady: true }); - void refreshCameraInputDevices(); - return true; - } - - localCameraStream.getTracks().forEach((track) => track.stop()); - localCameraStream = null; - } - - const constraintCandidates = []; - if (requestedCameraInputId) { - constraintCandidates.push( - { - video: buildCameraConstraints(requestedCameraInputId), - audio: false - }, - { - video: { - deviceId: { exact: requestedCameraInputId } - }, - audio: false - } - ); - } - - constraintCandidates.push( - { - video: buildCameraConstraints(), - audio: false - }, - { - video: true, - audio: false - } - ); - - let lastError = null; - for (const constraints of constraintCandidates) { - try { - localCameraStream = await navigator.mediaDevices.getUserMedia(constraints); - break; - } catch (error) { - lastError = error; - } - } - - if (!localCameraStream) { - pushToast('Camera permission denied or unavailable', 'error'); - addActivity('Camera', 'Camera access failed'); - setAppState({ cameraPreviewReady: false }); - return false; - } - - const nextSelectedCameraInputId = getCameraDeviceIdFromStream(localCameraStream) || requestedCameraInputId || ''; - attachCameraStreamToElement(); - setAppState({ - cameraPreviewReady: true, - selectedCameraInputId: nextSelectedCameraInputId - }); - await refreshCameraInputDevices(); - - if (requestedCameraInputId && nextSelectedCameraInputId && nextSelectedCameraInputId !== requestedCameraInputId) { - pushToast('Selected camera unavailable, using another camera', 'info'); - } - if (lastError && !requestedCameraInputId) { - addActivity('Camera', `Applied fallback camera constraints (${lastError.name || 'unknown_error'})`); - } - addActivity('Camera', 'Camera access granted'); - return true; -}; - -const stopCameraPreview = () => { - if (localCameraStream) { - localCameraStream.getTracks().forEach((track) => track.stop()); - localCameraStream = null; - } - if (cameraVideoElement) { - cameraVideoElement.srcObject = null; - } - setAppState({ cameraPreviewReady: false }); - applyMotionDetectionReadiness(); -}; - -const updateMotionDetectionRuntime = (updates) => { - patchAppState((state) => ({ - motionDetection: { - ...state.motionDetection, - ...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; - } - - motionDetector = createMotionDetector({ - getSourceElement: () => cameraVideoElement, - getConfig: () => getAppState().motionDetection, - 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: motionBecameActive - ? activeSince - ? new Date(activeSince).toISOString() - : new Date().toISOString() - : current.lastTriggeredAt - }); - - if (motionBecameActive || motionBecameInactive) { - void syncAutoMotionLifecycle({ activeMotion }); - } - } - }); - - return motionDetector; -}; - -const shouldRunMotionDetector = () => { - if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { - return false; - } - - const state = getAppState(); - return Boolean( - state.device?.role === 'camera' && - state.motionDetection?.enabled && - state.socketConnected && - state.cameraPreviewReady && - localCameraStream && - cameraVideoElement - ); -}; - -const applyMotionDetectionReadiness = () => { - const detector = ensureMotionDetector(); - if (shouldRunMotionDetector()) { - if (!detector.isRunning()) { - detector.start(); - addActivity('Motion Detection', 'Detector monitoring started'); - } - return; - } - - const pauseReason = getMotionDetectionPauseReason(); - detectorMotionActive = false; - if (isAutoMotionEventActive()) { - void syncAutoMotionLifecycle({ activeMotion: false }); - } - - if (detector.isRunning()) { - detector.stop('idle'); - addActivity('Motion Detection', `Detector monitoring paused (${pauseReason})`); - return; - } - - updateMotionDetectionRuntime({ state: 'idle', score: 0 }); -}; - -const onVisibilityChange = () => { - applyMotionDetectionReadiness(); -}; - const clearClientStream = () => { const { activeStreamSessionId } = getAppState(); if (activeStreamSessionId && streamTimers.has(activeStreamSessionId)) { @@ -1084,197 +399,6 @@ const closeRecordingModal = () => { }); }; -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); - setAppState({ cameraStatus: 'recording' }); - addActivity('Recording', 'Local recording started'); - return true; -}; - -const stopLocalRecording = async () => { - if (!activeMediaRecorder || activeMediaRecorder.state === 'inactive') { - setAppState({ cameraStatus: 'idle' }); - 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; - setAppState({ cameraStatus: 'idle' }); - - resolve(blob ? { blob, durationSeconds } : null); - }; - - recorder.onerror = () => { - activeMediaRecorder = null; - activeRecordingChunks = []; - activeRecordingStartedAt = null; - setAppState({ cameraStatus: 'idle' }); - 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) { for (const connection of peerConnections.values()) { @@ -1447,6 +571,7 @@ const ensurePeerConnection = async ({ streamSessionId, targetDeviceId, asCamera if (asCamera) { const ready = await startCameraPreview(); + const localCameraStream = getLocalCameraStream(); if (!ready || !localCameraStream || localCameraStream.getVideoTracks().length === 0) { throw new Error('Camera stream unavailable for WebRTC publish'); } @@ -1478,349 +603,6 @@ const startOfferToClient = async (streamSessionId, requesterDeviceId) => { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const summarizeFetchFailure = async (response) => { - if (!response) { - return 'no response returned'; - } - - const parts = [`status ${response.status}`]; - try { - const bodyText = (await response.text()).trim(); - if (bodyText) { - parts.push(bodyText.slice(0, 240)); - } - } catch { - // Ignore body parsing failures for diagnostics. - } - - return parts.join(' · '); -}; - -const finalizeRecordingForStream = async (streamSessionId, captureResult) => { - const currentDevice = getAppState().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'); - } - - addActivity('Recording', `Preparing upload for stream ${streamSessionId}`); - 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 uploadOrigin = (() => { - try { - return new URL(uploadMeta.uploadUrl).origin; - } catch { - return 'invalid upload URL'; - } - })(); - console.info('[recording.upload] upload URL issued', { - recordingId: recording.id, - streamSessionId, - objectKey: uploadMeta.objectKey, - bucket: uploadMeta.bucket, - uploadOrigin, - blobSize: compressedBlob.size, - blobType: compressedBlob.type || 'video/webm' - }); - addActivity( - 'Recording', - `Upload URL ready for ${uploadMeta.objectKey} via ${uploadOrigin}` - ); - - 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: ${await summarizeFetchFailure(uploadResponse)}`); - } - - console.info('[recording.upload] object uploaded', { - recordingId: recording.id, - streamSessionId, - objectKey: uploadMeta.objectKey, - bucket: uploadMeta.bucket, - status: uploadResponse.status, - sizeBytes: compressedBlob.size - }); - addActivity('Recording', `Upload completed for ${uploadMeta.objectKey}`); - - await api.events.finalizeRecording(recording.id, { - objectKey: uploadMeta.objectKey, - bucket: uploadMeta.bucket, - durationSeconds: captureResult.durationSeconds, - sizeBytes: compressedBlob.size - }); - - console.info('[recording.upload] recording finalized', { - recordingId: recording.id, - streamSessionId, - objectKey: uploadMeta.objectKey, - durationSeconds: captureResult.durationSeconds, - sizeBytes: compressedBlob.size - }); - addActivity('Recording', 'Recording uploaded and finalized'); - return true; - } catch (error) { - console.error('[recording.upload] stream upload failed, falling back to simulated key', { - recordingId: recording.id, - streamSessionId, - error: error instanceof Error ? error.message : String(error) - }); - addActivity( - 'Recording', - `Upload failed for stream ${streamSessionId}: ${error instanceof Error ? error.message : 'unknown error'}` - ); - const fallbackObjectKey = `sim/${streamSessionId}/${Date.now()}.webm`; - await api.events.finalizeRecording(recording.id, { - objectKey: fallbackObjectKey, - durationSeconds: captureResult?.durationSeconds ?? 6, - 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 = getAppState().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 { - addActivity('Recording', 'Preparing standalone motion clip upload'); - 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 uploadOrigin = (() => { - try { - return new URL(uploadMeta.uploadUrl).origin; - } catch { - return 'invalid upload URL'; - } - })(); - console.info('[recording.upload] standalone upload URL issued', { - eventId: lastMotionEventId, - recordingId: uploadMeta.video?.id, - objectKey: uploadMeta.objectKey, - bucket: uploadMeta.bucket, - uploadOrigin, - blobSize: compressedBlob.size, - blobType: compressedBlob.type || 'video/webm' - }); - addActivity('Recording', `Standalone upload URL ready via ${uploadOrigin}`); - - 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: ${await summarizeFetchFailure(uploadResponse)}`); - } - - console.info('[recording.upload] standalone object uploaded', { - eventId: lastMotionEventId, - recordingId: uploadMeta.video?.id, - objectKey: uploadMeta.objectKey, - bucket: uploadMeta.bucket, - status: uploadResponse.status, - sizeBytes: compressedBlob.size - }); - addActivity('Recording', `Standalone upload completed for ${uploadMeta.objectKey}`); - - await api.events.finalizeRecording(uploadMeta.video.id, { - objectKey: uploadMeta.objectKey, - bucket: uploadMeta.bucket, - durationSeconds: captureResult.durationSeconds, - sizeBytes: compressedBlob.size - }); - - console.info('[recording.upload] standalone recording finalized', { - eventId: lastMotionEventId, - recordingId: uploadMeta.video?.id, - objectKey: uploadMeta.objectKey, - durationSeconds: captureResult.durationSeconds, - sizeBytes: compressedBlob.size - }); - addActivity('Recording', `Motion clip uploaded (${uploadMeta.objectKey})`); - return true; - } catch (error) { - console.error('[recording.upload] standalone motion upload failed', { - eventId: lastMotionEventId, - error: error instanceof Error ? error.message : String(error) - }); - addActivity( - 'Recording', - `Standalone motion upload failed: ${error instanceof Error ? error.message : 'unknown error'}` - ); - return false; - } -}; - -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'); - } - - const ready = await startCameraPreview(); - if (!ready) { - throw new Error('Camera permission is required before streaming'); - } - - activeRecordingStreamSessionId = streamId; - await api.streams.accept(streamId); - await startLocalRecording(); - await startOfferToClient(streamId, requesterDeviceId); - addActivity('Stream', 'Accepted stream request and started WebRTC offer'); -}; - const stopSocketHeartbeat = () => { if (socketHeartbeatInterval) { clearInterval(socketHeartbeatInterval); @@ -1891,6 +673,7 @@ const connectSocket = () => { stopSocketHeartbeat(); setAppState({ socketConnected: false }); void stopLocalRecording(); + clearActiveRecordingStreamSession(); teardownPeerConnection(); setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null }); patchAppState((state) => ({ @@ -1939,7 +722,8 @@ const connectSocket = () => { if (payload.commandType === 'start_stream') { await handleCameraStreamRequest({ streamId: payload.payload.streamSessionId, - requesterDeviceId: payload.sourceDeviceId + requesterDeviceId: payload.sourceDeviceId, + startOfferToClient }); socket.emit('command:ack', { commandId: payload.commandId, status: 'acknowledged' }); } @@ -1963,7 +747,8 @@ const connectSocket = () => { try { await handleCameraStreamRequest({ streamId: payload.streamSessionId, - requesterDeviceId: payload.requesterDeviceId + requesterDeviceId: payload.requesterDeviceId, + startOfferToClient }); } catch (error) { console.error('Failed handling direct stream request', error); @@ -2014,16 +799,14 @@ const connectSocket = () => { if (getAppState().device?.role === 'camera') { const shouldFinalize = - activeRecordingStreamSessionId === streamSessionId || activeMediaRecorder?.state === 'recording'; + getActiveRecordingStreamSessionId() === streamSessionId || isRecordingActive(); if (shouldFinalize) { const captureResult = await stopLocalRecording(); await finalizeRecordingForStream(streamSessionId, captureResult); } - if (activeRecordingStreamSessionId === streamSessionId) { - activeRecordingStreamSessionId = null; - } + clearActiveRecordingStreamSession(streamSessionId); } }); @@ -2170,12 +953,8 @@ const startPolling = () => { const cleanupConnectionState = async () => { stopPolling(); stopSocketHeartbeat(); - await stopLocalRecording(); + await cleanupMediaState(); teardownPeerConnection(); - stopCameraPreview(); - if (motionDetector?.isRunning()) { - motionDetector.stop('idle'); - } if (socket) { socket.disconnect(); socket = null; @@ -2419,7 +1198,6 @@ const actions = { }, async endMotion() { - if (!lastMotionEventId) return; try { await endMotionEvent({ source: 'manual' }); } catch (error) { @@ -2447,7 +1225,7 @@ const actions = { if (!nextCameraInputId) return; setAppState({ selectedCameraInputId: nextCameraInputId }); - const isPreviewRunning = Boolean(localCameraStream); + const isPreviewRunning = Boolean(getLocalCameraStream()); if (!isPreviewRunning) return; const ready = await startCameraPreview(nextCameraInputId); @@ -2684,9 +1462,7 @@ const actions = { removeToast, setCameraVideoElement(element) { - cameraVideoElement = element; - attachCameraStreamToElement(); - void refreshCameraInputDevices(); + bindCameraVideoElement(element); }, setClientVideoElement(element) {