Add motion detection controls and persistence
This commit is contained in:
@@ -14,6 +14,46 @@ const PAGE_PATHS = {
|
|||||||
settings: '/settings'
|
settings: '/settings'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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: 9000
|
||||||
|
},
|
||||||
|
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: 8000
|
||||||
|
},
|
||||||
|
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: 7000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const RECORDING_VIDEO_BITS_PER_SECOND = 850_000;
|
const RECORDING_VIDEO_BITS_PER_SECOND = 850_000;
|
||||||
const COMPRESSED_UPLOAD_MAX_WIDTH = 640;
|
const COMPRESSED_UPLOAD_MAX_WIDTH = 640;
|
||||||
const COMPRESSED_UPLOAD_MAX_HEIGHT = 360;
|
const COMPRESSED_UPLOAD_MAX_HEIGHT = 360;
|
||||||
@@ -57,6 +97,110 @@ const makeId = () => {
|
|||||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
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 getMotionDetectionProfile = (profile) =>
|
||||||
|
MOTION_DETECTION_PROFILES[profile] ?? MOTION_DETECTION_PROFILES.balanced;
|
||||||
|
|
||||||
|
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 normalizePath = (path) => path.replace(/\/+$/, '') || '/';
|
||||||
|
|
||||||
const getCurrentPath = () => normalizePath(window.location.pathname);
|
const getCurrentPath = () => normalizePath(window.location.pathname);
|
||||||
@@ -781,7 +925,8 @@ const finalizeRecordingForStream = async (streamSessionId, captureResult) => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
fileName: `stream-${streamSessionId}.webm`,
|
fileName: `stream-${streamSessionId}.webm`,
|
||||||
deviceId: currentDevice.id,
|
deviceId: currentDevice.id,
|
||||||
prefix: 'recordings'
|
prefix: 'recordings',
|
||||||
|
recordingId: recording.id
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -843,7 +988,8 @@ const uploadStandaloneMotionRecording = async (captureResult) => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
fileName: `motion-${Date.now()}.webm`,
|
fileName: `motion-${Date.now()}.webm`,
|
||||||
deviceId: currentDevice.id,
|
deviceId: currentDevice.id,
|
||||||
prefix: 'recordings'
|
prefix: 'recordings',
|
||||||
|
eventId: lastMotionEventId
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -857,6 +1003,13 @@ const uploadStandaloneMotionRecording = async (captureResult) => {
|
|||||||
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await api.events.finalizeRecording(uploadMeta.video.id, {
|
||||||
|
objectKey: uploadMeta.objectKey,
|
||||||
|
bucket: uploadMeta.bucket,
|
||||||
|
durationSeconds: captureResult.durationSeconds,
|
||||||
|
sizeBytes: compressedBlob.size
|
||||||
|
});
|
||||||
|
|
||||||
addActivity('Recording', `Motion clip uploaded (${uploadMeta.objectKey})`);
|
addActivity('Recording', `Motion clip uploaded (${uploadMeta.objectKey})`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1193,6 +1346,8 @@ const init = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAppState({ motionDetection: loadMotionDetectionSettings() });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await api.auth.getSession();
|
const session = await api.auth.getSession();
|
||||||
if (session?.session) {
|
if (session?.session) {
|
||||||
@@ -1426,6 +1581,24 @@ const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setMotionDetectionEnabled(enabled) {
|
||||||
|
const motionDetection = updateMotionDetectionState({ enabled: Boolean(enabled) });
|
||||||
|
pushToast(motionDetection.enabled ? 'Automatic detection armed' : 'Automatic detection paused', 'info');
|
||||||
|
addActivity('Motion Detection', motionDetection.enabled ? 'Detector armed' : 'Detector paused');
|
||||||
|
},
|
||||||
|
|
||||||
|
setMotionDetectionProfile(profile) {
|
||||||
|
const nextProfile = getMotionDetectionProfile(profile);
|
||||||
|
const motionDetection = updateMotionDetectionState({ profile: nextProfile.profile });
|
||||||
|
pushToast(`${nextProfile.label} profile selected`, 'success');
|
||||||
|
addActivity('Motion Detection', `Profile set to ${motionDetection.profile}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
setMotionDetectionDebug(debug) {
|
||||||
|
const motionDetection = updateMotionDetectionState({ debug: Boolean(debug) });
|
||||||
|
pushToast(motionDetection.debug ? 'Motion debug enabled' : 'Motion debug hidden', 'info');
|
||||||
|
},
|
||||||
|
|
||||||
async linkCamera() {
|
async linkCamera() {
|
||||||
const id = prompt('Enter Camera Device ID:');
|
const id = prompt('Enter Camera Device ID:');
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ export const createInitialState = () => ({
|
|||||||
title: 'Recording Playback',
|
title: 'Recording Playback',
|
||||||
url: ''
|
url: ''
|
||||||
},
|
},
|
||||||
|
motionDetection: {
|
||||||
|
enabled: false,
|
||||||
|
profile: 'balanced',
|
||||||
|
state: 'idle',
|
||||||
|
score: 0,
|
||||||
|
debug: false,
|
||||||
|
lastTriggeredAt: null
|
||||||
|
},
|
||||||
clientStreamMode: 'none',
|
clientStreamMode: 'none',
|
||||||
clientPlaceholderText: 'Select a camera to view',
|
clientPlaceholderText: 'Select a camera to view',
|
||||||
lastError: null
|
lastError: null
|
||||||
|
|||||||
@@ -3,18 +3,44 @@
|
|||||||
import AppChrome from '$lib/sim/ui/AppChrome.svelte';
|
import AppChrome from '$lib/sim/ui/AppChrome.svelte';
|
||||||
import { appController } from '$lib/app/controller';
|
import { appController } from '$lib/app/controller';
|
||||||
import { appState } from '$lib/app/store';
|
import { appState } from '$lib/app/store';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
|
||||||
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select/index.js';
|
||||||
|
|
||||||
let cameraVideoElement: HTMLVideoElement | null = null;
|
let cameraVideoElement: HTMLVideoElement | null = null;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
appController.setCameraVideoElement(cameraVideoElement);
|
appController.setCameraVideoElement(cameraVideoElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cameraInputItems = () =>
|
||||||
|
$appState.cameraInputDevices.map((cameraInput) => ({
|
||||||
|
value: cameraInput.id,
|
||||||
|
label: cameraInput.label
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selectedCameraInputLabel = () =>
|
||||||
|
$appState.cameraInputDevices.find((cameraInput) => cameraInput.id === $appState.selectedCameraInputId)?.label ||
|
||||||
|
($appState.cameraInputDevices.length > 0 ? 'Choose camera input' : 'No camera inputs found');
|
||||||
|
|
||||||
|
const motionDetectionProfileItems = [
|
||||||
|
{ value: 'low_power', label: 'Low Power' },
|
||||||
|
{ value: 'balanced', label: 'Balanced' },
|
||||||
|
{ value: 'responsive', label: 'Responsive' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const motionDetectionProfileLabel = () =>
|
||||||
|
motionDetectionProfileItems.find((item) => item.value === $appState.motionDetection.profile)?.label || 'Balanced';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AppChrome pageKey="camera">
|
<AppChrome pageKey="camera">
|
||||||
<section id="screen-home-camera" class="flex flex-col gap-6 max-w-7xl mx-auto h-full min-h-0">
|
<section id="screen-home-camera" class="flex flex-col gap-6 max-w-7xl mx-auto h-full min-h-0">
|
||||||
<div class="flex-1 min-h-0 flex flex-col gap-6">
|
<div class="flex-1 min-h-0 flex flex-col gap-6">
|
||||||
<div class="flex-[3] min-h-[260px] glass-card rounded-3xl overflow-hidden relative flex flex-col border border-white/10">
|
<Card class="glass-card relative flex min-h-[260px] flex-[3] overflow-hidden rounded-3xl border-white/10 bg-[#16161d]/80">
|
||||||
<div id="cameraPreview" class="flex-1 bg-black relative flex items-center justify-center {$appState.isMotionActive ? 'bg-red-900/20' : ''}">
|
<div id="cameraPreview" class="flex-1 bg-black relative flex items-center justify-center {$appState.isMotionActive ? 'bg-red-900/20' : ''}">
|
||||||
<video
|
<video
|
||||||
id="cameraVideo"
|
id="cameraVideo"
|
||||||
@@ -24,43 +50,55 @@
|
|||||||
muted
|
muted
|
||||||
bind:this={cameraVideoElement}
|
bind:this={cameraVideoElement}
|
||||||
></video>
|
></video>
|
||||||
<div
|
<Badge
|
||||||
class="absolute top-4 left-4 z-20 flex items-center gap-2 px-3 py-1.5 rounded-full bg-black/50 backdrop-blur border border-white/10"
|
variant="destructive"
|
||||||
|
class="absolute top-4 left-4 z-20 gap-2 rounded-full border border-white/10 bg-black/50 px-3 py-1.5 text-white backdrop-blur"
|
||||||
>
|
>
|
||||||
<span class="w-2.5 h-2.5 bg-red-500 rounded-full shadow-[0_0_8px_rgba(239,68,68,0.8)] animate-pulse"></span>
|
<span class="w-2.5 h-2.5 bg-red-500 rounded-full shadow-[0_0_8px_rgba(239,68,68,0.8)] animate-pulse"></span>
|
||||||
<span class="text-xs text-white font-medium tracking-wide">REC</span>
|
<span class="text-xs font-medium tracking-wide">REC</span>
|
||||||
</div>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="cameraOfflineOverlay"
|
id="cameraOfflineOverlay"
|
||||||
class="absolute inset-0 bg-[#0a0a0c]/80 backdrop-blur-sm z-10 flex flex-col items-center justify-center gap-4 {$appState.socketConnected ? 'hidden' : ''}"
|
class="absolute inset-0 bg-[#0a0a0c]/80 backdrop-blur-sm z-10 flex flex-col items-center justify-center gap-4 {$appState.socketConnected ? 'hidden' : ''}"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<Alert class="w-full max-w-sm border-white/10 bg-[#141418]/90 text-center shadow-xl">
|
||||||
<path
|
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-16 w-16 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
stroke-linecap="round"
|
<path
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
stroke-width="2"
|
stroke-linejoin="round"
|
||||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
stroke-width="2"
|
||||||
/>
|
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||||
</svg>
|
/>
|
||||||
<p class="text-gray-400 font-medium tracking-wide">Camera Offline</p>
|
</svg>
|
||||||
<button
|
<AlertTitle class="justify-self-center text-base text-white">Camera Offline</AlertTitle>
|
||||||
|
<AlertDescription class="justify-self-center text-sm text-gray-400">
|
||||||
|
Reconnect this device to resume live monitoring.
|
||||||
|
</AlertDescription>
|
||||||
|
<div class="mt-2 flex justify-center">
|
||||||
|
<Button
|
||||||
id="cameraGoOnlineBtn"
|
id="cameraGoOnlineBtn"
|
||||||
class="btn btn-outline btn-success rounded-xl border-green-500/50 text-green-400 hover:bg-green-500/10 hover:border-green-400"
|
variant="outline"
|
||||||
|
class="rounded-xl border-green-500/50 text-green-400 hover:border-green-400 hover:bg-green-500/10"
|
||||||
onclick={() => appController.goOnline()}
|
onclick={() => appController.goOnline()}
|
||||||
>
|
>
|
||||||
Go Online
|
Go Online
|
||||||
</button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div class="flex-[2] min-h-0 grid grid-cols-1 xl:grid-cols-[2fr_1fr] gap-6">
|
<div class="flex-[2] min-h-0 grid grid-cols-1 xl:grid-cols-[2fr_1fr] gap-6">
|
||||||
<div class="glass-card p-6 rounded-3xl border border-white/5 flex flex-col min-h-[220px] overflow-hidden">
|
<Card class="glass-card min-h-[220px] overflow-hidden rounded-3xl border-white/10 bg-[#16161d]/80">
|
||||||
<h3 class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4 shrink-0">Logs</h3>
|
<CardHeader class="pb-2">
|
||||||
<div
|
<CardTitle class="text-xs font-bold uppercase tracking-wider text-gray-500">Logs</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="flex-1">
|
||||||
|
<ScrollArea
|
||||||
id="cameraLogs"
|
id="cameraLogs"
|
||||||
class="flex-1 min-h-0 bg-black/40 rounded-xl p-4 text-xs font-mono text-gray-400 overflow-y-auto border border-white/5"
|
class="min-h-[220px] rounded-xl border border-white/5 bg-black/40 p-4 text-xs font-mono text-gray-400"
|
||||||
>
|
>
|
||||||
{#if $appState.activityLog.length === 0}
|
{#if $appState.activityLog.length === 0}
|
||||||
<div class="text-gray-600 italic">Awaiting connection...</div>
|
<div class="text-gray-600 italic">Awaiting connection...</div>
|
||||||
@@ -69,66 +107,157 @@
|
|||||||
<div>[{new Date(log.createdAt).toLocaleTimeString()}] {log.type}: {log.message}</div>
|
<div>[{new Date(log.createdAt).toLocaleTimeString()}] {log.type}: {log.message}</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</ScrollArea>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div class="glass-card p-6 rounded-3xl border border-white/5 space-y-4 min-h-[220px]">
|
<Card class="glass-card min-h-[220px] rounded-3xl border-white/10 bg-[#16161d]/80">
|
||||||
<h3 class="text-xs font-bold text-gray-500 uppercase tracking-wider">Actions / Options</h3>
|
<CardHeader class="pb-2">
|
||||||
<div class="space-y-2">
|
<CardTitle class="text-xs font-bold uppercase tracking-wider text-gray-500">Actions / Options</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<label for="cameraInputSelect" class="text-[11px] text-gray-400 uppercase tracking-wider font-semibold">
|
<Label for="cameraInputSelect" class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
|
||||||
Camera Input
|
Camera Input
|
||||||
</label>
|
</Label>
|
||||||
<button
|
<Button
|
||||||
id="refreshCameraInputsBtn"
|
id="refreshCameraInputsBtn"
|
||||||
class="btn btn-ghost btn-xs rounded-lg text-gray-400 hover:text-white"
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
class="rounded-lg text-gray-400 hover:text-white"
|
||||||
onclick={() => appController.refreshCameraInputs()}
|
onclick={() => appController.refreshCameraInputs()}
|
||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<Select
|
||||||
id="cameraInputSelect"
|
type="single"
|
||||||
class="select select-sm w-full bg-black/40 border-white/10 text-gray-200 rounded-xl"
|
|
||||||
value={$appState.selectedCameraInputId}
|
value={$appState.selectedCameraInputId}
|
||||||
onchange={(event) => appController.selectCameraInput((event.currentTarget as HTMLSelectElement).value)}
|
items={cameraInputItems()}
|
||||||
|
onValueChange={(value) => appController.selectCameraInput(value)}
|
||||||
>
|
>
|
||||||
{#if $appState.cameraInputDevices.length === 0}
|
<SelectTrigger
|
||||||
<option value="">No camera inputs found</option>
|
id="cameraInputSelect"
|
||||||
{:else}
|
class="h-10 w-full rounded-xl border-white/10 bg-black/40 text-gray-200"
|
||||||
|
>
|
||||||
|
{selectedCameraInputLabel()}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
{#each $appState.cameraInputDevices as cameraInput (cameraInput.id)}
|
{#each $appState.cameraInputDevices as cameraInput (cameraInput.id)}
|
||||||
<option value={cameraInput.id}>{cameraInput.label}</option>
|
<SelectItem value={cameraInput.id} label={cameraInput.label} />
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
</SelectContent>
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="rounded-2xl border border-white/10 bg-black/30 p-4 space-y-4">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">Automatic Detection</p>
|
||||||
|
<p class="text-sm text-gray-200">
|
||||||
|
{$appState.motionDetection.enabled ? 'Armed for automatic motion events.' : 'Paused until manually armed.'}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Designed for a plugged-in browser kept in the foreground.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={$appState.motionDetection.enabled ? 'secondary' : 'outline'}
|
||||||
|
class="min-w-[112px] rounded-xl"
|
||||||
|
onclick={() => appController.setMotionDetectionEnabled(!$appState.motionDetection.enabled)}
|
||||||
|
>
|
||||||
|
{$appState.motionDetection.enabled ? 'Pause' : 'Arm'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<Label for="motionDetectionProfile" class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
|
||||||
|
Detection Profile
|
||||||
|
</Label>
|
||||||
|
<span class="text-[11px] uppercase tracking-wider text-gray-500">{$appState.motionDetection.state}</span>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={$appState.motionDetection.profile}
|
||||||
|
items={motionDetectionProfileItems}
|
||||||
|
onValueChange={(value) => appController.setMotionDetectionProfile(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="motionDetectionProfile"
|
||||||
|
class="h-10 w-full rounded-xl border-white/10 bg-black/40 text-gray-200"
|
||||||
|
>
|
||||||
|
{motionDetectionProfileLabel()}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each motionDetectionProfileItems as item (item.value)}
|
||||||
|
<SelectItem value={item.value} label={item.label} />
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="rounded-xl border border-white/5 bg-white/5 px-3 py-2">
|
||||||
|
<p class="text-[10px] uppercase tracking-wider text-gray-500">Detector State</p>
|
||||||
|
<p class="mt-1 text-sm font-medium text-gray-200">{$appState.motionDetection.state}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-white/5 bg-white/5 px-3 py-2">
|
||||||
|
<p class="text-[10px] uppercase tracking-wider text-gray-500">Motion Score</p>
|
||||||
|
<p class="mt-1 text-sm font-medium text-gray-200">{$appState.motionDetection.score.toFixed(3)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-3 rounded-xl border border-white/5 bg-white/5 px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-200">Show detector debug info</p>
|
||||||
|
<p class="text-[11px] text-gray-500">Useful while tuning thresholds and sample profiles.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={$appState.motionDetection.debug}
|
||||||
|
aria-label="Toggle motion detector debug info"
|
||||||
|
title="Toggle motion detector debug info"
|
||||||
|
class="inline-flex h-7 w-12 items-center rounded-full border border-white/10 p-1 transition-colors {$appState.motionDetection.debug ? 'bg-blue-500/80' : 'bg-white/10'}"
|
||||||
|
onclick={() => appController.setMotionDetectionDebug(!$appState.motionDetection.debug)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-5 w-5 rounded-full bg-white transition-transform {$appState.motionDetection.debug ? 'translate-x-5' : 'translate-x-0'}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
{#if !$appState.isMotionActive}
|
{#if !$appState.isMotionActive}
|
||||||
<button
|
<Button
|
||||||
id="startMotionBtn"
|
id="startMotionBtn"
|
||||||
class="btn w-full justify-start h-14 rounded-xl bg-white/5 border-white/5 hover:bg-red-500/10 hover:border-red-500/30 hover:text-red-400 transition-all font-medium group"
|
variant="outline"
|
||||||
|
class="h-14 w-full justify-start rounded-xl border-white/5 bg-white/5 font-medium hover:border-red-500/30 hover:bg-red-500/10 hover:text-red-400"
|
||||||
onclick={() => appController.startMotion()}
|
onclick={() => appController.startMotion()}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400 group-hover:text-red-400 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg data-icon="inline-start" xmlns="http://www.w3.org/2000/svg" class="mr-2 h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
Simulate Motion Event
|
Simulate Motion Event
|
||||||
</button>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<Button
|
||||||
id="endMotionBtn"
|
id="endMotionBtn"
|
||||||
class="btn w-full justify-start h-14 rounded-xl bg-white/5 border-white/5 hover:bg-white/10 font-medium disabled:opacity-30"
|
variant="outline"
|
||||||
|
class="h-14 w-full justify-start rounded-xl border-white/5 bg-white/5 font-medium hover:bg-white/10 disabled:opacity-30"
|
||||||
onclick={() => appController.endMotion()}
|
onclick={() => appController.endMotion()}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg data-icon="inline-start" xmlns="http://www.w3.org/2000/svg" class="mr-2 h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||||
</svg>
|
</svg>
|
||||||
Stop Recording
|
Stop Recording
|
||||||
</button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user