mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-14 11:02:08 +03:00
Compare commits
No commits in common. "2e5ad9776dbd54f3684d11aeb4f0a8a0e43eb99d" and "7ebc58a63f008cf34aa87ac7f74f699122c593f4" have entirely different histories.
2e5ad9776d
...
7ebc58a63f
@ -159,8 +159,7 @@ Published when a license plate is recognized on a car object. See the [License P
|
|||||||
"plate": "123ABC",
|
"plate": "123ABC",
|
||||||
"score": 0.95,
|
"score": 0.95,
|
||||||
"camera": "driveway_cam",
|
"camera": "driveway_cam",
|
||||||
"timestamp": 1607123958.748393,
|
"timestamp": 1607123958.748393
|
||||||
"plate_box": [917, 487, 1029, 529] // box coordinates of the detected license plate in the frame
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
"""Camera apis."""
|
"""Camera apis."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
@ -12,9 +11,7 @@ import httpx
|
|||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from filelock import FileLock, Timeout
|
|
||||||
from onvif import ONVIFCamera, ONVIFError
|
from onvif import ONVIFCamera, ONVIFError
|
||||||
from ruamel.yaml import YAML
|
|
||||||
from zeep.exceptions import Fault, TransportError
|
from zeep.exceptions import Fault, TransportError
|
||||||
from zeep.transports import AsyncTransport
|
from zeep.transports import AsyncTransport
|
||||||
|
|
||||||
@ -24,14 +21,8 @@ from frigate.api.auth import (
|
|||||||
require_role,
|
require_role,
|
||||||
)
|
)
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config.config import FrigateConfig
|
||||||
from frigate.config.camera.updater import (
|
|
||||||
CameraConfigUpdateEnum,
|
|
||||||
CameraConfigUpdateTopic,
|
|
||||||
)
|
|
||||||
from frigate.util.builtin import clean_camera_user_pass
|
from frigate.util.builtin import clean_camera_user_pass
|
||||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
|
||||||
from frigate.util.config import find_config_file
|
|
||||||
from frigate.util.image import run_ffmpeg_snapshot
|
from frigate.util.image import run_ffmpeg_snapshot
|
||||||
from frigate.util.services import ffprobe_stream
|
from frigate.util.services import ffprobe_stream
|
||||||
|
|
||||||
@ -1004,154 +995,3 @@ async def onvif_probe(
|
|||||||
await onvif_camera.close()
|
await onvif_camera.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error closing ONVIF camera session: {e}")
|
logger.debug(f"Error closing ONVIF camera session: {e}")
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/cameras/{camera_name}",
|
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
|
||||||
)
|
|
||||||
async def delete_camera(
|
|
||||||
request: Request,
|
|
||||||
camera_name: str,
|
|
||||||
delete_exports: bool = Query(default=False),
|
|
||||||
):
|
|
||||||
"""Delete a camera and all its associated data.
|
|
||||||
|
|
||||||
Removes the camera from config, stops processes, and cleans up
|
|
||||||
all database entries and media files.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
camera_name: Name of the camera to delete
|
|
||||||
delete_exports: Whether to also delete exports for this camera
|
|
||||||
"""
|
|
||||||
frigate_config: FrigateConfig = request.app.frigate_config
|
|
||||||
|
|
||||||
if camera_name not in frigate_config.cameras:
|
|
||||||
return JSONResponse(
|
|
||||||
content={
|
|
||||||
"success": False,
|
|
||||||
"message": f"Camera {camera_name} not found",
|
|
||||||
},
|
|
||||||
status_code=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
old_camera_config = frigate_config.cameras[camera_name]
|
|
||||||
config_file = find_config_file()
|
|
||||||
lock = FileLock(f"{config_file}.lock", timeout=5)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with lock:
|
|
||||||
with open(config_file, "r") as f:
|
|
||||||
old_raw_config = f.read()
|
|
||||||
|
|
||||||
try:
|
|
||||||
yaml = YAML()
|
|
||||||
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
||||||
|
|
||||||
with open(config_file, "r") as f:
|
|
||||||
data = yaml.load(f)
|
|
||||||
|
|
||||||
# Remove camera from config
|
|
||||||
if "cameras" in data and camera_name in data["cameras"]:
|
|
||||||
del data["cameras"][camera_name]
|
|
||||||
|
|
||||||
# Remove camera from auth roles
|
|
||||||
auth = data.get("auth", {})
|
|
||||||
if auth and "roles" in auth:
|
|
||||||
empty_roles = []
|
|
||||||
for role_name, cameras_list in auth["roles"].items():
|
|
||||||
if (
|
|
||||||
isinstance(cameras_list, list)
|
|
||||||
and camera_name in cameras_list
|
|
||||||
):
|
|
||||||
cameras_list.remove(camera_name)
|
|
||||||
# Custom roles can't be empty; mark for removal
|
|
||||||
if not cameras_list and role_name not in (
|
|
||||||
"admin",
|
|
||||||
"viewer",
|
|
||||||
):
|
|
||||||
empty_roles.append(role_name)
|
|
||||||
for role_name in empty_roles:
|
|
||||||
del auth["roles"][role_name]
|
|
||||||
|
|
||||||
with open(config_file, "w") as f:
|
|
||||||
yaml.dump(data, f)
|
|
||||||
|
|
||||||
with open(config_file, "r") as f:
|
|
||||||
new_raw_config = f.read()
|
|
||||||
|
|
||||||
try:
|
|
||||||
config = FrigateConfig.parse(new_raw_config)
|
|
||||||
except Exception:
|
|
||||||
with open(config_file, "w") as f:
|
|
||||||
f.write(old_raw_config)
|
|
||||||
logger.exception(
|
|
||||||
"Config error after removing camera %s",
|
|
||||||
camera_name,
|
|
||||||
)
|
|
||||||
return JSONResponse(
|
|
||||||
content={
|
|
||||||
"success": False,
|
|
||||||
"message": "Error parsing config after camera removal",
|
|
||||||
},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Error updating config to remove camera %s: %s", camera_name, e
|
|
||||||
)
|
|
||||||
return JSONResponse(
|
|
||||||
content={
|
|
||||||
"success": False,
|
|
||||||
"message": "Error updating config",
|
|
||||||
},
|
|
||||||
status_code=500,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update runtime config
|
|
||||||
request.app.frigate_config = config
|
|
||||||
request.app.genai_manager.update_config(config)
|
|
||||||
|
|
||||||
# Publish removal to stop ffmpeg processes and clean up runtime state
|
|
||||||
request.app.config_publisher.publish_update(
|
|
||||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, camera_name),
|
|
||||||
old_camera_config,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Timeout:
|
|
||||||
return JSONResponse(
|
|
||||||
content={
|
|
||||||
"success": False,
|
|
||||||
"message": "Another process is currently updating the config",
|
|
||||||
},
|
|
||||||
status_code=409,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Clean up database entries
|
|
||||||
counts, export_paths = await asyncio.to_thread(
|
|
||||||
cleanup_camera_db, camera_name, delete_exports
|
|
||||||
)
|
|
||||||
|
|
||||||
# Clean up media files in background thread
|
|
||||||
await asyncio.to_thread(
|
|
||||||
cleanup_camera_files, camera_name, export_paths if delete_exports else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Best-effort go2rtc stream removal
|
|
||||||
try:
|
|
||||||
requests.delete(
|
|
||||||
"http://127.0.0.1:1984/api/streams",
|
|
||||||
params={"src": camera_name},
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Failed to remove go2rtc stream for %s", camera_name)
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
content={
|
|
||||||
"success": True,
|
|
||||||
"message": f"Camera {camera_name} has been deleted",
|
|
||||||
"cleanup": counts,
|
|
||||||
},
|
|
||||||
status_code=200,
|
|
||||||
)
|
|
||||||
|
|||||||
@ -1225,8 +1225,6 @@ class LicensePlateProcessingMixin:
|
|||||||
logger.debug(f"{camera}: License plate area below minimum threshold.")
|
logger.debug(f"{camera}: License plate area below minimum threshold.")
|
||||||
return
|
return
|
||||||
|
|
||||||
plate_box = license_plate
|
|
||||||
|
|
||||||
license_plate_frame = rgb[
|
license_plate_frame = rgb[
|
||||||
license_plate[1] : license_plate[3],
|
license_plate[1] : license_plate[3],
|
||||||
license_plate[0] : license_plate[2],
|
license_plate[0] : license_plate[2],
|
||||||
@ -1343,20 +1341,6 @@ class LicensePlateProcessingMixin:
|
|||||||
logger.debug(f"{camera}: License plate is less than min_area")
|
logger.debug(f"{camera}: License plate is less than min_area")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Scale back to original car coordinates and then to frame
|
|
||||||
plate_box_in_car = (
|
|
||||||
license_plate[0] // 2,
|
|
||||||
license_plate[1] // 2,
|
|
||||||
license_plate[2] // 2,
|
|
||||||
license_plate[3] // 2,
|
|
||||||
)
|
|
||||||
plate_box = (
|
|
||||||
left + plate_box_in_car[0],
|
|
||||||
top + plate_box_in_car[1],
|
|
||||||
left + plate_box_in_car[2],
|
|
||||||
top + plate_box_in_car[3],
|
|
||||||
)
|
|
||||||
|
|
||||||
license_plate_frame = car[
|
license_plate_frame = car[
|
||||||
license_plate[1] : license_plate[3],
|
license_plate[1] : license_plate[3],
|
||||||
license_plate[0] : license_plate[2],
|
license_plate[0] : license_plate[2],
|
||||||
@ -1420,8 +1404,6 @@ class LicensePlateProcessingMixin:
|
|||||||
0, [license_plate_frame.shape[1], license_plate_frame.shape[0]] * 2
|
0, [license_plate_frame.shape[1], license_plate_frame.shape[0]] * 2
|
||||||
)
|
)
|
||||||
|
|
||||||
plate_box = tuple(int(x) for x in expanded_box)
|
|
||||||
|
|
||||||
# Crop using the expanded box
|
# Crop using the expanded box
|
||||||
license_plate_frame = license_plate_frame[
|
license_plate_frame = license_plate_frame[
|
||||||
int(expanded_box[1]) : int(expanded_box[3]),
|
int(expanded_box[1]) : int(expanded_box[3]),
|
||||||
@ -1629,7 +1611,6 @@ class LicensePlateProcessingMixin:
|
|||||||
"id": id,
|
"id": id,
|
||||||
"camera": camera,
|
"camera": camera,
|
||||||
"timestamp": start,
|
"timestamp": start,
|
||||||
"plate_box": plate_box,
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -21,8 +21,7 @@ from frigate.const import (
|
|||||||
REPLAY_DIR,
|
REPLAY_DIR,
|
||||||
THUMB_DIR,
|
THUMB_DIR,
|
||||||
)
|
)
|
||||||
from frigate.models import Recordings
|
from frigate.models import Event, Recordings, ReviewSegment, Timeline
|
||||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
|
||||||
from frigate.util.config import find_config_file
|
from frigate.util.config import find_config_file
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -358,13 +357,43 @@ class DebugReplayManager:
|
|||||||
|
|
||||||
def _cleanup_db(self, camera_name: str) -> None:
|
def _cleanup_db(self, camera_name: str) -> None:
|
||||||
"""Defensively remove any database rows for the replay camera."""
|
"""Defensively remove any database rows for the replay camera."""
|
||||||
cleanup_camera_db(camera_name)
|
try:
|
||||||
|
Event.delete().where(Event.camera == camera_name).execute()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete replay events: %s", e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
Timeline.delete().where(Timeline.camera == camera_name).execute()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete replay timeline: %s", e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
Recordings.delete().where(Recordings.camera == camera_name).execute()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete replay recordings: %s", e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ReviewSegment.delete().where(ReviewSegment.camera == camera_name).execute()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete replay review segments: %s", e)
|
||||||
|
|
||||||
def _cleanup_files(self, camera_name: str) -> None:
|
def _cleanup_files(self, camera_name: str) -> None:
|
||||||
"""Remove filesystem artifacts for the replay camera."""
|
"""Remove filesystem artifacts for the replay camera."""
|
||||||
cleanup_camera_files(camera_name)
|
dirs_to_clean = [
|
||||||
|
os.path.join(RECORD_DIR, camera_name),
|
||||||
|
os.path.join(CLIPS_DIR, camera_name),
|
||||||
|
os.path.join(THUMB_DIR, camera_name),
|
||||||
|
]
|
||||||
|
|
||||||
# Remove replay-specific cache directory
|
for dir_path in dirs_to_clean:
|
||||||
|
if os.path.exists(dir_path):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(dir_path)
|
||||||
|
logger.debug("Removed replay directory: %s", dir_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to remove %s: %s", dir_path, e)
|
||||||
|
|
||||||
|
# Remove replay clip and any related files
|
||||||
if os.path.exists(REPLAY_DIR):
|
if os.path.exists(REPLAY_DIR):
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(REPLAY_DIR)
|
shutil.rmtree(REPLAY_DIR)
|
||||||
|
|||||||
@ -1,153 +0,0 @@
|
|||||||
"""Utilities for cleaning up camera data from database and filesystem."""
|
|
||||||
|
|
||||||
import glob
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
from frigate.const import CLIPS_DIR, RECORD_DIR, THUMB_DIR
|
|
||||||
from frigate.models import (
|
|
||||||
Event,
|
|
||||||
Export,
|
|
||||||
Previews,
|
|
||||||
Recordings,
|
|
||||||
Regions,
|
|
||||||
ReviewSegment,
|
|
||||||
Timeline,
|
|
||||||
Trigger,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_camera_db(
|
|
||||||
camera_name: str, delete_exports: bool = False
|
|
||||||
) -> tuple[dict[str, int], list[str]]:
|
|
||||||
"""Remove all database rows for a camera.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
camera_name: The camera name to clean up
|
|
||||||
delete_exports: Whether to also delete export records
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (deletion counts dict, list of export file paths to remove)
|
|
||||||
"""
|
|
||||||
counts: dict[str, int] = {}
|
|
||||||
export_paths: list[str] = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
counts["events"] = Event.delete().where(Event.camera == camera_name).execute()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to delete events for camera %s: %s", camera_name, e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
counts["timeline"] = (
|
|
||||||
Timeline.delete().where(Timeline.camera == camera_name).execute()
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to delete timeline for camera %s: %s", camera_name, e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
counts["recordings"] = (
|
|
||||||
Recordings.delete().where(Recordings.camera == camera_name).execute()
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to delete recordings for camera %s: %s", camera_name, e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
counts["review_segments"] = (
|
|
||||||
ReviewSegment.delete().where(ReviewSegment.camera == camera_name).execute()
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Failed to delete review segments for camera %s: %s", camera_name, e
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
counts["previews"] = (
|
|
||||||
Previews.delete().where(Previews.camera == camera_name).execute()
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to delete previews for camera %s: %s", camera_name, e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
counts["regions"] = (
|
|
||||||
Regions.delete().where(Regions.camera == camera_name).execute()
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to delete regions for camera %s: %s", camera_name, e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
counts["triggers"] = (
|
|
||||||
Trigger.delete().where(Trigger.camera == camera_name).execute()
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to delete triggers for camera %s: %s", camera_name, e)
|
|
||||||
|
|
||||||
if delete_exports:
|
|
||||||
try:
|
|
||||||
exports = Export.select(Export.video_path, Export.thumb_path).where(
|
|
||||||
Export.camera == camera_name
|
|
||||||
)
|
|
||||||
for export in exports:
|
|
||||||
export_paths.append(export.video_path)
|
|
||||||
export_paths.append(export.thumb_path)
|
|
||||||
|
|
||||||
counts["exports"] = (
|
|
||||||
Export.delete().where(Export.camera == camera_name).execute()
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to delete exports for camera %s: %s", camera_name, e)
|
|
||||||
|
|
||||||
return counts, export_paths
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_camera_files(
|
|
||||||
camera_name: str, export_paths: list[str] | None = None
|
|
||||||
) -> None:
|
|
||||||
"""Remove filesystem artifacts for a camera.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
camera_name: The camera name to clean up
|
|
||||||
export_paths: Optional list of export file paths to remove
|
|
||||||
"""
|
|
||||||
dirs_to_clean = [
|
|
||||||
os.path.join(RECORD_DIR, camera_name),
|
|
||||||
os.path.join(CLIPS_DIR, camera_name),
|
|
||||||
os.path.join(THUMB_DIR, camera_name),
|
|
||||||
os.path.join(CLIPS_DIR, "previews", camera_name),
|
|
||||||
]
|
|
||||||
|
|
||||||
for dir_path in dirs_to_clean:
|
|
||||||
if os.path.exists(dir_path):
|
|
||||||
try:
|
|
||||||
shutil.rmtree(dir_path)
|
|
||||||
logger.debug("Removed directory: %s", dir_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to remove %s: %s", dir_path, e)
|
|
||||||
|
|
||||||
# Remove event snapshot files
|
|
||||||
for snapshot in glob.glob(os.path.join(CLIPS_DIR, f"{camera_name}-*.jpg")):
|
|
||||||
try:
|
|
||||||
os.remove(snapshot)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to remove snapshot %s: %s", snapshot, e)
|
|
||||||
|
|
||||||
# Remove review thumbnail files
|
|
||||||
for thumb in glob.glob(
|
|
||||||
os.path.join(CLIPS_DIR, "review", f"thumb-{camera_name}-*.webp")
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
os.remove(thumb)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to remove review thumbnail %s: %s", thumb, e)
|
|
||||||
|
|
||||||
# Remove export files if requested
|
|
||||||
if export_paths:
|
|
||||||
for path in export_paths:
|
|
||||||
if path and os.path.exists(path):
|
|
||||||
try:
|
|
||||||
os.remove(path)
|
|
||||||
logger.debug("Removed export file: %s", path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to remove export file %s: %s", path, e)
|
|
||||||
23
web/patches/react-use-websocket+4.8.1.patch
Normal file
23
web/patches/react-use-websocket+4.8.1.patch
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
diff --git a/node_modules/react-use-websocket/dist/lib/use-websocket.js b/node_modules/react-use-websocket/dist/lib/use-websocket.js
|
||||||
|
index f01db48..b30aff2 100644
|
||||||
|
--- a/node_modules/react-use-websocket/dist/lib/use-websocket.js
|
||||||
|
+++ b/node_modules/react-use-websocket/dist/lib/use-websocket.js
|
||||||
|
@@ -139,15 +139,15 @@ var useWebSocket = function (url, options, connect) {
|
||||||
|
}
|
||||||
|
protectedSetLastMessage = function (message) {
|
||||||
|
if (!expectClose_1) {
|
||||||
|
- (0, react_dom_1.flushSync)(function () { return setLastMessage(message); });
|
||||||
|
+ setLastMessage(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
protectedSetReadyState = function (state) {
|
||||||
|
if (!expectClose_1) {
|
||||||
|
- (0, react_dom_1.flushSync)(function () { return setReadyState(function (prev) {
|
||||||
|
+ setReadyState(function (prev) {
|
||||||
|
var _a;
|
||||||
|
return (__assign(__assign({}, prev), (convertedUrl.current && (_a = {}, _a[convertedUrl.current] = state, _a))));
|
||||||
|
- }); });
|
||||||
|
+ });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (createOrJoin_1) {
|
||||||
@ -80,9 +80,6 @@
|
|||||||
"back": "Back",
|
"back": "Back",
|
||||||
"empty": "No previews available",
|
"empty": "No previews available",
|
||||||
"noPreview": "Preview unavailable",
|
"noPreview": "Preview unavailable",
|
||||||
"seekAria": "Seek {{camera}} player to {{time}}",
|
"seekAria": "Seek {{camera}} player to {{time}}"
|
||||||
"filter": "Filter",
|
|
||||||
"filterDesc": "Select areas to only show clips with motion in those regions.",
|
|
||||||
"filterClear": "Clear"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -422,18 +422,6 @@
|
|||||||
"cameraManagement": {
|
"cameraManagement": {
|
||||||
"title": "Manage Cameras",
|
"title": "Manage Cameras",
|
||||||
"addCamera": "Add New Camera",
|
"addCamera": "Add New Camera",
|
||||||
"deleteCamera": "Delete Camera",
|
|
||||||
"deleteCameraDialog": {
|
|
||||||
"title": "Delete Camera",
|
|
||||||
"description": "Deleting a camera will permanently remove all recordings, tracked objects, and configuration for that camera. Any go2rtc streams associated with this camera may still need to be manually removed.",
|
|
||||||
"selectPlaceholder": "Choose camera...",
|
|
||||||
"confirmTitle": "Are you sure?",
|
|
||||||
"confirmWarning": "Deleting <strong>{{cameraName}}</strong> cannot be undone.",
|
|
||||||
"deleteExports": "Also delete exports for this camera",
|
|
||||||
"confirmButton": "Delete Permanently",
|
|
||||||
"success": "Camera {{cameraName}} deleted successfully",
|
|
||||||
"error": "Failed to delete camera {{cameraName}}"
|
|
||||||
},
|
|
||||||
"editCamera": "Edit Camera:",
|
"editCamera": "Edit Camera:",
|
||||||
"selectCamera": "Select a Camera",
|
"selectCamera": "Select a Camera",
|
||||||
"backToSettings": "Back to Camera Settings",
|
"backToSettings": "Back to Camera Settings",
|
||||||
|
|||||||
@ -1,150 +0,0 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
|
||||||
import { useCallback, useRef } from "react";
|
|
||||||
|
|
||||||
const GRID_SIZE = 16;
|
|
||||||
|
|
||||||
type MotionRegionFilterGridProps = {
|
|
||||||
cameraName: string;
|
|
||||||
selectedCells: Set<number>;
|
|
||||||
onCellsChange: (cells: Set<number>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MotionRegionFilterGrid({
|
|
||||||
cameraName,
|
|
||||||
selectedCells,
|
|
||||||
onCellsChange,
|
|
||||||
}: MotionRegionFilterGridProps) {
|
|
||||||
const paintingRef = useRef<{ active: boolean; adding: boolean }>({
|
|
||||||
active: false,
|
|
||||||
adding: true,
|
|
||||||
});
|
|
||||||
const lastCellRef = useRef<number>(-1);
|
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const toggleCell = useCallback(
|
|
||||||
(index: number, forceAdd?: boolean) => {
|
|
||||||
const next = new Set(selectedCells);
|
|
||||||
|
|
||||||
if (forceAdd !== undefined) {
|
|
||||||
if (forceAdd) {
|
|
||||||
next.add(index);
|
|
||||||
} else {
|
|
||||||
next.delete(index);
|
|
||||||
}
|
|
||||||
} else if (next.has(index)) {
|
|
||||||
next.delete(index);
|
|
||||||
} else {
|
|
||||||
next.add(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
onCellsChange(next);
|
|
||||||
},
|
|
||||||
[selectedCells, onCellsChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getCellFromPoint = useCallback(
|
|
||||||
(clientX: number, clientY: number): number | null => {
|
|
||||||
const grid = gridRef.current;
|
|
||||||
|
|
||||||
if (!grid) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rect = grid.getBoundingClientRect();
|
|
||||||
const x = clientX - rect.left;
|
|
||||||
const y = clientY - rect.top;
|
|
||||||
|
|
||||||
if (x < 0 || y < 0 || x >= rect.width || y >= rect.height) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const col = Math.floor((x / rect.width) * GRID_SIZE);
|
|
||||||
const row = Math.floor((y / rect.height) * GRID_SIZE);
|
|
||||||
|
|
||||||
return row * GRID_SIZE + col;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
|
||||||
(e: React.PointerEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const index = getCellFromPoint(e.clientX, e.clientY);
|
|
||||||
|
|
||||||
if (index === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const adding = !selectedCells.has(index);
|
|
||||||
paintingRef.current = { active: true, adding };
|
|
||||||
lastCellRef.current = index;
|
|
||||||
toggleCell(index, adding);
|
|
||||||
},
|
|
||||||
[selectedCells, toggleCell, getCellFromPoint],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerMove = useCallback(
|
|
||||||
(e: React.PointerEvent) => {
|
|
||||||
if (!paintingRef.current.active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = getCellFromPoint(e.clientX, e.clientY);
|
|
||||||
|
|
||||||
if (index === null || index === lastCellRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastCellRef.current = index;
|
|
||||||
toggleCell(index, paintingRef.current.adding);
|
|
||||||
},
|
|
||||||
[toggleCell, getCellFromPoint],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerUp = useCallback(() => {
|
|
||||||
paintingRef.current.active = false;
|
|
||||||
lastCellRef.current = -1;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div
|
|
||||||
className="relative aspect-video w-full select-none overflow-hidden rounded-lg"
|
|
||||||
style={{ touchAction: "none" }}
|
|
||||||
onPointerUp={handlePointerUp}
|
|
||||||
onPointerLeave={handlePointerUp}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={`${baseUrl}api/${cameraName}/latest.jpg?h=500`}
|
|
||||||
className="absolute inset-0 size-full object-contain"
|
|
||||||
draggable={false}
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
ref={gridRef}
|
|
||||||
className="absolute inset-0 grid"
|
|
||||||
style={{
|
|
||||||
gridTemplateColumns: `repeat(${GRID_SIZE}, 1fr)`,
|
|
||||||
gridTemplateRows: `repeat(${GRID_SIZE}, 1fr)`,
|
|
||||||
}}
|
|
||||||
onPointerDown={handlePointerDown}
|
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
>
|
|
||||||
{Array.from({ length: GRID_SIZE * GRID_SIZE }, (_, index) => {
|
|
||||||
const isSelected = selectedCells.has(index);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={
|
|
||||||
isSelected
|
|
||||||
? "border border-severity_alert/60 bg-severity_alert/40"
|
|
||||||
: "border border-transparent hover:bg-white/20"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,215 +0,0 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Trans } from "react-i18next";
|
|
||||||
import axios from "axios";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
|
|
||||||
type DeleteCameraDialogProps = {
|
|
||||||
show: boolean;
|
|
||||||
cameras: string[];
|
|
||||||
onClose: () => void;
|
|
||||||
onDeleted: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DeleteCameraDialog({
|
|
||||||
show,
|
|
||||||
cameras,
|
|
||||||
onClose,
|
|
||||||
onDeleted,
|
|
||||||
}: DeleteCameraDialogProps) {
|
|
||||||
const { t } = useTranslation(["views/settings", "common"]);
|
|
||||||
const [phase, setPhase] = useState<"select" | "confirm">("select");
|
|
||||||
const [selectedCamera, setSelectedCamera] = useState<string>("");
|
|
||||||
const [deleteExports, setDeleteExports] = useState(false);
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
if (isDeleting) return;
|
|
||||||
setPhase("select");
|
|
||||||
setSelectedCamera("");
|
|
||||||
setDeleteExports(false);
|
|
||||||
onClose();
|
|
||||||
}, [isDeleting, onClose]);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
|
||||||
setPhase("confirm");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
|
||||||
setPhase("select");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleConfirmDelete = useCallback(async () => {
|
|
||||||
if (!selectedCamera || isDeleting) return;
|
|
||||||
|
|
||||||
setIsDeleting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await axios.delete(
|
|
||||||
`cameras/${selectedCamera}?delete_exports=${deleteExports}`,
|
|
||||||
);
|
|
||||||
toast.success(
|
|
||||||
t("cameraManagement.deleteCameraDialog.success", {
|
|
||||||
cameraName: selectedCamera,
|
|
||||||
}),
|
|
||||||
{ position: "top-center" },
|
|
||||||
);
|
|
||||||
setPhase("select");
|
|
||||||
setSelectedCamera("");
|
|
||||||
setDeleteExports(false);
|
|
||||||
onDeleted();
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
axios.isAxiosError(error) &&
|
|
||||||
(error.response?.data?.message || error.response?.data?.detail)
|
|
||||||
? error.response?.data?.message || error.response?.data?.detail
|
|
||||||
: t("cameraManagement.deleteCameraDialog.error", {
|
|
||||||
cameraName: selectedCamera,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.error(errorMessage, { position: "top-center" });
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
|
||||||
}, [selectedCamera, deleteExports, isDeleting, onDeleted, t]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={show} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
|
||||||
{phase === "select" ? (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{t("cameraManagement.deleteCameraDialog.title")}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t("cameraManagement.deleteCameraDialog.description")}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Select value={selectedCamera} onValueChange={setSelectedCamera}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t(
|
|
||||||
"cameraManagement.deleteCameraDialog.selectPlaceholder",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{cameras.map((camera) => (
|
|
||||||
<SelectItem key={camera} value={camera}>
|
|
||||||
{camera}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<DialogFooter className="flex gap-3 sm:justify-end">
|
|
||||||
<div className="flex flex-1 flex-col justify-end">
|
|
||||||
<div className="flex flex-row gap-2 pt-5">
|
|
||||||
<Button
|
|
||||||
className="flex flex-1"
|
|
||||||
aria-label={t("button.cancel", { ns: "common" })}
|
|
||||||
onClick={handleClose}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{t("button.cancel", { ns: "common" })}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
aria-label={t("button.delete", { ns: "common" })}
|
|
||||||
className="flex flex-1 text-white"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={!selectedCamera}
|
|
||||||
>
|
|
||||||
{t("button.delete", { ns: "common" })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{t("cameraManagement.deleteCameraDialog.confirmTitle")}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans
|
|
||||||
ns="views/settings"
|
|
||||||
values={{ cameraName: selectedCamera }}
|
|
||||||
components={{ strong: <span className="font-medium" /> }}
|
|
||||||
>
|
|
||||||
cameraManagement.deleteCameraDialog.confirmWarning
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id="delete-exports"
|
|
||||||
checked={deleteExports}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setDeleteExports(checked === true)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="delete-exports" className="cursor-pointer">
|
|
||||||
{t("cameraManagement.deleteCameraDialog.deleteExports")}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<DialogFooter className="flex gap-3 sm:justify-end">
|
|
||||||
<div className="flex flex-1 flex-col justify-end">
|
|
||||||
<div className="flex flex-row gap-2 pt-5">
|
|
||||||
<Button
|
|
||||||
className="flex flex-1"
|
|
||||||
aria-label={t("button.back", { ns: "common" })}
|
|
||||||
onClick={handleBack}
|
|
||||||
type="button"
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
{t("button.back", { ns: "common" })}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
className="flex flex-1 text-white"
|
|
||||||
onClick={handleConfirmDelete}
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
{isDeleting ? (
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
|
||||||
<ActivityIndicator />
|
|
||||||
<span>
|
|
||||||
{t(
|
|
||||||
"cameraManagement.deleteCameraDialog.confirmButton",
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
t("cameraManagement.deleteCameraDialog.confirmButton")
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -79,17 +79,7 @@ import { GiSoundWaves } from "react-icons/gi";
|
|||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FaCog, FaFilter } from "react-icons/fa";
|
import { FaCog } from "react-icons/fa";
|
||||||
import MotionRegionFilterGrid from "@/components/filter/MotionRegionFilterGrid";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import ReviewActivityCalendar from "@/components/overlay/ReviewActivityCalendar";
|
import ReviewActivityCalendar from "@/components/overlay/ReviewActivityCalendar";
|
||||||
import PlatformAwareDialog from "@/components/overlay/dialog/PlatformAwareDialog";
|
import PlatformAwareDialog from "@/components/overlay/dialog/PlatformAwareDialog";
|
||||||
import MotionPreviewsPane from "./MotionPreviewsPane";
|
import MotionPreviewsPane from "./MotionPreviewsPane";
|
||||||
@ -1127,13 +1117,6 @@ function MotionReview({
|
|||||||
const [controlsOpen, setControlsOpen] = useState(false);
|
const [controlsOpen, setControlsOpen] = useState(false);
|
||||||
const [dimStrength, setDimStrength] = useState(82);
|
const [dimStrength, setDimStrength] = useState(82);
|
||||||
const [isPreviewSettingsOpen, setIsPreviewSettingsOpen] = useState(false);
|
const [isPreviewSettingsOpen, setIsPreviewSettingsOpen] = useState(false);
|
||||||
const [motionFilterCells, setMotionFilterCells] = useState<Set<number>>(
|
|
||||||
new Set(),
|
|
||||||
);
|
|
||||||
const [pendingFilterCells, setPendingFilterCells] = useState<Set<number>>(
|
|
||||||
new Set(),
|
|
||||||
);
|
|
||||||
const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
|
|
||||||
|
|
||||||
const objectReviewItems = useMemo(
|
const objectReviewItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -1331,68 +1314,6 @@ function MotionReview({
|
|||||||
updateSelectedDay={onUpdateSelectedDay}
|
updateSelectedDay={onUpdateSelectedDay}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Dialog
|
|
||||||
open={isRegionFilterOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (open) {
|
|
||||||
setPendingFilterCells(new Set(motionFilterCells));
|
|
||||||
}
|
|
||||||
setIsRegionFilterOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className={cn(
|
|
||||||
isDesktop ? "flex items-center gap-2" : "rounded-lg",
|
|
||||||
)}
|
|
||||||
size="sm"
|
|
||||||
variant={motionFilterCells.size > 0 ? "select" : "default"}
|
|
||||||
aria-label={t("motionPreviews.filter")}
|
|
||||||
>
|
|
||||||
<FaFilter
|
|
||||||
className={
|
|
||||||
motionFilterCells.size > 0
|
|
||||||
? "text-selected-foreground"
|
|
||||||
: "text-secondary-foreground"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{isDesktop && t("motionPreviews.filter")}
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-[85%] md:max-w-[70%] lg:max-w-[60%]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("motionPreviews.filter")}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t("motionPreviews.filterDesc")}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<MotionRegionFilterGrid
|
|
||||||
cameraName={selectedMotionPreviewCamera.name}
|
|
||||||
selectedCells={pendingFilterCells}
|
|
||||||
onCellsChange={setPendingFilterCells}
|
|
||||||
/>
|
|
||||||
<DialogFooter className="justify-end gap-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={pendingFilterCells.size === 0}
|
|
||||||
onClick={() => {
|
|
||||||
setPendingFilterCells(new Set());
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("motionPreviews.filterClear")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="select"
|
|
||||||
onClick={() => {
|
|
||||||
setMotionFilterCells(new Set(pendingFilterCells));
|
|
||||||
setIsRegionFilterOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("button.apply", { ns: "common" })}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
<PlatformAwareDialog
|
<PlatformAwareDialog
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
@ -1535,7 +1456,6 @@ function MotionReview({
|
|||||||
}
|
}
|
||||||
playbackRate={playbackRate}
|
playbackRate={playbackRate}
|
||||||
nonMotionAlpha={dimStrength / 100}
|
nonMotionAlpha={dimStrength / 100}
|
||||||
motionFilterCells={motionFilterCells}
|
|
||||||
onSeek={(timestamp) => {
|
onSeek={(timestamp) => {
|
||||||
onOpenRecording({
|
onOpenRecording({
|
||||||
camera: selectedMotionPreviewCamera.name,
|
camera: selectedMotionPreviewCamera.name,
|
||||||
|
|||||||
@ -589,7 +589,6 @@ type MotionPreviewsPaneProps = {
|
|||||||
isLoadingMotionRanges?: boolean;
|
isLoadingMotionRanges?: boolean;
|
||||||
playbackRate: number;
|
playbackRate: number;
|
||||||
nonMotionAlpha: number;
|
nonMotionAlpha: number;
|
||||||
motionFilterCells?: Set<number>;
|
|
||||||
onSeek: (timestamp: number) => void;
|
onSeek: (timestamp: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -601,7 +600,6 @@ export default function MotionPreviewsPane({
|
|||||||
isLoadingMotionRanges = false,
|
isLoadingMotionRanges = false,
|
||||||
playbackRate,
|
playbackRate,
|
||||||
nonMotionAlpha,
|
nonMotionAlpha,
|
||||||
motionFilterCells,
|
|
||||||
onSeek,
|
onSeek,
|
||||||
}: MotionPreviewsPaneProps) {
|
}: MotionPreviewsPaneProps) {
|
||||||
const { t } = useTranslation(["views/events"]);
|
const { t } = useTranslation(["views/events"]);
|
||||||
@ -881,26 +879,6 @@ export default function MotionPreviewsPane({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredClipData = useMemo(() => {
|
|
||||||
if (!motionFilterCells || motionFilterCells.size === 0) {
|
|
||||||
return clipData;
|
|
||||||
}
|
|
||||||
|
|
||||||
return clipData.filter(({ motionHeatmap }) => {
|
|
||||||
if (!motionHeatmap) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const cellIndex of motionFilterCells) {
|
|
||||||
if ((motionHeatmap[cellIndex.toString()] ?? 0) > 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}, [clipData, motionFilterCells]);
|
|
||||||
|
|
||||||
const hasCurrentHourRanges = useMemo(
|
const hasCurrentHourRanges = useMemo(
|
||||||
() => motionRanges.some((range) => isCurrentHour(range.end_time)),
|
() => motionRanges.some((range) => isCurrentHour(range.end_time)),
|
||||||
[motionRanges],
|
[motionRanges],
|
||||||
@ -923,13 +901,13 @@ export default function MotionPreviewsPane({
|
|||||||
ref={setContentNode}
|
ref={setContentNode}
|
||||||
className="no-scrollbar min-h-0 flex-1 overflow-y-auto"
|
className="no-scrollbar min-h-0 flex-1 overflow-y-auto"
|
||||||
>
|
>
|
||||||
{filteredClipData.length === 0 ? (
|
{clipData.length === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center text-lg text-primary">
|
<div className="flex h-full items-center justify-center text-lg text-primary">
|
||||||
{t("motionPreviews.empty")}
|
{t("motionPreviews.empty")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-2 pb-2 sm:grid-cols-2 md:gap-4 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-2 pb-2 sm:grid-cols-2 md:gap-4 xl:grid-cols-4">
|
||||||
{filteredClipData.map(
|
{clipData.map(
|
||||||
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => {
|
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => {
|
||||||
const clipId = `${camera.name}-${range.start_time}-${range.end_time}-${idx}`;
|
const clipId = `${camera.name}-${range.start_time}-${range.end_time}-${idx}`;
|
||||||
const isMounted = mountedClips.has(clipId);
|
const isMounted = mountedClips.has(clipId);
|
||||||
|
|||||||
@ -13,8 +13,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import CameraEditForm from "@/components/settings/CameraEditForm";
|
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||||
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||||
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
|
import { LuPlus } from "react-icons/lu";
|
||||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
|
||||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
@ -46,7 +45,6 @@ export default function CameraManagementView({
|
|||||||
undefined,
|
undefined,
|
||||||
); // Track camera being edited
|
); // Track camera being edited
|
||||||
const [showWizard, setShowWizard] = useState(false);
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
||||||
|
|
||||||
// State for restart dialog when enabling a disabled camera
|
// State for restart dialog when enabling a disabled camera
|
||||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||||
@ -100,26 +98,14 @@ export default function CameraManagementView({
|
|||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<div className="w-full max-w-5xl space-y-6">
|
<div className="w-full max-w-5xl space-y-6">
|
||||||
<div className="flex gap-2">
|
<Button
|
||||||
<Button
|
variant="select"
|
||||||
variant="select"
|
onClick={() => setShowWizard(true)}
|
||||||
onClick={() => setShowWizard(true)}
|
className="mb-2 flex max-w-48 items-center gap-2"
|
||||||
className="mb-2 flex max-w-48 items-center gap-2"
|
>
|
||||||
>
|
<LuPlus className="h-4 w-4" />
|
||||||
<LuPlus className="h-4 w-4" />
|
{t("cameraManagement.addCamera")}
|
||||||
{t("cameraManagement.addCamera")}
|
</Button>
|
||||||
</Button>
|
|
||||||
{enabledCameras.length + disabledCameras.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => setShowDeleteDialog(true)}
|
|
||||||
className="mb-2 flex max-w-48 items-center gap-2 text-white"
|
|
||||||
>
|
|
||||||
<LuTrash2 className="h-4 w-4" />
|
|
||||||
{t("cameraManagement.deleteCamera")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{enabledCameras.length > 0 && (
|
{enabledCameras.length > 0 && (
|
||||||
<SettingsGroupCard
|
<SettingsGroupCard
|
||||||
@ -235,15 +221,6 @@ export default function CameraManagementView({
|
|||||||
open={showWizard}
|
open={showWizard}
|
||||||
onClose={() => setShowWizard(false)}
|
onClose={() => setShowWizard(false)}
|
||||||
/>
|
/>
|
||||||
<DeleteCameraDialog
|
|
||||||
show={showDeleteDialog}
|
|
||||||
cameras={[...enabledCameras, ...disabledCameras]}
|
|
||||||
onClose={() => setShowDeleteDialog(false)}
|
|
||||||
onDeleted={() => {
|
|
||||||
setShowDeleteDialog(false);
|
|
||||||
updateConfig();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<RestartDialog
|
<RestartDialog
|
||||||
isOpen={restartDialogOpen}
|
isOpen={restartDialogOpen}
|
||||||
onClose={() => setRestartDialogOpen(false)}
|
onClose={() => setRestartDialogOpen(false)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user