Merge branch 'blakeblackshear:dev' into dev

This commit is contained in:
Remon Nashid 2024-05-09 08:57:11 -06:00 committed by GitHub
commit 92e239603f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
111 changed files with 4086 additions and 1560 deletions

View File

@ -51,7 +51,7 @@ ARG DEBIAN_FRONTEND
# Install OpenVino Runtime and Dev library
COPY docker/main/requirements-ov.txt /requirements-ov.txt
RUN apt-get -qq update \
&& apt-get -qq install -y wget python3 python3-distutils \
&& apt-get -qq install -y wget python3 python3-dev python3-distutils gcc pkg-config libhdf5-dev \
&& wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
&& python3 get-pip.py "pip" \
&& pip install -r /requirements-ov.txt

View File

@ -94,3 +94,23 @@ This list of working and non-working PTZ cameras is based on user feedback.
| Tapo C210 | ❌ | ❌ | Incomplete ONVIF support |
| Uniview IPC672LR-AX4DUPK | ✅ | ❌ | Firmware says FOV relative movement is supported, but camera doesn't actually move when sending ONVIF commands |
| Vikylin PTZ-2804X-I2 | ❌ | ❌ | Incomplete ONVIF support |
## Setting up camera groups
:::tip
It is recommended to set up camera groups using the UI.
:::
Cameras can be grouped together and assigned a name and icon, this allows them to be reviewed and filtered together. There will always be the default group for all cameras.
```yaml
camera_groups:
front:
cameras:
- driveway_cam
- garage_cam
icon: car
order: 0
```

View File

@ -653,4 +653,19 @@ telemetry:
# Optional: Enable the latest version outbound check (default: shown below)
# NOTE: If you use the HomeAssistant integration, disabling this will prevent it from reporting new versions
version_check: True
# Optional: Camera groups (default: no groups are setup)
# NOTE: It is recommended to use the UI to setup camera groups
camera_groups:
# Required: Name of camera group
front:
# Required: list of cameras in the group
cameras:
- front_cam
- side_cam
- front_doorbell_cam
# Required: icon used for group
icon: car
# Required: index of this group
order: 0
```

View File

@ -0,0 +1,47 @@
---
id: review
title: Review
---
Review items are saved as periods of time where frigate detected events. After watching the preview of a review item it is marked as reviewed.
## Restricting alerts to specific labels
By default a review item will only be marked as an alert if a person or car is detected. This can be configured to include any object or audio label using the following config:
```yaml
# can be overridden at the camera level
review:
alerts:
labels:
- car
- cat
- dog
- person
- speech
```
## Restricting detections to specific labels
By default all detections that do not qualify as an alert qualify as a detection. However, detections can further be filtered to only include certain labels or certain zones.
By default a review item will only be marked as an alert if a person or car is detected. This can be configured to include any object or audio label using the following config:
```yaml
# can be overridden at the camera level
review:
detections:
labels:
- bark
- dog
```
## Restricting review items to specific zones
By default a review item will be created if any `review -> alerts -> labels` and `review -> detections -> labels` are detected anywhere in the camera frame. You will likely want to configure review items to only be created when the object enters an area of interest, [see the zone docs for more information](./zones.md#restricting-alerts-and-detections-to-specific-zones)
:::info
Because zones don't apply to audio, audio labels will always be marked as an alert.
:::

View File

@ -3,6 +3,8 @@ id: snapshots
title: Snapshots
---
Frigate can save a snapshot image to `/media/frigate/clips` for each event named as `<camera>-<id>.jpg`.
Frigate can save a snapshot image to `/media/frigate/clips` for each object that is detected named as `<camera>-<id>.jpg`. They are also accessible [via the api](../integrations/api.md#get-apieventsidsnapshotjpg)
To only save snapshots for objects that enter a specific zone, [see the zone docs](./zones.md#restricting-snapshots-to-specific-zones)
Snapshots sent via MQTT are configured in the [config file](https://docs.frigate.video/configuration/) under `cameras -> your_camera -> mqtt`

View File

@ -14,17 +14,47 @@ During testing, enable the Zones option for the debug feed so you can adjust as
To create a zone, follow [the steps for a "Motion mask"](masks.md), but use the section of the web UI for creating a zone instead.
### Restricting events to specific zones
### Restricting alerts and detections to specific zones
Often you will only want events to be created when an object enters areas of interest. This is done using zones along with setting required_zones. Let's say you only want to be notified when an object enters your entire_yard zone, the config would be:
Often you will only want alerts to be created when an object enters areas of interest. This is done using zones along with setting required_zones. Let's say you only want to have an alert created when an object enters your entire_yard zone, the config would be:
```yaml
cameras:
name_of_your_camera:
record:
events:
review:
alerts:
required_zones:
- entire_yard
zones:
entire_yard:
coordinates: ...
```
You may also want to filter detections to only be created when an object enters a secondary area of interest. This is done using zones along with setting required_zones. Let's say you want alerts when an object enters the inner area of the yard but detections when an object enters the edge of the yard, the config would be
```yaml
cameras:
name_of_your_camera:
review:
alerts:
required_zones:
- inner_yard
detections:
required_zones:
- edge_yard
zones:
edge_yard:
coordinates: ...
inner_yard:
coordinates: ...
```
### Restricting snapshots to specific zones
```yaml
cameras:
name_of_your_camera:
snapshots:
required_zones:
- entire_yard

View File

@ -32,6 +32,7 @@ module.exports = {
],
Cameras: [
"configuration/cameras",
"configuration/review",
"configuration/record",
"configuration/snapshots",
"configuration/motion_detection",

View File

@ -164,6 +164,7 @@ def config():
camera_dict["zones"][zone_name]["color"] = zone.color
config["plus"] = {"enabled": current_app.plus_api.is_active()}
config["model"]["colormap"] = config_obj.model.colormap
for detector_config in config["detectors"].values():
detector_config["model"]["labelmap"] = (

View File

@ -26,6 +26,7 @@ from frigate.const import (
)
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
from frigate.util.builtin import get_tz_modifiers
from frigate.util.image import get_image_from_recording
logger = logging.getLogger(__name__)
@ -205,30 +206,20 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str):
try:
recording: Recordings = recording_query.get()
time_in_segment = frame_time - recording.start_time
image_data = get_image_from_recording(recording.path, time_in_segment)
ffmpeg_cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"warning",
"-ss",
f"00:00:{time_in_segment}",
"-i",
recording.path,
"-frames:v",
"1",
"-c:v",
"png",
"-f",
"image2pipe",
"-",
]
if not image_data:
return make_response(
jsonify(
{
"success": False,
"message": f"Unable to parse frame at time {frame_time}",
}
),
404,
)
process = sp.run(
ffmpeg_cmd,
capture_output=True,
)
response = make_response(process.stdout)
response = make_response(image_data)
response.headers["Content-Type"] = "image/png"
return response
except DoesNotExist:
@ -243,6 +234,71 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str):
)
@MediaBp.route("/<camera_name>/plus/<frame_time>", methods=("POST",))
def submit_recording_snapshot_to_plus(camera_name: str, frame_time: str):
if camera_name not in current_app.frigate_config.cameras:
return make_response(
jsonify({"success": False, "message": "Camera not found"}),
404,
)
frame_time = float(frame_time)
recording_query = (
Recordings.select(
Recordings.path,
Recordings.start_time,
)
.where(
(
(frame_time >= Recordings.start_time)
& (frame_time <= Recordings.end_time)
)
)
.where(Recordings.camera == camera_name)
.order_by(Recordings.start_time.desc())
.limit(1)
)
try:
recording: Recordings = recording_query.get()
time_in_segment = frame_time - recording.start_time
image_data = get_image_from_recording(recording.path, time_in_segment)
if not image_data:
return make_response(
jsonify(
{
"success": False,
"message": f"Unable to parse frame at time {frame_time}",
}
),
404,
)
nd = cv2.imdecode(np.frombuffer(image_data, dtype=np.int8), cv2.IMREAD_COLOR)
current_app.plus_api.upload_image(nd, camera_name)
return make_response(
jsonify(
{
"success": True,
"message": "Successfully submitted image.",
}
),
200,
)
except DoesNotExist:
return make_response(
jsonify(
{
"success": False,
"message": "Recording not found at {}".format(frame_time),
}
),
404,
)
@MediaBp.route("/recordings/storage", methods=["GET"])
def get_recordings_storage_usage():
recording_stats = current_app.stats_emitter.get_latest_stats()["service"][
@ -392,7 +448,17 @@ def recording_clip(camera_name, start_ts, end_ts):
if clip.end_time > end_ts:
playlist_lines.append(f"outpoint {int(end_ts - clip.start_time)}")
file_name = secure_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4")
file_name = f"clip_{camera_name}_{start_ts}-{end_ts}.mp4"
if len(file_name) > 1000:
return make_response(
jsonify(
{"success": False, "message": "Filename exceeded max length of 1000"}
),
403,
)
file_name = secure_filename(file_name)
path = os.path.join(CACHE_DIR, file_name)
if not os.path.exists(path):
@ -1167,7 +1233,20 @@ def preview_gif(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
@MediaBp.route("/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/preview.mp4")
@MediaBp.route("/<camera_name>/start/<float:start_ts>/end/<float:end_ts>/preview.mp4")
def preview_mp4(camera_name: str, start_ts, end_ts):
file_name = secure_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4")
file_name = f"clip_{camera_name}_{start_ts}-{end_ts}.mp4"
if len(file_name) > 1000:
return make_response(
jsonify(
{
"success": False,
"message": "Filename exceeded max length of 1000 characters.",
}
),
403,
)
file_name = secure_filename(file_name)
path = os.path.join(CACHE_DIR, file_name)
if datetime.fromtimestamp(start_ts) < datetime.now().replace(minute=0, second=0):
@ -1337,6 +1416,14 @@ def review_preview(id: str):
@MediaBp.route("/preview/<file_name>/thumbnail.webp")
def preview_thumbnail(file_name: str):
"""Get a thumbnail from the cached preview frames."""
if len(file_name) > 1000:
return make_response(
jsonify(
{"success": False, "message": "Filename exceeded max length of 1000"}
),
403,
)
safe_file_name_current = secure_filename(file_name)
preview_dir = os.path.join(CACHE_DIR, "preview_frames")

View File

@ -440,6 +440,7 @@ def motion_activity():
# resample data using pandas to get activity on scaled basis
df = pd.DataFrame(data, columns=["start_time", "motion", "camera"])
df = df.astype(dtype={"motion": "float16"})
# set date as datetime index
df["start_time"] = pd.to_datetime(df["start_time"], unit="s")
@ -517,6 +518,7 @@ def audio_activity():
# resample data using pandas to get activity on scaled basis
df = pd.DataFrame(data, columns=["start_time", "audio"])
df = df.astype(dtype={"audio": "float16"})
# set date as datetime index
df["start_time"] = pd.to_datetime(df["start_time"], unit="s")

View File

@ -16,6 +16,7 @@ import psutil
from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase
from pydantic import ValidationError
from frigate.api.app import create_app
from frigate.comms.config_updater import ConfigPublisher
@ -597,24 +598,6 @@ class FrigateApp:
self.init_logger()
logger.info(f"Starting Frigate ({VERSION})")
if not os.environ.get("I_PROMISE_I_WONT_MAKE_AN_ISSUE_ON_GITHUB"):
print(
"**********************************************************************************"
)
print(
"**********************************************************************************"
)
print("Frigate 0.14 UNSTABLE")
print("This build is not for public use. Please use Frigate stable.")
print("Unstable/experimental builds are not enabled, Frigate is exiting.")
print(
"**********************************************************************************"
)
print(
"**********************************************************************************"
)
sys.exit(1)
try:
self.ensure_dirs()
try:
@ -629,8 +612,13 @@ class FrigateApp:
print("*************************************************************")
print("*** Config Validation Errors ***")
print("*************************************************************")
print(e)
print(traceback.format_exc())
if isinstance(e, ValidationError):
for error in e.errors():
location = ".".join(str(item) for item in error["loc"])
print(f"{location}: {error['msg']}")
else:
print(e)
print(traceback.format_exc())
print("*************************************************************")
print("*** End Config Validation Errors ***")
print("*************************************************************")
@ -695,9 +683,9 @@ class FrigateApp:
self.stop_event.set()
# set an end_time on entries without an end_time before exiting
Event.update(end_time=datetime.datetime.now().timestamp()).where(
Event.end_time == None
).execute()
Event.update(
end_time=datetime.datetime.now().timestamp(), has_snapshot=False
).where(Event.end_time == None).execute()
ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where(
ReviewSegment.end_time == None
).execute()

View File

@ -1,6 +1,7 @@
"""Handle communication between Frigate and other applications."""
import datetime
import json
import logging
from abc import ABC, abstractmethod
from typing import Any, Callable, Optional
@ -12,6 +13,7 @@ from frigate.const import (
INSERT_MANY_RECORDINGS,
INSERT_PREVIEW,
REQUEST_REGION_GRID,
UPDATE_CAMERA_ACTIVITY,
UPSERT_REVIEW_SEGMENT,
)
from frigate.models import Previews, Recordings, ReviewSegment
@ -76,6 +78,8 @@ class Dispatcher:
for comm in self.comms:
comm.subscribe(self._receive)
self.camera_activity = {}
def _receive(self, topic: str, payload: str) -> Optional[Any]:
"""Handle receiving of payload from communicators."""
if topic.endswith("set"):
@ -122,6 +126,10 @@ class Dispatcher:
ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where(
ReviewSegment.end_time == None
).execute()
elif topic == UPDATE_CAMERA_ACTIVITY:
self.camera_activity = payload
elif topic == "onConnect":
self.publish("camera_activity", json.dumps(self.camera_activity))
else:
self.publish(topic, payload, retain=False)

View File

@ -518,7 +518,7 @@ class ZoneConfig(BaseModel):
ge=0,
title="Number of seconds that an object must loiter to be considered in the zone.",
)
objects: List[str] = Field(
objects: Union[str, List[str]] = Field(
default_factory=list,
title="List of objects that can trigger the zone.",
)
@ -555,19 +555,24 @@ class ZoneConfig(BaseModel):
# old native resolution coordinates
if isinstance(coordinates, list):
explicit = any(p.split(",")[0] > "1.0" for p in coordinates)
self._contour = np.array(
[
(
[int(p.split(",")[0]), int(p.split(",")[1])]
if explicit
else [
int(float(p.split(",")[0]) * frame_shape[1]),
int(float(p.split(",")[1]) * frame_shape[0]),
]
)
for p in coordinates
]
)
try:
self._contour = np.array(
[
(
[int(p.split(",")[0]), int(p.split(",")[1])]
if explicit
else [
int(float(p.split(",")[0]) * frame_shape[1]),
int(float(p.split(",")[1]) * frame_shape[0]),
]
)
for p in coordinates
]
)
except ValueError:
raise ValueError(
f"Invalid coordinates found in configuration file. Coordinates must be relative (between 0-1): {coordinates}"
)
if explicit:
self.coordinates = ",".join(
@ -579,19 +584,24 @@ class ZoneConfig(BaseModel):
elif isinstance(coordinates, str):
points = coordinates.split(",")
explicit = any(p > "1.0" for p in points)
self._contour = np.array(
[
(
[int(points[i]), int(points[i + 1])]
if explicit
else [
int(float(points[i]) * frame_shape[1]),
int(float(points[i + 1]) * frame_shape[0]),
]
)
for i in range(0, len(points), 2)
]
)
try:
self._contour = np.array(
[
(
[int(points[i]), int(points[i + 1])]
if explicit
else [
int(float(points[i]) * frame_shape[1]),
int(float(points[i + 1]) * frame_shape[0]),
]
)
for i in range(0, len(points), 2)
]
)
except ValueError:
raise ValueError(
f"Invalid coordinates found in configuration file. Coordinates must be relative (between 0-1): {coordinates}"
)
if explicit:
self.coordinates = ",".join(
@ -616,7 +626,7 @@ class AlertsConfig(FrigateBaseModel):
labels: List[str] = Field(
default=DEFAULT_ALERT_OBJECTS, title="Labels to create alerts for."
)
required_zones: List[str] = Field(
required_zones: Union[str, List[str]] = Field(
default_factory=list,
title="List of required zones to be entered in order to save the event as an alert.",
)
@ -636,7 +646,7 @@ class DetectionsConfig(FrigateBaseModel):
labels: Optional[List[str]] = Field(
default=None, title="Labels to create detections for."
)
required_zones: List[str] = Field(
required_zones: Union[str, List[str]] = Field(
default_factory=list,
title="List of required zones to be entered in order to save the event as a detection.",
)
@ -1505,29 +1515,26 @@ class FrigateConfig(FrigateBaseModel):
for key, detector in config.detectors.items():
adapter = TypeAdapter(DetectorConfig)
model_dict = (
detector if isinstance(detector, dict) else detector.model_dump()
detector
if isinstance(detector, dict)
else detector.model_dump(warnings="none")
)
detector_config: DetectorConfig = adapter.validate_python(model_dict)
if detector_config.model is None:
detector_config.model = config.model
detector_config.model = config.model.model_copy()
else:
model = detector_config.model
schema = ModelConfig.model_json_schema()["properties"]
if (
model.width != schema["width"]["default"]
or model.height != schema["height"]["default"]
or model.labelmap_path is not None
or model.labelmap
or model.input_tensor != schema["input_tensor"]["default"]
or model.input_pixel_format
!= schema["input_pixel_format"]["default"]
):
path = detector_config.model.path
detector_config.model = config.model.model_copy()
detector_config.model.path = path
if "path" not in model_dict or len(model_dict.keys()) > 1:
logger.warning(
"Customizing more than a detector model path is unsupported."
)
merged_model = deep_merge(
detector_config.model.model_dump(exclude_unset=True),
config.model.model_dump(exclude_unset=True),
detector_config.model.model_dump(exclude_unset=True, warnings="none"),
config.model.model_dump(exclude_unset=True, warnings="none"),
)
if "path" not in merged_model:

View File

@ -80,6 +80,7 @@ INSERT_PREVIEW = "insert_preview"
REQUEST_REGION_GRID = "request_region_grid"
UPSERT_REVIEW_SEGMENT = "upsert_review_segment"
CLEAR_ONGOING_REVIEW_SEGMENTS = "clear_ongoing_review_segments"
UPDATE_CAMERA_ACTIVITY = "update_camera_activity"
# Autotracking

View File

@ -184,43 +184,6 @@ class EventCleanup(threading.Thread):
Event.update(update_params).where(Event.id << events_to_update).execute()
return events_to_update
def purge_duplicates(self) -> None:
duplicate_query = """with grouped_events as (
select id,
label,
camera,
has_snapshot,
has_clip,
end_time,
row_number() over (
partition by label, camera, round(start_time/5,0)*5
order by end_time-start_time desc
) as copy_number
from event
)
select distinct id, camera, has_snapshot, has_clip from grouped_events
where copy_number > 1 and end_time not null;"""
duplicate_events: list[Event] = Event.raw(duplicate_query)
for event in duplicate_events:
logger.debug(f"Removing duplicate: {event.id}")
try:
media_name = f"{event.camera}-{event.id}"
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
media_path.unlink(missing_ok=True)
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
media_path.unlink(missing_ok=True)
except OSError as e:
logger.warning(f"Unable to delete event images: {e}")
(
Event.delete()
.where(Event.id << [event.id for event in duplicate_events])
.execute()
)
def run(self) -> None:
# only expire events every 5 minutes
while not self.stop_event.wait(300):
@ -232,7 +195,6 @@ class EventCleanup(threading.Thread):
).execute()
self.expire(EventCleanupType.snapshots)
self.purge_duplicates()
# drop events from db where has_clip and has_snapshot are false
delete_query = Event.delete().where(

View File

@ -16,6 +16,7 @@ import numpy as np
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
from frigate.comms.dispatcher import Dispatcher
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import (
CameraConfig,
FrigateConfig,
@ -24,7 +25,7 @@ from frigate.config import (
SnapshotsConfig,
ZoomingModeEnum,
)
from frigate.const import CLIPS_DIR
from frigate.const import ALL_ATTRIBUTE_LABELS, CLIPS_DIR, UPDATE_CAMERA_ACTIVITY
from frigate.events.types import EventStateEnum, EventTypeEnum
from frigate.ptz.autotrack import PtzAutoTrackerThread
from frigate.util.image import (
@ -724,8 +725,41 @@ class CameraState:
# TODO: can i switch to looking this up and only changing when an event ends?
# maintain best objects
camera_activity: dict[str, list[any]] = {
"motion": len(motion_boxes) > 0,
"objects": [],
}
for obj in tracked_objects.values():
object_type = obj.obj_data["label"]
active = (
obj.obj_data["motionless_count"]
< self.camera_config.detect.stationary.threshold
)
if not obj.false_positive:
label = object_type
sub_label = None
if obj.obj_data.get("sub_label"):
if obj.obj_data.get("sub_label")[0] in ALL_ATTRIBUTE_LABELS:
label = obj.obj_data["sub_label"][0]
else:
label = f"{object_type}-verified"
sub_label = obj.obj_data["sub_label"][0]
camera_activity["objects"].append(
{
"id": obj.obj_data["id"],
"label": label,
"stationary": not active,
"area": obj.obj_data["area"],
"ratio": obj.obj_data["ratio"],
"score": obj.obj_data["score"],
"sub_label": sub_label,
}
)
# if the object's thumbnail is not from the current frame
if obj.false_positive or obj.thumbnail_data["frame_time"] != frame_time:
continue
@ -752,6 +786,9 @@ class CameraState:
for c in self.callbacks["snapshot"]:
c(self.name, self.best_objects[object_type], frame_time)
for c in self.callbacks["camera_activity"]:
c(self.name, camera_activity)
# update overall camera state for each object type
obj_counter = Counter(
obj.obj_data["label"]
@ -841,10 +878,14 @@ class TrackedObjectProcessor(threading.Thread):
self.frame_manager = SharedMemoryFrameManager()
self.last_motion_detected: dict[str, float] = {}
self.ptz_autotracker_thread = ptz_autotracker_thread
self.requestor = InterProcessRequestor()
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
self.event_sender = EventUpdatePublisher()
self.event_end_subscriber = EventEndSubscriber()
self.camera_activity: dict[str, dict[str, any]] = {}
def start(camera, obj: TrackedObject, current_frame_time):
self.event_sender.publish(
(
@ -962,6 +1003,13 @@ class TrackedObjectProcessor(threading.Thread):
def object_status(camera, object_name, status):
self.dispatcher.publish(f"{camera}/{object_name}", status, retain=False)
def camera_activity(camera, activity):
last_activity = self.camera_activity.get(camera)
if not last_activity or activity != last_activity:
self.camera_activity[camera] = activity
self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity)
for camera in self.config.cameras.keys():
camera_state = CameraState(
camera, self.config, self.frame_manager, self.ptz_autotracker_thread
@ -972,6 +1020,7 @@ class TrackedObjectProcessor(threading.Thread):
camera_state.on("end", end)
camera_state.on("snapshot", snapshot)
camera_state.on("object_status", object_status)
camera_state.on("camera_activity", camera_activity)
self.camera_states[camera] = camera_state
# {
@ -1228,6 +1277,7 @@ class TrackedObjectProcessor(threading.Thread):
event_id, camera = update
self.camera_states[camera].finished(event_id)
self.requestor.stop()
self.detection_publisher.stop()
self.event_sender.stop()
self.event_end_subscriber.stop()

View File

@ -110,6 +110,8 @@ class RecordingExporter(threading.Thread):
f"00:{minutes}:{seconds}",
"-i",
preview.path,
"-frames",
"1",
"-c:v",
"libwebp",
thumb_path,

View File

@ -110,6 +110,18 @@ class PendingReviewSegment:
self.frame_path, self.frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60]
)
def save_full_frame(self, camera_config: CameraConfig, frame):
color_frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
width = int(THUMB_HEIGHT * color_frame.shape[1] / color_frame.shape[0])
self.frame = cv2.resize(
color_frame, dsize=(width, THUMB_HEIGHT), interpolation=cv2.INTER_AREA
)
if self.frame is not None:
cv2.imwrite(
self.frame_path, self.frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60]
)
def get_data(self, ended: bool) -> dict:
return {
ReviewSegment.id: self.id,
@ -273,8 +285,30 @@ class ReviewSegmentMaintainer(threading.Thread):
if segment.severity == SeverityEnum.alert and frame_time > (
segment.last_update + THRESHOLD_ALERT_ACTIVITY
):
if segment.frame is None:
try:
frame_id = f"{camera_config.name}{frame_time}"
yuv_frame = self.frame_manager.get(
frame_id, camera_config.frame_shape_yuv
)
segment.save_full_frame(camera_config, yuv_frame)
self.frame_manager.close(frame_id)
except FileNotFoundError:
return
self.end_segment(segment)
elif frame_time > (segment.last_update + THRESHOLD_DETECTION_ACTIVITY):
if segment.frame is None:
try:
frame_id = f"{camera_config.name}{frame_time}"
yuv_frame = self.frame_manager.get(
frame_id, camera_config.frame_shape_yuv
)
segment.save_full_frame(camera_config, yuv_frame)
self.frame_manager.close(frame_id)
except FileNotFoundError:
return
self.end_segment(segment)
def check_if_new_segment(
@ -511,7 +545,7 @@ class ReviewSegmentMaintainer(threading.Thread):
manual_info["label"]
)
# temporarily make it so this event can not end
self.active_review_segments[camera] = sys.maxsize
self.active_review_segments[camera].last_update = sys.maxsize
elif manual_info["state"] == ManualEventState.complete:
self.active_review_segments[camera].last_update = manual_info[
"end_time"

View File

@ -162,7 +162,7 @@ async def set_gpu_stats(
for args in hwaccel_args:
if args in hwaccel_errors:
# known erroring args should automatically return as error
stats["error-gpu"] = {"gpu": -1, "mem": -1}
stats["error-gpu"] = {"gpu": "", "mem": ""}
elif "cuvid" in args or "nvidia" in args:
# nvidia GPU
nvidia_usage = get_nvidia_gpu_stats()
@ -177,7 +177,7 @@ async def set_gpu_stats(
}
else:
stats["nvidia-gpu"] = {"gpu": -1, "mem": -1}
stats["nvidia-gpu"] = {"gpu": "", "mem": ""}
hwaccel_errors.append(args)
elif "nvmpi" in args or "jetson" in args:
# nvidia Jetson
@ -186,7 +186,7 @@ async def set_gpu_stats(
if jetson_usage:
stats["jetson-gpu"] = jetson_usage
else:
stats["jetson-gpu"] = {"gpu": -1, "mem": -1}
stats["jetson-gpu"] = {"gpu": "", "mem": ""}
hwaccel_errors.append(args)
elif "qsv" in args:
if not config.telemetry.stats.intel_gpu_stats:
@ -198,7 +198,7 @@ async def set_gpu_stats(
if intel_usage:
stats["intel-qsv"] = intel_usage
else:
stats["intel-qsv"] = {"gpu": -1, "mem": -1}
stats["intel-qsv"] = {"gpu": "", "mem": ""}
hwaccel_errors.append(args)
elif "vaapi" in args:
if is_vaapi_amd_driver():
@ -211,7 +211,7 @@ async def set_gpu_stats(
if amd_usage:
stats["amd-vaapi"] = amd_usage
else:
stats["amd-vaapi"] = {"gpu": -1, "mem": -1}
stats["amd-vaapi"] = {"gpu": "", "mem": ""}
hwaccel_errors.append(args)
else:
if not config.telemetry.stats.intel_gpu_stats:
@ -223,11 +223,11 @@ async def set_gpu_stats(
if intel_usage:
stats["intel-vaapi"] = intel_usage
else:
stats["intel-vaapi"] = {"gpu": -1, "mem": -1}
stats["intel-vaapi"] = {"gpu": "", "mem": ""}
hwaccel_errors.append(args)
elif "v4l2m2m" in args or "rpi" in args:
# RPi v4l2m2m is currently not able to get usage stats
stats["rpi-v4l2m2m"] = {"gpu": -1, "mem": -1}
stats["rpi-v4l2m2m"] = {"gpu": "", "mem": ""}
if stats:
all_stats["gpu_usages"] = stats

View File

@ -82,7 +82,7 @@ class TestConfig(unittest.TestCase):
},
"edgetpu": {
"type": "edgetpu",
"model": {"path": "/edgetpu_model.tflite", "width": 160},
"model": {"path": "/edgetpu_model.tflite"},
},
"openvino": {
"type": "openvino",
@ -112,11 +112,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.detectors["edgetpu"].model.path == "/edgetpu_model.tflite"
assert runtime_config.detectors["openvino"].model.path == "/etc/hosts"
assert runtime_config.model.width == 512
assert runtime_config.detectors["cpu"].model.width == 320
assert runtime_config.detectors["edgetpu"].model.width == 160
assert runtime_config.detectors["openvino"].model.width == 512
def test_invalid_mqtt_config(self):
config = {
"mqtt": {"host": "mqtt", "user": "test"},

View File

@ -22,8 +22,9 @@ from frigate.util.object import average_boxes, median_of_boxes
logger = logging.getLogger(__name__)
THRESHOLD_ACTIVE_IOU = 0.2
THRESHOLD_STATIONARY_IOU = 0.6
THRESHOLD_KNOWN_ACTIVE_IOU = 0.2
THRESHOLD_STATIONARY_CHECK_IOU = 0.6
THRESHOLD_ACTIVE_CHECK_IOU = 0.9
MAX_STATIONARY_HISTORY = 10
@ -146,7 +147,7 @@ class NorfairTracker(ObjectTracker):
# tracks the current position of the object based on the last N bounding boxes
# returns False if the object has moved outside its previous position
def update_position(self, id: str, box: list[int, int, int, int]):
def update_position(self, id: str, box: list[int, int, int, int], stationary: bool):
xmin, ymin, xmax, ymax = box
position = self.positions[id]
self.stationary_box_history[id].append(box)
@ -162,7 +163,7 @@ class NorfairTracker(ObjectTracker):
# object has minimal or zero iou
# assume object is active
if avg_iou < THRESHOLD_ACTIVE_IOU:
if avg_iou < THRESHOLD_KNOWN_ACTIVE_IOU:
self.positions[id] = {
"xmins": [xmin],
"ymins": [ymin],
@ -175,8 +176,12 @@ class NorfairTracker(ObjectTracker):
}
return False
threshold = (
THRESHOLD_STATIONARY_CHECK_IOU if stationary else THRESHOLD_ACTIVE_CHECK_IOU
)
# object has iou below threshold, check median to reduce outliers
if avg_iou < THRESHOLD_STATIONARY_IOU:
if avg_iou < threshold:
median_iou = intersection_over_union(
(
position["xmin"],
@ -189,7 +194,7 @@ class NorfairTracker(ObjectTracker):
# if the median iou drops below the threshold
# assume object is no longer stationary
if median_iou < THRESHOLD_STATIONARY_IOU:
if median_iou < threshold:
self.positions[id] = {
"xmins": [xmin],
"ymins": [ymin],
@ -240,8 +245,12 @@ class NorfairTracker(ObjectTracker):
def update(self, track_id, obj):
id = self.track_id_map[track_id]
self.disappeared[id] = 0
stationary = (
self.tracked_objects[id]["motionless_count"]
>= self.detect_config.stationary.threshold
)
# update the motionless count if the object has not moved to a new position
if self.update_position(id, obj["box"]):
if self.update_position(id, obj["box"], stationary):
self.tracked_objects[id]["motionless_count"] += 1
if self.is_expired(id):
self.deregister(id, track_id)

View File

@ -155,14 +155,18 @@ def get_relative_coordinates(
relative_masks = []
for m in mask:
points = m.split(",")
relative_masks.append(
",".join(
[
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
for i in range(0, len(points), 2)
]
if any(x > "1.0" for x in points):
relative_masks.append(
",".join(
[
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
for i in range(0, len(points), 2)
]
)
)
)
else:
relative_masks.append(m)
mask = relative_masks
elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")):

View File

@ -2,6 +2,7 @@
import datetime
import logging
import subprocess as sp
from abc import ABC, abstractmethod
from multiprocessing import shared_memory
from string import printable
@ -746,3 +747,37 @@ def add_mask(mask: str, mask_img: np.ndarray):
]
)
cv2.fillPoly(mask_img, pts=[contour], color=(0))
def get_image_from_recording(
file_path: str, relative_frame_time: float
) -> Optional[any]:
"""retrieve a frame from given time in recording file."""
ffmpeg_cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"warning",
"-ss",
f"00:00:{relative_frame_time}",
"-i",
file_path,
"-frames:v",
"1",
"-c:v",
"png",
"-f",
"image2pipe",
"-",
]
process = sp.run(
ffmpeg_cmd,
capture_output=True,
)
if process.returncode == 0:
return process.stdout
else:
return None

384
web/package-lock.json generated
View File

@ -29,31 +29,32 @@
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"apexcharts": "^3.48.0",
"apexcharts": "^3.49.0",
"axios": "^1.6.8",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"hls.js": "^1.5.8",
"idb-keyval": "^6.2.1",
"immer": "^10.0.4",
"immer": "^10.1.1",
"konva": "^9.3.6",
"lodash": "^4.17.21",
"lucide-react": "^0.372.0",
"lucide-react": "^0.378.0",
"monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0",
"react": "^18.2.0",
"react": "^18.3.1",
"react-apexcharts": "^1.4.1",
"react-day-picker": "^8.10.1",
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.3",
"react-icons": "^5.1.0",
"react-dom": "^18.3.1",
"react-grid-layout": "^1.4.4",
"react-hook-form": "^7.51.4",
"react-icons": "^5.2.1",
"react-konva": "^18.2.10",
"react-router-dom": "^6.22.3",
"react-router-dom": "^6.23.0",
"react-swipeable": "^7.0.1",
"react-tracked": "^1.7.14",
"react-tracked": "^2.0.0",
"react-transition-group": "^4.4.5",
"react-use-websocket": "^4.8.1",
"react-zoom-pan-pinch": "^3.4.4",
@ -65,24 +66,25 @@
"swr": "^2.2.5",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.0",
"vaul": "^0.9.1",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.22.5"
"zod": "^3.23.7"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.1.5",
"@types/lodash": "^4.17.0",
"@types/node": "^20.12.7",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@types/lodash": "^4.17.1",
"@types/node": "^20.12.11",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@types/react-grid-layout": "^1.3.5",
"@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"@vitejs/plugin-react-swc": "^3.6.0",
"@vitest/coverage-v8": "^1.4.0",
"@vitest/coverage-v8": "^1.6.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.55.0",
"eslint-config-prettier": "^9.1.0",
@ -99,8 +101,8 @@
"prettier": "^3.2.5",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.9",
"vitest": "^1.4.0"
"vite": "^5.2.11",
"vitest": "^1.6.0"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@ -2029,9 +2031,9 @@
}
},
"node_modules/@remix-run/router": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.3.tgz",
"integrity": "sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.0.tgz",
"integrity": "sha512-Quz1KOffeEf/zwkCBM3kBtH4ZoZ+pT3xIXBG4PPW/XFtDP7EGhtTiC2+gpL9GnR7+Qdet5Oa6cYSvwKYg6kN9Q==",
"engines": {
"node": ">=14.0.0"
}
@ -2514,21 +2516,15 @@
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"dev": true
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"dev": true
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
},
"node_modules/@types/lodash": {
"version": "4.17.0",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz",
"integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==",
"version": "4.17.1",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.1.tgz",
"integrity": "sha512-X+2qazGS3jxLAIz5JDXDzglAF3KpijdhFxlf/V1+hEsOUc+HnWi81L/uv/EvGuV90WY+7mPGFCUDGfQC3Gj95Q==",
"dev": true
},
"node_modules/@types/mute-stream": {
@ -2541,9 +2537,9 @@
}
},
"node_modules/@types/node": {
"version": "20.12.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz",
"integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==",
"version": "20.12.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.11.tgz",
"integrity": "sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
@ -2555,23 +2551,32 @@
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
},
"node_modules/@types/react": {
"version": "18.2.79",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.79.tgz",
"integrity": "sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==",
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz",
"integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.2.25",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.25.tgz",
"integrity": "sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==",
"version": "18.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz",
"integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==",
"devOptional": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-grid-layout": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz",
"integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-icons": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/react-icons/-/react-icons-3.0.0.tgz",
@ -2856,9 +2861,9 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.4.0.tgz",
"integrity": "sha512-4hDGyH1SvKpgZnIByr9LhGgCEuF9DKM34IBLCC/fVfy24Z3+PZ+Ii9hsVBsHvY1umM1aGPEjceRkzxCfcQ10wg==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.0.tgz",
"integrity": "sha512-KvapcbMY/8GYIG0rlwwOKCVNRc0OL20rrhFkg/CHNzncV03TE2XWvO5w9uZYoxNiMEBacAJt3unSOiZ7svePew==",
"dev": true,
"dependencies": {
"@ampproject/remapping": "^2.2.1",
@ -2873,24 +2878,23 @@
"picocolors": "^1.0.0",
"std-env": "^3.5.0",
"strip-literal": "^2.0.0",
"test-exclude": "^6.0.0",
"v8-to-istanbul": "^9.2.0"
"test-exclude": "^6.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "1.4.0"
"vitest": "1.6.0"
}
},
"node_modules/@vitest/expect": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.4.0.tgz",
"integrity": "sha512-Jths0sWCJZ8BxjKe+p+eKsoqev1/T8lYcrjavEaz8auEJ4jAVY0GwW3JKmdVU4mmNPLPHixh4GNXP7GFtAiDHA==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz",
"integrity": "sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==",
"dev": true,
"dependencies": {
"@vitest/spy": "1.4.0",
"@vitest/utils": "1.4.0",
"@vitest/spy": "1.6.0",
"@vitest/utils": "1.6.0",
"chai": "^4.3.10"
},
"funding": {
@ -2898,12 +2902,12 @@
}
},
"node_modules/@vitest/runner": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.4.0.tgz",
"integrity": "sha512-EDYVSmesqlQ4RD2VvWo3hQgTJ7ZrFQ2VSJdfiJiArkCerDAGeyF1i6dHkmySqk573jLp6d/cfqCN+7wUB5tLgg==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.0.tgz",
"integrity": "sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==",
"dev": true,
"dependencies": {
"@vitest/utils": "1.4.0",
"@vitest/utils": "1.6.0",
"p-limit": "^5.0.0",
"pathe": "^1.1.1"
},
@ -2939,9 +2943,9 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.4.0.tgz",
"integrity": "sha512-saAFnt5pPIA5qDGxOHxJ/XxhMFKkUSBJmVt5VgDsAqPTX6JP326r5C/c9UuCMPoXNzuudTPsYDZCoJ5ilpqG2A==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.0.tgz",
"integrity": "sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==",
"dev": true,
"dependencies": {
"magic-string": "^0.30.5",
@ -2953,9 +2957,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.4.0.tgz",
"integrity": "sha512-Ywau/Qs1DzM/8Uc+yA77CwSegizMlcgTJuYGAi0jujOteJOUf1ujunHThYo243KG9nAyWT3L9ifPYZ5+As/+6Q==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.0.tgz",
"integrity": "sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==",
"dev": true,
"dependencies": {
"tinyspy": "^2.2.0"
@ -2965,9 +2969,9 @@
}
},
"node_modules/@vitest/utils": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.4.0.tgz",
"integrity": "sha512-mx3Yd1/6e2Vt/PUC98DcqTirtfxUyAZ32uK82r8rZzbtBeBo+nqgnjx/LvqQdWsrvNtm14VmurNgcf4nqY5gJg==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.0.tgz",
"integrity": "sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==",
"dev": true,
"dependencies": {
"diff-sequences": "^29.6.3",
@ -3111,9 +3115,9 @@
}
},
"node_modules/apexcharts": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.48.0.tgz",
"integrity": "sha512-Lhpj1Ij6lKlrUke8gf+P+SE6uGUn+Pe1TnCJ+zqrY0YMvbqM3LMb1lY+eybbTczUyk0RmMZomlTa2NgX2EUs4Q==",
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.49.0.tgz",
"integrity": "sha512-2T9HnbQFLCuYRPndQLmh+bEQFoz0meUbvASaGgiSKDuYhWcLBodJtIpKql2aOtMx4B/sHrWW0dm90HsW4+h2PQ==",
"dependencies": {
"@yr/monotone-cubic-spline": "^1.0.3",
"svg.draggable.js": "^2.2.2",
@ -3532,9 +3536,9 @@
}
},
"node_modules/clsx": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz",
"integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
@ -3586,12 +3590,6 @@
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
@ -4392,6 +4390,11 @@
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"dev": true
},
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg=="
},
"node_modules/fast-glob": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
@ -4815,9 +4818,9 @@
}
},
"node_modules/immer": {
"version": "10.0.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.0.4.tgz",
"integrity": "sha512-cuBuGK40P/sk5IzWa9QPUaAdvPHjkk1c+xYsd9oZw+YQQEV+10G0P5uMpGctZZKnyQ+ibRO08bD25nWLmYi2pw==",
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@ -5348,9 +5351,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.372.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.372.0.tgz",
"integrity": "sha512-0cKdqmilHXWUwWAWnf6CrrjHD8YaqPMtLrmEHXolZusNTr9epULCsiJwIOHk2q1yFxdEwd96D4zShlAj67UJdA==",
"version": "0.378.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.378.0.tgz",
"integrity": "sha512-u6EPU8juLUk9ytRcyapkWI18epAv3RU+6+TC23ivjR0e+glWKBobFeSgRwOIJihzktILQuy6E0E80P2jVTDR5g==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
@ -6220,9 +6223,9 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/proxy-compare": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.6.0.tgz",
"integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw=="
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.0.tgz",
"integrity": "sha512-y44MCkgtZUCT9tZGuE278fB7PWVf7fRYy0vbRXAts2o5F0EfC4fIQrvQQGBJo1WJbFcVLXzApOscyJuZqHQc1w=="
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
@ -6270,9 +6273,9 @@
]
},
"node_modules/react": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -6318,21 +6321,59 @@
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.2.0"
"react": "^18.3.1"
}
},
"node_modules/react-draggable": {
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz",
"integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==",
"dependencies": {
"clsx": "^1.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-draggable/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"engines": {
"node": ">=6"
}
},
"node_modules/react-grid-layout": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.4.4.tgz",
"integrity": "sha512-7+Lg8E8O8HfOH5FrY80GCIR1SHTn2QnAYKh27/5spoz+OHhMmEhU/14gIkRzJOtympDPaXcVRX/nT1FjmeOUmQ==",
"dependencies": {
"clsx": "^2.0.0",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.5",
"react-resizable": "^3.0.5",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-hook-form": {
"version": "7.51.3",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.3.tgz",
"integrity": "sha512-cvJ/wbHdhYx8aviSWh28w9ImjmVsb5Y05n1+FW786vEZQJV5STNM0pW6ujS+oiBecb0ARBxJFyAnXj9+GHXACQ==",
"version": "7.51.4",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.4.tgz",
"integrity": "sha512-V14i8SEkh+V1gs6YtD0hdHYnoL4tp/HX/A45wWQN15CYr9bFRmmRdYStSO5L65lCCZRF+kYiSKhm9alqbcdiVA==",
"engines": {
"node": ">=12.22.0"
},
@ -6345,9 +6386,9 @@
}
},
"node_modules/react-icons": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.1.0.tgz",
"integrity": "sha512-D3zug1270S4hbSlIRJ0CUS97QE1yNNKDjzQe3HqY0aefp2CBn9VgzgES27sRR2gOvFK+0CNx/BW0ggOESp6fqQ==",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.2.1.tgz",
"integrity": "sha512-zdbW5GstTzXaVKvGSyTaBalt7HSfuK5ovrzlpyiWHAFXndXTdd/1hdDHI4xBM1Mn7YriT6aqESucFl9kEXzrdw==",
"peerDependencies": {
"react": "*"
}
@ -6448,12 +6489,24 @@
}
}
},
"node_modules/react-router": {
"version": "6.22.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.3.tgz",
"integrity": "sha512-dr2eb3Mj5zK2YISHK++foM9w4eBnO23eKnZEDs7c880P6oKbrjz/Svg9+nxqtHQK+oMW4OtjZca0RqPglXxguQ==",
"node_modules/react-resizable": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz",
"integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==",
"dependencies": {
"@remix-run/router": "1.15.3"
"prop-types": "15.x",
"react-draggable": "^4.0.3"
},
"peerDependencies": {
"react": ">= 16.3"
}
},
"node_modules/react-router": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.23.0.tgz",
"integrity": "sha512-wPMZ8S2TuPadH0sF5irFGjkNLIcRvOSaEe7v+JER8508dyJumm6XZB1u5kztlX0RVq6AzRVndzqcUh6sFIauzA==",
"dependencies": {
"@remix-run/router": "1.16.0"
},
"engines": {
"node": ">=14.0.0"
@ -6463,12 +6516,12 @@
}
},
"node_modules/react-router-dom": {
"version": "6.22.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.3.tgz",
"integrity": "sha512-7ZILI7HjcE+p31oQvwbokjk6OA/bnFxrhJ19n82Ex9Ph8fNAq+Hm/7KchpMGlTgWhUxRHMMCut+vEtNpWpowKw==",
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.23.0.tgz",
"integrity": "sha512-Q9YaSYvubwgbal2c9DJKfx6hTNoBp3iJDsl+Duva/DwxoJH+OTXkxGpql4iUK2sla/8z4RpjAm6EWx1qUDuopQ==",
"dependencies": {
"@remix-run/router": "1.15.3",
"react-router": "6.22.3"
"@remix-run/router": "1.16.0",
"react-router": "6.23.0"
},
"engines": {
"node": ">=14.0.0"
@ -6509,26 +6562,16 @@
}
},
"node_modules/react-tracked": {
"version": "1.7.14",
"resolved": "https://registry.npmjs.org/react-tracked/-/react-tracked-1.7.14.tgz",
"integrity": "sha512-6UMlgQeRAGA+uyYzuQGm7kZB6ZQYFhc7sntgP7Oxwwd6M0Ud/POyb4K3QWT1eXvoifSa80nrAWnXWFGpOvbwkw==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-tracked/-/react-tracked-2.0.0.tgz",
"integrity": "sha512-Px8Ms9zhQKzAj3gnwQm6L+sJwzB0uPa8/BgHKOhB8bIuQEgB2iJfryM7GVja9oviiGAa7vtgEBtM+poT1E7V2w==",
"dependencies": {
"proxy-compare": "2.6.0",
"use-context-selector": "1.4.4"
"proxy-compare": "^3.0.0",
"use-context-selector": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": "*",
"react-native": "*",
"react": ">=18.0.0",
"scheduler": ">=0.19.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-transition-group": {
@ -6639,6 +6682,11 @@
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"node_modules/resolve": {
"version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
@ -6898,9 +6946,9 @@
}
},
"node_modules/scheduler": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
"integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"dependencies": {
"loose-envify": "^1.1.0"
}
@ -7669,22 +7717,12 @@
}
},
"node_modules/use-context-selector": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-1.4.4.tgz",
"integrity": "sha512-pS790zwGxxe59GoBha3QYOwk8AFGp4DN6DOtH+eoqVmgBBRXVx4IlPDhJmmMiNQAgUaLlP+58aqRC3A4rdaSjg==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-2.0.0.tgz",
"integrity": "sha512-owfuSmUNd3eNp3J9CdDl0kMgfidV+MkDvHPpvthN5ThqM+ibMccNE0k+Iq7TWC6JPFvGZqanqiGCuQx6DyV24g==",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": "*",
"react-native": "*",
"react": ">=18.0.0",
"scheduler": ">=0.19.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
@ -7721,24 +7759,10 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/v8-to-istanbul": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz",
"integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==",
"dev": true,
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.12",
"@types/istanbul-lib-coverage": "^2.0.1",
"convert-source-map": "^2.0.0"
},
"engines": {
"node": ">=10.12.0"
}
},
"node_modules/vaul": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.0.tgz",
"integrity": "sha512-bZSySGbAHiTXmZychprnX/dE0EsSige88xtyyL3/MCRbrFotRPQZo7UdydGXZWw+CKbNOw5Ow8gwAo93/nB/Cg==",
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.1.tgz",
"integrity": "sha512-fAhd7i4RNMinx+WEm6pF3nOl78DFkAazcN04ElLPFF9BMCNGbY/kou8UMhIcicm0rJCNePJP0Yyza60gGOD0Jw==",
"dependencies": {
"@radix-ui/react-dialog": "^1.0.4"
},
@ -7748,9 +7772,9 @@
}
},
"node_modules/vite": {
"version": "5.2.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.9.tgz",
"integrity": "sha512-uOQWfuZBlc6Y3W/DTuQ1Sr+oIXWvqljLvS881SVmAj00d5RdgShLcuXWxseWPd4HXwiYBFW/vXHfKFeqj9uQnw==",
"version": "5.2.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz",
"integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==",
"dev": true,
"dependencies": {
"esbuild": "^0.20.1",
@ -7803,9 +7827,9 @@
}
},
"node_modules/vite-node": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.4.0.tgz",
"integrity": "sha512-VZDAseqjrHgNd4Kh8icYHWzTKSCZMhia7GyHfhtzLW33fZlG9SwsB6CEhgyVOWkJfJ2pFLrp/Gj1FSfAiqH9Lw==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.0.tgz",
"integrity": "sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==",
"dev": true,
"dependencies": {
"cac": "^6.7.14",
@ -7833,16 +7857,16 @@
}
},
"node_modules/vitest": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.4.0.tgz",
"integrity": "sha512-gujzn0g7fmwf83/WzrDTnncZt2UiXP41mHuFYFrdwaLRVQ6JYQEiME2IfEjU3vcFL3VKa75XhI3lFgn+hfVsQw==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.0.tgz",
"integrity": "sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==",
"dev": true,
"dependencies": {
"@vitest/expect": "1.4.0",
"@vitest/runner": "1.4.0",
"@vitest/snapshot": "1.4.0",
"@vitest/spy": "1.4.0",
"@vitest/utils": "1.4.0",
"@vitest/expect": "1.6.0",
"@vitest/runner": "1.6.0",
"@vitest/snapshot": "1.6.0",
"@vitest/spy": "1.6.0",
"@vitest/utils": "1.6.0",
"acorn-walk": "^8.3.2",
"chai": "^4.3.10",
"debug": "^4.3.4",
@ -7854,9 +7878,9 @@
"std-env": "^3.5.0",
"strip-literal": "^2.0.0",
"tinybench": "^2.5.1",
"tinypool": "^0.8.2",
"tinypool": "^0.8.3",
"vite": "^5.0.0",
"vite-node": "1.4.0",
"vite-node": "1.6.0",
"why-is-node-running": "^2.2.2"
},
"bin": {
@ -7871,8 +7895,8 @@
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "1.4.0",
"@vitest/ui": "1.4.0",
"@vitest/browser": "1.6.0",
"@vitest/ui": "1.6.0",
"happy-dom": "*",
"jsdom": "*"
},
@ -8145,9 +8169,9 @@
}
},
"node_modules/zod": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.5.tgz",
"integrity": "sha512-HqnGsCdVZ2xc0qWPLdO25WnseXThh0kEYKIdV5F/hTHO75hNZFp8thxSeHhiPrHZKrFTo1SOgkAj9po5bexZlw==",
"version": "3.23.7",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.7.tgz",
"integrity": "sha512-NBeIoqbtOiUMomACV/y+V3Qfs9+Okr18vR5c/5pHClPpufWOrsx8TENboDPe265lFdfewX2yBtNTLPvnmCxwog==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -34,31 +34,32 @@
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"apexcharts": "^3.48.0",
"apexcharts": "^3.49.0",
"axios": "^1.6.8",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"hls.js": "^1.5.8",
"idb-keyval": "^6.2.1",
"immer": "^10.0.4",
"immer": "^10.1.1",
"konva": "^9.3.6",
"lodash": "^4.17.21",
"lucide-react": "^0.372.0",
"lucide-react": "^0.378.0",
"monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0",
"react": "^18.2.0",
"react": "^18.3.1",
"react-apexcharts": "^1.4.1",
"react-day-picker": "^8.10.1",
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.3",
"react-icons": "^5.1.0",
"react-dom": "^18.3.1",
"react-grid-layout": "^1.4.4",
"react-hook-form": "^7.51.4",
"react-icons": "^5.2.1",
"react-konva": "^18.2.10",
"react-router-dom": "^6.22.3",
"react-router-dom": "^6.23.0",
"react-swipeable": "^7.0.1",
"react-tracked": "^1.7.14",
"react-tracked": "^2.0.0",
"react-transition-group": "^4.4.5",
"react-use-websocket": "^4.8.1",
"react-zoom-pan-pinch": "^3.4.4",
@ -70,24 +71,25 @@
"swr": "^2.2.5",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.0",
"vaul": "^0.9.1",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.22.5"
"zod": "^3.23.7"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.1.5",
"@types/lodash": "^4.17.0",
"@types/node": "^20.12.7",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@types/lodash": "^4.17.1",
"@types/node": "^20.12.11",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@types/react-grid-layout": "^1.3.5",
"@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"@vitejs/plugin-react-swc": "^3.6.0",
"@vitest/coverage-v8": "^1.4.0",
"@vitest/coverage-v8": "^1.6.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.55.0",
"eslint-config-prettier": "^9.1.0",
@ -99,12 +101,12 @@
"fake-indexeddb": "^5.0.2",
"jest-websocket-mock": "^2.5.0",
"jsdom": "^24.0.0",
"msw": "^2.2.14",
"msw": "^2.3.0",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.9",
"vitest": "^1.4.0"
"vite": "^5.2.11",
"vitest": "^1.6.0"
}
}

View File

@ -2,7 +2,12 @@ import { baseUrl } from "./baseUrl";
import { useCallback, useEffect, useState } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket";
import { FrigateConfig } from "@/types/frigateConfig";
import { FrigateEvent, FrigateReview, ToggleableSetting } from "@/types/ws";
import {
FrigateCameraState,
FrigateEvent,
FrigateReview,
ToggleableSetting,
} from "@/types/ws";
import { FrigateStats } from "@/types/stats";
import useSWR from "swr";
import { createContainer } from "react-tracked";
@ -60,7 +65,13 @@ function useValue(): useValueReturn {
setWsState({ ...wsState, [data.topic]: data.payload });
}
},
onOpen: () => {},
onOpen: () => {
sendJsonMessage({
topic: "onConnect",
message: "",
retain: false,
});
},
shouldReconnect: () => true,
});
@ -193,6 +204,16 @@ export function useFrigateStats(): { payload: FrigateStats } {
return { payload: JSON.parse(payload as string) };
}
export function useInitialCameraState(camera: string): {
payload: FrigateCameraState;
} {
const {
value: { payload },
} = useWs("camera_activity", "");
const data = JSON.parse(payload as string);
return { payload: data ? data[camera] : undefined };
}
export function useMotionActivity(camera: string): { payload: string } {
const {
value: { payload },

View File

@ -1,9 +1,11 @@
import { cn } from "@/lib/utils";
type LogoProps = {
className?: string;
};
export default function Logo({ className }: LogoProps) {
return (
<svg viewBox="0 0 512 512" className={`fill-current ${className}`}>
<svg viewBox="0 0 512 512" className={cn("fill-current", className)}>
<path d="M130 446.5C131.6 459.3 145 468 137 470C129 472 94 406.5 86 378.5C78 350.5 73.5 319 75.5 301C77.4999 283 181 255 181 247.5C181 240 147.5 247 146 241C144.5 235 171.3 238.6 178.5 229C189.75 214 204 216.5 213 208.5C222 200.5 233 170 235 157C237 144 215 129 209 119C203 109 222 102 268 83C314 64 460 22 462 27C464 32 414 53 379 66C344 79 287 104 287 111C287 118 290 123.5 288 139.5C286 155.5 285.76 162.971 282 173.5C279.5 180.5 277 197 282 212C286 224 299 233 305 235C310 235.333 323.8 235.8 339 235C358 234 385 236 385 241C385 246 344 243 344 250C344 257 386 249 385 256C384 263 350 260 332 260C317.6 260 296.333 259.333 287 256L285 263C281.667 263 274.7 265 267.5 265C258.5 265 258 268 241.5 268C225 268 230 267 215 266C200 265 144 308 134 322C124 336 130 370 130 385.5C130 399.428 128 430.5 130 446.5Z" />
</svg>
);

View File

@ -9,6 +9,7 @@ import { useContext, useEffect, useMemo } from "react";
import { FaCheck } from "react-icons/fa";
import { IoIosWarning } from "react-icons/io";
import { MdCircle } from "react-icons/md";
import { Link } from "react-router-dom";
import useSWR from "swr";
export default function Statusbar() {
@ -43,7 +44,13 @@ export default function Statusbar() {
useEffect(() => {
clearMessages("stats");
potentialProblems.forEach((problem) => {
addMessage("stats", problem.text, problem.color);
addMessage(
"stats",
problem.text,
problem.color,
undefined,
problem.relevantLink,
);
});
}, [potentialProblems, addMessage, clearMessages]);
@ -51,18 +58,20 @@ export default function Statusbar() {
<div className="absolute left-0 bottom-0 right-0 w-full h-8 flex justify-between items-center px-4 bg-background_alt z-10 dark:text-secondary-foreground border-t border-secondary-highlight">
<div className="h-full flex items-center gap-2">
{cpuPercent && (
<div className="flex items-center text-sm gap-2">
<MdCircle
className={`size-2 ${
cpuPercent < 50
? "text-success"
: cpuPercent < 80
? "text-orange-400"
: "text-danger"
}`}
/>
CPU {cpuPercent}%
</div>
<Link to="/system#general">
<div className="flex items-center text-sm gap-2 cursor-pointer hover:underline">
<MdCircle
className={`size-2 ${
cpuPercent < 50
? "text-success"
: cpuPercent < 80
? "text-orange-400"
: "text-danger"
}`}
/>
CPU {cpuPercent}%
</div>
</Link>
)}
{Object.entries(stats?.gpu_usages || {}).map(([name, stats]) => {
if (name == "error-gpu") {
@ -86,18 +95,24 @@ export default function Statusbar() {
const gpu = parseInt(stats.gpu);
return (
<div key={gpuTitle} className="flex items-center text-sm gap-2">
<MdCircle
className={`size-2 ${
gpu < 50
? "text-success"
: gpu < 80
? "text-orange-400"
: "text-danger"
}`}
/>
{gpuTitle} {gpu}%
</div>
<Link key={gpuTitle} to="/system#general">
{" "}
<div
key={gpuTitle}
className="flex items-center text-sm gap-2 cursor-pointer hover:underline"
>
<MdCircle
className={`size-2 ${
gpu < 50
? "text-success"
: gpu < 80
? "text-orange-400"
: "text-danger"
}`}
/>
{gpuTitle} {gpu}%
</div>
</Link>
);
})}
</div>
@ -110,14 +125,29 @@ export default function Statusbar() {
) : (
Object.entries(messages).map(([key, messageArray]) => (
<div key={key} className="h-full flex items-center gap-2">
{messageArray.map(({ id, text, color }: StatusMessage) => (
<div key={id} className="flex items-center text-sm gap-2">
<IoIosWarning
className={`size-5 ${color || "text-danger"}`}
/>
{text}
</div>
))}
{messageArray.map(({ id, text, color, link }: StatusMessage) => {
const message = (
<div
key={id}
className={`flex items-center text-sm gap-2 ${link ? "hover:underline cursor-pointer" : ""}`}
>
<IoIosWarning
className={`size-5 ${color || "text-danger"}`}
/>
{text}
</div>
);
if (link) {
return (
<Link key={id} to={link}>
{message}
</Link>
);
} else {
return message;
}
})}
</div>
))
)}

View File

@ -2,6 +2,7 @@ import { useApiHost } from "@/api";
import { useEffect, useRef, useState } from "react";
import useSWR from "swr";
import ActivityIndicator from "../indicators/activity-indicator";
import { useResizeObserver } from "@/hooks/resize-observer";
type CameraImageProps = {
className?: string;
@ -24,6 +25,7 @@ export default function CameraImage({
const { name } = config ? config.cameras[camera] : "";
const enabled = config ? config.cameras[camera].enabled : "True";
const [isPortraitImage, setIsPortraitImage] = useState(false);
useEffect(() => {
if (!config || !imgRef.current) {
@ -35,15 +37,25 @@ export default function CameraImage({
}`;
}, [apiHost, name, imgRef, searchParams, config]);
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
return (
<div className={className} ref={containerRef}>
{enabled ? (
<img
ref={imgRef}
className="object-contain rounded-lg md:rounded-2xl"
className={`object-contain ${isPortraitImage ? "h-full w-auto" : "w-full h-auto"} rounded-lg md:rounded-2xl`}
onLoad={() => {
setHasLoaded(true);
if (imgRef.current) {
const { naturalHeight, naturalWidth } = imgRef.current;
setIsPortraitImage(
naturalWidth / naturalHeight < containerWidth / containerHeight,
);
}
if (onload) {
onload();
}

View File

@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import ActivityIndicator from "../indicators/activity-indicator";
import { useResizeObserver } from "@/hooks/resize-observer";
import { cn } from "@/lib/utils";
type CameraImageProps = {
className?: string;
@ -95,7 +96,7 @@ export default function CameraImage({
return (
<div
className={`relative w-full h-full flex justify-center ${className}`}
className={cn("relative w-full h-full flex justify-center", className)}
ref={containerRef}
>
{enabled ? (

View File

@ -3,16 +3,16 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useCallback, useMemo } from "react";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review";
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
import { useNavigate } from "react-router-dom";
import { RecordingStartingPoint } from "@/types/record";
import axios from "axios";
import { Preview } from "@/types/preview";
import {
InProgressPreview,
VideoPreview,
} from "../player/PreviewThumbnailPlayer";
import { isCurrentHour } from "@/utils/dateUtil";
import { useCameraPreviews } from "@/hooks/use-camera-previews";
type AnimatedEventCardProps = {
event: ReviewSegment;
@ -24,10 +24,15 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
// preview
const { data: previews } = useSWR<Preview[]>(
currentHour
? null
: `/preview/${event.camera}/start/${Math.round(event.start_time)}/end/${Math.round(event.end_time || event.start_time + 20)}`,
const previews = useCameraPreviews(
{
after: Math.round(event.start_time),
before: Math.round(event.end_time || event.start_time + 20),
},
{
camera: event.camera,
fetchPreviews: !currentHour,
},
);
// interaction
@ -39,7 +44,7 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
severity: event.severity,
recording: {
camera: event.camera,
startTime: event.start_time,
startTime: event.start_time - REVIEW_PADDING,
severity: event.severity,
} as RecordingStartingPoint,
},
@ -62,7 +67,7 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
<Tooltip>
<TooltipTrigger asChild>
<div
className="h-24 relative"
className="h-24 4k:h-32 relative"
style={{
aspectRatio: aspectRatio,
}}

View File

@ -18,6 +18,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { DeleteClipType, Export } from "@/types/export";
import { MdEditSquare } from "react-icons/md";
import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
type ExportProps = {
className: string;
@ -104,7 +105,10 @@ export default function ExportCard({
</Dialog>
<div
className={`relative aspect-video bg-black rounded-lg md:rounded-2xl flex justify-center items-center ${className}`}
className={cn(
"relative aspect-video bg-black rounded-lg md:rounded-2xl flex justify-center items-center",
className,
)}
onMouseEnter={
isDesktop && !exportedRecording.in_progress
? () => setHovered(true)

View File

@ -5,6 +5,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { isDesktop } from "react-device-detect";
import { cn } from "@/lib/utils";
const variants = {
primary: {
@ -38,9 +39,11 @@ export default function CameraFeatureToggle({
const content = (
<div
onClick={onClick}
className={`${className} flex flex-col justify-center items-center ${
variants[variant][isActive ? "active" : "inactive"]
}`}
className={cn(
className,
"flex flex-col justify-center items-center",
variants[variant][isActive ? "active" : "inactive"],
)}
>
<Icon
className={`size-5 md:m-[6px] ${isActive ? "text-white" : "text-secondary-foreground"}`}

View File

@ -1,29 +1,58 @@
import {
CameraGroupConfig,
FrigateConfig,
GROUP_ICONS,
} from "@/types/frigateConfig";
import { isDesktop } from "react-device-detect";
import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig";
import { isDesktop, isMobile } from "react-device-detect";
import useSWR from "swr";
import { MdHome } from "react-icons/md";
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
import { Button } from "../ui/button";
import { useCallback, useMemo, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { getIconForGroup } from "@/utils/iconUtil";
import { LuPencil, LuPlus, LuTrash } from "react-icons/lu";
import { LuPencil, LuPlus } from "react-icons/lu";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
import { Drawer, DrawerContent } from "../ui/drawer";
import { Input } from "../ui/input";
import { Separator } from "../ui/separator";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import axios from "axios";
import FilterSwitch from "./FilterSwitch";
import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
import IconWrapper from "../ui/icon-wrapper";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { ScrollArea, ScrollBar } from "../ui/scroll-area";
import { usePersistence } from "@/hooks/use-persistence";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
import * as LuIcons from "react-icons/lu";
import IconPicker, { IconName, IconRenderer } from "../icons/IconPicker";
import { isValidIconName } from "@/utils/iconUtil";
type CameraGroupSelectorProps = {
className?: string;
@ -52,7 +81,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
// groups
const [group, setGroup] = usePersistedOverlayState(
const [group, setGroup, deleteGroup] = usePersistedOverlayState(
"cameraGroup",
"default" as string,
);
@ -71,70 +100,93 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
const [addGroup, setAddGroup] = useState(false);
const Scroller = isMobile ? ScrollArea : "div";
return (
<div
className={`flex items-center justify-start gap-2 ${className ?? ""} ${isDesktop ? "flex-col" : ""}`}
>
<>
<NewGroupDialog
open={addGroup}
setOpen={setAddGroup}
currentGroups={groups}
activeGroup={group}
setGroup={setGroup}
deleteGroup={deleteGroup}
/>
<Tooltip open={tooltip == "default"}>
<TooltipTrigger asChild>
<Button
className={
group == "default"
? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
: "text-secondary-foreground bg-secondary focus:text-secondary-foreground focus:bg-secondary"
}
size="xs"
onClick={() => (group ? setGroup("default", true) : null)}
onMouseEnter={() => (isDesktop ? showTooltip("default") : null)}
onMouseLeave={() => (isDesktop ? showTooltip(undefined) : null)}
>
<MdHome className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="capitalize" side="right">
All Cameras
</TooltipContent>
</Tooltip>
{groups.map(([name, config]) => {
return (
<Tooltip key={name} open={tooltip == name}>
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
<div
className={cn(
"flex items-center justify-start gap-2",
className,
isDesktop ? "flex-col" : "whitespace-nowrap",
)}
>
<Tooltip open={tooltip == "default"}>
<TooltipTrigger asChild>
<Button
className={
group == name
group == "default"
? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
: "text-secondary-foreground bg-secondary"
: "text-secondary-foreground bg-secondary focus:text-secondary-foreground focus:bg-secondary"
}
size="xs"
onClick={() => setGroup(name, group != "default")}
onMouseEnter={() => (isDesktop ? showTooltip(name) : null)}
onClick={() => (group ? setGroup("default", true) : null)}
onMouseEnter={() => (isDesktop ? showTooltip("default") : null)}
onMouseLeave={() => (isDesktop ? showTooltip(undefined) : null)}
>
{getIconForGroup(config.icon)}
<MdHome className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="capitalize" side="right">
{name}
</TooltipContent>
<TooltipPortal>
<TooltipContent className="capitalize" side="right">
All Cameras
</TooltipContent>
</TooltipPortal>
</Tooltip>
);
})}
{isDesktop && (
<Button
className="text-muted-foreground bg-secondary"
size="xs"
onClick={() => setAddGroup(true)}
>
<LuPlus className="size-4 text-primary" />
</Button>
)}
</div>
{groups.map(([name, config]) => {
return (
<Tooltip key={name} open={tooltip == name}>
<TooltipTrigger asChild>
<Button
className={
group == name
? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
: "text-secondary-foreground bg-secondary"
}
size="xs"
onClick={() => setGroup(name, group != "default")}
onMouseEnter={() => (isDesktop ? showTooltip(name) : null)}
onMouseLeave={() =>
isDesktop ? showTooltip(undefined) : null
}
>
{config && config.icon && isValidIconName(config.icon) && (
<IconRenderer
icon={LuIcons[config.icon]}
className="size-4"
/>
)}
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent className="capitalize" side="right">
{name}
</TooltipContent>
</TooltipPortal>
</Tooltip>
);
})}
<Button
className="text-muted-foreground bg-secondary"
size="xs"
onClick={() => setAddGroup(true)}
>
<LuPlus className="size-4 text-primary" />
</Button>
{isMobile && <ScrollBar orientation="horizontal" className="h-0" />}
</div>
</Scroller>
</>
);
}
@ -142,195 +194,487 @@ type NewGroupDialogProps = {
open: boolean;
setOpen: (open: boolean) => void;
currentGroups: [string, CameraGroupConfig][];
activeGroup?: string;
setGroup: (value: string | undefined, replace?: boolean | undefined) => void;
deleteGroup: () => void;
};
function NewGroupDialog({ open, setOpen, currentGroups }: NewGroupDialogProps) {
function NewGroupDialog({
open,
setOpen,
currentGroups,
activeGroup,
setGroup,
deleteGroup,
}: NewGroupDialogProps) {
const { mutate: updateConfig } = useSWR<FrigateConfig>("config");
// editing group and state
const [editingGroupName, setEditingGroupName] = useState("");
const editingGroup = useMemo(() => {
if (currentGroups && editingGroupName !== undefined) {
return currentGroups.find(
([groupName]) => groupName === editingGroupName,
);
} else {
return undefined;
}
}, [currentGroups, editingGroupName]);
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
const [isLoading, setIsLoading] = useState(false);
const [, , , deleteGridLayout] = usePersistence(
`${activeGroup}-draggable-layout`,
);
// callbacks
const onDeleteGroup = useCallback(
async (name: string) => {
deleteGridLayout();
deleteGroup();
await axios
.put(`config/set?camera_groups.${name}`, { requires_restart: 0 })
.then((res) => {
if (res.status === 200) {
if (activeGroup == name) {
// deleting current group
setGroup("default");
}
updateConfig();
} else {
setOpen(false);
setEditState("none");
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
}
})
.catch((error) => {
setOpen(false);
setEditState("none");
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
},
[
updateConfig,
activeGroup,
setGroup,
setOpen,
deleteGroup,
deleteGridLayout,
],
);
const onSave = () => {
setOpen(false);
setEditState("none");
};
const onCancel = () => {
setEditingGroupName("");
setEditState("none");
};
const onEditGroup = useCallback((group: [string, CameraGroupConfig]) => {
setEditingGroupName(group[0]);
setEditState("edit");
}, []);
const Overlay = isDesktop ? Dialog : Drawer;
const Content = isDesktop ? DialogContent : DrawerContent;
return (
<>
<Toaster
className="toaster group z-[100]"
position="top-center"
closeButton={true}
/>
<Overlay
open={open}
onOpenChange={(open) => {
setEditState("none");
setOpen(open);
}}
>
<Content
className={`min-w-0 ${isMobile ? "w-full p-3 rounded-t-2xl max-h-[90%]" : "w-6/12 max-h-dvh overflow-y-hidden"}`}
>
<div className="flex flex-col my-4 overflow-y-auto">
{editState === "none" && (
<>
<div className="flex flex-row justify-between items-center py-2">
<DialogTitle>Camera Groups</DialogTitle>
<Button
variant="secondary"
className="size-6 p-1 rounded-md text-background bg-secondary-foreground"
onClick={() => {
setEditState("add");
}}
>
<LuPlus />
</Button>
</div>
{currentGroups.map((group) => (
<CameraGroupRow
key={group[0]}
group={group}
onDeleteGroup={() => onDeleteGroup(group[0])}
onEditGroup={() => onEditGroup(group)}
/>
))}
</>
)}
{editState != "none" && (
<>
<div className="flex flex-row justify-between items-center mb-3">
<DialogTitle>
{editState == "add" ? "Add" : "Edit"} Camera Group
</DialogTitle>
</div>
<CameraGroupEdit
currentGroups={currentGroups}
editingGroup={editingGroup}
isLoading={isLoading}
setIsLoading={setIsLoading}
onSave={onSave}
onCancel={onCancel}
/>
</>
)}
</div>
</Content>
</Overlay>
</>
);
}
type CameraGroupRowProps = {
group: [string, CameraGroupConfig];
onDeleteGroup: () => void;
onEditGroup: () => void;
};
export function CameraGroupRow({
group,
onDeleteGroup,
onEditGroup,
}: CameraGroupRowProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
if (!group) {
return;
}
return (
<>
<div
key={group[0]}
className="flex md:p-1 rounded-lg flex-row items-center justify-between md:mx-2 my-1.5 transition-background duration-100"
>
<div className={`flex items-center`}>
<p className="cursor-default">{group[0]}</p>
</div>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete the camera group{" "}
<em>{group[0]}</em>?
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onDeleteGroup}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{isMobile && (
<>
<DropdownMenu>
<DropdownMenuTrigger>
<HiOutlineDotsVertical className="size-5" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={onEditGroup}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{!isMobile && (
<div className="flex flex-row gap-2 items-center">
<Tooltip>
<TooltipTrigger asChild>
<IconWrapper
icon={LuPencil}
className={`size-[15px] cursor-pointer`}
onClick={onEditGroup}
/>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<IconWrapper
icon={HiTrash}
className={`size-[15px] cursor-pointer`}
onClick={() => setDeleteDialogOpen(true)}
/>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
)}
</div>
</>
);
}
type CameraGroupEditProps = {
currentGroups: [string, CameraGroupConfig][];
editingGroup?: [string, CameraGroupConfig];
isLoading: boolean;
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
onSave?: () => void;
onCancel?: () => void;
};
export function CameraGroupEdit({
currentGroups,
editingGroup,
isLoading,
setIsLoading,
onSave,
onCancel,
}: CameraGroupEditProps) {
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
// add fields
const formSchema = z.object({
name: z
.string()
.min(2, {
message: "Camera group name must be at least 2 characters.",
})
.transform((val: string) => val.trim().replace(/\s+/g, "_"))
.refine(
(value: string) => {
return (
editingGroup !== undefined ||
!currentGroups.map((group) => group[0]).includes(value)
);
},
{
message: "Camera group name already exists.",
},
)
.refine((value: string) => value.toLowerCase() !== "default", {
message: "Invalid camera group name.",
}),
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
const [newTitle, setNewTitle] = useState("");
const [icon, setIcon] = useState("");
const [cameras, setCameras] = useState<string[]>([]);
cameras: z.array(z.string()).min(2, {
message: "You must select at least two cameras.",
}),
icon: z
.string()
.min(1, { message: "You must select an icon." })
.refine((value) => Object.keys(LuIcons).includes(value), {
message: "Invalid icon",
}),
});
// validation
const [error, setError] = useState("");
const onCreateGroup = useCallback(async () => {
if (!newTitle) {
setError("A title must be selected");
return;
}
if (!icon) {
setError("An icon must be selected");
return;
}
if (!cameras || cameras.length < 2) {
setError("At least 2 cameras must be selected");
return;
}
setError("");
const orderQuery = `camera_groups.${newTitle}.order=${currentGroups.length}`;
const iconQuery = `camera_groups.${newTitle}.icon=${icon}`;
const cameraQueries = cameras
.map((cam) => `&camera_groups.${newTitle}.cameras=${cam}`)
.join("");
const req = axios.put(
`config/set?${orderQuery}&${iconQuery}${cameraQueries}`,
{ requires_restart: 0 },
);
setOpen(false);
if ((await req).status == 200) {
setNewTitle("");
setIcon("");
setCameras([]);
updateConfig();
}
}, [currentGroups, cameras, newTitle, icon, setOpen, updateConfig]);
const onDeleteGroup = useCallback(
async (name: string) => {
const req = axios.put(`config/set?camera_groups.${name}`, {
requires_restart: 0,
});
if ((await req).status == 200) {
updateConfig();
const onSubmit = useCallback(
async (values: z.infer<typeof formSchema>) => {
if (!values) {
return;
}
setIsLoading(true);
const order =
editingGroup === undefined
? currentGroups.length + 1
: editingGroup[1].order;
const orderQuery = `camera_groups.${values.name}.order=${+order}`;
const iconQuery = `camera_groups.${values.name}.icon=${values.icon}`;
const cameraQueries = values.cameras
.map((cam) => `&camera_groups.${values.name}.cameras=${cam}`)
.join("");
axios
.put(`config/set?${orderQuery}&${iconQuery}${cameraQueries}`, {
requires_restart: 0,
})
.then((res) => {
if (res.status === 200) {
toast.success(`Camera group (${values.name}) has been saved.`, {
position: "top-center",
});
updateConfig();
if (onSave) {
onSave();
}
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
},
[updateConfig],
[currentGroups, setIsLoading, onSave, updateConfig, editingGroup],
);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onSubmit",
defaultValues: {
name: (editingGroup && editingGroup[0]) ?? "",
icon: editingGroup && (editingGroup[1].icon as IconName),
cameras: editingGroup && editingGroup[1].cameras,
},
});
return (
<Dialog
open={open}
onOpenChange={(open) => {
setEditState("none");
setNewTitle("");
setIcon("");
setCameras([]);
setOpen(open);
}}
>
<DialogContent className="min-w-0 w-96">
<DialogTitle>Camera Groups</DialogTitle>
{currentGroups.map((group) => (
<div key={group[0]} className="flex justify-between items-center">
{group[0]}
<div className="flex justify-center gap-1">
<Button
className="bg-transparent"
size="icon"
onClick={() => {
setNewTitle(group[0]);
setIcon(group[1].icon);
setCameras(group[1].cameras);
setEditState("edit");
}}
>
<LuPencil />
</Button>
<Button
className="text-destructive bg-transparent"
size="icon"
onClick={() => onDeleteGroup(group[0])}
>
<LuTrash />
</Button>
</div>
</div>
))}
{currentGroups.length > 0 && <DropdownMenuSeparator />}
{editState == "none" && (
<Button
className="text-primary justify-start"
variant="ghost"
onClick={() => setEditState("add")}
>
<LuPlus className="size-4 mr-1" />
Create new group
</Button>
)}
{editState != "none" && (
<>
<Input
type="text"
placeholder="Name"
disabled={editState == "edit"}
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex justify-start gap-2 items-center cursor-pointer">
{icon.length == 0 ? "Select Icon" : "Icon: "}
{icon ? getIconForGroup(icon) : <div className="size-4" />}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuRadioGroup value={icon} onValueChange={setIcon}>
{GROUP_ICONS.map((gIcon) => (
<DropdownMenuRadioItem
key={gIcon}
className="w-full flex justify-start items-center gap-2 cursor-pointer hover:bg-secondary"
value={gIcon}
>
{getIconForGroup(gIcon)}
{gIcon}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex justify-start gap-2 items-center cursor-pointer">
{cameras.length == 0
? "Select Cameras"
: `${cameras.length} Cameras`}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mt-2 space-y-6 overflow-y-auto"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
className="w-full p-2 border border-input bg-background hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="Enter a name..."
disabled={editingGroup !== undefined}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Separator className="flex my-2 bg-secondary" />
<div className="max-h-[25dvh] md:max-h-[40dvh] overflow-y-auto">
<FormField
control={form.control}
name="cameras"
render={({ field }) => (
<FormItem>
<FormLabel>Cameras</FormLabel>
<FormDescription>
Select cameras for this group.
</FormDescription>
{[
...(birdseyeConfig?.enabled ? ["birdseye"] : []),
...Object.keys(config?.cameras ?? {}),
].map((camera) => (
<FilterSwitch
key={camera}
isChecked={cameras.includes(camera)}
label={camera.replaceAll("_", " ")}
onCheckedChange={(checked) => {
if (checked) {
setCameras([...cameras, camera]);
} else {
const index = cameras.indexOf(camera);
setCameras([
...cameras.slice(0, index),
...cameras.slice(index + 1),
]);
}
}}
/>
<FormControl key={camera}>
<FilterSwitch
isChecked={field.value && field.value.includes(camera)}
label={camera.replaceAll("_", " ")}
onCheckedChange={(checked) => {
const updatedCameras = checked
? [...(field.value || []), camera]
: (field.value || []).filter((c) => c !== camera);
form.setValue("cameras", updatedCameras);
}}
/>
</FormControl>
))}
</DropdownMenuContent>
</DropdownMenu>
{error && <div className="text-danger">{error}</div>}
<Button variant="select" onClick={onCreateGroup}>
Submit
</Button>
</>
)}
</DialogContent>
</Dialog>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator className="flex my-2 bg-secondary" />
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem className="flex flex-col space-y-2">
<FormLabel>Icon</FormLabel>
<FormControl>
<IconPicker
selectedIcon={{
name: field.value,
Icon: field.value
? LuIcons[field.value as IconName]
: undefined,
}}
setSelectedIcon={(newIcon) => {
field.onChange(newIcon?.name ?? undefined);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Separator className="flex my-2 bg-secondary" />
<div className="flex flex-row gap-2 py-5 md:pb-0">
<Button className="flex flex-1" onClick={onCancel}>
Cancel
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button>
</div>
</form>
</Form>
);
}

View File

@ -3,24 +3,27 @@ import { Label } from "../ui/label";
type FilterSwitchProps = {
label: string;
disabled?: boolean;
isChecked: boolean;
onCheckedChange: (checked: boolean) => void;
};
export default function FilterSwitch({
label,
disabled = false,
isChecked,
onCheckedChange,
}: FilterSwitchProps) {
return (
<div className="flex justify-between items-center gap-1">
<Label
className="w-full mx-2 text-primary capitalize cursor-pointer"
className={`w-full mx-2 text-primary capitalize cursor-pointer ${disabled ? "text-secondary-foreground" : ""}`}
htmlFor={label}
>
{label}
</Label>
<Switch
id={label}
disabled={disabled}
checked={isChecked}
onCheckedChange={onCheckedChange}
/>

View File

@ -10,7 +10,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { ReviewFilter, ReviewSummary } from "@/types/review";
import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review";
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import {
@ -49,19 +49,21 @@ const DEFAULT_REVIEW_FILTERS: ReviewFilters[] = [
type ReviewFilterGroupProps = {
filters?: ReviewFilters[];
currentSeverity?: ReviewSeverity;
reviewSummary?: ReviewSummary;
filter?: ReviewFilter;
onUpdateFilter: (filter: ReviewFilter) => void;
motionOnly: boolean;
onUpdateFilter: (filter: ReviewFilter) => void;
setMotionOnly: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function ReviewFilterGroup({
filters = DEFAULT_REVIEW_FILTERS,
currentSeverity,
reviewSummary,
filter,
onUpdateFilter,
motionOnly,
onUpdateFilter,
setMotionOnly,
}: ReviewFilterGroupProps) {
const { data: config } = useSWR<FrigateConfig>("config");
@ -179,6 +181,11 @@ export default function ReviewFilterGroup({
<GeneralFilterButton
allLabels={filterValues.labels}
selectedLabels={filter?.labels}
currentSeverity={currentSeverity}
showAll={filter?.showAll == true}
setShowAll={(showAll) => {
onUpdateFilter({ ...filter, showAll });
}}
updateLabelFilter={(newLabels) => {
onUpdateFilter({ ...filter, labels: newLabels });
}}
@ -188,6 +195,7 @@ export default function ReviewFilterGroup({
<MobileReviewSettingsDrawer
features={mobileSettingsFeatures}
filter={filter}
currentSeverity={currentSeverity}
reviewSummary={reviewSummary}
onUpdateFilter={onUpdateFilter}
// not applicable as exports are not used
@ -248,7 +256,7 @@ export function CamerasFilterButton({
<DropdownMenuSeparator />
</>
)}
<div className="h-auto p-4 overflow-y-auto overflow-x-hidden">
<div className="h-auto max-h-[80dvh] p-4 overflow-y-auto overflow-x-hidden">
<FilterSwitch
isChecked={currentCameras == undefined}
label="All Cameras"
@ -477,11 +485,17 @@ function CalendarFilterButton({
type GeneralFilterButtonProps = {
allLabels: string[];
selectedLabels: string[] | undefined;
currentSeverity?: ReviewSeverity;
showAll: boolean;
setShowAll: (showAll: boolean) => void;
updateLabelFilter: (labels: string[] | undefined) => void;
};
function GeneralFilterButton({
allLabels,
selectedLabels,
currentSeverity,
showAll,
setShowAll,
updateLabelFilter,
}: GeneralFilterButtonProps) {
const [open, setOpen] = useState(false);
@ -510,6 +524,9 @@ function GeneralFilterButton({
allLabels={allLabels}
selectedLabels={selectedLabels}
currentLabels={currentLabels}
currentSeverity={currentSeverity}
showAll={showAll}
setShowAll={setShowAll}
updateLabelFilter={updateLabelFilter}
setCurrentLabels={setCurrentLabels}
onClose={() => setOpen(false)}
@ -557,6 +574,9 @@ type GeneralFilterContentProps = {
allLabels: string[];
selectedLabels: string[] | undefined;
currentLabels: string[] | undefined;
currentSeverity?: ReviewSeverity;
showAll?: boolean;
setShowAll?: (showAll: boolean) => void;
updateLabelFilter: (labels: string[] | undefined) => void;
setCurrentLabels: (labels: string[] | undefined) => void;
onClose: () => void;
@ -565,13 +585,35 @@ export function GeneralFilterContent({
allLabels,
selectedLabels,
currentLabels,
currentSeverity,
showAll,
setShowAll,
updateLabelFilter,
setCurrentLabels,
onClose,
}: GeneralFilterContentProps) {
return (
<>
<div className="h-auto overflow-y-auto overflow-x-hidden">
<div className="h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
{currentSeverity && setShowAll && (
<div className="my-2.5 flex flex-col gap-2.5">
<FilterSwitch
label="Alerts"
disabled={currentSeverity == "alert"}
isChecked={currentSeverity == "alert" ? true : showAll == true}
onCheckedChange={setShowAll}
/>
<FilterSwitch
label="Detections"
disabled={currentSeverity == "detection"}
isChecked={
currentSeverity == "detection" ? true : showAll == true
}
onCheckedChange={setShowAll}
/>
<DropdownMenuSeparator />
</div>
)}
<div className="flex justify-between items-center my-2.5">
<Label
className="mx-2 text-primary cursor-pointer"

View File

@ -37,10 +37,6 @@ export function ThresholdBarGraph({
const formatTime = useCallback(
(val: unknown) => {
if (val == 1) {
return;
}
const date = new Date(updateTimes[Math.round(val as number) - 1] * 1000);
return date.toLocaleTimeString([], {
hour12: config?.ui.time_format != "24hour",
@ -110,7 +106,7 @@ export function ThresholdBarGraph({
tickAmount: isMobileOnly ? 3 : 4,
tickPlacement: "on",
labels: {
offsetX: -18,
rotate: 0,
formatter: formatTime,
},
axisBorder: {
@ -149,8 +145,8 @@ export function ThresholdBarGraph({
}
const getUnitSize = (MB: number) => {
if (isNaN(MB) || MB < 0) return "Invalid number";
if (MB < 1024) return `${MB} MiB`;
if (MB === null || isNaN(MB) || MB < 0) return "Invalid number";
if (MB < 1024) return `${MB.toFixed(2)} MiB`;
if (MB < 1048576) return `${(MB / 1024).toFixed(2)} GiB`;
return `${(MB / 1048576).toFixed(2)} TiB`;
@ -301,10 +297,6 @@ export function CameraLineGraph({
const formatTime = useCallback(
(val: unknown) => {
if (val == 1) {
return;
}
const date = new Date(updateTimes[Math.round(val as number)] * 1000);
return date.toLocaleTimeString([], {
hour12: config?.ui.time_format != "24hour",
@ -352,7 +344,7 @@ export function CameraLineGraph({
tickAmount: isMobileOnly ? 3 : 4,
tickPlacement: "on",
labels: {
offsetX: isMobileOnly ? -18 : 0,
rotate: 0,
formatter: formatTime,
},
axisBorder: {

View File

@ -0,0 +1,22 @@
import { LuPlus } from "react-icons/lu";
import Logo from "../Logo";
import { cn } from "@/lib/utils";
type FrigatePlusIconProps = {
className?: string;
onClick?: () => void;
};
export default function FrigatePlusIcon({
className,
onClick,
}: FrigatePlusIconProps) {
return (
<div
className={cn("relative flex items-center", className)}
onClick={onClick}
>
<Logo className="size-full" />
<LuPlus className="absolute size-2 translate-x-3 translate-y-3/4" />
</div>
);
}

View File

@ -0,0 +1,154 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import { IconType } from "react-icons";
import * as LuIcons from "react-icons/lu";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { IoClose } from "react-icons/io5";
import Heading from "../ui/heading";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
export type IconName = keyof typeof LuIcons;
export type IconElement = {
name?: string;
Icon?: IconType;
};
type IconPickerProps = {
selectedIcon?: IconElement;
setSelectedIcon?: React.Dispatch<
React.SetStateAction<IconElement | undefined>
>;
};
export default function IconPicker({
selectedIcon,
setSelectedIcon,
}: IconPickerProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState("");
const iconSets = useMemo(() => [...Object.entries(LuIcons)], []);
const icons = useMemo(
() =>
iconSets.filter(
([name]) =>
name.toLowerCase().includes(searchTerm.toLowerCase()) ||
searchTerm === "",
),
[iconSets, searchTerm],
);
const handleIconSelect = useCallback(
({ name, Icon }: IconElement) => {
if (setSelectedIcon) {
setSelectedIcon({ name, Icon });
}
setSearchTerm("");
},
[setSelectedIcon],
);
return (
<div ref={containerRef}>
<Popover
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
>
<PopoverTrigger asChild>
{!selectedIcon?.name || !selectedIcon?.Icon ? (
<Button className="text-muted-foreground w-full mt-2">
Select an icon
</Button>
) : (
<div className="hover:cursor-pointer">
<div className="flex flex-row w-full justify-between items-center gap-2 my-3">
<div className="flex flex-row items-center gap-2">
<selectedIcon.Icon size={15} />
<div className="text-sm">
{selectedIcon.name
.replace(/^Lu/, "")
.replace(/([A-Z])/g, " $1")}
</div>
</div>
<IoClose
className="mx-2 hover:cursor-pointer"
onClick={() => {
handleIconSelect({ name: undefined, Icon: undefined });
}}
/>
</div>
</div>
)}
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
container={containerRef.current}
className="max-h-[50dvh]"
>
<div className="flex flex-row justify-between items-center mb-3">
<Heading as="h4">Select an icon</Heading>
<IoClose
size={15}
className="hover:cursor-pointer"
onClick={() => {
setOpen(false);
}}
/>
</div>
<Input
type="text"
placeholder="Search for an icon..."
className="mb-3"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="flex flex-col flex-1 h-[20dvh]">
<div className="grid grid-cols-6 my-2 gap-2 max-h-[20dvh] overflow-y-auto pr-1">
{icons.map(([name, Icon]) => (
<div
key={name}
className={cn(
"flex flex-row justify-center items-start hover:cursor-pointer p-1 rounded-lg",
selectedIcon?.name === name
? "bg-selected text-white"
: "hover:bg-secondary-foreground",
)}
>
<Icon
size={20}
onClick={() => {
handleIconSelect({ name, Icon });
setOpen(false);
}}
/>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}
type IconRendererProps = {
icon: IconType;
size?: number;
className?: string;
};
export function IconRenderer({ icon, size, className }: IconRendererProps) {
return <>{React.createElement(icon, { size, className })}</>;
}

View File

@ -1,3 +1,4 @@
import { cn } from "@/lib/utils";
import { LogSeverity } from "@/types/log";
import { ReactNode, useMemo, useRef } from "react";
import { CSSTransition } from "react-transition-group";
@ -32,7 +33,10 @@ export default function Chip({
>
<div
ref={nodeRef}
className={`flex px-2 py-1.5 rounded-2xl items-center z-10 ${className}`}
className={cn(
"flex px-2 py-1.5 rounded-2xl items-center z-10",
className,
)}
onClick={onClick}
>
{children}

View File

@ -1,5 +1,6 @@
import { isSafari } from "react-device-detect";
import { Skeleton } from "../ui/skeleton";
import { cn } from "@/lib/utils";
export default function ImageLoadingIndicator({
className,
@ -13,8 +14,8 @@ export default function ImageLoadingIndicator({
}
return isSafari ? (
<div className={`bg-gray-300 pointer-events-none ${className ?? ""}`} />
<div className={cn("bg-gray-300 pointer-events-none", className)} />
) : (
<Skeleton className={`pointer-events-none ${className ?? ""}`} />
<Skeleton className={cn("pointer-events-none", className)} />
);
}

View File

@ -1,9 +1,10 @@
import { cn } from "@/lib/utils";
import { LuLoader2 } from "react-icons/lu";
export default function ActivityIndicator({ className = "w-full", size = 30 }) {
return (
<div
className={`flex items-center justify-center ${className}`}
className={cn("flex items-center justify-center", className)}
aria-label="Loading…"
>
<LuLoader2 className="animate-spin" size={size} />

View File

@ -3,22 +3,35 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { isDesktop } from "react-device-detect";
import { VscAccount } from "react-icons/vsc";
export default function AccountSettings() {
type AccountSettingsProps = {
className?: string;
};
export default function AccountSettings({ className }: AccountSettingsProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={`flex flex-col justify-center items-center ${isDesktop ? "rounded-lg text-secondary-foreground bg-secondary hover:bg-muted cursor-pointer" : "text-secondary-foreground"}`}
className={cn(
"flex flex-col justify-center items-center",
isDesktop
? "rounded-lg text-secondary-foreground bg-secondary hover:bg-muted cursor-pointer"
: "text-secondary-foreground",
className,
)}
>
<VscAccount className="size-5 md:m-[6px]" />
</div>
</TooltipTrigger>
<TooltipContent side="right">
<p>Account</p>
</TooltipContent>
<TooltipPortal>
<TooltipContent side="right">
<p>Account</p>
</TooltipContent>
</TooltipPortal>
</Tooltip>
);
}

View File

@ -65,11 +65,12 @@ import {
DialogPortal,
DialogTrigger,
} from "../ui/dialog";
import { TooltipPortal } from "@radix-ui/react-tooltip";
type GeneralSettings = {
type GeneralSettingsProps = {
className?: string;
};
export default function GeneralSettings({ className }: GeneralSettings) {
export default function GeneralSettings({ className }: GeneralSettingsProps) {
const { theme, colorScheme, setTheme, setColorScheme } = useTheme();
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
@ -124,9 +125,11 @@ export default function GeneralSettings({ className }: GeneralSettings) {
<LuSettings className="size-5 md:m-[6px]" />
</div>
</TooltipTrigger>
<TooltipContent side="right">
<p>Settings</p>
</TooltipContent>
<TooltipPortal>
<TooltipContent side="right">
<p>Settings</p>
</TooltipContent>
</TooltipPortal>
</Tooltip>
</a>
</Trigger>
@ -139,7 +142,7 @@ export default function GeneralSettings({ className }: GeneralSettings) {
<DropdownMenuLabel>System</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
<Link to="/system">
<Link to="/system#general">
<MenuItem
className={
isDesktop

View File

@ -13,6 +13,8 @@ import {
StatusBarMessagesContext,
StatusMessage,
} from "@/context/statusbar-provider";
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
function Bottombar() {
const navItems = useNavigation("secondary");
@ -20,16 +22,19 @@ function Bottombar() {
return (
<div className="absolute h-16 inset-x-4 bottom-0 flex flex-row items-center justify-between">
{navItems.map((item) => (
<NavItem key={item.id} item={item} Icon={item.icon} />
<NavItem key={item.id} className="p-2" item={item} Icon={item.icon} />
))}
<GeneralSettings />
<AccountSettings />
<StatusAlertNav />
<GeneralSettings className="p-2" />
<AccountSettings className="p-2" />
<StatusAlertNav className="p-2" />
</div>
);
}
function StatusAlertNav() {
type StatusAlertNavProps = {
className?: string;
};
function StatusAlertNav({ className }: StatusAlertNavProps) {
const { data: initialStats } = useSWR<FrigateStats>("stats", {
revalidateOnFocus: false,
});
@ -51,7 +56,13 @@ function StatusAlertNav() {
useEffect(() => {
clearMessages("stats");
potentialProblems.forEach((problem) => {
addMessage("stats", problem.text, problem.color);
addMessage(
"stats",
problem.text,
problem.color,
undefined,
problem.relevantLink,
);
});
}, [potentialProblems, addMessage, clearMessages]);
@ -64,18 +75,31 @@ function StatusAlertNav() {
<DrawerTrigger>
<IoIosWarning className="size-5 text-danger" />
</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] px-2 mx-1 rounded-t-2xl overflow-hidden">
<DrawerContent
className={cn(
"max-h-[75dvh] px-2 mx-1 rounded-t-2xl overflow-hidden",
className,
)}
>
<div className="w-full h-auto py-4 overflow-y-auto overflow-x-hidden flex flex-col items-center gap-2">
{Object.entries(messages).map(([key, messageArray]) => (
<div key={key} className="w-full flex items-center gap-2">
{messageArray.map(({ id, text, color }: StatusMessage) => (
<div key={id} className="flex items-center text-xs gap-2">
<IoIosWarning
className={`size-5 ${color || "text-danger"}`}
/>
{text}
</div>
))}
{messageArray.map(({ id, text, color, link }: StatusMessage) => {
const message = (
<div key={id} className="flex items-center text-xs gap-2">
<IoIosWarning
className={`size-5 ${color || "text-danger"}`}
/>
{text}
</div>
);
if (link) {
return <Link to={link}>{message}</Link>;
} else {
return message;
}
})}
</div>
))}
</div>

View File

@ -8,6 +8,7 @@ import { isDesktop } from "react-device-detect";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { NavData } from "@/types/navigation";
import { IconType } from "react-icons";
import { cn } from "@/lib/utils";
const variants = {
primary: {
@ -42,9 +43,11 @@ export default function NavItem({
to={item.url}
onClick={onClick}
className={({ isActive }) =>
`flex flex-col justify-center items-center rounded-lg ${className ?? ""} ${
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"]
}`
cn(
"flex flex-col justify-center items-center rounded-lg",
className,
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"],
)
}
>
<Icon className="size-5 md:m-[6px]" />

View File

@ -121,6 +121,14 @@ export default function ExportDialog({
className="flex items-center gap-2"
size="sm"
onClick={() => {
const now = new Date(latestTime * 1000);
let start = 0;
now.setHours(now.getHours() - 1);
start = now.getTime() / 1000;
setRange({
before: latestTime,
after: start,
});
setMode("select");
}}
>

View File

@ -7,7 +7,7 @@ import { ExportContent } from "./ExportDialog";
import { ExportMode } from "@/types/filter";
import ReviewActivityCalendar from "./ReviewActivityCalendar";
import { SelectSeparator } from "../ui/select";
import { ReviewFilter, ReviewSummary } from "@/types/review";
import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review";
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
import useSWR from "swr";
@ -31,6 +31,7 @@ type MobileReviewSettingsDrawerProps = {
features?: DrawerFeatures[];
camera: string;
filter?: ReviewFilter;
currentSeverity?: ReviewSeverity;
latestTime: number;
currentTime: number;
range?: TimeRange;
@ -44,6 +45,7 @@ export default function MobileReviewSettingsDrawer({
features = DEFAULT_DRAWER_FEATURES,
camera,
filter,
currentSeverity,
latestTime,
currentTime,
range,
@ -109,6 +111,9 @@ export default function MobileReviewSettingsDrawer({
const cameras = filter?.cameras || Object.keys(config.cameras);
cameras.forEach((camera) => {
if (camera == "birdseye") {
return;
}
const cameraConfig = config.cameras[camera];
cameraConfig.objects.track.forEach((label) => {
labels.add(label);
@ -138,7 +143,10 @@ export default function MobileReviewSettingsDrawer({
{features.includes("export") && (
<Button
className="w-full flex justify-center items-center gap-2"
onClick={() => setDrawerMode("export")}
onClick={() => {
setDrawerMode("export");
setMode("select");
}}
>
<FaArrowDown className="p-1 fill-secondary bg-secondary-foreground rounded-md" />
Export
@ -257,6 +265,11 @@ export default function MobileReviewSettingsDrawer({
allLabels={allLabels}
selectedLabels={filter?.labels}
currentLabels={currentLabels}
currentSeverity={currentSeverity}
showAll={filter?.showAll == true}
setShowAll={(showAll) => {
onUpdateFilter({ ...filter, showAll });
}}
setCurrentLabels={setCurrentLabels}
updateLabelFilter={(newLabels) =>
onUpdateFilter({ ...filter, labels: newLabels })

View File

@ -4,6 +4,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
import JSMpegPlayer from "./JSMpegPlayer";
import MSEPlayer from "./MsePlayer";
import { LivePlayerMode } from "@/types/live";
import { cn } from "@/lib/utils";
type LivePlayerProps = {
className?: string;
@ -57,7 +58,10 @@ export default function BirdseyeLivePlayer({
return (
<div
className={`relative flex justify-center w-full cursor-pointer ${className ?? ""}`}
className={cn(
"relative flex justify-center w-full cursor-pointer",
className,
)}
onClick={onClick}
>
<div className="absolute top-0 inset-x-0 rounded-lg md:rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none"></div>

View File

@ -1,8 +1,19 @@
import { MutableRefObject, useEffect, useRef, useState } from "react";
import {
MutableRefObject,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import Hls from "hls.js";
import { isAndroid, isDesktop, isMobile } from "react-device-detect";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import VideoControls from "./VideoControls";
import { VideoResolutionType } from "@/types/live";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { AxiosResponse } from "axios";
import { toast } from "sonner";
// Android native hls does not seek correctly
const USE_NATIVE_HLS = !isAndroid;
@ -21,6 +32,8 @@ type HlsVideoPlayerProps = {
onPlayerLoaded?: () => void;
onTimeUpdate?: (time: number) => void;
onPlaying?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
};
export default function HlsVideoPlayer({
videoRef,
@ -31,13 +44,29 @@ export default function HlsVideoPlayer({
onPlayerLoaded,
onTimeUpdate,
onPlaying,
setFullResolution,
onUploadFrame,
}: HlsVideoPlayerProps) {
const { data: config } = useSWR<FrigateConfig>("config");
// playback
const hlsRef = useRef<Hls>();
const [useHlsCompat, setUseHlsCompat] = useState(false);
const [loadedMetadata, setLoadedMetadata] = useState(false);
const handleLoadedMetadata = useCallback(() => {
setLoadedMetadata(true);
if (videoRef.current) {
if (setFullResolution) {
setFullResolution({
width: videoRef.current.videoWidth,
height: videoRef.current.videoHeight,
});
}
}
}, [videoRef, setFullResolution]);
useEffect(() => {
if (!videoRef.current) {
return;
@ -116,10 +145,15 @@ export default function HlsVideoPlayer({
className="absolute bottom-5 left-1/2 -translate-x-1/2 z-50"
video={videoRef.current}
isPlaying={isPlaying}
show={visible && controls}
show={visible && (controls || controlsOpen)}
muted={muted}
volume={volume}
controlsOpen={controlsOpen}
features={{
volume: true,
seek: true,
playbackRate: true,
plusUpload: config?.plus?.enabled == true,
}}
setControlsOpen={setControlsOpen}
setMuted={setMuted}
playbackRate={videoRef.current?.playbackRate ?? 1}
@ -147,6 +181,21 @@ export default function HlsVideoPlayer({
onSetPlaybackRate={(rate) =>
videoRef.current ? (videoRef.current.playbackRate = rate) : null
}
onUploadFrame={async () => {
if (videoRef.current && onUploadFrame) {
const resp = await onUploadFrame(videoRef.current.currentTime);
if (resp && resp.status == 200) {
toast.success("Successfully submitted frame to Frigate+", {
position: "top-center",
});
} else {
toast.success("Failed to submit frame to Frigate+", {
position: "top-center",
});
}
}
}}
/>
<TransformComponent
wrapperStyle={{
@ -193,7 +242,7 @@ export default function HlsVideoPlayer({
: undefined
}
onLoadedData={onPlayerLoaded}
onLoadedMetadata={() => setLoadedMetadata(true)}
onLoadedMetadata={handleLoadedMetadata}
onEnded={onClipEnded}
onError={(e) => {
if (

View File

@ -6,9 +6,14 @@ import { useEffect, useMemo, useState } from "react";
import MSEPlayer from "./MsePlayer";
import JSMpegPlayer from "./JSMpegPlayer";
import { MdCircle } from "react-icons/md";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useCameraActivity } from "@/hooks/use-camera-activity";
import { LivePlayerMode } from "@/types/live";
import { LivePlayerMode, VideoResolutionType } from "@/types/live";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import { getIconForLabel } from "@/utils/iconUtil";
import Chip from "../indicators/Chip";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { cn } from "@/lib/utils";
type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void;
@ -22,6 +27,7 @@ type LivePlayerProps = {
iOSCompatFullScreen?: boolean;
pip?: boolean;
onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
};
export default function LivePlayer({
@ -36,10 +42,12 @@ export default function LivePlayer({
iOSCompatFullScreen = false,
pip,
onClick,
setFullResolution,
}: LivePlayerProps) {
// camera activity
const { activeMotion, activeTracking } = useCameraActivity(cameraConfig);
const { activeMotion, activeTracking, objects } =
useCameraActivity(cameraConfig);
const cameraActive = useMemo(
() =>
@ -110,11 +118,12 @@ export default function LivePlayer({
player = (
<MSEPlayer
className={`rounded-lg md:rounded-2xl size-full ${liveReady ? "" : "hidden"}`}
camera={cameraConfig.name}
camera={cameraConfig.live.stream_name}
playbackEnabled={cameraActive}
audioEnabled={playAudio}
onPlaying={() => setLiveReady(true)}
pip={pip}
setFullResolution={setFullResolution}
/>
);
} else {
@ -129,7 +138,7 @@ export default function LivePlayer({
player = (
<JSMpegPlayer
className="size-full flex justify-center rounded-lg md:rounded-2xl overflow-hidden"
camera={cameraConfig.name}
camera={cameraConfig.live.stream_name}
width={cameraConfig.detect.width}
height={cameraConfig.detect.height}
/>
@ -142,17 +151,65 @@ export default function LivePlayer({
<div
ref={cameraRef}
data-camera={cameraConfig.name}
className={`relative flex justify-center ${liveMode == "jsmpeg" ? "size-full" : "w-full"} outline cursor-pointer ${
className={cn(
"relative flex justify-center",
liveMode === "jsmpeg" ? "size-full" : "w-full",
"outline cursor-pointer",
activeTracking
? "outline-severity_alert outline-3 rounded-lg md:rounded-2xl shadow-severity_alert"
: "outline-0 outline-background"
} transition-all duration-500 ${className}`}
: "outline-0 outline-background",
"transition-all duration-500",
className,
)}
onClick={onClick}
>
<div className="absolute top-0 inset-x-0 rounded-lg md:rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none"></div>
<div className="absolute bottom-0 inset-x-0 rounded-lg md:rounded-2xl z-10 w-full h-[10%] bg-gradient-to-t from-black/20 to-transparent pointer-events-none"></div>
{player}
{objects.length > 0 && (
<div className="absolute left-0 top-2 z-40">
<Tooltip>
<div className="flex">
<TooltipTrigger asChild>
<div className="mx-3 pb-1 text-white text-sm">
<Chip
className={`flex items-start justify-between space-x-1 bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 z-0`}
>
{[
...new Set([
...(objects || []).map(({ label }) => label),
]),
]
.map((label) => {
return getIconForLabel(label, "size-3 text-white");
})
.sort()}
</Chip>
</div>
</TooltipTrigger>
</div>
<TooltipContent className="capitalize">
{[
...new Set([
...(objects || []).map(({ label, sub_label }) =>
label.endsWith("verified") ? sub_label : label,
),
]),
]
.filter(
(label) =>
label !== undefined && !label.includes("-verified"),
)
.map((label) => capitalizeFirstLetter(label))
.sort()
.join(", ")
.replaceAll("-verified", "")}
</TooltipContent>
</Tooltip>
</div>
)}
<div
className={`absolute inset-0 w-full ${
showStillWithoutActivity && !liveReady ? "visible" : "invisible"

View File

@ -1,5 +1,13 @@
import { baseUrl } from "@/api/baseUrl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { VideoResolutionType } from "@/types/live";
import {
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
type MSEPlayerProps = {
camera: string;
@ -8,6 +16,7 @@ type MSEPlayerProps = {
audioEnabled?: boolean;
pip?: boolean;
onPlaying?: () => void;
setFullResolution?: React.Dispatch<SetStateAction<VideoResolutionType>>;
};
function MSEPlayer({
@ -17,6 +26,7 @@ function MSEPlayer({
audioEnabled = false,
pip = false,
onPlaying,
setFullResolution,
}: MSEPlayerProps) {
let connectTS: number = 0;
@ -50,6 +60,15 @@ function MSEPlayer({
return `${baseUrl.replace(/^http/, "ws")}live/mse/api/ws?src=${camera}`;
}, [camera]);
const handleLoadedMetadata = useCallback(() => {
if (videoRef.current && setFullResolution) {
setFullResolution({
width: videoRef.current.videoWidth,
height: videoRef.current.videoHeight,
});
}
}, [setFullResolution]);
const play = () => {
const currentVideo = videoRef.current;
@ -196,8 +215,7 @@ function MSEPlayer({
}
}
} catch (e) {
// eslint-disable-next-line no-console
console.debug(e);
// no-op
}
});
@ -214,8 +232,7 @@ function MSEPlayer({
try {
sb?.appendBuffer(data);
} catch (e) {
// eslint-disable-next-line no-console
console.debug(e);
// no-op
}
}
};
@ -286,6 +303,7 @@ function MSEPlayer({
playsInline
preload="auto"
onLoadedData={onPlaying}
onLoadedMetadata={handleLoadedMetadata}
muted={!audioEnabled}
/>
);

View File

@ -15,6 +15,7 @@ import { baseUrl } from "@/api/baseUrl";
import { isAndroid, isChrome, isMobile } from "react-device-detect";
import { TimeRange } from "@/types/timeline";
import { Skeleton } from "../ui/skeleton";
import { cn } from "@/lib/utils";
type PreviewPlayerProps = {
className?: string;
@ -238,7 +239,11 @@ function PreviewVideoPlayer({
return (
<div
className={`relative rounded-lg md:rounded-2xl w-full flex justify-center bg-black overflow-hidden ${onClick ? "cursor-pointer" : ""} ${className ?? ""}`}
className={cn(
"relative rounded-lg md:rounded-2xl w-full flex justify-center bg-black overflow-hidden",
onClick && "cursor-pointer",
className,
)}
onClick={onClick}
>
<img
@ -476,7 +481,11 @@ function PreviewFramesPlayer({
return (
<div
className={`relative w-full flex justify-center ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`}
className={cn(
"relative w-full flex justify-center",
className,
onClick && "cursor-pointer",
)}
onClick={onClick}
>
<img

View File

@ -23,6 +23,8 @@ import useContextMenu from "@/hooks/use-contextmenu";
import ActivityIndicator from "../indicators/activity-indicator";
import { TimeRange } from "@/types/timeline";
import { NoThumbSlider } from "../ui/slider";
import { PREVIEW_FPS, PREVIEW_PADDING } from "@/types/preview";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
type PreviewPlayerProps = {
review: ReviewSegment;
@ -262,7 +264,7 @@ export default function PreviewThumbnailPlayer({
.filter(
(item) => item !== undefined && !item.includes("-verified"),
)
.map((text) => text.charAt(0).toUpperCase() + text.substring(1))
.map((text) => capitalizeFirstLetter(text))
.sort()
.join(", ")
.replaceAll("-verified", "")}
@ -337,7 +339,6 @@ function PreviewContent({
}
}
const PREVIEW_PADDING = 16;
type VideoPreviewProps = {
relevantPreview: Preview;
startTime: number;
@ -398,7 +399,7 @@ export function VideoPreview({
setManualPlayback(true);
} else {
playerRef.current.currentTime = playerStartTime;
playerRef.current.playbackRate = 8;
playerRef.current.playbackRate = PREVIEW_FPS;
}
// we know that these deps are correct
@ -430,6 +431,11 @@ export function VideoPreview({
setReviewed();
if (loop && playerRef.current) {
if (manualPlayback) {
setManualPlayback(false);
setTimeout(() => setManualPlayback(true), 100);
}
playerRef.current.currentTime = playerStartTime;
return;
}
@ -470,7 +476,7 @@ export function VideoPreview({
playerRef.current.currentTime = playerStartTime + counter;
counter += 1;
}
}, 125);
}, 1000 / PREVIEW_FPS);
return () => clearInterval(intervalId);
// we know that these deps are correct

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { isSafari } from "react-device-detect";
import { LuPause, LuPlay } from "react-icons/lu";
import {
@ -18,17 +18,31 @@ import {
} from "react-icons/md";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { VolumeSlider } from "../ui/slider";
import FrigatePlusIcon from "../icons/FrigatePlusIcon";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "../ui/alert-dialog";
import { cn } from "@/lib/utils";
type VideoControls = {
volume?: boolean;
seek?: boolean;
playbackRate?: boolean;
plusUpload?: boolean;
};
const CONTROLS_DEFAULT: VideoControls = {
volume: true,
seek: true,
playbackRate: true,
plusUpload: false,
};
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
@ -40,7 +54,6 @@ type VideoControlsProps = {
show: boolean;
muted?: boolean;
volume?: number;
controlsOpen?: boolean;
playbackRates?: number[];
playbackRate: number;
hotKeys?: boolean;
@ -49,6 +62,7 @@ type VideoControlsProps = {
onPlayPause: (play: boolean) => void;
onSeek: (diff: number) => void;
onSetPlaybackRate: (rate: number) => void;
onUploadFrame?: () => void;
};
export default function VideoControls({
className,
@ -58,7 +72,6 @@ export default function VideoControls({
show,
muted,
volume,
controlsOpen,
playbackRates = PLAYBACK_RATE_DEFAULT,
playbackRate,
hotKeys = true,
@ -67,6 +80,7 @@ export default function VideoControls({
onPlayPause,
onSeek,
onSetPlaybackRate,
onUploadFrame,
}: VideoControlsProps) {
const onReplay = useCallback(
(e: React.MouseEvent<SVGElement>) => {
@ -148,7 +162,10 @@ export default function VideoControls({
return (
<div
className={`px-4 py-2 flex justify-between items-center gap-8 text-primary z-50 bg-background/60 rounded-lg ${className ?? ""}`}
className={cn(
"px-4 py-2 flex justify-between items-center gap-8 text-primary z-50 bg-background/60 rounded-lg",
className,
)}
>
{video && features.volume && (
<div className="flex justify-normal items-center gap-2 cursor-pointer">
@ -189,7 +206,6 @@ export default function VideoControls({
)}
{features.playbackRate && (
<DropdownMenu
open={controlsOpen == true}
onOpenChange={(open) => {
if (setControlsOpen) {
setControlsOpen(open);
@ -214,6 +230,84 @@ export default function VideoControls({
</DropdownMenuContent>
</DropdownMenu>
)}
{features.plusUpload && onUploadFrame && (
<FrigatePlusUploadButton
video={video}
onClose={() => {
if (setControlsOpen) {
setControlsOpen(false);
}
}}
onOpen={() => {
onPlayPause(false);
if (setControlsOpen) {
setControlsOpen(true);
}
}}
onUploadFrame={onUploadFrame}
/>
)}
</div>
);
}
type FrigatePlusUploadButtonProps = {
video?: HTMLVideoElement | null;
onOpen: () => void;
onClose: () => void;
onUploadFrame: () => void;
};
function FrigatePlusUploadButton({
video,
onOpen,
onClose,
onUploadFrame,
}: FrigatePlusUploadButtonProps) {
const [videoImg, setVideoImg] = useState<string>();
return (
<AlertDialog
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<AlertDialogTrigger asChild>
<FrigatePlusIcon
className="size-5 cursor-pointer"
onClick={() => {
onOpen();
if (video) {
const videoSize = [video.clientWidth, video.clientHeight];
const canvas = document.createElement("canvas");
canvas.width = videoSize[0];
canvas.height = videoSize[1];
const context = canvas?.getContext("2d");
if (context) {
context.drawImage(video, 0, 0, videoSize[0], videoSize[1]);
setVideoImg(canvas.toDataURL("image/webp"));
}
}
}}
/>
</AlertDialogTrigger>
<AlertDialogContent className="md:max-w-[80%]">
<AlertDialogHeader>
<AlertDialogTitle>Submit this frame to Frigate+?</AlertDialogTitle>
</AlertDialogHeader>
<img className="w-full object-contain" src={videoImg} />
<AlertDialogFooter>
<AlertDialogAction className="bg-selected" onClick={onUploadFrame}>
Submit
</AlertDialogAction>
<AlertDialogCancel>Cancel</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -101,7 +101,7 @@ export class DynamicVideoController {
this.playerController.pause();
}
} else {
console.log(`seek time is 0`);
// no op
}
}

View File

@ -9,6 +9,9 @@ import { DynamicVideoController } from "./DynamicVideoController";
import HlsVideoPlayer from "../HlsVideoPlayer";
import { TimeRange } from "@/types/timeline";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { VideoResolutionType } from "@/types/live";
import axios from "axios";
import { cn } from "@/lib/utils";
/**
* Dynamically switches between video playback and scrubbing preview player.
@ -24,6 +27,7 @@ type DynamicVideoPlayerProps = {
onControllerReady: (controller: DynamicVideoController) => void;
onTimestampUpdate?: (timestamp: number) => void;
onClipEnded?: () => void;
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
};
export default function DynamicVideoPlayer({
className,
@ -36,6 +40,7 @@ export default function DynamicVideoPlayer({
onControllerReady,
onTimestampUpdate,
onClipEnded,
setFullResolution,
}: DynamicVideoPlayerProps) {
const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
@ -124,6 +129,18 @@ export default function DynamicVideoPlayer({
[controller, onTimestampUpdate, isScrubbing, isLoading],
);
const onUploadFrameToPlus = useCallback(
(playTime: number) => {
if (!controller) {
return;
}
const time = controller.getProgress(playTime);
return axios.post(`/${camera}/plus/${time}`);
},
[camera, controller],
);
// state of playback player
const recordingParams = useMemo(() => {
@ -182,9 +199,14 @@ export default function DynamicVideoPlayer({
setIsLoading(false);
setNoRecording(false);
}}
setFullResolution={setFullResolution}
onUploadFrame={onUploadFrameToPlus}
/>
<PreviewPlayer
className={`${isScrubbing || isLoading ? "visible" : "hidden"} ${className}`}
className={cn(
className,
isScrubbing || isLoading ? "visible" : "hidden",
)}
camera={camera}
timeRange={timeRange}
cameraPreviews={cameraPreviews}

View File

@ -1,39 +1,91 @@
import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useCallback, useEffect } from "react";
import { Toaster } from "sonner";
import { toast } from "sonner";
import { Separator } from "../ui/separator";
import { Button } from "../ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { del as delData } from "idb-keyval";
export default function General() {
const { data: config } = useSWR<FrigateConfig>("config");
const clearStoredLayouts = useCallback(() => {
if (!config) {
return [];
}
Object.entries(config.camera_groups).forEach(async (value) => {
await delData(`${value[0]}-draggable-layout`)
.then(() => {
toast.success(`Cleared stored layout for ${value[0]}`, {
position: "top-center",
});
})
.catch((error) => {
toast.error(
`Failed to clear stored layout: ${error.response.data.message}`,
{ position: "top-center" },
);
});
});
}, [config]);
useEffect(() => {
document.title = "General Settings - Frigate";
}, []);
return (
<>
<Heading as="h2">Settings</Heading>
<div className="flex items-center space-x-2 mt-5">
<Switch id="lowdata" checked={false} onCheckedChange={() => {}} />
<Label htmlFor="lowdata">Low Data Mode (this device only)</Label>
</div>
<div className="flex flex-col md:flex-row size-full">
<Toaster position="top-center" closeButton={true} />
<div className="flex flex-col h-full w-full overflow-y-auto mt-2 md:mt-0 mb-10 md:mb-0 order-last md:order-none md:mr-2 rounded-lg border-secondary-foreground border-[1px] p-2 bg-background_alt">
<Heading as="h3" className="my-2">
General Settings
</Heading>
<div className="flex items-center space-x-2 mt-5">
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Another General Option" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Live Mode</SelectLabel>
<SelectItem value="jsmpeg">JSMpeg</SelectItem>
<SelectItem value="mse">MSE</SelectItem>
<SelectItem value="webrtc">WebRTC</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div className="flex flex-col w-full space-y-6">
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<div className="text-md">Stored Layouts</div>
<div className="text-sm text-muted-foreground my-2">
<p>
The layout of cameras in a camera group can be
dragged/resized. The positions are stored in your browser's
local storage.
</p>
</div>
</div>
<div className="flex flex-row justify-start items-center gap-2">
<Button onClick={clearStoredLayouts}>Clear All Layouts</Button>
</div>
</div>
<Separator className="flex my-2 bg-secondary" />
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<div className="text-md">Low Data Mode</div>
<div className="text-sm text-muted-foreground my-2">
<p>
Not yet implemented. <em>Default: disabled</em>
</p>
</div>
</div>
<div className="flex flex-row justify-start items-center gap-2">
<Switch
id="lowdata"
checked={false}
onCheckedChange={() => {}}
/>
<Label htmlFor="lowdata">
Low Data Mode (this device only)
</Label>
</div>
</div>
</div>
</div>
</div>
</>
);

View File

@ -171,13 +171,19 @@ export default function MasksAndZones({
setActivePolygonIndex(undefined);
setHoveredPolygonIndex(null);
setUnsavedChanges(false);
document.title = "Mask and Zone Editor - Frigate";
}, [allPolygons, setUnsavedChanges]);
const handleSave = useCallback(() => {
setAllPolygons([...(editingPolygons ?? [])]);
setHoveredPolygonIndex(null);
setUnsavedChanges(false);
addMessage("masks_zones", "Restart required (masks/zones changed)");
addMessage(
"masks_zones",
"Restart required (masks/zones changed)",
undefined,
"masks_zones",
);
}, [editingPolygons, setUnsavedChanges, addMessage]);
useEffect(() => {
@ -353,6 +359,10 @@ export default function MasksAndZones({
}
}, [selectedCamera]);
useEffect(() => {
document.title = "Mask and Zone Editor - Frigate";
}, []);
if (!cameraConfig && !selectedCamera) {
return <ActivityIndicator />;
}
@ -361,7 +371,7 @@ export default function MasksAndZones({
<>
{cameraConfig && editingPolygons && (
<div className="flex flex-col md:flex-row size-full">
<Toaster position="top-center" />
<Toaster position="top-center" closeButton={true} />
<div className="flex flex-col h-full w-full overflow-y-auto mt-2 md:mt-0 mb-10 md:mb-0 md:w-3/12 order-last md:order-none md:mr-2 rounded-lg border-secondary-foreground border-[1px] p-2 bg-background_alt">
{editPane == "zone" && (
<ZoneEditPane
@ -624,6 +634,7 @@ export default function MasksAndZones({
scaledHeight &&
editingPolygons ? (
<PolygonCanvas
containerRef={containerRef}
camera={cameraConfig.name}
width={scaledWidth}
height={scaledHeight}

View File

@ -2,7 +2,7 @@ import Heading from "../ui/heading";
import { Separator } from "../ui/separator";
import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
@ -173,13 +173,17 @@ export default function MotionMaskEditPane({
}
}
useEffect(() => {
document.title = "Edit Motion Mask - Frigate";
}, []);
if (!polygon) {
return;
}
return (
<>
<Toaster position="top-center" />
<Toaster position="top-center" closeButton={true} />
<Heading as="h3" className="my-2">
{polygon.name.length ? "Edit" : "New"} Motion Mask
</Heading>

View File

@ -153,19 +153,28 @@ export default function MotionTuner({
useEffect(() => {
if (changedValue) {
addMessage("motion_tuner", "Unsaved motion tuner changes");
addMessage(
"motion_tuner",
"Unsaved motion tuner changes",
undefined,
"motion_tuner",
);
} else {
clearMessages("motion_tuner");
}
}, [changedValue, addMessage, clearMessages]);
useEffect(() => {
document.title = "Motion Tuner - Frigate";
}, []);
if (!cameraConfig && !selectedCamera) {
return <ActivityIndicator />;
}
return (
<div className="flex flex-col md:flex-row size-full">
<Toaster position="top-center" />
<Toaster position="top-center" closeButton={true} />
<div className="flex flex-col h-full w-full overflow-y-auto mt-2 md:mt-0 mb-10 md:mb-0 md:w-3/12 order-last md:order-none md:mr-2 rounded-lg border-secondary-foreground border-[1px] p-2 bg-background_alt">
<Heading as="h3" className="my-2">
Motion Detection Tuner

View File

@ -19,7 +19,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import { zodResolver } from "@hookform/resolvers/zod";
@ -235,13 +235,17 @@ export default function ObjectMaskEditPane({
}
}
useEffect(() => {
document.title = "Edit Object Mask - Frigate";
}, []);
if (!polygon) {
return;
}
return (
<>
<Toaster position="top-center" />
<Toaster position="top-center" closeButton={true} />
<Heading as="h3" className="my-2">
{polygon.name.length ? "Edit" : "New"} Object Mask
</Heading>

View File

@ -1,31 +1,284 @@
import { useMemo } from "react";
import DebugCameraImage from "../camera/DebugCameraImage";
import { FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useEffect, useMemo } from "react";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { Toaster } from "@/components/ui/sonner";
import { Label } from "@/components/ui/label";
import useSWR from "swr";
import ActivityIndicator from "../indicators/activity-indicator";
import Heading from "../ui/heading";
import { Switch } from "../ui/switch";
import { usePersistence } from "@/hooks/use-persistence";
import { Skeleton } from "../ui/skeleton";
import { useCameraActivity } from "@/hooks/use-camera-activity";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { ObjectType } from "@/types/ws";
import useDeepMemo from "@/hooks/use-deep-memo";
import { Card } from "../ui/card";
import { getIconForLabel } from "@/utils/iconUtil";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
type ObjectSettingsProps = {
selectedCamera?: string;
};
type Options = { [key: string]: boolean };
const emptyObject = Object.freeze({});
export default function ObjectSettings({
selectedCamera,
}: ObjectSettingsProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const DEBUG_OPTIONS = [
{
param: "bbox",
title: "Bounding boxes",
description: "Show bounding boxes around detected objects",
},
{
param: "timestamp",
title: "Timestamp",
description: "Overlay a timestamp on the image",
},
{
param: "zones",
title: "Zones",
description: "Show an outline of any defined zones",
},
{
param: "mask",
title: "Motion masks",
description: "Show motion mask polygons",
},
{
param: "motion",
title: "Motion boxes",
description: "Show boxes around areas where motion is detected",
},
{
param: "regions",
title: "Regions",
description:
"Show a box of the region of interest sent to the object detector",
},
];
const [options, setOptions, optionsLoaded] = usePersistence<Options>(
`${selectedCamera}-feed`,
emptyObject,
);
const handleSetOption = useCallback(
(id: string, value: boolean) => {
const newOptions = { ...options, [id]: value };
setOptions(newOptions);
},
[options, setOptions],
);
const cameraConfig = useMemo(() => {
if (config && selectedCamera) {
return config.cameras[selectedCamera];
}
}, [config, selectedCamera]);
const { objects } = useCameraActivity(cameraConfig ?? ({} as CameraConfig));
const memoizedObjects = useDeepMemo(objects);
const searchParams = useMemo(() => {
if (!optionsLoaded) {
return new URLSearchParams();
}
const params = new URLSearchParams(
Object.keys(options || {}).reduce((memo, key) => {
//@ts-expect-error we know this is correct
memo.push([key, options[key] === true ? "1" : "0"]);
return memo;
}, []),
);
return params;
}, [options, optionsLoaded]);
useEffect(() => {
document.title = "Object Settings - Frigate";
}, []);
if (!cameraConfig) {
return <ActivityIndicator />;
}
return (
<div className="flex flex-col h-50">
<DebugCameraImage cameraConfig={cameraConfig} className="size-full" />
<div className="flex flex-col md:flex-row size-full">
<Toaster position="top-center" closeButton={true} />
<div className="flex flex-col h-full w-full overflow-y-auto mt-2 md:mt-0 mb-10 md:mb-0 md:w-3/12 order-last md:order-none md:mr-2 rounded-lg border-secondary-foreground border-[1px] p-2 bg-background_alt">
<Heading as="h3" className="my-2">
Debug
</Heading>
<div className="text-sm text-muted-foreground mb-5 space-y-3">
<p>
Frigate uses your detectors{" "}
{config
? "(" +
Object.keys(config?.detectors)
.map((detector) => capitalizeFirstLetter(detector))
.join(",") +
")"
: ""}{" "}
to detect objects in your camera's video stream.
</p>
<p>
Debugging view shows a real-time view of detected objects and their
statistics. The object list shows a time-delayed summary of detected
objects.
</p>
</div>
<Tabs defaultValue="debug" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="debug">Debugging</TabsTrigger>
<TabsTrigger value="objectlist">Object List</TabsTrigger>
</TabsList>
<TabsContent value="debug">
<div className="flex flex-col w-full space-y-6">
<div className="mt-2 space-y-6">
<div className="my-2.5 flex flex-col gap-2.5">
{DEBUG_OPTIONS.map(({ param, title, description }) => (
<div
key={param}
className="flex flex-row w-full justify-between items-center"
>
<div className="flex flex-col mb-2">
<Label
className="w-full text-primary capitalize cursor-pointer mb-2"
htmlFor={param}
>
{title}
</Label>
<div className="text-xs text-muted-foreground">
{description}
</div>
</div>
<Switch
key={param}
className="ml-1"
id={param}
checked={options && options[param]}
onCheckedChange={(isChecked) => {
handleSetOption(param, isChecked);
}}
/>
</div>
))}
</div>
</div>
</div>
</TabsContent>
<TabsContent value="objectlist">
{ObjectList(memoizedObjects)}
</TabsContent>
</Tabs>
</div>
{cameraConfig ? (
<div className="flex md:w-7/12 md:grow md:h-dvh md:max-h-full">
<div className="size-full min-h-10">
<AutoUpdatingCameraImage
camera={cameraConfig.name}
searchParams={searchParams}
showFps={false}
className="size-full"
cameraClasses="relative w-full h-full flex flex-col justify-start"
/>
</div>
</div>
) : (
<Skeleton className="size-full rounded-lg md:rounded-2xl" />
)}
</div>
);
}
function ObjectList(objects?: ObjectType[]) {
const { data: config } = useSWR<FrigateConfig>("config");
const colormap = useMemo(() => {
if (!config) {
return;
}
return config.model?.colormap;
}, [config]);
const getColorForObjectName = useCallback(
(objectName: string) => {
return colormap && colormap[objectName]
? `rgb(${colormap[objectName][2]}, ${colormap[objectName][1]}, ${colormap[objectName][0]})`
: "rgb(128, 128, 128)";
},
[colormap],
);
return (
<div className="flex flex-col w-full overflow-y-auto">
{objects && objects.length > 0 ? (
objects.map((obj) => {
return (
<Card className="text-sm p-2 mb-1" key={obj.id}>
<div className="flex flex-row items-center gap-3 pb-1">
<div className="flex flex-row flex-1 items-center justify-start p-3 pl-1">
<div
className="p-2 rounded-lg"
style={{
backgroundColor: obj.stationary
? "rgb(110,110,110)"
: getColorForObjectName(obj.label),
}}
>
{getIconForLabel(obj.label, "size-5 text-white")}
</div>
<div className="ml-3 text-lg">
{capitalizeFirstLetter(obj.label)}
</div>
</div>
<div className="flex flex-row w-8/12 items-end justify-end">
<div className="mr-2 text-md w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="text-sm mb-1.5 text-primary-variant">
Score
</p>
{obj.score
? (obj.score * 100).toFixed(1).toString()
: "-"}
%
</div>
</div>
<div className="mr-2 text-md w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="text-sm mb-1.5 text-primary-variant">
Ratio
</p>
{obj.ratio ? obj.ratio.toFixed(2).toString() : "-"}
</div>
</div>
<div className="mr-2 text-md w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="text-sm mb-1.5 text-primary-variant">
Area
</p>
{obj.area ? obj.area.toString() : "-"}
</div>
</div>
</div>
</div>
</Card>
);
})
) : (
<div className="p-3 text-center">No objects</div>
)}
</div>
);
}

View File

@ -1,4 +1,4 @@
import React, { useMemo, useRef, useState, useEffect } from "react";
import React, { useMemo, useRef, useState, useEffect, RefObject } from "react";
import PolygonDrawer from "./PolygonDrawer";
import { Stage, Layer, Image } from "react-konva";
import Konva from "konva";
@ -7,6 +7,7 @@ import { Polygon, PolygonType } from "@/types/canvas";
import { useApiHost } from "@/api";
type PolygonCanvasProps = {
containerRef: RefObject<HTMLDivElement>;
camera: string;
width: number;
height: number;
@ -18,6 +19,7 @@ type PolygonCanvasProps = {
};
export function PolygonCanvas({
containerRef,
camera,
width,
height,
@ -55,10 +57,6 @@ export function PolygonCanvas({
};
}, [videoElement]);
const getMousePos = (stage: Konva.Stage) => {
return [stage.getPointerPosition()!.x, stage.getPointerPosition()!.y];
};
const addPointToPolygon = (polygon: Polygon, newPoint: number[]) => {
const points = polygon.points;
const pointsOrder = polygon.pointsOrder;
@ -99,37 +97,6 @@ export function PolygonCanvas({
return { updatedPoints, updatedPointsOrder };
};
const isMouseOverFirstPoint = (polygon: Polygon, mousePos: number[]) => {
if (!polygon || !polygon.points || polygon.points.length < 1) {
return false;
}
const [firstPoint] = polygon.points;
const distance = Math.hypot(
mousePos[0] - firstPoint[0],
mousePos[1] - firstPoint[1],
);
return distance < 10;
};
const isMouseOverAnyPoint = (polygon: Polygon, mousePos: number[]) => {
if (!polygon || !polygon.points || polygon.points.length === 0) {
return false;
}
for (let i = 1; i < polygon.points.length; i++) {
const point = polygon.points[i];
const distance = Math.hypot(
mousePos[0] - point[0],
mousePos[1] - point[1],
);
if (distance < 10) {
return true;
}
}
return false;
};
const handleMouseDown = (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
if (activePolygonIndex === undefined || !polygons) {
return;
@ -138,11 +105,13 @@ export function PolygonCanvas({
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
const stage = e.target.getStage()!;
const mousePos = getMousePos(stage);
const mousePos = stage.getPointerPosition() ?? { x: 0, y: 0 };
const intersection = stage.getIntersection(mousePos);
if (
activePolygon.points.length >= 3 &&
isMouseOverFirstPoint(activePolygon, mousePos)
intersection?.getClassName() == "Circle" &&
intersection?.name() == "point-0"
) {
// Close the polygon
updatedPolygons[activePolygonIndex] = {
@ -152,12 +121,13 @@ export function PolygonCanvas({
setPolygons(updatedPolygons);
} else {
if (
!activePolygon.isFinished &&
!isMouseOverAnyPoint(activePolygon, mousePos)
(!activePolygon.isFinished &&
intersection?.getClassName() !== "Circle") ||
(activePolygon.isFinished && intersection?.name() == "unfilled-line")
) {
const { updatedPoints, updatedPointsOrder } = addPointToPolygon(
activePolygon,
mousePos,
[mousePos.x, mousePos.y],
);
updatedPolygons[activePolygonIndex] = {
@ -168,62 +138,6 @@ export function PolygonCanvas({
setPolygons(updatedPolygons);
}
}
// }
};
const handleMouseOverStartPoint = (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => {
if (activePolygonIndex === undefined || !polygons) {
return;
}
const activePolygon = polygons[activePolygonIndex];
if (!activePolygon.isFinished && activePolygon.points.length >= 3) {
e.target.getStage()!.container().style.cursor = "default";
e.currentTarget.scale({ x: 2, y: 2 });
}
};
const handleMouseOutStartPoint = (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => {
e.currentTarget.scale({ x: 1, y: 1 });
if (activePolygonIndex === undefined || !polygons) {
return;
}
const activePolygon = polygons[activePolygonIndex];
if (
(!activePolygon.isFinished && activePolygon.points.length >= 3) ||
activePolygon.isFinished
) {
e.currentTarget.scale({ x: 1, y: 1 });
}
};
const handleMouseOverAnyPoint = (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => {
if (!polygons) {
return;
}
e.target.getStage()!.container().style.cursor = "move";
};
const handleMouseOutAnyPoint = (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => {
if (activePolygonIndex === undefined || !polygons) {
return;
}
const activePolygon = polygons[activePolygonIndex];
if (activePolygon.isFinished) {
e.target.getStage()!.container().style.cursor = "default";
} else {
e.target.getStage()!.container().style.cursor = "crosshair";
}
};
const handlePointDragMove = (
@ -237,7 +151,8 @@ export function PolygonCanvas({
const activePolygon = updatedPolygons[activePolygonIndex];
const stage = e.target.getStage();
if (stage) {
const index = e.target.index - 1;
// we add an unfilled line for adding points when finished
const index = e.target.index - (activePolygon.isFinished ? 2 : 1);
const pos = [e.target._lastPos!.x, e.target._lastPos!.y];
if (pos[0] < 0) pos[0] = 0;
if (pos[1] < 0) pos[1] = 0;
@ -272,26 +187,17 @@ export function PolygonCanvas({
}
};
const handleStageMouseOver = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => {
const handleStageMouseOver = () => {
if (activePolygonIndex === undefined || !polygons) {
return;
}
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
const stage = e.target.getStage()!;
const mousePos = getMousePos(stage);
if (
activePolygon.isFinished ||
isMouseOverAnyPoint(activePolygon, mousePos) ||
isMouseOverFirstPoint(activePolygon, mousePos)
)
return;
e.target.getStage()!.container().style.cursor = "crosshair";
if (containerRef.current && !activePolygon.isFinished) {
containerRef.current.style.cursor = "crosshair";
}
};
useEffect(() => {
@ -336,6 +242,7 @@ export function PolygonCanvas({
selectedZoneMask.includes(polygon.type)) &&
index !== activePolygonIndex && (
<PolygonDrawer
stageRef={stageRef}
key={index}
points={polygon.points}
isActive={index === activePolygonIndex}
@ -344,10 +251,6 @@ export function PolygonCanvas({
color={polygon.color}
handlePointDragMove={handlePointDragMove}
handleGroupDragEnd={handleGroupDragEnd}
handleMouseOverStartPoint={handleMouseOverStartPoint}
handleMouseOutStartPoint={handleMouseOutStartPoint}
handleMouseOverAnyPoint={handleMouseOverAnyPoint}
handleMouseOutAnyPoint={handleMouseOutAnyPoint}
/>
),
)}
@ -356,6 +259,7 @@ export function PolygonCanvas({
(selectedZoneMask === undefined ||
selectedZoneMask.includes(polygons[activePolygonIndex].type)) && (
<PolygonDrawer
stageRef={stageRef}
key={activePolygonIndex}
points={polygons[activePolygonIndex].points}
isActive={true}
@ -364,10 +268,6 @@ export function PolygonCanvas({
color={polygons[activePolygonIndex].color}
handlePointDragMove={handlePointDragMove}
handleGroupDragEnd={handleGroupDragEnd}
handleMouseOverStartPoint={handleMouseOverStartPoint}
handleMouseOutStartPoint={handleMouseOutStartPoint}
handleMouseOverAnyPoint={handleMouseOverAnyPoint}
handleMouseOutAnyPoint={handleMouseOutAnyPoint}
/>
)}
</Layer>

View File

@ -1,4 +1,11 @@
import { useCallback, useMemo, useRef, useState } from "react";
import {
RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Line, Circle, Group } from "react-konva";
import {
minMax,
@ -11,6 +18,7 @@ import Konva from "konva";
import { Vector2d } from "konva/lib/types";
type PolygonDrawerProps = {
stageRef: RefObject<Konva.Stage>;
points: number[][];
isActive: boolean;
isHovered: boolean;
@ -18,21 +26,10 @@ type PolygonDrawerProps = {
color: number[];
handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
handleGroupDragEnd: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
handleMouseOverStartPoint: (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
handleMouseOutStartPoint: (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
handleMouseOverAnyPoint: (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
handleMouseOutAnyPoint: (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
};
export default function PolygonDrawer({
stageRef,
points,
isActive,
isHovered,
@ -40,31 +37,41 @@ export default function PolygonDrawer({
color,
handlePointDragMove,
handleGroupDragEnd,
handleMouseOverStartPoint,
handleMouseOutStartPoint,
handleMouseOverAnyPoint,
handleMouseOutAnyPoint,
}: PolygonDrawerProps) {
const vertexRadius = 6;
const flattenedPoints = useMemo(() => flattenPoints(points), [points]);
const [stage, setStage] = useState<Konva.Stage>();
const [minMaxX, setMinMaxX] = useState([0, 0]);
const [minMaxY, setMinMaxY] = useState([0, 0]);
const groupRef = useRef<Konva.Group>(null);
const [cursor, setCursor] = useState("default");
const handleGroupMouseOver = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
const handleMouseOverPoint = (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => {
if (!isFinished) return;
e.target.getStage()!.container().style.cursor = "move";
setStage(e.target.getStage()!);
if (!e.target) return;
if (!isFinished && points.length >= 3 && e.target.name() === "point-0") {
e.target.scale({ x: 2, y: 2 });
setCursor("crosshair");
} else {
setCursor("move");
}
};
const handleGroupMouseOut = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
const handleMouseOutPoint = (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => {
if (!e.target || !isFinished) return;
e.target.getStage()!.container().style.cursor = "default";
if (!e.target) return;
if (isFinished) {
setCursor("default");
} else {
setCursor("crosshair");
}
if (e.target.name() === "point-0") {
e.target.scale({ x: 1, y: 1 });
}
};
const handleGroupDragStart = () => {
@ -75,13 +82,13 @@ export default function PolygonDrawer({
};
const groupDragBound = (pos: Vector2d) => {
if (!stage) {
if (!stageRef.current) {
return pos;
}
let { x, y } = pos;
const sw = stage.width();
const sh = stage.height();
const sw = stageRef.current.width();
const sh = stageRef.current.height();
if (minMaxY[0] + y < 0) y = -1 * minMaxY[0];
if (minMaxX[0] + x < 0) x = -1 * minMaxX[0];
@ -98,6 +105,14 @@ export default function PolygonDrawer({
[color],
);
useEffect(() => {
if (!stageRef.current) {
return;
}
stageRef.current.container().style.cursor = cursor;
}, [stageRef, cursor]);
return (
<Group
name="polygon"
@ -106,55 +121,62 @@ export default function PolygonDrawer({
onDragStart={isActive ? handleGroupDragStart : undefined}
onDragEnd={isActive ? handleGroupDragEnd : undefined}
dragBoundFunc={isActive ? groupDragBound : undefined}
onMouseOver={isActive ? handleGroupMouseOver : undefined}
onTouchStart={isActive ? handleGroupMouseOver : undefined}
onMouseOut={isActive ? handleGroupMouseOut : undefined}
>
<Line
name="filled-line"
points={flattenedPoints}
stroke={colorString(true)}
strokeWidth={3}
hitStrokeWidth={12}
closed={isFinished}
fill={colorString(isActive || isHovered ? true : false)}
onMouseOver={() =>
isFinished ? setCursor("move") : setCursor("crosshair")
}
onMouseOut={() =>
isFinished ? setCursor("default") : setCursor("crosshair")
}
/>
{isFinished && isActive && (
<Line
name="unfilled-line"
points={flattenedPoints}
hitStrokeWidth={12}
closed={isFinished}
fillEnabled={false}
onMouseOver={() => setCursor("crosshair")}
onMouseOut={() =>
isFinished ? setCursor("default") : setCursor("crosshair")
}
/>
)}
{points.map((point, index) => {
if (!isActive) {
return;
}
const x = point[0];
const y = point[1];
const startPointAttr =
index === 0
? {
hitStrokeWidth: 12,
onMouseOver: handleMouseOverStartPoint,
onMouseOut: handleMouseOutStartPoint,
}
: null;
const otherPointsAttr =
index !== 0
? {
onMouseOver: handleMouseOverAnyPoint,
onMouseOut: handleMouseOutAnyPoint,
}
: null;
return (
<Circle
key={index}
name={`point-${index}`}
x={x}
y={y}
radius={vertexRadius}
stroke={colorString(true)}
fill="#ffffff"
strokeWidth={3}
hitStrokeWidth={index === 0 ? 12 : 9}
onMouseOver={handleMouseOverPoint}
onMouseOut={handleMouseOutPoint}
draggable={isActive}
onDragMove={isActive ? handlePointDragMove : undefined}
dragBoundFunc={(pos) => {
if (stage) {
if (stageRef.current) {
return dragBoundFunc(
stage.width(),
stage.height(),
stageRef.current.width(),
stageRef.current.height(),
vertexRadius,
pos,
);
@ -162,8 +184,6 @@ export default function PolygonDrawer({
return pos;
}
}}
{...startPointAttr}
{...otherPointsAttr}
/>
);
})}

View File

@ -41,7 +41,7 @@ export default function PolygonEditControls({
...activePolygon.pointsOrder.slice(0, lastPointOrderIndex),
...activePolygon.pointsOrder.slice(lastPointOrderIndex + 1),
],
isFinished: false,
isFinished: activePolygon.isFinished && activePolygon.points.length > 3,
};
setPolygons(updatedPolygons);

View File

@ -202,7 +202,7 @@ export default function PolygonItem({
return (
<>
<Toaster position="top-center" />
<Toaster position="top-center" closeButton={true} />
<div
key={index}

View File

@ -308,13 +308,17 @@ export default function ZoneEditPane({
}
}
useEffect(() => {
document.title = "Edit Zone - Frigate";
}, []);
if (!polygon) {
return;
}
return (
<>
<Toaster position="top-center" />
<Toaster position="top-center" closeButton={true} />
<Heading as="h3" className="my-2">
{polygon.name.length ? "Edit" : "New"} Zone
</Heading>
@ -551,14 +555,6 @@ export function ZoneObjectSelector({
const labels = new Set<string>();
// Object.values(config.cameras).forEach((camera) => {
// camera.objects.track.forEach((label) => {
// if (!ATTRIBUTE_LABELS.includes(label)) {
// labels.add(label);
// }
// });
// });
cameraConfig.objects.track.forEach((label) => {
if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label);

View File

@ -38,12 +38,8 @@ export function MotionSegment({
dense,
}: MotionSegmentProps) {
const severityType = "all";
const {
getSeverity,
getReviewed,
displaySeverityType,
shouldShowRoundedCorners,
} = useEventSegmentUtils(segmentDuration, events, severityType);
const { getSeverity, getReviewed, displaySeverityType } =
useEventSegmentUtils(segmentDuration, events, severityType);
const { interpolateMotionAudioData } = useMotionSegmentUtils(
segmentDuration,
@ -68,11 +64,6 @@ export function MotionSegment({
[getReviewed, segmentTime],
);
const { roundTopSecondary, roundBottomSecondary } = useMemo(
() => shouldShowRoundedCorners(segmentTime),
[shouldShowRoundedCorners, segmentTime],
);
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
const segmentKey = useMemo(() => segmentTime, [segmentTime]);
@ -152,16 +143,16 @@ export function MotionSegment({
const animationClassesFirstHalf = `motion-segment ${firstHalfSegmentWidth > 0 ? "hidden" : ""}
zoom-in-[0.2] ${firstHalfSegmentWidth < 5 ? "duration-200" : "duration-1000"}`;
const severityColors: { [key: number]: string } = {
const severityColorsBg: { [key: number]: string } = {
1: reviewed
? "from-severity_significant_motion-dimmed/50 to-severity_significant_motion/50"
: "from-severity_significant_motion-dimmed to-severity_significant_motion",
? "from-severity_significant_motion-dimmed/10 to-severity_significant_motion/10"
: "from-severity_significant_motion-dimmed/20 to-severity_significant_motion/20",
2: reviewed
? "from-severity_detection-dimmed/50 to-severity_detection/50"
: "from-severity_detection-dimmed to-severity_detection",
? "from-severity_detection-dimmed/10 to-severity_detection/10"
: "from-severity_detection-dimmed/20 to-severity_detection/20",
3: reviewed
? "from-severity_alert-dimmed/50 to-severity_alert/50"
: "from-severity_alert-dimmed to-severity_alert",
? "from-severity_alert-dimmed/10 to-severity_alert/10"
: "from-severity_alert-dimmed/20 to-severity_alert/20",
};
const segmentClick = useCallback(() => {
@ -179,7 +170,7 @@ export function MotionSegment({
<div
key={segmentKey}
data-segment-id={segmentKey}
className={`segment ${firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0 ? "has-data" : ""} ${segmentClasses}`}
className={`segment ${firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0 ? "has-data" : ""} ${segmentClasses} bg-gradient-to-r ${severityColorsBg[severity[0]]}`}
onClick={segmentClick}
onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
>
@ -219,7 +210,7 @@ export function MotionSegment({
<div
key={`${segmentKey}_motion_data_1`}
data-motion-value={secondHalfSegmentWidth}
className={`${isDesktop && animationClassesSecondHalf} h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`}
className={`${isDesktop && animationClassesSecondHalf} h-[2px] rounded-full bg-motion_review`}
style={{
width: secondHalfSegmentWidth || 1,
}}
@ -232,7 +223,7 @@ export function MotionSegment({
<div
key={`${segmentKey}_motion_data_2`}
data-motion-value={firstHalfSegmentWidth}
className={`${isDesktop && animationClassesFirstHalf} h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`}
className={`${isDesktop && animationClassesFirstHalf} h-[2px] rounded-full bg-motion_review`}
style={{
width: firstHalfSegmentWidth || 1,
}}
@ -240,29 +231,6 @@ export function MotionSegment({
</div>
</div>
</div>
{!motionOnly &&
severity.map((severityValue: number, index: number) => {
if (severityValue > 0) {
return (
<React.Fragment key={index}>
<div className="absolute right-0 h-[8px] z-10">
<div
key={`${segmentKey}_${index}_secondary_data`}
className={`
w-1 h-[8px] bg-gradient-to-r
${roundBottomSecondary ? "rounded-bl-full rounded-br-full" : ""}
${roundTopSecondary ? "rounded-tl-full rounded-tr-full" : ""}
${severityColors[severityValue]}
`}
></div>
</div>
</React.Fragment>
);
} else {
return null;
}
})}
</div>
)}
</>

View File

@ -9,7 +9,6 @@ import {
import { SummarySegment } from "./SummarySegment";
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { ReviewSegment, ReviewSeverity } from "@/types/review";
import { isMobile } from "react-device-detect";
export type SummaryTimelineProps = {
reviewTimelineRef: RefObject<HTMLDivElement>;
@ -188,7 +187,7 @@ export function SummaryTimeline({
e.stopPropagation();
let clientY;
if (isMobile && e.nativeEvent instanceof TouchEvent) {
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
clientY = e.nativeEvent.touches[0].clientY;
} else if (e.nativeEvent instanceof MouseEvent) {
clientY = e.nativeEvent.clientY;
@ -239,7 +238,7 @@ export function SummaryTimeline({
setIsDragging(true);
let clientY;
if (isMobile && e.nativeEvent instanceof TouchEvent) {
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
clientY = e.nativeEvent.touches[0].clientY;
} else if (e.nativeEvent instanceof MouseEvent) {
clientY = e.nativeEvent.clientY;
@ -277,7 +276,7 @@ export function SummaryTimeline({
}
e.stopPropagation();
let clientY;
if (isMobile && e instanceof TouchEvent) {
if ("TouchEvent" in window && e instanceof TouchEvent) {
clientY = e.touches[0].clientY;
} else if (e instanceof MouseEvent) {
clientY = e.clientY;

View File

@ -1,29 +1,36 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
container?: HTMLElement | null;
}
>(
(
{ className, container, align = "center", sideOffset = 4, ...props },
ref,
) => (
<PopoverPrimitive.Portal container={container}>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-lg border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
),
);
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent }
export { Popover, PopoverTrigger, PopoverContent };

View File

@ -18,6 +18,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
closeButton:
"group-[.toast]:bg-secondary border-primary border-[1px]",
success:
"group toast group-[.toaster]:bg-success group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
error:

View File

@ -10,6 +10,7 @@ export type StatusMessage = {
id: string;
text: string;
color?: string;
link?: string;
};
export type StatusMessagesState = {
@ -27,6 +28,7 @@ type StatusBarMessagesContextValue = {
message: string,
color?: string,
messageId?: string,
link?: string,
) => string;
removeMessage: (key: string, messageId: string) => void;
clearMessages: (key: string) => void;
@ -43,14 +45,20 @@ export function StatusBarMessagesProvider({
const messages = useMemo(() => messagesState, [messagesState]);
const addMessage = useCallback(
(key: string, message: string, color?: string, messageId?: string) => {
(
key: string,
message: string,
color?: string,
messageId?: string,
link?: string,
) => {
const id = messageId || Date.now().toString();
const msgColor = color || "text-danger";
setMessagesState((prevMessages) => ({
...prevMessages,
[key]: [
...(prevMessages[key] || []),
{ id, text: message, color: msgColor },
{ id, text: message, color: msgColor, link },
],
}));
return id;

View File

@ -1,62 +1,123 @@
import { useFrigateEvents, useMotionActivity } from "@/api/ws";
import { CameraConfig } from "@/types/frigateConfig";
import {
useFrigateEvents,
useInitialCameraState,
useMotionActivity,
} from "@/api/ws";
import { ATTRIBUTE_LABELS, CameraConfig } from "@/types/frigateConfig";
import { MotionData, ReviewSegment } from "@/types/review";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTimelineUtils } from "./use-timeline-utils";
import { ObjectType } from "@/types/ws";
import useDeepMemo from "./use-deep-memo";
import { isEqual } from "lodash";
type useCameraActivityReturn = {
activeTracking: boolean;
activeMotion: boolean;
objects: ObjectType[];
};
export function useCameraActivity(
camera: CameraConfig,
): useCameraActivityReturn {
const [activeObjects, setActiveObjects] = useState<string[]>([]);
const [objects, setObjects] = useState<ObjectType[]>([]);
// init camera activity
const { payload: initialCameraState } = useInitialCameraState(camera.name);
const updatedCameraState = useDeepMemo(initialCameraState);
useEffect(() => {
if (updatedCameraState) {
setObjects(updatedCameraState.objects);
}
}, [updatedCameraState, camera]);
// handle camera activity
const hasActiveObjects = useMemo(
() => activeObjects.length > 0,
[activeObjects],
() => objects.filter((obj) => !obj.stationary).length > 0,
[objects],
);
const { payload: detectingMotion } = useMotionActivity(camera.name);
const { payload: event } = useFrigateEvents();
const updatedEvent = useDeepMemo(event);
const handleSetObjects = useCallback(
(newObjects: ObjectType[]) => {
if (!isEqual(objects, newObjects)) {
setObjects(newObjects);
}
},
[objects],
);
useEffect(() => {
if (!event) {
if (!updatedEvent) {
return;
}
if (event.after.camera != camera.name) {
if (updatedEvent.after.camera !== camera.name) {
return;
}
const eventIndex = activeObjects.indexOf(event.after.id);
const updatedEventIndex = objects.findIndex(
(obj) => obj.id === updatedEvent.after.id,
);
if (event.type == "end") {
if (eventIndex != -1) {
const newActiveObjects = [...activeObjects];
newActiveObjects.splice(eventIndex, 1);
setActiveObjects(newActiveObjects);
let newObjects: ObjectType[] = [...objects];
if (updatedEvent.type === "end") {
if (updatedEventIndex !== -1) {
newObjects.splice(updatedEventIndex, 1);
}
} else {
if (eventIndex == -1) {
// add unknown event to list if not stationary
if (!event.after.stationary) {
const newActiveObjects = [...activeObjects, event.after.id];
setActiveObjects(newActiveObjects);
if (updatedEventIndex === -1) {
// add unknown updatedEvent to list if not stationary
if (!updatedEvent.after.stationary) {
const newActiveObject: ObjectType = {
id: updatedEvent.after.id,
label: updatedEvent.after.label,
stationary: updatedEvent.after.stationary,
area: updatedEvent.after.area,
ratio: updatedEvent.after.ratio,
score: updatedEvent.after.score,
sub_label: updatedEvent.after.sub_label?.[0] ?? "",
};
newObjects = [...objects, newActiveObject];
}
} else {
// remove known event from list if it has become stationary
if (event.after.stationary) {
activeObjects.splice(eventIndex, 1);
const newObjects = [...objects];
let label = updatedEvent.after.label;
if (updatedEvent.after.sub_label) {
const sub_label = updatedEvent.after.sub_label[0];
if (ATTRIBUTE_LABELS.includes(sub_label)) {
label = sub_label;
} else {
label = `${label}-verified`;
}
}
newObjects[updatedEventIndex].label = label;
newObjects[updatedEventIndex].stationary =
updatedEvent.after.stationary;
}
}
}, [camera, event, activeObjects]);
handleSetObjects(newObjects);
}, [camera, updatedEvent, objects, handleSetObjects]);
return {
activeTracking: hasActiveObjects,
activeMotion: detectingMotion == "ON",
activeMotion: detectingMotion
? detectingMotion === "ON"
: initialCameraState?.motion === true,
objects,
};
}

View File

@ -0,0 +1,57 @@
import { Preview } from "@/types/preview";
import { TimeRange } from "@/types/timeline";
import { useEffect, useState } from "react";
import useSWR from "swr";
type OptionalCameraPreviewProps = {
camera?: string;
autoRefresh?: boolean;
fetchPreviews?: boolean;
};
export function useCameraPreviews(
initialTimeRange: TimeRange,
{
camera = "all",
autoRefresh = true,
fetchPreviews = true,
}: OptionalCameraPreviewProps,
) {
const [timeRange, setTimeRange] = useState(initialTimeRange);
useEffect(() => {
setTimeRange(initialTimeRange);
}, [initialTimeRange]);
const { data: allPreviews } = useSWR<Preview[]>(
fetchPreviews
? `preview/${camera}/start/${Math.round(timeRange.after)}/end/${Math.round(timeRange.before)}`
: null,
{ revalidateOnFocus: false, revalidateOnReconnect: false },
);
// Set a timeout to update previews on the hour
useEffect(() => {
if (!autoRefresh || !fetchPreviews || !allPreviews) {
return;
}
const callback = () => {
const nextPreviewStart = new Date(
allPreviews[allPreviews.length - 1].end * 1000,
);
nextPreviewStart.setHours(nextPreviewStart.getHours() + 1);
if (Date.now() > nextPreviewStart.getTime()) {
setTimeRange({ after: timeRange.after, before: Date.now() / 1000 });
}
};
document.addEventListener("focusin", callback);
return () => {
document.removeEventListener("focusin", callback);
};
}, [allPreviews, autoRefresh, fetchPreviews, timeRange]);
return allPreviews;
}

View File

@ -1,5 +1,4 @@
import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { isMobile } from "react-device-detect";
import scrollIntoView from "scroll-into-view-if-needed";
import { useTimelineUtils } from "./use-timeline-utils";
@ -88,7 +87,7 @@ function useDraggableElement({
const getClientYPosition = useCallback(
(e: MouseEvent | TouchEvent) => {
let clientY;
if (isMobile && e instanceof TouchEvent) {
if ("TouchEvent" in window && e instanceof TouchEvent) {
clientY = e.touches[0].clientY;
} else if (e instanceof MouseEvent) {
clientY = e.clientY;
@ -114,7 +113,7 @@ function useDraggableElement({
setIsDragging(true);
let clientY;
if (isMobile && e.nativeEvent instanceof TouchEvent) {
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
clientY = e.nativeEvent.touches[0].clientY;
} else if (e.nativeEvent instanceof MouseEvent) {
clientY = e.nativeEvent.clientY;

View File

@ -8,7 +8,8 @@ export function useOverlayState<S>(
): [S | undefined, (value: S, replace?: boolean) => void] {
const location = useLocation();
const navigate = useNavigate();
const currentLocationState = location.state;
const currentLocationState = useMemo(() => location.state, [location]);
const setOverlayStateValue = useCallback(
(value: S, replace: boolean = false) => {
@ -18,7 +19,7 @@ export function useOverlayState<S>(
},
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[key, navigate],
[key, currentLocationState, navigate],
);
const overlayStateValue = useMemo<S | undefined>(
@ -32,14 +33,16 @@ export function useOverlayState<S>(
export function usePersistedOverlayState<S extends string>(
key: string,
defaultValue: S | undefined = undefined,
): [S | undefined, (value: S | undefined, replace?: boolean) => void] {
const [persistedValue, setPersistedValue] = usePersistence<S>(
key,
defaultValue,
);
): [
S | undefined,
(value: S | undefined, replace?: boolean) => void,
() => void,
] {
const [persistedValue, setPersistedValue, , deletePersistedValue] =
usePersistence<S>(key, defaultValue);
const location = useLocation();
const navigate = useNavigate();
const currentLocationState = location.state;
const currentLocationState = useMemo(() => location.state, [location]);
const setOverlayStateValue = useCallback(
(value: S | undefined, replace: boolean = false) => {
@ -50,7 +53,7 @@ export function usePersistedOverlayState<S extends string>(
},
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[key, navigate],
[key, currentLocationState, navigate],
);
const overlayStateValue = useMemo<S | undefined>(
@ -61,6 +64,7 @@ export function usePersistedOverlayState<S extends string>(
return [
overlayStateValue ?? persistedValue ?? defaultValue,
setOverlayStateValue,
deletePersistedValue,
];
}

View File

@ -1,10 +1,11 @@
import { useEffect, useState, useCallback } from "react";
import { get as getData, set as setData } from "idb-keyval";
import { get as getData, set as setData, del as delData } from "idb-keyval";
type usePersistenceReturn<S> = [
value: S | undefined,
setValue: (value: S | undefined) => void,
loaded: boolean,
deleteValue: () => void,
];
export function usePersistence<S>(
@ -26,6 +27,11 @@ export function usePersistence<S>(
[key],
);
const deleteValue = useCallback(async () => {
await delData(key);
setInternalValue(defaultValue);
}, [key, defaultValue]);
useEffect(() => {
setLoaded(false);
setInternalValue(defaultValue);
@ -41,5 +47,5 @@ export function usePersistence<S>(
load();
}, [key, defaultValue, setValue]);
return [value, setValue, loaded];
return [value, setValue, loaded, deleteValue];
}

View File

@ -34,11 +34,13 @@ export default function useStats(stats: FrigateStats | undefined) {
problems.push({
text: `${capitalizeFirstLetter(key)} is very slow (${det["inference_speed"]} ms)`,
color: "text-danger",
relevantLink: "/system#general",
});
} else if (det["inference_speed"] > InferenceThreshold.warning) {
problems.push({
text: `${capitalizeFirstLetter(key)} is slow (${det["inference_speed"]} ms)`,
color: "text-orange-400",
relevantLink: "/system#general",
});
}
});
@ -53,6 +55,7 @@ export default function useStats(stats: FrigateStats | undefined) {
problems.push({
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} is offline`,
color: "text-danger",
relevantLink: "logs",
});
}
});
@ -70,6 +73,7 @@ export default function useStats(stats: FrigateStats | undefined) {
problems.push({
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high FFMPEG CPU usage (${ffmpegAvg}%)`,
color: "text-danger",
relevantLink: "/system#cameras",
});
}
@ -77,6 +81,7 @@ export default function useStats(stats: FrigateStats | undefined) {
problems.push({
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high detect CPU usage (${detectAvg}%)`,
color: "text-danger",
relevantLink: "/system#cameras",
});
}
});

View File

@ -158,7 +158,7 @@ function ConfigEditor() {
)}
<div ref={configRef} className="h-full mt-2" />
<Toaster />
<Toaster closeButton={true} />
</div>
);
}

View File

@ -1,9 +1,9 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import useApiFilter from "@/hooks/use-api-filter";
import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { useTimezone } from "@/hooks/use-date-utils";
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
import { FrigateConfig } from "@/types/frigateConfig";
import { Preview } from "@/types/preview";
import { RecordingStartingPoint } from "@/types/record";
import {
ReviewFilter,
@ -161,7 +161,6 @@ export default function Events() {
}, [updateSummary]);
// preview videos
const [previewKey, setPreviewKey] = useState(0);
const previewTimes = useMemo(() => {
if (!reviews || reviews.length == 0) {
return undefined;
@ -170,50 +169,22 @@ export default function Events() {
const startDate = new Date();
startDate.setMinutes(0, 0, 0);
let endDate;
if (previewKey == 0) {
endDate = new Date(reviews.at(-1)?.end_time || 0);
endDate.setHours(0, 0, 0, 0);
} else {
endDate = new Date();
endDate.setMilliseconds(0);
}
const endDate = new Date(reviews.at(-1)?.end_time || 0);
endDate.setHours(0, 0, 0, 0);
return {
start: startDate.getTime() / 1000,
end: endDate.getTime() / 1000,
after: startDate.getTime() / 1000,
before: endDate.getTime() / 1000,
};
}, [reviews, previewKey]);
const { data: allPreviews } = useSWR<Preview[]>(
previewTimes
? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}`
: null,
{ revalidateOnFocus: false, revalidateOnReconnect: false },
}, [reviews]);
const allPreviews = useCameraPreviews(
previewTimes ?? { after: 0, before: 0 },
{
fetchPreviews: previewTimes != undefined,
},
);
// Set a timeout to update previews on the hour
useEffect(() => {
if (!allPreviews || allPreviews.length == 0) {
return;
}
const callback = () => {
const nextPreviewStart = new Date(
allPreviews[allPreviews.length - 1].end * 1000,
);
nextPreviewStart.setHours(nextPreviewStart.getHours() + 1);
if (Date.now() > nextPreviewStart.getTime()) {
setPreviewKey(10 * Math.random());
}
};
document.addEventListener("focusin", callback);
return () => {
document.removeEventListener("focusin", callback);
};
}, [allPreviews]);
// review status
const markAllItemsAsReviewed = useCallback(

View File

@ -38,7 +38,13 @@ function Live() {
// settings
const includesBirdseye = useMemo(() => {
if (config && cameraGroup && cameraGroup != "default") {
if (
config &&
Object.keys(config.camera_groups).length &&
cameraGroup &&
config.camera_groups[cameraGroup] &&
cameraGroup != "default"
) {
return config.camera_groups[cameraGroup].cameras.includes("birdseye");
} else {
return false;
@ -50,7 +56,12 @@ function Live() {
return [];
}
if (cameraGroup && cameraGroup != "default") {
if (
Object.keys(config.camera_groups).length &&
cameraGroup &&
config.camera_groups[cameraGroup] &&
cameraGroup != "default"
) {
const group = config.camera_groups[cameraGroup];
return Object.values(config.cameras)
.filter((conf) => conf.enabled && group.cameras.includes(conf.name))
@ -78,6 +89,7 @@ function Live() {
return (
<LiveDashboardView
cameras={cameras}
cameraGroup={cameraGroup}
includeBirdseye={includesBirdseye}
onSelectCamera={setSelectedCameraName}
/>

View File

@ -12,6 +12,7 @@ import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { isDesktop } from "react-device-detect";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { cn } from "@/lib/utils";
const logTypes = ["frigate", "go2rtc", "nginx"] as const;
type LogType = (typeof logTypes)[number];
@ -332,7 +333,7 @@ function Logs() {
return (
<div className="size-full p-2 flex flex-col">
<Toaster position="top-center" />
<Toaster position="top-center" closeButton={true} />
<LogInfoDialog logLine={selectedLog} setLogLine={setSelectedLog} />
<div className="flex justify-between items-center">
@ -472,7 +473,11 @@ function LogLineData({
return (
<div
ref={startRef}
className={`w-full py-2 grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 gap-2 border-secondary border-t cursor-pointer hover:bg-muted ${className} *:text-sm`}
className={cn(
"w-full py-2 grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 gap-2 border-secondary border-t cursor-pointer hover:bg-muted",
className,
"*:text-sm",
)}
onClick={onSelect}
>
<div className="h-full p-1 flex items-center gap-2">

View File

@ -37,9 +37,9 @@ import scrollIntoView from "scroll-into-view-if-needed";
export default function Settings() {
const settingsViews = [
"general",
"objects",
"masks / zones",
"motion tuner",
"debug",
] as const;
type SettingsType = (typeof settingsViews)[number];
@ -100,6 +100,10 @@ export default function Settings() {
}
}, [tabsRef, pageToggle]);
useEffect(() => {
document.title = "Settings - Frigate";
}, []);
return (
<div className="size-full p-2 flex flex-col">
<div className="w-full h-11 relative flex justify-between items-center">
@ -131,7 +135,7 @@ export default function Settings() {
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
{(page == "objects" ||
{(page == "debug" ||
page == "masks / zones" ||
page == "motion tuner") && (
<div className="flex items-center gap-2 ml-2 flex-shrink-0">
@ -151,9 +155,7 @@ export default function Settings() {
</div>
<div className="mt-2 flex flex-col items-start w-full h-full md:h-dvh md:pb-24">
{page == "general" && <General />}
{page == "objects" && (
<ObjectSettings selectedCamera={selectedCamera} />
)}
{page == "debug" && <ObjectSettings selectedCamera={selectedCamera} />}
{page == "masks / zones" && (
<MasksAndZones
selectedCamera={selectedCamera}
@ -235,7 +237,7 @@ function CameraSelectButton({
<DropdownMenuSeparator />
</>
)}
<div className="h-auto p-4 mb-5 md:mb-1 overflow-y-auto overflow-x-hidden">
<div className="h-auto max-h-[80dvh] p-4 mb-5 md:mb-1 overflow-y-auto overflow-x-hidden">
<div className="flex flex-col gap-2.5">
{allCameras.map((item) => (
<FilterSwitch

View File

@ -3,6 +3,8 @@ import {
CamerasFilterButton,
GeneralFilterContent,
} from "@/components/filter/ReviewFilterGroup";
import Chip from "@/components/indicators/Chip";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -23,10 +25,17 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { DualThumbSlider } from "@/components/ui/slider";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Event } from "@/types/event";
import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
import { getIconForLabel } from "@/utils/iconUtil";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isMobile } from "react-device-detect";
import {
FaList,
@ -36,6 +45,9 @@ import {
} from "react-icons/fa";
import { PiSlidersHorizontalFill } from "react-icons/pi";
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
const API_LIMIT = 100;
export default function SubmitPlus() {
const { data: config } = useSWR<FrigateConfig>("config");
@ -56,21 +68,93 @@ export default function SubmitPlus() {
// data
const { data: events, mutate: refresh } = useSWR<Event[]>([
"events",
{
limit: 100,
in_progress: 0,
is_submitted: 0,
cameras: selectedCameras ? selectedCameras.join(",") : null,
labels: selectedLabels ? selectedLabels.join(",") : null,
min_score: scoreRange ? scoreRange[0] : null,
max_score: scoreRange ? scoreRange[1] : null,
sort: sort ? sort : null,
const eventFetcher = useCallback((key: string) => {
const [path, params] = Array.isArray(key) ? key : [key, undefined];
return axios.get(path, { params }).then((res) => res.data);
}, []);
const getKey = useCallback(
(index: number, prevData: Event[]) => {
if (index > 0) {
const lastDate = prevData[prevData.length - 1].start_time;
return [
"events",
{
limit: API_LIMIT,
in_progress: 0,
is_submitted: 0,
cameras: selectedCameras ? selectedCameras.join(",") : null,
labels: selectedLabels ? selectedLabels.join(",") : null,
min_score: scoreRange ? scoreRange[0] : null,
max_score: scoreRange ? scoreRange[1] : null,
sort: sort ? sort : null,
before: lastDate,
},
];
}
return [
"events",
{
limit: 100,
in_progress: 0,
is_submitted: 0,
cameras: selectedCameras ? selectedCameras.join(",") : null,
labels: selectedLabels ? selectedLabels.join(",") : null,
min_score: scoreRange ? scoreRange[0] : null,
max_score: scoreRange ? scoreRange[1] : null,
sort: sort ? sort : null,
},
];
},
]);
[scoreRange, selectedCameras, selectedLabels, sort],
);
const {
data: eventPages,
mutate: refresh,
size,
setSize,
isValidating,
} = useSWRInfinite<Event[]>(getKey, eventFetcher, {
revalidateOnFocus: false,
});
const events = useMemo(
() => (eventPages ? eventPages.flat() : []),
[eventPages],
);
const [upload, setUpload] = useState<Event>();
// paging
const isDone = useMemo(
() => (eventPages?.at(-1)?.length ?? 0) < API_LIMIT,
[eventPages],
);
const pagingObserver = useRef<IntersectionObserver | null>();
const lastEventRef = useCallback(
(node: HTMLElement | null) => {
if (isValidating) return;
if (pagingObserver.current) pagingObserver.current.disconnect();
try {
pagingObserver.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isDone) {
setSize(size + 1);
}
});
if (node) pagingObserver.current.observe(node);
} catch (e) {
// no op
}
},
[isValidating, isDone, size, setSize],
);
// layout
const grow = useMemo(() => {
if (!config || !upload) {
return "";
@ -102,18 +186,35 @@ export default function SubmitPlus() {
});
refresh(
(data: Event[] | undefined) => {
(data: Event[][] | undefined) => {
if (!data) {
return data;
}
const index = data.findIndex((e) => e.id == upload.id);
let pageIndex = -1;
let index = -1;
data.forEach((page, pIdx) => {
const search = page.findIndex((e) => e.id == upload.id);
if (search != -1) {
pageIndex = pIdx;
index = search;
}
});
if (index == -1) {
return data;
}
return [...data.slice(0, index), ...data.slice(index + 1)];
return [
...data.slice(0, pageIndex),
[
...data[pageIndex].slice(0, index),
...data[pageIndex].slice(index + 1),
],
...data.slice(pageIndex + 1),
];
},
{ revalidate: false, populateCache: true },
);
@ -141,7 +242,7 @@ export default function SubmitPlus() {
open={upload != undefined}
onOpenChange={(open) => (!open ? setUpload(undefined) : null)}
>
<DialogContent className="md:max-w-4xl">
<DialogContent className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl">
<DialogHeader>
<DialogTitle>Submit To Frigate+</DialogTitle>
<DialogDescription>
@ -174,17 +275,47 @@ export default function SubmitPlus() {
</DialogContent>
</Dialog>
{events?.map((event) => {
{events?.map((event, eIdx) => {
if (event.data.type != "object") {
return;
}
const lastRow = eIdx == events.length - 1;
return (
<div
key={event.id}
className="w-full rounded-lg md:rounded-2xl aspect-video flex justify-center items-center bg-black cursor-pointer"
ref={lastRow ? lastEventRef : null}
className="w-full relative rounded-lg md:rounded-2xl aspect-video flex justify-center items-center bg-black cursor-pointer"
onClick={() => setUpload(event)}
>
<div className="absolute left-0 top-2 z-40">
<Tooltip>
<div className="flex">
<TooltipTrigger asChild>
<div className="mx-3 pb-1 text-white text-sm">
<Chip
className={`flex items-start justify-between space-x-1 bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 z-0`}
>
{[event.label].map((object) => {
return getIconForLabel(
object,
"size-3 text-white",
);
})}
</Chip>
</div>
</TooltipTrigger>
</div>
<TooltipContent className="capitalize">
{[event.label]
.map((text) => capitalizeFirstLetter(text))
.sort()
.join(", ")
.replaceAll("-verified", "")}
</TooltipContent>
</Tooltip>
</div>
<img
className="aspect-video h-full object-contain rounded-lg md:rounded-2xl"
src={`${baseUrl}api/events/${event.id}/snapshot.jpg`}
@ -193,6 +324,8 @@ export default function SubmitPlus() {
</div>
);
})}
{isValidating && <ActivityIndicator />}
</div>
</div>
</div>

View File

@ -11,6 +11,8 @@ import { FaVideo } from "react-icons/fa";
import Logo from "@/components/Logo";
import useOptimisticState from "@/hooks/use-optimistic-state";
import CameraMetrics from "@/views/system/CameraMetrics";
import { useHashState } from "@/hooks/use-overlay-state";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
const metrics = ["general", "storage", "cameras"] as const;
type SystemMetric = (typeof metrics)[number];
@ -18,12 +20,18 @@ type SystemMetric = (typeof metrics)[number];
function System() {
// stats page
const [page, setPage] = useState<SystemMetric>("general");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const [page, setPage] = useHashState<SystemMetric>();
const [pageToggle, setPageToggle] = useOptimisticState(
page ?? "general",
setPage,
100,
);
const [lastUpdated, setLastUpdated] = useState<number>(Date.now() / 1000);
useEffect(() => {
document.title = `${pageToggle[0].toUpperCase()}${pageToggle.substring(1)} Stats - Frigate`;
if (pageToggle) {
document.title = `${capitalizeFirstLetter(pageToggle)} Stats - Frigate`;
}
}, [pageToggle]);
// stats collection

View File

@ -28,6 +28,7 @@ import { Label } from "@/components/ui/label";
import { useNavigate } from "react-router-dom";
import SummaryTimeline from "@/components/timeline/SummaryTimeline";
import { isMobile } from "react-device-detect";
import IconPicker, { IconElement } from "@/components/icons/IconPicker";
// Color data
const colors = [
@ -207,6 +208,8 @@ function UIPlayground() {
const [isEventsReviewTimeline, setIsEventsReviewTimeline] = useState(true);
const birdseyeConfig = config?.birdseye;
const [selectedIcon, setSelectedIcon] = useState<IconElement>();
return (
<>
<div className="w-full h-full">
@ -214,6 +217,15 @@ function UIPlayground() {
<div className="flex-1 content-start gap-2 overflow-y-auto no-scrollbar mt-4 mr-5">
<Heading as="h2">UI Playground</Heading>
<IconPicker
selectedIcon={selectedIcon}
setSelectedIcon={setSelectedIcon}
/>
{selectedIcon?.name && (
<p>Selected icon name: {selectedIcon.name}</p>
)}
<Heading as="h4" className="my-5">
Scrubber
</Heading>

View File

@ -1,3 +1,4 @@
import { IconName } from "@/components/icons/IconPicker";
import { LivePlayerMode } from "./live";
export interface UiConfig {
@ -222,11 +223,9 @@ export interface CameraConfig {
};
}
export const GROUP_ICONS = ["car", "cat", "dog", "leaf"] as const;
export type CameraGroupConfig = {
cameras: string[];
icon: (typeof GROUP_ICONS)[number];
icon: IconName;
order: number;
};
@ -324,6 +323,7 @@ export interface FrigateConfig {
model_type: string;
path: string | null;
width: number;
colormap: { [key: string]: [number, number, number] };
};
motion: Record<string, unknown> | null;

View File

@ -1 +1,5 @@
export type LivePlayerMode = "webrtc" | "mse" | "jsmpeg" | "debug";
export type VideoResolutionType = {
width: number;
height: number;
};

View File

@ -1,3 +1,5 @@
import { REVIEW_PADDING } from "./review";
export type Preview = {
camera: string;
src: string;
@ -5,3 +7,6 @@ export type Preview = {
start: number;
end: number;
};
export const PREVIEW_FPS = 8;
export const PREVIEW_PADDING = REVIEW_PADDING * PREVIEW_FPS;

View File

@ -38,3 +38,6 @@ export type RecordingStartingPoint = {
startTime: number;
severity: ReviewSeverity;
};
export const ASPECT_VERTICAL_LAYOUT = 1.5;
export const ASPECT_WIDE_LAYOUT = 2;

View File

@ -26,6 +26,7 @@ export type ReviewFilter = {
before?: number;
after?: number;
showReviewed?: 0 | 1;
showAll?: boolean;
};
type ReviewSummaryDay = {
@ -48,3 +49,5 @@ export type MotionData = {
audio?: number;
camera: string;
};
export const REVIEW_PADDING = 2;

View File

@ -62,6 +62,7 @@ export type StorageStats = {
export type PotentialProblem = {
text: string;
color: string;
relevantLink?: string;
};
export type Vainfo = {

View File

@ -41,4 +41,19 @@ export interface FrigateEvent {
after: FrigateObjectState;
}
export type ObjectType = {
id: string;
label: string;
stationary: boolean;
area: number;
ratio: number;
score: number;
sub_label: string;
};
export interface FrigateCameraState {
motion: boolean;
objects: ObjectType[];
}
export type ToggleableSetting = "ON" | "OFF";

View File

@ -1,37 +1,24 @@
import { IconName } from "@/components/icons/IconPicker";
import { BsPersonWalking } from "react-icons/bs";
import {
FaAmazon,
FaBicycle,
FaBus,
FaCarSide,
FaCat,
FaCheckCircle,
FaCircle,
FaDog,
FaFedex,
FaFire,
FaLeaf,
FaUps,
} from "react-icons/fa";
import { GiHummingbird } from "react-icons/gi";
import { LuBox, LuLassoSelect } from "react-icons/lu";
import * as LuIcons from "react-icons/lu";
import { MdRecordVoiceOver } from "react-icons/md";
export function getIconTypeForGroup(icon: string) {
switch (icon) {
case "car":
return FaCarSide;
case "cat":
return FaCat;
case "dog":
return FaDog;
case "leaf":
return FaLeaf;
default:
return FaCircle;
}
}
export function getIconForGroup(icon: string, className: string = "size-4") {
const GroupIcon = getIconTypeForGroup(icon);
return <GroupIcon className={className} />;
export function isValidIconName(value: string): value is IconName {
return Object.keys(LuIcons).includes(value as IconName);
}
export function getIconForLabel(label: string, className?: string) {
@ -40,10 +27,18 @@ export function getIconForLabel(label: string, className?: string) {
}
switch (label) {
case "bicycle":
return <FaBicycle key={label} className={className} />;
case "bird":
return <GiHummingbird key={label} className={className} />;
case "bus":
return <FaBus key={label} className={className} />;
case "car":
case "vehicle":
return <FaCarSide key={label} className={className} />;
case "cat":
return <FaCat key={label} className={className} />;
case "animal":
case "bark":
case "dog":
return <FaDog key={label} className={className} />;

View File

@ -12,6 +12,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { Preview } from "@/types/preview";
import {
MotionData,
REVIEW_PADDING,
ReviewFilter,
ReviewSegment,
ReviewSeverity,
@ -44,6 +45,8 @@ import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity";
import useOptimisticState from "@/hooks/use-optimistic-state";
import { Skeleton } from "@/components/ui/skeleton";
import scrollIntoView from "scroll-into-view-if-needed";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
type EventViewProps = {
reviews?: ReviewSegment[];
@ -175,7 +178,7 @@ export default function EventView({
} else {
onOpenRecording({
camera: review.camera,
startTime: review.start_time,
startTime: review.start_time - REVIEW_PADDING,
severity: review.severity,
});
@ -193,10 +196,31 @@ export default function EventView({
return;
}
axios.post(
`export/${review.camera}/start/${review.start_time}/end/${review.end_time}`,
{ playback: "realtime" },
);
axios
.post(
`export/${review.camera}/start/${review.start_time}/end/${review.end_time}`,
{ playback: "realtime" },
)
.then((response) => {
if (response.status == 200) {
toast.success(
"Successfully started export. View the file in the /exports folder.",
{ position: "top-center" },
);
}
})
.catch((error) => {
if (error.response?.data?.message) {
toast.error(
`Failed to start export: ${error.response.data.message}`,
{ position: "top-center" },
);
} else {
toast.error(`Failed to start export: ${error.message}`, {
position: "top-center",
});
}
});
},
[reviewItems],
);
@ -214,6 +238,7 @@ export default function EventView({
return (
<div className="py-2 flex flex-col size-full">
<Toaster closeButton={true} />
<div className="h-11 mb-2 pl-3 pr-2 relative flex justify-between items-center">
{isMobile && (
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
@ -269,6 +294,7 @@ export default function EventView({
? ["cameras", "date", "motionOnly"]
: ["cameras", "reviewed", "date", "general"]
}
currentSeverity={severityToggle}
reviewSummary={reviewSummary}
filter={filter}
onUpdateFilter={updateFilter}
@ -369,7 +395,13 @@ function DetectionReview({
return null;
}
const current = reviewItems[severity];
let current;
if (filter?.showAll) {
current = reviewItems.all;
} else {
current = reviewItems[severity];
}
if (!current || current.length == 0) {
return [];
@ -512,7 +544,7 @@ function DetectionReview({
}
const element = contentRef.current?.querySelector(
`[data-start="${startTime}"]`,
`[data-start="${startTime + REVIEW_PADDING}"]`,
);
if (element) {
scrollIntoView(element, {
@ -797,6 +829,11 @@ function MotionReview({
return;
}
if (nextTimestamp >= timeRange.before - 4) {
setPlaying(false);
return;
}
const handleTimeout = () => {
setCurrentTime(nextTimestamp);
timeoutIdRef.current = setTimeout(handleTimeout, 500 / playbackRate);
@ -810,7 +847,7 @@ function MotionReview({
}
};
}
}, [playing, playbackRate, nextTimestamp]);
}, [playing, playbackRate, nextTimestamp, setPlaying, timeRange]);
const { alignStartDateToTimeline } = useTimelineUtils({
segmentDuration,
@ -954,37 +991,34 @@ function MotionReview({
)}
</div>
{!scrubbing && (
<VideoControls
className="absolute bottom-16 left-1/2 -translate-x-1/2 bg-secondary"
features={{
volume: false,
seek: true,
playbackRate: true,
}}
isPlaying={playing}
playbackRates={[4, 8, 12, 16]}
playbackRate={playbackRate}
controlsOpen={controlsOpen}
setControlsOpen={setControlsOpen}
onPlayPause={setPlaying}
onSeek={(diff) => {
const wasPlaying = playing;
<VideoControls
className="absolute bottom-16 left-1/2 -translate-x-1/2 bg-secondary"
features={{
volume: false,
seek: true,
playbackRate: true,
}}
isPlaying={playing}
show={!scrubbing || controlsOpen}
playbackRates={[4, 8, 12, 16]}
playbackRate={playbackRate}
setControlsOpen={setControlsOpen}
onPlayPause={setPlaying}
onSeek={(diff) => {
const wasPlaying = playing;
if (wasPlaying) {
setPlaying(false);
}
if (wasPlaying) {
setPlaying(false);
}
setCurrentTime(currentTime + diff);
setCurrentTime(currentTime + diff);
if (wasPlaying) {
setTimeout(() => setPlaying(true), 100);
}
}}
onSetPlaybackRate={setPlaybackRate}
show={currentTime < timeRange.before - 4}
/>
)}
if (wasPlaying) {
setTimeout(() => setPlaying(true), 100);
}
}}
onSetPlaybackRate={setPlaybackRate}
/>
</>
);
}

View File

@ -15,6 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { Preview } from "@/types/preview";
import {
MotionData,
REVIEW_PADDING,
ReviewFilter,
ReviewSegment,
ReviewSummary,
@ -40,6 +41,8 @@ import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSetting
import Logo from "@/components/Logo";
import { Skeleton } from "@/components/ui/skeleton";
import { FaVideo } from "react-icons/fa";
import { VideoResolutionType } from "@/types/live";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
const SEGMENT_DURATION = 30;
@ -188,9 +191,18 @@ export function RecordingView({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTime, scrubbing]);
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
width: 0,
height: 0,
});
const onSelectCamera = useCallback(
(newCam: string) => {
setMainCamera(newCam);
setFullResolution({
width: 0,
height: 0,
});
setPlaybackStart(currentTime);
},
[currentTime],
@ -204,6 +216,10 @@ export function RecordingView({
return undefined;
}
if (cam == mainCamera && fullResolution.width && fullResolution.height) {
return fullResolution.width / fullResolution.height;
}
const camera = config.cameras[cam];
if (!camera) {
@ -212,7 +228,7 @@ export function RecordingView({
return camera.detect.width / camera.detect.height;
},
[config],
[config, fullResolution, mainCamera],
);
const mainCameraAspect = useMemo(() => {
@ -220,9 +236,9 @@ export function RecordingView({
if (!aspectRatio) {
return "normal";
} else if (aspectRatio > 2) {
} else if (aspectRatio > ASPECT_WIDE_LAYOUT) {
return "wide";
} else if (aspectRatio < 16 / 9) {
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
return "tall";
} else {
return "normal";
@ -245,7 +261,7 @@ export function RecordingView({
return (
<div ref={contentRef} className="size-full pt-2 flex flex-col">
<Toaster />
<Toaster closeButton={true} />
<div
className={`w-full h-11 mb-2 px-2 relative flex items-center justify-between`}
>
@ -396,6 +412,7 @@ export function RecordingView({
mainControllerRef.current = controller;
}}
isScrubbing={scrubbing || exportMode == "timeline"}
setFullResolution={setFullResolution}
/>
</div>
{isDesktop && (
@ -558,7 +575,7 @@ function Timeline({
currentTime={currentTime}
onClick={() => {
setScrubbing(true);
setCurrentTime(review.start_time);
setCurrentTime(review.start_time - REVIEW_PADDING);
setScrubbing(false);
}}
/>

View File

@ -0,0 +1,528 @@
import { usePersistence } from "@/hooks/use-persistence";
import {
BirdseyeConfig,
CameraConfig,
FrigateConfig,
} from "@/types/frigateConfig";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { Layout, Responsive, WidthProvider } from "react-grid-layout";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
import { LivePlayerMode } from "@/types/live";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import { Skeleton } from "@/components/ui/skeleton";
import { useResizeObserver } from "@/hooks/resize-observer";
import { isEqual } from "lodash";
import useSWR from "swr";
import { isDesktop, isMobile, isSafari } from "react-device-detect";
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
import LivePlayer from "@/components/player/LivePlayer";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { IoClose } from "react-icons/io5";
import { LuMove } from "react-icons/lu";
import { cn } from "@/lib/utils";
type DraggableGridLayoutProps = {
cameras: CameraConfig[];
cameraGroup: string;
cameraRef: (node: HTMLElement | null) => void;
containerRef: React.RefObject<HTMLDivElement>;
includeBirdseye: boolean;
onSelectCamera: (camera: string) => void;
windowVisible: boolean;
visibleCameras: string[];
isEditMode: boolean;
setIsEditMode: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function DraggableGridLayout({
cameras,
cameraGroup,
containerRef,
cameraRef,
includeBirdseye,
onSelectCamera,
windowVisible,
visibleCameras,
isEditMode,
setIsEditMode,
}: DraggableGridLayoutProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence<
Layout[]
>(`${cameraGroup}-draggable-layout`);
const [currentCameras, setCurrentCameras] = useState<CameraConfig[]>();
const [currentIncludeBirdseye, setCurrentIncludeBirdseye] =
useState<boolean>();
const [currentGridLayout, setCurrentGridLayout] = useState<
Layout[] | undefined
>();
const handleLayoutChange = useCallback(
(currentLayout: Layout[]) => {
if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) {
return;
}
// save layout to idb
setGridLayout(currentLayout);
},
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
);
const generateLayout = useCallback(() => {
if (!isGridLayoutLoaded) {
return;
}
const cameraNames =
includeBirdseye && birdseyeConfig?.enabled
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
: cameras.map((camera) => camera?.name || "");
const optionsMap: Layout[] = currentGridLayout
? currentGridLayout.filter((layout) => cameraNames?.includes(layout.i))
: [];
cameraNames.forEach((cameraName, index) => {
const existingLayout = optionsMap.find(
(layout) => layout.i === cameraName,
);
// Skip if the camera already exists in the layout
if (existingLayout) {
return;
}
let aspectRatio;
let col;
// Handle "birdseye" camera as a special case
if (cameraName === "birdseye") {
aspectRatio =
(birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1);
col = 0; // Set birdseye camera in the first column
} else {
const camera = cameras.find((cam) => cam.name === cameraName);
aspectRatio =
(camera && camera?.detect.width / camera?.detect.height) || 16 / 9;
col = index % 3; // Regular cameras distributed across columns
}
// Calculate layout options based on aspect ratio
const columnsPerPlayer = 4;
let height;
let width;
if (aspectRatio < 1) {
// Portrait
height = 2 * columnsPerPlayer;
width = columnsPerPlayer;
} else if (aspectRatio > 2) {
// Wide
height = 1 * columnsPerPlayer;
width = 2 * columnsPerPlayer;
} else {
// Landscape
height = 1 * columnsPerPlayer;
width = columnsPerPlayer;
}
const options = {
i: cameraName,
x: col * width,
y: 0, // don't set y, grid does automatically
w: width,
h: height,
isDraggable: isEditMode,
isResizable: isEditMode,
};
optionsMap.push(options);
});
return optionsMap;
}, [
cameras,
isEditMode,
isGridLayoutLoaded,
currentGridLayout,
includeBirdseye,
birdseyeConfig,
]);
useEffect(() => {
if (currentGridLayout) {
const updatedGridLayout = currentGridLayout.map((layout) => ({
...layout,
isDraggable: isEditMode,
isResizable: isEditMode,
}));
if (isEditMode) {
setGridLayout(updatedGridLayout);
setCurrentGridLayout(updatedGridLayout);
} else {
setGridLayout(updatedGridLayout);
}
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEditMode, setGridLayout]);
useEffect(() => {
if (isGridLayoutLoaded) {
if (gridLayout) {
// set current grid layout from loaded
setCurrentGridLayout(gridLayout);
} else {
// idb is empty, set it with an initial layout
setGridLayout(generateLayout());
}
}
}, [
isEditMode,
gridLayout,
currentGridLayout,
setGridLayout,
isGridLayoutLoaded,
generateLayout,
]);
useEffect(() => {
if (
!isEqual(cameras, currentCameras) ||
includeBirdseye !== currentIncludeBirdseye
) {
setCurrentCameras(cameras);
setCurrentIncludeBirdseye(includeBirdseye);
// set new grid layout in idb
setGridLayout(generateLayout());
}
}, [
cameras,
includeBirdseye,
currentCameras,
currentIncludeBirdseye,
setCurrentGridLayout,
generateLayout,
setGridLayout,
isGridLayoutLoaded,
]);
const [marginValue, setMarginValue] = useState(16);
// calculate margin value for browsers that don't have default font size of 16px
useLayoutEffect(() => {
const calculateRemValue = () => {
const htmlElement = document.documentElement;
const fontSize = window.getComputedStyle(htmlElement).fontSize;
setMarginValue(parseFloat(fontSize));
};
calculateRemValue();
}, []);
const gridContainerRef = useRef<HTMLDivElement>(null);
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(gridContainerRef);
const hasScrollbar = useMemo(() => {
return (
containerHeight &&
containerRef.current &&
containerRef.current.offsetHeight <
(gridContainerRef.current?.scrollHeight ?? 0)
);
}, [containerRef, gridContainerRef, containerHeight]);
const cellHeight = useMemo(() => {
const aspectRatio = 16 / 9;
// subtract container margin, 1 camera takes up at least 4 rows
// account for additional margin on bottom of each row
return (
((containerWidth ?? window.innerWidth) - 2 * marginValue) /
12 /
aspectRatio -
marginValue +
marginValue / 4
);
}, [containerWidth, marginValue]);
return (
<>
{!isGridLayoutLoaded || !currentGridLayout ? (
<div className="mt-2 px-2 grid grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4 gap-2 md:gap-4">
{includeBirdseye && birdseyeConfig?.enabled && (
<Skeleton className="size-full rounded-lg md:rounded-2xl" />
)}
{cameras.map((camera) => {
return (
<Skeleton
key={camera.name}
className="aspect-video size-full rounded-lg md:rounded-2xl"
/>
);
})}
</div>
) : (
<div
className="my-2 px-2 pb-8 no-scrollbar overflow-x-hidden"
ref={gridContainerRef}
>
<ResponsiveGridLayout
className="grid-layout"
layouts={{
lg: currentGridLayout,
md: currentGridLayout,
sm: currentGridLayout,
xs: currentGridLayout,
xxs: currentGridLayout,
}}
rowHeight={cellHeight}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }}
margin={[marginValue, marginValue]}
containerPadding={[0, isEditMode ? 6 : 3]}
resizeHandles={isEditMode ? ["sw", "nw", "se", "ne"] : []}
onDragStop={handleLayoutChange}
onResizeStop={handleLayoutChange}
>
{includeBirdseye && birdseyeConfig?.enabled && (
<BirdseyeLivePlayerGridItem
key="birdseye"
className={cn(
isEditMode &&
"outline outline-2 hover:outline-4 outline-muted-foreground hover:cursor-grab active:cursor-grabbing",
)}
birdseyeConfig={birdseyeConfig}
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
onClick={() => onSelectCamera("birdseye")}
>
{isEditMode && <CornerCircles />}
</BirdseyeLivePlayerGridItem>
)}
{cameras.map((camera) => {
let grow;
const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > ASPECT_WIDE_LAYOUT) {
grow = `aspect-wide w-full`;
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
grow = `aspect-tall h-full`;
} else {
grow = "aspect-video";
}
return (
<LivePlayerGridItem
key={camera.name}
cameraRef={cameraRef}
className={cn(
"rounded-lg md:rounded-2xl bg-black",
grow,
isEditMode &&
"outline-2 hover:outline-4 outline-muted-foreground hover:cursor-grab active:cursor-grabbing",
)}
windowVisible={
windowVisible && visibleCameras.includes(camera.name)
}
cameraConfig={camera}
preferredLiveMode={isSafari ? "webrtc" : "mse"}
onClick={() => {
!isEditMode && onSelectCamera(camera.name);
}}
>
{isEditMode && <CornerCircles />}
</LivePlayerGridItem>
);
})}
</ResponsiveGridLayout>
{isDesktop && (
<DesktopEditLayoutButton
isEditMode={isEditMode}
setIsEditMode={setIsEditMode}
hasScrollbar={hasScrollbar}
/>
)}
</div>
)}
</>
);
}
type DesktopEditLayoutButtonProps = {
isEditMode?: boolean;
setIsEditMode: React.Dispatch<React.SetStateAction<boolean>>;
hasScrollbar?: boolean | 0 | null;
};
function DesktopEditLayoutButton({
isEditMode,
setIsEditMode,
hasScrollbar,
}: DesktopEditLayoutButtonProps) {
return (
<div className="flex flex-row gap-2 items-center text-primary">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
className={cn(
"fixed",
isDesktop && "bottom-12 lg:bottom-9",
isMobile && "bottom-12 lg:bottom-16",
hasScrollbar && isDesktop ? "right-6" : "right-1",
"z-50 h-8 w-8 p-0 rounded-full opacity-30 hover:opacity-100 transition-all duration-300",
)}
onClick={() => setIsEditMode((prevIsEditMode) => !prevIsEditMode)}
>
{isEditMode ? (
<IoClose className="size-5" />
) : (
<LuMove className="size-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{isEditMode ? "Exit Editing" : "Edit Layout"}
</TooltipContent>
</Tooltip>
</div>
);
}
function CornerCircles() {
return (
<>
<div className="absolute top-[-4px] left-[-4px] z-50 size-3 p-2 rounded-full bg-primary-variant outline-2 outline-muted text-background pointer-events-none" />
<div className="absolute top-[-4px] right-[-4px] z-50 size-3 p-2 rounded-full bg-primary-variant outline-2 outline-muted text-background pointer-events-none" />
<div className="absolute bottom-[-4px] right-[-4px] z-50 size-3 p-2 rounded-full bg-primary-variant outline-2 outline-muted text-background pointer-events-none" />
<div className="absolute bottom-[-4px] left-[-4px] z-50 size-3 p-2 rounded-full bg-primary-variant outline-2 outline-muted text-background pointer-events-none" />
</>
);
}
type BirdseyeLivePlayerGridItemProps = {
style?: React.CSSProperties;
className?: string;
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
children?: React.ReactNode;
birdseyeConfig: BirdseyeConfig;
liveMode: LivePlayerMode;
onClick: () => void;
};
const BirdseyeLivePlayerGridItem = React.forwardRef<
HTMLDivElement,
BirdseyeLivePlayerGridItemProps
>(
(
{
style,
className,
onMouseDown,
onMouseUp,
onTouchEnd,
children,
birdseyeConfig,
liveMode,
onClick,
...props
},
ref,
) => {
return (
<div
style={{ ...style }}
ref={ref}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onTouchEnd={onTouchEnd}
{...props}
>
<BirdseyeLivePlayer
className={className}
birdseyeConfig={birdseyeConfig}
liveMode={liveMode}
onClick={onClick}
/>
{children}
</div>
);
},
);
type LivePlayerGridItemProps = {
style?: React.CSSProperties;
className: string;
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
children?: React.ReactNode;
cameraRef: (node: HTMLElement | null) => void;
windowVisible: boolean;
cameraConfig: CameraConfig;
preferredLiveMode: LivePlayerMode;
onClick: () => void;
};
const LivePlayerGridItem = React.forwardRef<
HTMLDivElement,
LivePlayerGridItemProps
>(
(
{
style,
className,
onMouseDown,
onMouseUp,
onTouchEnd,
children,
cameraRef,
windowVisible,
cameraConfig,
preferredLiveMode,
onClick,
...props
},
ref,
) => {
return (
<div
style={{ ...style }}
ref={ref}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onTouchEnd={onTouchEnd}
{...props}
>
<LivePlayer
cameraRef={cameraRef}
className={className}
windowVisible={windowVisible}
cameraConfig={cameraConfig}
preferredLiveMode={preferredLiveMode}
onClick={onClick}
/>
{children}
</div>
);
},
);

View File

@ -20,6 +20,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { useResizeObserver } from "@/hooks/resize-observer";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { CameraConfig } from "@/types/frigateConfig";
import { VideoResolutionType } from "@/types/live";
import { CameraPtzInfo } from "@/types/ptz";
import { RecordingStartingPoint } from "@/types/record";
import React, {
@ -97,7 +98,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
let clientX;
let clientY;
if (isMobile && e.nativeEvent instanceof TouchEvent) {
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
clientX = e.nativeEvent.touches[0].clientX;
clientY = e.nativeEvent.touches[0].clientY;
} else if (e.nativeEvent instanceof MouseEvent) {
@ -149,14 +150,24 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
const [fullscreen, setFullscreen] = useState(false);
const [pip, setPip] = useState(false);
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
width: 0,
height: 0,
});
const growClassName = useMemo(() => {
const aspect = camera.detect.width / camera.detect.height;
let aspect;
if (fullResolution.width && fullResolution.height) {
aspect = fullResolution.width / fullResolution.height;
} else {
aspect = camera.detect.width / camera.detect.height;
}
if (isMobile) {
if (isPortrait) {
return "absolute left-2 right-2 top-[50%] -translate-y-[50%]";
} else {
if (aspect > 16 / 9) {
if (aspect > 1.5) {
return "p-2 absolute left-0 top-[50%] -translate-y-[50%]";
} else {
return "p-2 absolute top-2 bottom-2 left-[50%] -translate-x-[50%]";
@ -165,7 +176,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
}
if (fullscreen) {
if (aspect > 16 / 9) {
if (aspect > 1.5) {
return "absolute inset-x-2 top-[50%] -translate-y-[50%]";
} else {
return "absolute inset-y-2 left-[50%] -translate-x-[50%]";
@ -173,7 +184,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
} else {
return "absolute top-2 bottom-2 left-[50%] -translate-x-[50%]";
}
}, [camera, fullscreen, isPortrait]);
}, [camera, fullscreen, isPortrait, fullResolution]);
const preferredLiveMode = useMemo(() => {
if (isSafari || mic) {
@ -188,8 +199,12 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
}, [windowWidth, windowHeight]);
const cameraAspectRatio = useMemo(() => {
return camera.detect.width / camera.detect.height;
}, [camera]);
if (fullResolution.width && fullResolution.height) {
return fullResolution.width / fullResolution.height;
} else {
return camera.detect.width / camera.detect.height;
}
}, [camera, fullResolution]);
const aspectRatio = useMemo<number>(() => {
if (isMobile || fullscreen) {
@ -347,6 +362,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
iOSCompatFullScreen={isIOS}
preferredLiveMode={preferredLiveMode}
pip={pip}
setFullResolution={setFullResolution}
/>
</div>
{camera.onvif.host != "" && (
@ -529,7 +545,7 @@ function PtzControlPanel({
<BsThreeDotsVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuContent className="max-h-[40dvh] overflow-y-auto">
{ptz?.presets.map((preset) => {
return (
<DropdownMenuItem

View File

@ -12,16 +12,27 @@ import { usePersistence } from "@/hooks/use-persistence";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isDesktop, isMobile, isSafari } from "react-device-detect";
import {
isDesktop,
isMobile,
isMobileOnly,
isSafari,
isTablet,
} from "react-device-detect";
import useSWR from "swr";
import DraggableGridLayout from "./DraggableGridLayout";
import { IoClose } from "react-icons/io5";
import { LuMove } from "react-icons/lu";
type LiveDashboardViewProps = {
cameras: CameraConfig[];
cameraGroup?: string;
includeBirdseye: boolean;
onSelectCamera: (camera: string) => void;
};
export default function LiveDashboardView({
cameras,
cameraGroup,
includeBirdseye,
onSelectCamera,
}: LiveDashboardViewProps) {
@ -29,11 +40,14 @@ export default function LiveDashboardView({
// layout
const [layout, setLayout] = usePersistence<"grid" | "list">(
const [mobileLayout, setMobileLayout] = usePersistence<"grid" | "list">(
"live-layout",
isDesktop ? "grid" : "list",
);
const [isEditMode, setIsEditMode] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
// recent events
const { payload: eventUpdate } = useFrigateReviews();
const { data: allEvents, mutate: updateEvents } = useSWR<ReviewSegment[]>([
@ -140,35 +154,52 @@ export default function LiveDashboardView({
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
return (
<div className="size-full p-2 overflow-y-auto">
<div className="size-full p-2 overflow-y-auto" ref={containerRef}>
{isMobile && (
<div className="h-11 relative flex items-center justify-between">
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
<CameraGroupSelector />
<div className="flex items-center gap-1">
<Button
className={`p-1 ${
layout == "grid"
? "bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
: "bg-secondary"
}`}
size="xs"
onClick={() => setLayout("grid")}
>
<LiveGridIcon layout={layout} />
</Button>
<Button
className={`p-1 ${
layout == "list"
? "bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
: "bg-secondary"
}`}
size="xs"
onClick={() => setLayout("list")}
>
<LiveListIcon layout={layout} />
</Button>
<div className="max-w-[45%]">
<CameraGroupSelector />
</div>
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
<div className="flex items-center gap-1">
<Button
className={`p-1 ${
mobileLayout == "grid"
? "bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
: "bg-secondary"
}`}
size="xs"
onClick={() => setMobileLayout("grid")}
>
<LiveGridIcon layout={mobileLayout} />
</Button>
<Button
className={`p-1 ${
mobileLayout == "list"
? "bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
: "bg-secondary"
}`}
size="xs"
onClick={() => setMobileLayout("list")}
>
<LiveListIcon layout={mobileLayout} />
</Button>
</div>
)}
{cameraGroup && cameraGroup !== "default" && isTablet && (
<div className="flex items-center gap-1">
<Button
className="p-1"
size="xs"
onClick={() =>
setIsEditMode((prevIsEditMode) => !prevIsEditMode)
}
>
{isEditMode ? <IoClose /> : <LuMove />}
</Button>
</div>
)}
</div>
)}
@ -185,41 +216,56 @@ export default function LiveDashboardView({
</ScrollArea>
)}
<div
className={`mt-2 px-2 grid ${layout == "grid" ? "grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" : ""} gap-2 md:gap-4`}
>
{includeBirdseye && birdseyeConfig?.enabled && (
<BirdseyeLivePlayer
birdseyeConfig={birdseyeConfig}
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
onClick={() => onSelectCamera("birdseye")}
/>
)}
{cameras.map((camera) => {
let grow;
const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > 2) {
grow = `${layout == "grid" ? "col-span-2" : ""} aspect-wide`;
} else if (aspectRatio < 1) {
grow = `${layout == "grid" ? "row-span-2 aspect-tall md:h-full" : ""} aspect-tall`;
} else {
grow = "aspect-video";
}
return (
<LivePlayer
cameraRef={cameraRef}
key={camera.name}
className={`${grow} rounded-lg md:rounded-2xl bg-black`}
windowVisible={
windowVisible && visibleCameras.includes(camera.name)
}
cameraConfig={camera}
preferredLiveMode={isSafari ? "webrtc" : "mse"}
onClick={() => onSelectCamera(camera.name)}
{!cameraGroup || cameraGroup == "default" || isMobileOnly ? (
<div
className={`mt-2 px-2 grid ${mobileLayout == "grid" ? "grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" : ""} gap-2 md:gap-4`}
>
{includeBirdseye && birdseyeConfig?.enabled && (
<BirdseyeLivePlayer
birdseyeConfig={birdseyeConfig}
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
onClick={() => onSelectCamera("birdseye")}
/>
);
})}
</div>
)}
{cameras.map((camera) => {
let grow;
const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > 2) {
grow = `${mobileLayout == "grid" ? "col-span-2" : ""} aspect-wide`;
} else if (aspectRatio < 1) {
grow = `${mobileLayout == "grid" ? "row-span-2 aspect-tall md:h-full" : ""} aspect-tall`;
} else {
grow = "aspect-video";
}
return (
<LivePlayer
cameraRef={cameraRef}
key={camera.name}
className={`${grow} rounded-lg md:rounded-2xl bg-black`}
windowVisible={
windowVisible && visibleCameras.includes(camera.name)
}
cameraConfig={camera}
preferredLiveMode={isSafari ? "webrtc" : "mse"}
onClick={() => onSelectCamera(camera.name)}
/>
);
})}
</div>
) : (
<DraggableGridLayout
cameras={cameras}
cameraGroup={cameraGroup}
containerRef={containerRef}
cameraRef={cameraRef}
includeBirdseye={includeBirdseye}
onSelectCamera={onSelectCamera}
windowVisible={windowVisible}
visibleCameras={visibleCameras}
isEditMode={isEditMode}
setIsEditMode={setIsEditMode}
/>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More