mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-11 13:45:25 +03:00
Merge branch 'blakeblackshear:dev' into dev
This commit is contained in:
commit
92e239603f
@ -51,7 +51,7 @@ ARG DEBIAN_FRONTEND
|
|||||||
# Install OpenVino Runtime and Dev library
|
# Install OpenVino Runtime and Dev library
|
||||||
COPY docker/main/requirements-ov.txt /requirements-ov.txt
|
COPY docker/main/requirements-ov.txt /requirements-ov.txt
|
||||||
RUN apt-get -qq update \
|
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 \
|
&& wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
||||||
&& python3 get-pip.py "pip" \
|
&& python3 get-pip.py "pip" \
|
||||||
&& pip install -r /requirements-ov.txt
|
&& pip install -r /requirements-ov.txt
|
||||||
|
|||||||
@ -94,3 +94,23 @@ This list of working and non-working PTZ cameras is based on user feedback.
|
|||||||
| Tapo C210 | ❌ | ❌ | Incomplete ONVIF support |
|
| 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 |
|
| 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 |
|
| 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
|
||||||
|
```
|
||||||
@ -653,4 +653,19 @@ telemetry:
|
|||||||
# Optional: Enable the latest version outbound check (default: shown below)
|
# 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
|
# NOTE: If you use the HomeAssistant integration, disabling this will prevent it from reporting new versions
|
||||||
version_check: True
|
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
|
||||||
```
|
```
|
||||||
|
|||||||
47
docs/docs/configuration/review.md
Normal file
47
docs/docs/configuration/review.md
Normal 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.
|
||||||
|
|
||||||
|
:::
|
||||||
@ -3,6 +3,8 @@ id: snapshots
|
|||||||
title: 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`
|
Snapshots sent via MQTT are configured in the [config file](https://docs.frigate.video/configuration/) under `cameras -> your_camera -> mqtt`
|
||||||
|
|||||||
@ -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.
|
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
|
```yaml
|
||||||
cameras:
|
cameras:
|
||||||
name_of_your_camera:
|
name_of_your_camera:
|
||||||
record:
|
review:
|
||||||
events:
|
alerts:
|
||||||
required_zones:
|
required_zones:
|
||||||
- entire_yard
|
- 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:
|
snapshots:
|
||||||
required_zones:
|
required_zones:
|
||||||
- entire_yard
|
- entire_yard
|
||||||
|
|||||||
@ -32,6 +32,7 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
Cameras: [
|
Cameras: [
|
||||||
"configuration/cameras",
|
"configuration/cameras",
|
||||||
|
"configuration/review",
|
||||||
"configuration/record",
|
"configuration/record",
|
||||||
"configuration/snapshots",
|
"configuration/snapshots",
|
||||||
"configuration/motion_detection",
|
"configuration/motion_detection",
|
||||||
|
|||||||
@ -164,6 +164,7 @@ def config():
|
|||||||
camera_dict["zones"][zone_name]["color"] = zone.color
|
camera_dict["zones"][zone_name]["color"] = zone.color
|
||||||
|
|
||||||
config["plus"] = {"enabled": current_app.plus_api.is_active()}
|
config["plus"] = {"enabled": current_app.plus_api.is_active()}
|
||||||
|
config["model"]["colormap"] = config_obj.model.colormap
|
||||||
|
|
||||||
for detector_config in config["detectors"].values():
|
for detector_config in config["detectors"].values():
|
||||||
detector_config["model"]["labelmap"] = (
|
detector_config["model"]["labelmap"] = (
|
||||||
|
|||||||
@ -26,6 +26,7 @@ from frigate.const import (
|
|||||||
)
|
)
|
||||||
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
||||||
from frigate.util.builtin import get_tz_modifiers
|
from frigate.util.builtin import get_tz_modifiers
|
||||||
|
from frigate.util.image import get_image_from_recording
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -205,30 +206,20 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str):
|
|||||||
try:
|
try:
|
||||||
recording: Recordings = recording_query.get()
|
recording: Recordings = recording_query.get()
|
||||||
time_in_segment = frame_time - recording.start_time
|
time_in_segment = frame_time - recording.start_time
|
||||||
|
image_data = get_image_from_recording(recording.path, time_in_segment)
|
||||||
|
|
||||||
ffmpeg_cmd = [
|
if not image_data:
|
||||||
"ffmpeg",
|
return make_response(
|
||||||
"-hide_banner",
|
jsonify(
|
||||||
"-loglevel",
|
{
|
||||||
"warning",
|
"success": False,
|
||||||
"-ss",
|
"message": f"Unable to parse frame at time {frame_time}",
|
||||||
f"00:00:{time_in_segment}",
|
}
|
||||||
"-i",
|
),
|
||||||
recording.path,
|
404,
|
||||||
"-frames:v",
|
)
|
||||||
"1",
|
|
||||||
"-c:v",
|
|
||||||
"png",
|
|
||||||
"-f",
|
|
||||||
"image2pipe",
|
|
||||||
"-",
|
|
||||||
]
|
|
||||||
|
|
||||||
process = sp.run(
|
response = make_response(image_data)
|
||||||
ffmpeg_cmd,
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
response = make_response(process.stdout)
|
|
||||||
response.headers["Content-Type"] = "image/png"
|
response.headers["Content-Type"] = "image/png"
|
||||||
return response
|
return response
|
||||||
except DoesNotExist:
|
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"])
|
@MediaBp.route("/recordings/storage", methods=["GET"])
|
||||||
def get_recordings_storage_usage():
|
def get_recordings_storage_usage():
|
||||||
recording_stats = current_app.stats_emitter.get_latest_stats()["service"][
|
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:
|
if clip.end_time > end_ts:
|
||||||
playlist_lines.append(f"outpoint {int(end_ts - clip.start_time)}")
|
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)
|
path = os.path.join(CACHE_DIR, file_name)
|
||||||
|
|
||||||
if not os.path.exists(path):
|
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/<int:start_ts>/end/<int:end_ts>/preview.mp4")
|
||||||
@MediaBp.route("/<camera_name>/start/<float:start_ts>/end/<float: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):
|
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)
|
path = os.path.join(CACHE_DIR, file_name)
|
||||||
|
|
||||||
if datetime.fromtimestamp(start_ts) < datetime.now().replace(minute=0, second=0):
|
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")
|
@MediaBp.route("/preview/<file_name>/thumbnail.webp")
|
||||||
def preview_thumbnail(file_name: str):
|
def preview_thumbnail(file_name: str):
|
||||||
"""Get a thumbnail from the cached preview frames."""
|
"""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)
|
safe_file_name_current = secure_filename(file_name)
|
||||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||||
|
|
||||||
|
|||||||
@ -440,6 +440,7 @@ def motion_activity():
|
|||||||
|
|
||||||
# resample data using pandas to get activity on scaled basis
|
# resample data using pandas to get activity on scaled basis
|
||||||
df = pd.DataFrame(data, columns=["start_time", "motion", "camera"])
|
df = pd.DataFrame(data, columns=["start_time", "motion", "camera"])
|
||||||
|
df = df.astype(dtype={"motion": "float16"})
|
||||||
|
|
||||||
# set date as datetime index
|
# set date as datetime index
|
||||||
df["start_time"] = pd.to_datetime(df["start_time"], unit="s")
|
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
|
# resample data using pandas to get activity on scaled basis
|
||||||
df = pd.DataFrame(data, columns=["start_time", "audio"])
|
df = pd.DataFrame(data, columns=["start_time", "audio"])
|
||||||
|
df = df.astype(dtype={"audio": "float16"})
|
||||||
|
|
||||||
# set date as datetime index
|
# set date as datetime index
|
||||||
df["start_time"] = pd.to_datetime(df["start_time"], unit="s")
|
df["start_time"] = pd.to_datetime(df["start_time"], unit="s")
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import psutil
|
|||||||
from peewee_migrate import Router
|
from peewee_migrate import Router
|
||||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||||
from playhouse.sqliteq import SqliteQueueDatabase
|
from playhouse.sqliteq import SqliteQueueDatabase
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from frigate.api.app import create_app
|
from frigate.api.app import create_app
|
||||||
from frigate.comms.config_updater import ConfigPublisher
|
from frigate.comms.config_updater import ConfigPublisher
|
||||||
@ -597,24 +598,6 @@ class FrigateApp:
|
|||||||
self.init_logger()
|
self.init_logger()
|
||||||
logger.info(f"Starting Frigate ({VERSION})")
|
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:
|
try:
|
||||||
self.ensure_dirs()
|
self.ensure_dirs()
|
||||||
try:
|
try:
|
||||||
@ -629,8 +612,13 @@ class FrigateApp:
|
|||||||
print("*************************************************************")
|
print("*************************************************************")
|
||||||
print("*** Config Validation Errors ***")
|
print("*** Config Validation Errors ***")
|
||||||
print("*************************************************************")
|
print("*************************************************************")
|
||||||
print(e)
|
if isinstance(e, ValidationError):
|
||||||
print(traceback.format_exc())
|
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("*************************************************************")
|
||||||
print("*** End Config Validation Errors ***")
|
print("*** End Config Validation Errors ***")
|
||||||
print("*************************************************************")
|
print("*************************************************************")
|
||||||
@ -695,9 +683,9 @@ class FrigateApp:
|
|||||||
self.stop_event.set()
|
self.stop_event.set()
|
||||||
|
|
||||||
# set an end_time on entries without an end_time before exiting
|
# set an end_time on entries without an end_time before exiting
|
||||||
Event.update(end_time=datetime.datetime.now().timestamp()).where(
|
Event.update(
|
||||||
Event.end_time == None
|
end_time=datetime.datetime.now().timestamp(), has_snapshot=False
|
||||||
).execute()
|
).where(Event.end_time == None).execute()
|
||||||
ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where(
|
ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where(
|
||||||
ReviewSegment.end_time == None
|
ReviewSegment.end_time == None
|
||||||
).execute()
|
).execute()
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""Handle communication between Frigate and other applications."""
|
"""Handle communication between Frigate and other applications."""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
@ -12,6 +13,7 @@ from frigate.const import (
|
|||||||
INSERT_MANY_RECORDINGS,
|
INSERT_MANY_RECORDINGS,
|
||||||
INSERT_PREVIEW,
|
INSERT_PREVIEW,
|
||||||
REQUEST_REGION_GRID,
|
REQUEST_REGION_GRID,
|
||||||
|
UPDATE_CAMERA_ACTIVITY,
|
||||||
UPSERT_REVIEW_SEGMENT,
|
UPSERT_REVIEW_SEGMENT,
|
||||||
)
|
)
|
||||||
from frigate.models import Previews, Recordings, ReviewSegment
|
from frigate.models import Previews, Recordings, ReviewSegment
|
||||||
@ -76,6 +78,8 @@ class Dispatcher:
|
|||||||
for comm in self.comms:
|
for comm in self.comms:
|
||||||
comm.subscribe(self._receive)
|
comm.subscribe(self._receive)
|
||||||
|
|
||||||
|
self.camera_activity = {}
|
||||||
|
|
||||||
def _receive(self, topic: str, payload: str) -> Optional[Any]:
|
def _receive(self, topic: str, payload: str) -> Optional[Any]:
|
||||||
"""Handle receiving of payload from communicators."""
|
"""Handle receiving of payload from communicators."""
|
||||||
if topic.endswith("set"):
|
if topic.endswith("set"):
|
||||||
@ -122,6 +126,10 @@ class Dispatcher:
|
|||||||
ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where(
|
ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where(
|
||||||
ReviewSegment.end_time == None
|
ReviewSegment.end_time == None
|
||||||
).execute()
|
).execute()
|
||||||
|
elif topic == UPDATE_CAMERA_ACTIVITY:
|
||||||
|
self.camera_activity = payload
|
||||||
|
elif topic == "onConnect":
|
||||||
|
self.publish("camera_activity", json.dumps(self.camera_activity))
|
||||||
else:
|
else:
|
||||||
self.publish(topic, payload, retain=False)
|
self.publish(topic, payload, retain=False)
|
||||||
|
|
||||||
|
|||||||
@ -518,7 +518,7 @@ class ZoneConfig(BaseModel):
|
|||||||
ge=0,
|
ge=0,
|
||||||
title="Number of seconds that an object must loiter to be considered in the zone.",
|
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,
|
default_factory=list,
|
||||||
title="List of objects that can trigger the zone.",
|
title="List of objects that can trigger the zone.",
|
||||||
)
|
)
|
||||||
@ -555,19 +555,24 @@ class ZoneConfig(BaseModel):
|
|||||||
# old native resolution coordinates
|
# old native resolution coordinates
|
||||||
if isinstance(coordinates, list):
|
if isinstance(coordinates, list):
|
||||||
explicit = any(p.split(",")[0] > "1.0" for p in coordinates)
|
explicit = any(p.split(",")[0] > "1.0" for p in coordinates)
|
||||||
self._contour = np.array(
|
try:
|
||||||
[
|
self._contour = np.array(
|
||||||
(
|
[
|
||||||
[int(p.split(",")[0]), int(p.split(",")[1])]
|
(
|
||||||
if explicit
|
[int(p.split(",")[0]), int(p.split(",")[1])]
|
||||||
else [
|
if explicit
|
||||||
int(float(p.split(",")[0]) * frame_shape[1]),
|
else [
|
||||||
int(float(p.split(",")[1]) * frame_shape[0]),
|
int(float(p.split(",")[0]) * frame_shape[1]),
|
||||||
]
|
int(float(p.split(",")[1]) * frame_shape[0]),
|
||||||
)
|
]
|
||||||
for p in coordinates
|
)
|
||||||
]
|
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:
|
if explicit:
|
||||||
self.coordinates = ",".join(
|
self.coordinates = ",".join(
|
||||||
@ -579,19 +584,24 @@ class ZoneConfig(BaseModel):
|
|||||||
elif isinstance(coordinates, str):
|
elif isinstance(coordinates, str):
|
||||||
points = coordinates.split(",")
|
points = coordinates.split(",")
|
||||||
explicit = any(p > "1.0" for p in points)
|
explicit = any(p > "1.0" for p in points)
|
||||||
self._contour = np.array(
|
try:
|
||||||
[
|
self._contour = np.array(
|
||||||
(
|
[
|
||||||
[int(points[i]), int(points[i + 1])]
|
(
|
||||||
if explicit
|
[int(points[i]), int(points[i + 1])]
|
||||||
else [
|
if explicit
|
||||||
int(float(points[i]) * frame_shape[1]),
|
else [
|
||||||
int(float(points[i + 1]) * frame_shape[0]),
|
int(float(points[i]) * frame_shape[1]),
|
||||||
]
|
int(float(points[i + 1]) * frame_shape[0]),
|
||||||
)
|
]
|
||||||
for i in range(0, len(points), 2)
|
)
|
||||||
]
|
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:
|
if explicit:
|
||||||
self.coordinates = ",".join(
|
self.coordinates = ",".join(
|
||||||
@ -616,7 +626,7 @@ class AlertsConfig(FrigateBaseModel):
|
|||||||
labels: List[str] = Field(
|
labels: List[str] = Field(
|
||||||
default=DEFAULT_ALERT_OBJECTS, title="Labels to create alerts for."
|
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,
|
default_factory=list,
|
||||||
title="List of required zones to be entered in order to save the event as an alert.",
|
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(
|
labels: Optional[List[str]] = Field(
|
||||||
default=None, title="Labels to create detections for."
|
default=None, title="Labels to create detections for."
|
||||||
)
|
)
|
||||||
required_zones: List[str] = Field(
|
required_zones: Union[str, List[str]] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
title="List of required zones to be entered in order to save the event as a detection.",
|
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():
|
for key, detector in config.detectors.items():
|
||||||
adapter = TypeAdapter(DetectorConfig)
|
adapter = TypeAdapter(DetectorConfig)
|
||||||
model_dict = (
|
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)
|
detector_config: DetectorConfig = adapter.validate_python(model_dict)
|
||||||
if detector_config.model is None:
|
if detector_config.model is None:
|
||||||
detector_config.model = config.model
|
detector_config.model = config.model.model_copy()
|
||||||
else:
|
else:
|
||||||
model = detector_config.model
|
path = detector_config.model.path
|
||||||
schema = ModelConfig.model_json_schema()["properties"]
|
detector_config.model = config.model.model_copy()
|
||||||
if (
|
detector_config.model.path = path
|
||||||
model.width != schema["width"]["default"]
|
|
||||||
or model.height != schema["height"]["default"]
|
if "path" not in model_dict or len(model_dict.keys()) > 1:
|
||||||
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"]
|
|
||||||
):
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Customizing more than a detector model path is unsupported."
|
"Customizing more than a detector model path is unsupported."
|
||||||
)
|
)
|
||||||
|
|
||||||
merged_model = deep_merge(
|
merged_model = deep_merge(
|
||||||
detector_config.model.model_dump(exclude_unset=True),
|
detector_config.model.model_dump(exclude_unset=True, warnings="none"),
|
||||||
config.model.model_dump(exclude_unset=True),
|
config.model.model_dump(exclude_unset=True, warnings="none"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if "path" not in merged_model:
|
if "path" not in merged_model:
|
||||||
|
|||||||
@ -80,6 +80,7 @@ INSERT_PREVIEW = "insert_preview"
|
|||||||
REQUEST_REGION_GRID = "request_region_grid"
|
REQUEST_REGION_GRID = "request_region_grid"
|
||||||
UPSERT_REVIEW_SEGMENT = "upsert_review_segment"
|
UPSERT_REVIEW_SEGMENT = "upsert_review_segment"
|
||||||
CLEAR_ONGOING_REVIEW_SEGMENTS = "clear_ongoing_review_segments"
|
CLEAR_ONGOING_REVIEW_SEGMENTS = "clear_ongoing_review_segments"
|
||||||
|
UPDATE_CAMERA_ACTIVITY = "update_camera_activity"
|
||||||
|
|
||||||
# Autotracking
|
# Autotracking
|
||||||
|
|
||||||
|
|||||||
@ -184,43 +184,6 @@ class EventCleanup(threading.Thread):
|
|||||||
Event.update(update_params).where(Event.id << events_to_update).execute()
|
Event.update(update_params).where(Event.id << events_to_update).execute()
|
||||||
return events_to_update
|
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:
|
def run(self) -> None:
|
||||||
# only expire events every 5 minutes
|
# only expire events every 5 minutes
|
||||||
while not self.stop_event.wait(300):
|
while not self.stop_event.wait(300):
|
||||||
@ -232,7 +195,6 @@ class EventCleanup(threading.Thread):
|
|||||||
).execute()
|
).execute()
|
||||||
|
|
||||||
self.expire(EventCleanupType.snapshots)
|
self.expire(EventCleanupType.snapshots)
|
||||||
self.purge_duplicates()
|
|
||||||
|
|
||||||
# drop events from db where has_clip and has_snapshot are false
|
# drop events from db where has_clip and has_snapshot are false
|
||||||
delete_query = Event.delete().where(
|
delete_query = Event.delete().where(
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import numpy as np
|
|||||||
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
|
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
|
||||||
from frigate.comms.dispatcher import Dispatcher
|
from frigate.comms.dispatcher import Dispatcher
|
||||||
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
|
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
|
||||||
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import (
|
from frigate.config import (
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
FrigateConfig,
|
FrigateConfig,
|
||||||
@ -24,7 +25,7 @@ from frigate.config import (
|
|||||||
SnapshotsConfig,
|
SnapshotsConfig,
|
||||||
ZoomingModeEnum,
|
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.events.types import EventStateEnum, EventTypeEnum
|
||||||
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||||
from frigate.util.image import (
|
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?
|
# TODO: can i switch to looking this up and only changing when an event ends?
|
||||||
# maintain best objects
|
# maintain best objects
|
||||||
|
camera_activity: dict[str, list[any]] = {
|
||||||
|
"motion": len(motion_boxes) > 0,
|
||||||
|
"objects": [],
|
||||||
|
}
|
||||||
|
|
||||||
for obj in tracked_objects.values():
|
for obj in tracked_objects.values():
|
||||||
object_type = obj.obj_data["label"]
|
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 the object's thumbnail is not from the current frame
|
||||||
if obj.false_positive or obj.thumbnail_data["frame_time"] != frame_time:
|
if obj.false_positive or obj.thumbnail_data["frame_time"] != frame_time:
|
||||||
continue
|
continue
|
||||||
@ -752,6 +786,9 @@ class CameraState:
|
|||||||
for c in self.callbacks["snapshot"]:
|
for c in self.callbacks["snapshot"]:
|
||||||
c(self.name, self.best_objects[object_type], frame_time)
|
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
|
# update overall camera state for each object type
|
||||||
obj_counter = Counter(
|
obj_counter = Counter(
|
||||||
obj.obj_data["label"]
|
obj.obj_data["label"]
|
||||||
@ -841,10 +878,14 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
self.last_motion_detected: dict[str, float] = {}
|
self.last_motion_detected: dict[str, float] = {}
|
||||||
self.ptz_autotracker_thread = ptz_autotracker_thread
|
self.ptz_autotracker_thread = ptz_autotracker_thread
|
||||||
|
|
||||||
|
self.requestor = InterProcessRequestor()
|
||||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
|
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
|
||||||
self.event_sender = EventUpdatePublisher()
|
self.event_sender = EventUpdatePublisher()
|
||||||
self.event_end_subscriber = EventEndSubscriber()
|
self.event_end_subscriber = EventEndSubscriber()
|
||||||
|
|
||||||
|
self.camera_activity: dict[str, dict[str, any]] = {}
|
||||||
|
|
||||||
def start(camera, obj: TrackedObject, current_frame_time):
|
def start(camera, obj: TrackedObject, current_frame_time):
|
||||||
self.event_sender.publish(
|
self.event_sender.publish(
|
||||||
(
|
(
|
||||||
@ -962,6 +1003,13 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
def object_status(camera, object_name, status):
|
def object_status(camera, object_name, status):
|
||||||
self.dispatcher.publish(f"{camera}/{object_name}", status, retain=False)
|
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():
|
for camera in self.config.cameras.keys():
|
||||||
camera_state = CameraState(
|
camera_state = CameraState(
|
||||||
camera, self.config, self.frame_manager, self.ptz_autotracker_thread
|
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("end", end)
|
||||||
camera_state.on("snapshot", snapshot)
|
camera_state.on("snapshot", snapshot)
|
||||||
camera_state.on("object_status", object_status)
|
camera_state.on("object_status", object_status)
|
||||||
|
camera_state.on("camera_activity", camera_activity)
|
||||||
self.camera_states[camera] = camera_state
|
self.camera_states[camera] = camera_state
|
||||||
|
|
||||||
# {
|
# {
|
||||||
@ -1228,6 +1277,7 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
event_id, camera = update
|
event_id, camera = update
|
||||||
self.camera_states[camera].finished(event_id)
|
self.camera_states[camera].finished(event_id)
|
||||||
|
|
||||||
|
self.requestor.stop()
|
||||||
self.detection_publisher.stop()
|
self.detection_publisher.stop()
|
||||||
self.event_sender.stop()
|
self.event_sender.stop()
|
||||||
self.event_end_subscriber.stop()
|
self.event_end_subscriber.stop()
|
||||||
|
|||||||
@ -110,6 +110,8 @@ class RecordingExporter(threading.Thread):
|
|||||||
f"00:{minutes}:{seconds}",
|
f"00:{minutes}:{seconds}",
|
||||||
"-i",
|
"-i",
|
||||||
preview.path,
|
preview.path,
|
||||||
|
"-frames",
|
||||||
|
"1",
|
||||||
"-c:v",
|
"-c:v",
|
||||||
"libwebp",
|
"libwebp",
|
||||||
thumb_path,
|
thumb_path,
|
||||||
|
|||||||
@ -110,6 +110,18 @@ class PendingReviewSegment:
|
|||||||
self.frame_path, self.frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60]
|
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:
|
def get_data(self, ended: bool) -> dict:
|
||||||
return {
|
return {
|
||||||
ReviewSegment.id: self.id,
|
ReviewSegment.id: self.id,
|
||||||
@ -273,8 +285,30 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
if segment.severity == SeverityEnum.alert and frame_time > (
|
if segment.severity == SeverityEnum.alert and frame_time > (
|
||||||
segment.last_update + THRESHOLD_ALERT_ACTIVITY
|
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)
|
self.end_segment(segment)
|
||||||
elif frame_time > (segment.last_update + THRESHOLD_DETECTION_ACTIVITY):
|
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)
|
self.end_segment(segment)
|
||||||
|
|
||||||
def check_if_new_segment(
|
def check_if_new_segment(
|
||||||
@ -511,7 +545,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
manual_info["label"]
|
manual_info["label"]
|
||||||
)
|
)
|
||||||
# temporarily make it so this event can not end
|
# 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:
|
elif manual_info["state"] == ManualEventState.complete:
|
||||||
self.active_review_segments[camera].last_update = manual_info[
|
self.active_review_segments[camera].last_update = manual_info[
|
||||||
"end_time"
|
"end_time"
|
||||||
|
|||||||
@ -162,7 +162,7 @@ async def set_gpu_stats(
|
|||||||
for args in hwaccel_args:
|
for args in hwaccel_args:
|
||||||
if args in hwaccel_errors:
|
if args in hwaccel_errors:
|
||||||
# known erroring args should automatically return as error
|
# 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:
|
elif "cuvid" in args or "nvidia" in args:
|
||||||
# nvidia GPU
|
# nvidia GPU
|
||||||
nvidia_usage = get_nvidia_gpu_stats()
|
nvidia_usage = get_nvidia_gpu_stats()
|
||||||
@ -177,7 +177,7 @@ async def set_gpu_stats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
stats["nvidia-gpu"] = {"gpu": -1, "mem": -1}
|
stats["nvidia-gpu"] = {"gpu": "", "mem": ""}
|
||||||
hwaccel_errors.append(args)
|
hwaccel_errors.append(args)
|
||||||
elif "nvmpi" in args or "jetson" in args:
|
elif "nvmpi" in args or "jetson" in args:
|
||||||
# nvidia Jetson
|
# nvidia Jetson
|
||||||
@ -186,7 +186,7 @@ async def set_gpu_stats(
|
|||||||
if jetson_usage:
|
if jetson_usage:
|
||||||
stats["jetson-gpu"] = jetson_usage
|
stats["jetson-gpu"] = jetson_usage
|
||||||
else:
|
else:
|
||||||
stats["jetson-gpu"] = {"gpu": -1, "mem": -1}
|
stats["jetson-gpu"] = {"gpu": "", "mem": ""}
|
||||||
hwaccel_errors.append(args)
|
hwaccel_errors.append(args)
|
||||||
elif "qsv" in args:
|
elif "qsv" in args:
|
||||||
if not config.telemetry.stats.intel_gpu_stats:
|
if not config.telemetry.stats.intel_gpu_stats:
|
||||||
@ -198,7 +198,7 @@ async def set_gpu_stats(
|
|||||||
if intel_usage:
|
if intel_usage:
|
||||||
stats["intel-qsv"] = intel_usage
|
stats["intel-qsv"] = intel_usage
|
||||||
else:
|
else:
|
||||||
stats["intel-qsv"] = {"gpu": -1, "mem": -1}
|
stats["intel-qsv"] = {"gpu": "", "mem": ""}
|
||||||
hwaccel_errors.append(args)
|
hwaccel_errors.append(args)
|
||||||
elif "vaapi" in args:
|
elif "vaapi" in args:
|
||||||
if is_vaapi_amd_driver():
|
if is_vaapi_amd_driver():
|
||||||
@ -211,7 +211,7 @@ async def set_gpu_stats(
|
|||||||
if amd_usage:
|
if amd_usage:
|
||||||
stats["amd-vaapi"] = amd_usage
|
stats["amd-vaapi"] = amd_usage
|
||||||
else:
|
else:
|
||||||
stats["amd-vaapi"] = {"gpu": -1, "mem": -1}
|
stats["amd-vaapi"] = {"gpu": "", "mem": ""}
|
||||||
hwaccel_errors.append(args)
|
hwaccel_errors.append(args)
|
||||||
else:
|
else:
|
||||||
if not config.telemetry.stats.intel_gpu_stats:
|
if not config.telemetry.stats.intel_gpu_stats:
|
||||||
@ -223,11 +223,11 @@ async def set_gpu_stats(
|
|||||||
if intel_usage:
|
if intel_usage:
|
||||||
stats["intel-vaapi"] = intel_usage
|
stats["intel-vaapi"] = intel_usage
|
||||||
else:
|
else:
|
||||||
stats["intel-vaapi"] = {"gpu": -1, "mem": -1}
|
stats["intel-vaapi"] = {"gpu": "", "mem": ""}
|
||||||
hwaccel_errors.append(args)
|
hwaccel_errors.append(args)
|
||||||
elif "v4l2m2m" in args or "rpi" in args:
|
elif "v4l2m2m" in args or "rpi" in args:
|
||||||
# RPi v4l2m2m is currently not able to get usage stats
|
# RPi v4l2m2m is currently not able to get usage stats
|
||||||
stats["rpi-v4l2m2m"] = {"gpu": -1, "mem": -1}
|
stats["rpi-v4l2m2m"] = {"gpu": "", "mem": ""}
|
||||||
|
|
||||||
if stats:
|
if stats:
|
||||||
all_stats["gpu_usages"] = stats
|
all_stats["gpu_usages"] = stats
|
||||||
|
|||||||
@ -82,7 +82,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
"edgetpu": {
|
"edgetpu": {
|
||||||
"type": "edgetpu",
|
"type": "edgetpu",
|
||||||
"model": {"path": "/edgetpu_model.tflite", "width": 160},
|
"model": {"path": "/edgetpu_model.tflite"},
|
||||||
},
|
},
|
||||||
"openvino": {
|
"openvino": {
|
||||||
"type": "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["edgetpu"].model.path == "/edgetpu_model.tflite"
|
||||||
assert runtime_config.detectors["openvino"].model.path == "/etc/hosts"
|
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):
|
def test_invalid_mqtt_config(self):
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt", "user": "test"},
|
"mqtt": {"host": "mqtt", "user": "test"},
|
||||||
|
|||||||
@ -22,8 +22,9 @@ from frigate.util.object import average_boxes, median_of_boxes
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
THRESHOLD_ACTIVE_IOU = 0.2
|
THRESHOLD_KNOWN_ACTIVE_IOU = 0.2
|
||||||
THRESHOLD_STATIONARY_IOU = 0.6
|
THRESHOLD_STATIONARY_CHECK_IOU = 0.6
|
||||||
|
THRESHOLD_ACTIVE_CHECK_IOU = 0.9
|
||||||
MAX_STATIONARY_HISTORY = 10
|
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
|
# 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
|
# 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
|
xmin, ymin, xmax, ymax = box
|
||||||
position = self.positions[id]
|
position = self.positions[id]
|
||||||
self.stationary_box_history[id].append(box)
|
self.stationary_box_history[id].append(box)
|
||||||
@ -162,7 +163,7 @@ class NorfairTracker(ObjectTracker):
|
|||||||
|
|
||||||
# object has minimal or zero iou
|
# object has minimal or zero iou
|
||||||
# assume object is active
|
# assume object is active
|
||||||
if avg_iou < THRESHOLD_ACTIVE_IOU:
|
if avg_iou < THRESHOLD_KNOWN_ACTIVE_IOU:
|
||||||
self.positions[id] = {
|
self.positions[id] = {
|
||||||
"xmins": [xmin],
|
"xmins": [xmin],
|
||||||
"ymins": [ymin],
|
"ymins": [ymin],
|
||||||
@ -175,8 +176,12 @@ class NorfairTracker(ObjectTracker):
|
|||||||
}
|
}
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
threshold = (
|
||||||
|
THRESHOLD_STATIONARY_CHECK_IOU if stationary else THRESHOLD_ACTIVE_CHECK_IOU
|
||||||
|
)
|
||||||
|
|
||||||
# object has iou below threshold, check median to reduce outliers
|
# 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(
|
median_iou = intersection_over_union(
|
||||||
(
|
(
|
||||||
position["xmin"],
|
position["xmin"],
|
||||||
@ -189,7 +194,7 @@ class NorfairTracker(ObjectTracker):
|
|||||||
|
|
||||||
# if the median iou drops below the threshold
|
# if the median iou drops below the threshold
|
||||||
# assume object is no longer stationary
|
# assume object is no longer stationary
|
||||||
if median_iou < THRESHOLD_STATIONARY_IOU:
|
if median_iou < threshold:
|
||||||
self.positions[id] = {
|
self.positions[id] = {
|
||||||
"xmins": [xmin],
|
"xmins": [xmin],
|
||||||
"ymins": [ymin],
|
"ymins": [ymin],
|
||||||
@ -240,8 +245,12 @@ class NorfairTracker(ObjectTracker):
|
|||||||
def update(self, track_id, obj):
|
def update(self, track_id, obj):
|
||||||
id = self.track_id_map[track_id]
|
id = self.track_id_map[track_id]
|
||||||
self.disappeared[id] = 0
|
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
|
# 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
|
self.tracked_objects[id]["motionless_count"] += 1
|
||||||
if self.is_expired(id):
|
if self.is_expired(id):
|
||||||
self.deregister(id, track_id)
|
self.deregister(id, track_id)
|
||||||
|
|||||||
@ -155,14 +155,18 @@ def get_relative_coordinates(
|
|||||||
relative_masks = []
|
relative_masks = []
|
||||||
for m in mask:
|
for m in mask:
|
||||||
points = m.split(",")
|
points = m.split(",")
|
||||||
relative_masks.append(
|
|
||||||
",".join(
|
if any(x > "1.0" for x in points):
|
||||||
[
|
relative_masks.append(
|
||||||
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
|
",".join(
|
||||||
for i in range(0, len(points), 2)
|
[
|
||||||
]
|
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
|
mask = relative_masks
|
||||||
elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")):
|
elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")):
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
import subprocess as sp
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from multiprocessing import shared_memory
|
from multiprocessing import shared_memory
|
||||||
from string import printable
|
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))
|
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
384
web/package-lock.json
generated
@ -29,31 +29,32 @@
|
|||||||
"@radix-ui/react-toggle": "^1.0.3",
|
"@radix-ui/react-toggle": "^1.0.3",
|
||||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"apexcharts": "^3.48.0",
|
"apexcharts": "^3.49.0",
|
||||||
"axios": "^1.6.8",
|
"axios": "^1.6.8",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.1",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"hls.js": "^1.5.8",
|
"hls.js": "^1.5.8",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"immer": "^10.0.4",
|
"immer": "^10.1.1",
|
||||||
"konva": "^9.3.6",
|
"konva": "^9.3.6",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.372.0",
|
"lucide-react": "^0.378.0",
|
||||||
"monaco-yaml": "^5.1.1",
|
"monaco-yaml": "^5.1.1",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.3.1",
|
||||||
"react-apexcharts": "^1.4.1",
|
"react-apexcharts": "^1.4.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-device-detect": "^2.2.3",
|
"react-device-detect": "^2.2.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.51.3",
|
"react-grid-layout": "^1.4.4",
|
||||||
"react-icons": "^5.1.0",
|
"react-hook-form": "^7.51.4",
|
||||||
|
"react-icons": "^5.2.1",
|
||||||
"react-konva": "^18.2.10",
|
"react-konva": "^18.2.10",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.23.0",
|
||||||
"react-swipeable": "^7.0.1",
|
"react-swipeable": "^7.0.1",
|
||||||
"react-tracked": "^1.7.14",
|
"react-tracked": "^2.0.0",
|
||||||
"react-transition-group": "^4.4.5",
|
"react-transition-group": "^4.4.5",
|
||||||
"react-use-websocket": "^4.8.1",
|
"react-use-websocket": "^4.8.1",
|
||||||
"react-zoom-pan-pinch": "^3.4.4",
|
"react-zoom-pan-pinch": "^3.4.4",
|
||||||
@ -65,24 +66,25 @@
|
|||||||
"swr": "^2.2.5",
|
"swr": "^2.2.5",
|
||||||
"tailwind-merge": "^2.3.0",
|
"tailwind-merge": "^2.3.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.9.0",
|
"vaul": "^0.9.1",
|
||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"zod": "^3.22.5"
|
"zod": "^3.23.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@testing-library/jest-dom": "^6.1.5",
|
"@testing-library/jest-dom": "^6.1.5",
|
||||||
"@types/lodash": "^4.17.0",
|
"@types/lodash": "^4.17.1",
|
||||||
"@types/node": "^20.12.7",
|
"@types/node": "^20.12.11",
|
||||||
"@types/react": "^18.2.79",
|
"@types/react": "^18.3.1",
|
||||||
"@types/react-dom": "^18.2.25",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/react-grid-layout": "^1.3.5",
|
||||||
"@types/react-icons": "^3.0.0",
|
"@types/react-icons": "^3.0.0",
|
||||||
"@types/react-transition-group": "^4.4.10",
|
"@types/react-transition-group": "^4.4.10",
|
||||||
"@types/strftime": "^0.9.8",
|
"@types/strftime": "^0.9.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||||
"@typescript-eslint/parser": "^7.5.0",
|
"@typescript-eslint/parser": "^7.5.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||||
"@vitest/coverage-v8": "^1.4.0",
|
"@vitest/coverage-v8": "^1.6.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^8.55.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
@ -99,8 +101,8 @@
|
|||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.9",
|
"vite": "^5.2.11",
|
||||||
"vitest": "^1.4.0"
|
"vitest": "^1.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aashutoshrathi/word-wrap": {
|
"node_modules/@aashutoshrathi/word-wrap": {
|
||||||
@ -2029,9 +2031,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.15.3",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.0.tgz",
|
||||||
"integrity": "sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w==",
|
"integrity": "sha512-Quz1KOffeEf/zwkCBM3kBtH4ZoZ+pT3xIXBG4PPW/XFtDP7EGhtTiC2+gpL9GnR7+Qdet5Oa6cYSvwKYg6kN9Q==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@ -2514,21 +2516,15 @@
|
|||||||
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
|
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
|
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
|
||||||
},
|
},
|
||||||
"node_modules/@types/lodash": {
|
"node_modules/@types/lodash": {
|
||||||
"version": "4.17.0",
|
"version": "4.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.1.tgz",
|
||||||
"integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==",
|
"integrity": "sha512-X+2qazGS3jxLAIz5JDXDzglAF3KpijdhFxlf/V1+hEsOUc+HnWi81L/uv/EvGuV90WY+7mPGFCUDGfQC3Gj95Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/mute-stream": {
|
"node_modules/@types/mute-stream": {
|
||||||
@ -2541,9 +2537,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.12.7",
|
"version": "20.12.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.11.tgz",
|
||||||
"integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==",
|
"integrity": "sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~5.26.4"
|
"undici-types": "~5.26.4"
|
||||||
@ -2555,23 +2551,32 @@
|
|||||||
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
|
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.2.79",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.79.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==",
|
"integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "18.2.25",
|
"version": "18.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.25.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz",
|
||||||
"integrity": "sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==",
|
"integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react": "*"
|
"@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": {
|
"node_modules/@types/react-icons": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-icons/-/react-icons-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-icons/-/react-icons-3.0.0.tgz",
|
||||||
@ -2856,9 +2861,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/coverage-v8": {
|
"node_modules/@vitest/coverage-v8": {
|
||||||
"version": "1.4.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.0.tgz",
|
||||||
"integrity": "sha512-4hDGyH1SvKpgZnIByr9LhGgCEuF9DKM34IBLCC/fVfy24Z3+PZ+Ii9hsVBsHvY1umM1aGPEjceRkzxCfcQ10wg==",
|
"integrity": "sha512-KvapcbMY/8GYIG0rlwwOKCVNRc0OL20rrhFkg/CHNzncV03TE2XWvO5w9uZYoxNiMEBacAJt3unSOiZ7svePew==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.1",
|
"@ampproject/remapping": "^2.2.1",
|
||||||
@ -2873,24 +2878,23 @@
|
|||||||
"picocolors": "^1.0.0",
|
"picocolors": "^1.0.0",
|
||||||
"std-env": "^3.5.0",
|
"std-env": "^3.5.0",
|
||||||
"strip-literal": "^2.0.0",
|
"strip-literal": "^2.0.0",
|
||||||
"test-exclude": "^6.0.0",
|
"test-exclude": "^6.0.0"
|
||||||
"v8-to-istanbul": "^9.2.0"
|
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vitest": "1.4.0"
|
"vitest": "1.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "1.4.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz",
|
||||||
"integrity": "sha512-Jths0sWCJZ8BxjKe+p+eKsoqev1/T8lYcrjavEaz8auEJ4jAVY0GwW3JKmdVU4mmNPLPHixh4GNXP7GFtAiDHA==",
|
"integrity": "sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/spy": "1.4.0",
|
"@vitest/spy": "1.6.0",
|
||||||
"@vitest/utils": "1.4.0",
|
"@vitest/utils": "1.6.0",
|
||||||
"chai": "^4.3.10"
|
"chai": "^4.3.10"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
@ -2898,12 +2902,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/runner": {
|
"node_modules/@vitest/runner": {
|
||||||
"version": "1.4.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.0.tgz",
|
||||||
"integrity": "sha512-EDYVSmesqlQ4RD2VvWo3hQgTJ7ZrFQ2VSJdfiJiArkCerDAGeyF1i6dHkmySqk573jLp6d/cfqCN+7wUB5tLgg==",
|
"integrity": "sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "1.4.0",
|
"@vitest/utils": "1.6.0",
|
||||||
"p-limit": "^5.0.0",
|
"p-limit": "^5.0.0",
|
||||||
"pathe": "^1.1.1"
|
"pathe": "^1.1.1"
|
||||||
},
|
},
|
||||||
@ -2939,9 +2943,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/snapshot": {
|
"node_modules/@vitest/snapshot": {
|
||||||
"version": "1.4.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.0.tgz",
|
||||||
"integrity": "sha512-saAFnt5pPIA5qDGxOHxJ/XxhMFKkUSBJmVt5VgDsAqPTX6JP326r5C/c9UuCMPoXNzuudTPsYDZCoJ5ilpqG2A==",
|
"integrity": "sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"magic-string": "^0.30.5",
|
"magic-string": "^0.30.5",
|
||||||
@ -2953,9 +2957,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/spy": {
|
"node_modules/@vitest/spy": {
|
||||||
"version": "1.4.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.0.tgz",
|
||||||
"integrity": "sha512-Ywau/Qs1DzM/8Uc+yA77CwSegizMlcgTJuYGAi0jujOteJOUf1ujunHThYo243KG9nAyWT3L9ifPYZ5+As/+6Q==",
|
"integrity": "sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tinyspy": "^2.2.0"
|
"tinyspy": "^2.2.0"
|
||||||
@ -2965,9 +2969,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/utils": {
|
"node_modules/@vitest/utils": {
|
||||||
"version": "1.4.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.0.tgz",
|
||||||
"integrity": "sha512-mx3Yd1/6e2Vt/PUC98DcqTirtfxUyAZ32uK82r8rZzbtBeBo+nqgnjx/LvqQdWsrvNtm14VmurNgcf4nqY5gJg==",
|
"integrity": "sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"diff-sequences": "^29.6.3",
|
"diff-sequences": "^29.6.3",
|
||||||
@ -3111,9 +3115,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/apexcharts": {
|
"node_modules/apexcharts": {
|
||||||
"version": "3.48.0",
|
"version": "3.49.0",
|
||||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.48.0.tgz",
|
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.49.0.tgz",
|
||||||
"integrity": "sha512-Lhpj1Ij6lKlrUke8gf+P+SE6uGUn+Pe1TnCJ+zqrY0YMvbqM3LMb1lY+eybbTczUyk0RmMZomlTa2NgX2EUs4Q==",
|
"integrity": "sha512-2T9HnbQFLCuYRPndQLmh+bEQFoz0meUbvASaGgiSKDuYhWcLBodJtIpKql2aOtMx4B/sHrWW0dm90HsW4+h2PQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@yr/monotone-cubic-spline": "^1.0.3",
|
"@yr/monotone-cubic-spline": "^1.0.3",
|
||||||
"svg.draggable.js": "^2.2.2",
|
"svg.draggable.js": "^2.2.2",
|
||||||
@ -3532,9 +3536,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
"integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==",
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
@ -3586,12 +3590,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
|
"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": {
|
"node_modules/cookie": {
|
||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
||||||
@ -4392,6 +4390,11 @@
|
|||||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
||||||
@ -4815,9 +4818,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/immer": {
|
"node_modules/immer": {
|
||||||
"version": "10.0.4",
|
"version": "10.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
|
||||||
"integrity": "sha512-cuBuGK40P/sk5IzWa9QPUaAdvPHjkk1c+xYsd9oZw+YQQEV+10G0P5uMpGctZZKnyQ+ibRO08bD25nWLmYi2pw==",
|
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/immer"
|
"url": "https://opencollective.com/immer"
|
||||||
@ -5348,9 +5351,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "0.372.0",
|
"version": "0.378.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.372.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.378.0.tgz",
|
||||||
"integrity": "sha512-0cKdqmilHXWUwWAWnf6CrrjHD8YaqPMtLrmEHXolZusNTr9epULCsiJwIOHk2q1yFxdEwd96D4zShlAj67UJdA==",
|
"integrity": "sha512-u6EPU8juLUk9ytRcyapkWI18epAv3RU+6+TC23ivjR0e+glWKBobFeSgRwOIJihzktILQuy6E0E80P2jVTDR5g==",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
||||||
}
|
}
|
||||||
@ -6220,9 +6223,9 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||||
},
|
},
|
||||||
"node_modules/proxy-compare": {
|
"node_modules/proxy-compare": {
|
||||||
"version": "2.6.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.0.tgz",
|
||||||
"integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw=="
|
"integrity": "sha512-y44MCkgtZUCT9tZGuE278fB7PWVf7fRYy0vbRXAts2o5F0EfC4fIQrvQQGBJo1WJbFcVLXzApOscyJuZqHQc1w=="
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
@ -6270,9 +6273,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.2.0",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@ -6318,21 +6321,59 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.2.0",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.0"
|
"scheduler": "^0.23.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"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": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.51.3",
|
"version": "7.51.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.4.tgz",
|
||||||
"integrity": "sha512-cvJ/wbHdhYx8aviSWh28w9ImjmVsb5Y05n1+FW786vEZQJV5STNM0pW6ujS+oiBecb0ARBxJFyAnXj9+GHXACQ==",
|
"integrity": "sha512-V14i8SEkh+V1gs6YtD0hdHYnoL4tp/HX/A45wWQN15CYr9bFRmmRdYStSO5L65lCCZRF+kYiSKhm9alqbcdiVA==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.22.0"
|
"node": ">=12.22.0"
|
||||||
},
|
},
|
||||||
@ -6345,9 +6386,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-icons": {
|
"node_modules/react-icons": {
|
||||||
"version": "5.1.0",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.2.1.tgz",
|
||||||
"integrity": "sha512-D3zug1270S4hbSlIRJ0CUS97QE1yNNKDjzQe3HqY0aefp2CBn9VgzgES27sRR2gOvFK+0CNx/BW0ggOESp6fqQ==",
|
"integrity": "sha512-zdbW5GstTzXaVKvGSyTaBalt7HSfuK5ovrzlpyiWHAFXndXTdd/1hdDHI4xBM1Mn7YriT6aqESucFl9kEXzrdw==",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "*"
|
"react": "*"
|
||||||
}
|
}
|
||||||
@ -6448,12 +6489,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-resizable": {
|
||||||
"version": "6.22.3",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz",
|
||||||
"integrity": "sha512-dr2eb3Mj5zK2YISHK++foM9w4eBnO23eKnZEDs7c880P6oKbrjz/Svg9+nxqtHQK+oMW4OtjZca0RqPglXxguQ==",
|
"integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==",
|
||||||
"dependencies": {
|
"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": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@ -6463,12 +6516,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "6.22.3",
|
"version": "6.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.23.0.tgz",
|
||||||
"integrity": "sha512-7ZILI7HjcE+p31oQvwbokjk6OA/bnFxrhJ19n82Ex9Ph8fNAq+Hm/7KchpMGlTgWhUxRHMMCut+vEtNpWpowKw==",
|
"integrity": "sha512-Q9YaSYvubwgbal2c9DJKfx6hTNoBp3iJDsl+Duva/DwxoJH+OTXkxGpql4iUK2sla/8z4RpjAm6EWx1qUDuopQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.15.3",
|
"@remix-run/router": "1.16.0",
|
||||||
"react-router": "6.22.3"
|
"react-router": "6.23.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@ -6509,26 +6562,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-tracked": {
|
"node_modules/react-tracked": {
|
||||||
"version": "1.7.14",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-tracked/-/react-tracked-1.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/react-tracked/-/react-tracked-2.0.0.tgz",
|
||||||
"integrity": "sha512-6UMlgQeRAGA+uyYzuQGm7kZB6ZQYFhc7sntgP7Oxwwd6M0Ud/POyb4K3QWT1eXvoifSa80nrAWnXWFGpOvbwkw==",
|
"integrity": "sha512-Px8Ms9zhQKzAj3gnwQm6L+sJwzB0uPa8/BgHKOhB8bIuQEgB2iJfryM7GVja9oviiGAa7vtgEBtM+poT1E7V2w==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"proxy-compare": "2.6.0",
|
"proxy-compare": "^3.0.0",
|
||||||
"use-context-selector": "1.4.4"
|
"use-context-selector": "^2.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=16.8.0",
|
"react": ">=18.0.0",
|
||||||
"react-dom": "*",
|
|
||||||
"react-native": "*",
|
|
||||||
"scheduler": ">=0.19.0"
|
"scheduler": ">=0.19.0"
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react-dom": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-native": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-transition-group": {
|
"node_modules/react-transition-group": {
|
||||||
@ -6639,6 +6682,11 @@
|
|||||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.8",
|
"version": "1.22.8",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
|
||||||
@ -6898,9 +6946,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.0",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
"integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
|
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
}
|
}
|
||||||
@ -7669,22 +7717,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/use-context-selector": {
|
"node_modules/use-context-selector": {
|
||||||
"version": "1.4.4",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-2.0.0.tgz",
|
||||||
"integrity": "sha512-pS790zwGxxe59GoBha3QYOwk8AFGp4DN6DOtH+eoqVmgBBRXVx4IlPDhJmmMiNQAgUaLlP+58aqRC3A4rdaSjg==",
|
"integrity": "sha512-owfuSmUNd3eNp3J9CdDl0kMgfidV+MkDvHPpvthN5ThqM+ibMccNE0k+Iq7TWC6JPFvGZqanqiGCuQx6DyV24g==",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=16.8.0",
|
"react": ">=18.0.0",
|
||||||
"react-dom": "*",
|
|
||||||
"react-native": "*",
|
|
||||||
"scheduler": ">=0.19.0"
|
"scheduler": ">=0.19.0"
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react-dom": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-native": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/use-sidecar": {
|
"node_modules/use-sidecar": {
|
||||||
@ -7721,24 +7759,10 @@
|
|||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
"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": {
|
"node_modules/vaul": {
|
||||||
"version": "0.9.0",
|
"version": "0.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.1.tgz",
|
||||||
"integrity": "sha512-bZSySGbAHiTXmZychprnX/dE0EsSige88xtyyL3/MCRbrFotRPQZo7UdydGXZWw+CKbNOw5Ow8gwAo93/nB/Cg==",
|
"integrity": "sha512-fAhd7i4RNMinx+WEm6pF3nOl78DFkAazcN04ElLPFF9BMCNGbY/kou8UMhIcicm0rJCNePJP0Yyza60gGOD0Jw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.0.4"
|
"@radix-ui/react-dialog": "^1.0.4"
|
||||||
},
|
},
|
||||||
@ -7748,9 +7772,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.2.9",
|
"version": "5.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz",
|
||||||
"integrity": "sha512-uOQWfuZBlc6Y3W/DTuQ1Sr+oIXWvqljLvS881SVmAj00d5RdgShLcuXWxseWPd4HXwiYBFW/vXHfKFeqj9uQnw==",
|
"integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.20.1",
|
"esbuild": "^0.20.1",
|
||||||
@ -7803,9 +7827,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite-node": {
|
"node_modules/vite-node": {
|
||||||
"version": "1.4.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.0.tgz",
|
||||||
"integrity": "sha512-VZDAseqjrHgNd4Kh8icYHWzTKSCZMhia7GyHfhtzLW33fZlG9SwsB6CEhgyVOWkJfJ2pFLrp/Gj1FSfAiqH9Lw==",
|
"integrity": "sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cac": "^6.7.14",
|
"cac": "^6.7.14",
|
||||||
@ -7833,16 +7857,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest": {
|
"node_modules/vitest": {
|
||||||
"version": "1.4.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.0.tgz",
|
||||||
"integrity": "sha512-gujzn0g7fmwf83/WzrDTnncZt2UiXP41mHuFYFrdwaLRVQ6JYQEiME2IfEjU3vcFL3VKa75XhI3lFgn+hfVsQw==",
|
"integrity": "sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "1.4.0",
|
"@vitest/expect": "1.6.0",
|
||||||
"@vitest/runner": "1.4.0",
|
"@vitest/runner": "1.6.0",
|
||||||
"@vitest/snapshot": "1.4.0",
|
"@vitest/snapshot": "1.6.0",
|
||||||
"@vitest/spy": "1.4.0",
|
"@vitest/spy": "1.6.0",
|
||||||
"@vitest/utils": "1.4.0",
|
"@vitest/utils": "1.6.0",
|
||||||
"acorn-walk": "^8.3.2",
|
"acorn-walk": "^8.3.2",
|
||||||
"chai": "^4.3.10",
|
"chai": "^4.3.10",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
@ -7854,9 +7878,9 @@
|
|||||||
"std-env": "^3.5.0",
|
"std-env": "^3.5.0",
|
||||||
"strip-literal": "^2.0.0",
|
"strip-literal": "^2.0.0",
|
||||||
"tinybench": "^2.5.1",
|
"tinybench": "^2.5.1",
|
||||||
"tinypool": "^0.8.2",
|
"tinypool": "^0.8.3",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
"vite-node": "1.4.0",
|
"vite-node": "1.6.0",
|
||||||
"why-is-node-running": "^2.2.2"
|
"why-is-node-running": "^2.2.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@ -7871,8 +7895,8 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edge-runtime/vm": "*",
|
"@edge-runtime/vm": "*",
|
||||||
"@types/node": "^18.0.0 || >=20.0.0",
|
"@types/node": "^18.0.0 || >=20.0.0",
|
||||||
"@vitest/browser": "1.4.0",
|
"@vitest/browser": "1.6.0",
|
||||||
"@vitest/ui": "1.4.0",
|
"@vitest/ui": "1.6.0",
|
||||||
"happy-dom": "*",
|
"happy-dom": "*",
|
||||||
"jsdom": "*"
|
"jsdom": "*"
|
||||||
},
|
},
|
||||||
@ -8145,9 +8169,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.22.5",
|
"version": "3.23.7",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.5.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.7.tgz",
|
||||||
"integrity": "sha512-HqnGsCdVZ2xc0qWPLdO25WnseXThh0kEYKIdV5F/hTHO75hNZFp8thxSeHhiPrHZKrFTo1SOgkAj9po5bexZlw==",
|
"integrity": "sha512-NBeIoqbtOiUMomACV/y+V3Qfs9+Okr18vR5c/5pHClPpufWOrsx8TENboDPe265lFdfewX2yBtNTLPvnmCxwog==",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,31 +34,32 @@
|
|||||||
"@radix-ui/react-toggle": "^1.0.3",
|
"@radix-ui/react-toggle": "^1.0.3",
|
||||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"apexcharts": "^3.48.0",
|
"apexcharts": "^3.49.0",
|
||||||
"axios": "^1.6.8",
|
"axios": "^1.6.8",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.1",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"hls.js": "^1.5.8",
|
"hls.js": "^1.5.8",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"immer": "^10.0.4",
|
"immer": "^10.1.1",
|
||||||
"konva": "^9.3.6",
|
"konva": "^9.3.6",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.372.0",
|
"lucide-react": "^0.378.0",
|
||||||
"monaco-yaml": "^5.1.1",
|
"monaco-yaml": "^5.1.1",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.3.1",
|
||||||
"react-apexcharts": "^1.4.1",
|
"react-apexcharts": "^1.4.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-device-detect": "^2.2.3",
|
"react-device-detect": "^2.2.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.51.3",
|
"react-grid-layout": "^1.4.4",
|
||||||
"react-icons": "^5.1.0",
|
"react-hook-form": "^7.51.4",
|
||||||
|
"react-icons": "^5.2.1",
|
||||||
"react-konva": "^18.2.10",
|
"react-konva": "^18.2.10",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.23.0",
|
||||||
"react-swipeable": "^7.0.1",
|
"react-swipeable": "^7.0.1",
|
||||||
"react-tracked": "^1.7.14",
|
"react-tracked": "^2.0.0",
|
||||||
"react-transition-group": "^4.4.5",
|
"react-transition-group": "^4.4.5",
|
||||||
"react-use-websocket": "^4.8.1",
|
"react-use-websocket": "^4.8.1",
|
||||||
"react-zoom-pan-pinch": "^3.4.4",
|
"react-zoom-pan-pinch": "^3.4.4",
|
||||||
@ -70,24 +71,25 @@
|
|||||||
"swr": "^2.2.5",
|
"swr": "^2.2.5",
|
||||||
"tailwind-merge": "^2.3.0",
|
"tailwind-merge": "^2.3.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.9.0",
|
"vaul": "^0.9.1",
|
||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"zod": "^3.22.5"
|
"zod": "^3.23.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@testing-library/jest-dom": "^6.1.5",
|
"@testing-library/jest-dom": "^6.1.5",
|
||||||
"@types/lodash": "^4.17.0",
|
"@types/lodash": "^4.17.1",
|
||||||
"@types/node": "^20.12.7",
|
"@types/node": "^20.12.11",
|
||||||
"@types/react": "^18.2.79",
|
"@types/react": "^18.3.1",
|
||||||
"@types/react-dom": "^18.2.25",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/react-grid-layout": "^1.3.5",
|
||||||
"@types/react-icons": "^3.0.0",
|
"@types/react-icons": "^3.0.0",
|
||||||
"@types/react-transition-group": "^4.4.10",
|
"@types/react-transition-group": "^4.4.10",
|
||||||
"@types/strftime": "^0.9.8",
|
"@types/strftime": "^0.9.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||||
"@typescript-eslint/parser": "^7.5.0",
|
"@typescript-eslint/parser": "^7.5.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||||
"@vitest/coverage-v8": "^1.4.0",
|
"@vitest/coverage-v8": "^1.6.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^8.55.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
@ -99,12 +101,12 @@
|
|||||||
"fake-indexeddb": "^5.0.2",
|
"fake-indexeddb": "^5.0.2",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
"msw": "^2.2.14",
|
"msw": "^2.3.0",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.9",
|
"vite": "^5.2.11",
|
||||||
"vitest": "^1.4.0"
|
"vitest": "^1.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,12 @@ import { baseUrl } from "./baseUrl";
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import useWebSocket, { ReadyState } from "react-use-websocket";
|
import useWebSocket, { ReadyState } from "react-use-websocket";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
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 { FrigateStats } from "@/types/stats";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { createContainer } from "react-tracked";
|
import { createContainer } from "react-tracked";
|
||||||
@ -60,7 +65,13 @@ function useValue(): useValueReturn {
|
|||||||
setWsState({ ...wsState, [data.topic]: data.payload });
|
setWsState({ ...wsState, [data.topic]: data.payload });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOpen: () => {},
|
onOpen: () => {
|
||||||
|
sendJsonMessage({
|
||||||
|
topic: "onConnect",
|
||||||
|
message: "",
|
||||||
|
retain: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
shouldReconnect: () => true,
|
shouldReconnect: () => true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -193,6 +204,16 @@ export function useFrigateStats(): { payload: FrigateStats } {
|
|||||||
return { payload: JSON.parse(payload as string) };
|
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 } {
|
export function useMotionActivity(camera: string): { payload: string } {
|
||||||
const {
|
const {
|
||||||
value: { payload },
|
value: { payload },
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type LogoProps = {
|
type LogoProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
export default function Logo({ className }: LogoProps) {
|
export default function Logo({ className }: LogoProps) {
|
||||||
return (
|
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" />
|
<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>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { useContext, useEffect, useMemo } from "react";
|
|||||||
import { FaCheck } from "react-icons/fa";
|
import { FaCheck } from "react-icons/fa";
|
||||||
import { IoIosWarning } from "react-icons/io";
|
import { IoIosWarning } from "react-icons/io";
|
||||||
import { MdCircle } from "react-icons/md";
|
import { MdCircle } from "react-icons/md";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
export default function Statusbar() {
|
export default function Statusbar() {
|
||||||
@ -43,7 +44,13 @@ export default function Statusbar() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearMessages("stats");
|
clearMessages("stats");
|
||||||
potentialProblems.forEach((problem) => {
|
potentialProblems.forEach((problem) => {
|
||||||
addMessage("stats", problem.text, problem.color);
|
addMessage(
|
||||||
|
"stats",
|
||||||
|
problem.text,
|
||||||
|
problem.color,
|
||||||
|
undefined,
|
||||||
|
problem.relevantLink,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}, [potentialProblems, addMessage, clearMessages]);
|
}, [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="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">
|
<div className="h-full flex items-center gap-2">
|
||||||
{cpuPercent && (
|
{cpuPercent && (
|
||||||
<div className="flex items-center text-sm gap-2">
|
<Link to="/system#general">
|
||||||
<MdCircle
|
<div className="flex items-center text-sm gap-2 cursor-pointer hover:underline">
|
||||||
className={`size-2 ${
|
<MdCircle
|
||||||
cpuPercent < 50
|
className={`size-2 ${
|
||||||
? "text-success"
|
cpuPercent < 50
|
||||||
: cpuPercent < 80
|
? "text-success"
|
||||||
? "text-orange-400"
|
: cpuPercent < 80
|
||||||
: "text-danger"
|
? "text-orange-400"
|
||||||
}`}
|
: "text-danger"
|
||||||
/>
|
}`}
|
||||||
CPU {cpuPercent}%
|
/>
|
||||||
</div>
|
CPU {cpuPercent}%
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
{Object.entries(stats?.gpu_usages || {}).map(([name, stats]) => {
|
{Object.entries(stats?.gpu_usages || {}).map(([name, stats]) => {
|
||||||
if (name == "error-gpu") {
|
if (name == "error-gpu") {
|
||||||
@ -86,18 +95,24 @@ export default function Statusbar() {
|
|||||||
const gpu = parseInt(stats.gpu);
|
const gpu = parseInt(stats.gpu);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={gpuTitle} className="flex items-center text-sm gap-2">
|
<Link key={gpuTitle} to="/system#general">
|
||||||
<MdCircle
|
{" "}
|
||||||
className={`size-2 ${
|
<div
|
||||||
gpu < 50
|
key={gpuTitle}
|
||||||
? "text-success"
|
className="flex items-center text-sm gap-2 cursor-pointer hover:underline"
|
||||||
: gpu < 80
|
>
|
||||||
? "text-orange-400"
|
<MdCircle
|
||||||
: "text-danger"
|
className={`size-2 ${
|
||||||
}`}
|
gpu < 50
|
||||||
/>
|
? "text-success"
|
||||||
{gpuTitle} {gpu}%
|
: gpu < 80
|
||||||
</div>
|
? "text-orange-400"
|
||||||
|
: "text-danger"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{gpuTitle} {gpu}%
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@ -110,14 +125,29 @@ export default function Statusbar() {
|
|||||||
) : (
|
) : (
|
||||||
Object.entries(messages).map(([key, messageArray]) => (
|
Object.entries(messages).map(([key, messageArray]) => (
|
||||||
<div key={key} className="h-full flex items-center gap-2">
|
<div key={key} className="h-full flex items-center gap-2">
|
||||||
{messageArray.map(({ id, text, color }: StatusMessage) => (
|
{messageArray.map(({ id, text, color, link }: StatusMessage) => {
|
||||||
<div key={id} className="flex items-center text-sm gap-2">
|
const message = (
|
||||||
<IoIosWarning
|
<div
|
||||||
className={`size-5 ${color || "text-danger"}`}
|
key={id}
|
||||||
/>
|
className={`flex items-center text-sm gap-2 ${link ? "hover:underline cursor-pointer" : ""}`}
|
||||||
{text}
|
>
|
||||||
</div>
|
<IoIosWarning
|
||||||
))}
|
className={`size-5 ${color || "text-danger"}`}
|
||||||
|
/>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (link) {
|
||||||
|
return (
|
||||||
|
<Link key={id} to={link}>
|
||||||
|
{message}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useApiHost } from "@/api";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import ActivityIndicator from "../indicators/activity-indicator";
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
|
|
||||||
type CameraImageProps = {
|
type CameraImageProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -24,6 +25,7 @@ export default function CameraImage({
|
|||||||
|
|
||||||
const { name } = config ? config.cameras[camera] : "";
|
const { name } = config ? config.cameras[camera] : "";
|
||||||
const enabled = config ? config.cameras[camera].enabled : "True";
|
const enabled = config ? config.cameras[camera].enabled : "True";
|
||||||
|
const [isPortraitImage, setIsPortraitImage] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!config || !imgRef.current) {
|
if (!config || !imgRef.current) {
|
||||||
@ -35,15 +37,25 @@ export default function CameraImage({
|
|||||||
}`;
|
}`;
|
||||||
}, [apiHost, name, imgRef, searchParams, config]);
|
}, [apiHost, name, imgRef, searchParams, config]);
|
||||||
|
|
||||||
|
const [{ width: containerWidth, height: containerHeight }] =
|
||||||
|
useResizeObserver(containerRef);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} ref={containerRef}>
|
<div className={className} ref={containerRef}>
|
||||||
{enabled ? (
|
{enabled ? (
|
||||||
<img
|
<img
|
||||||
ref={imgRef}
|
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={() => {
|
onLoad={() => {
|
||||||
setHasLoaded(true);
|
setHasLoaded(true);
|
||||||
|
|
||||||
|
if (imgRef.current) {
|
||||||
|
const { naturalHeight, naturalWidth } = imgRef.current;
|
||||||
|
setIsPortraitImage(
|
||||||
|
naturalWidth / naturalHeight < containerWidth / containerHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (onload) {
|
if (onload) {
|
||||||
onload();
|
onload();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import ActivityIndicator from "../indicators/activity-indicator";
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type CameraImageProps = {
|
type CameraImageProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -95,7 +96,7 @@ export default function CameraImage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative w-full h-full flex justify-center ${className}`}
|
className={cn("relative w-full h-full flex justify-center", className)}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
>
|
>
|
||||||
{enabled ? (
|
{enabled ? (
|
||||||
|
|||||||
@ -3,16 +3,16 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { ReviewSegment } from "@/types/review";
|
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { RecordingStartingPoint } from "@/types/record";
|
import { RecordingStartingPoint } from "@/types/record";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Preview } from "@/types/preview";
|
|
||||||
import {
|
import {
|
||||||
InProgressPreview,
|
InProgressPreview,
|
||||||
VideoPreview,
|
VideoPreview,
|
||||||
} from "../player/PreviewThumbnailPlayer";
|
} from "../player/PreviewThumbnailPlayer";
|
||||||
import { isCurrentHour } from "@/utils/dateUtil";
|
import { isCurrentHour } from "@/utils/dateUtil";
|
||||||
|
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
||||||
|
|
||||||
type AnimatedEventCardProps = {
|
type AnimatedEventCardProps = {
|
||||||
event: ReviewSegment;
|
event: ReviewSegment;
|
||||||
@ -24,10 +24,15 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
|
|||||||
|
|
||||||
// preview
|
// preview
|
||||||
|
|
||||||
const { data: previews } = useSWR<Preview[]>(
|
const previews = useCameraPreviews(
|
||||||
currentHour
|
{
|
||||||
? null
|
after: Math.round(event.start_time),
|
||||||
: `/preview/${event.camera}/start/${Math.round(event.start_time)}/end/${Math.round(event.end_time || event.start_time + 20)}`,
|
before: Math.round(event.end_time || event.start_time + 20),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
camera: event.camera,
|
||||||
|
fetchPreviews: !currentHour,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// interaction
|
// interaction
|
||||||
@ -39,7 +44,7 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
|
|||||||
severity: event.severity,
|
severity: event.severity,
|
||||||
recording: {
|
recording: {
|
||||||
camera: event.camera,
|
camera: event.camera,
|
||||||
startTime: event.start_time,
|
startTime: event.start_time - REVIEW_PADDING,
|
||||||
severity: event.severity,
|
severity: event.severity,
|
||||||
} as RecordingStartingPoint,
|
} as RecordingStartingPoint,
|
||||||
},
|
},
|
||||||
@ -62,7 +67,7 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className="h-24 relative"
|
className="h-24 4k:h-32 relative"
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: aspectRatio,
|
aspectRatio: aspectRatio,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
|||||||
import { DeleteClipType, Export } from "@/types/export";
|
import { DeleteClipType, Export } from "@/types/export";
|
||||||
import { MdEditSquare } from "react-icons/md";
|
import { MdEditSquare } from "react-icons/md";
|
||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type ExportProps = {
|
type ExportProps = {
|
||||||
className: string;
|
className: string;
|
||||||
@ -104,7 +105,10 @@ export default function ExportCard({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<div
|
<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={
|
onMouseEnter={
|
||||||
isDesktop && !exportedRecording.in_progress
|
isDesktop && !exportedRecording.in_progress
|
||||||
? () => setHovered(true)
|
? () => setHovered(true)
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
primary: {
|
primary: {
|
||||||
@ -38,9 +39,11 @@ export default function CameraFeatureToggle({
|
|||||||
const content = (
|
const content = (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`${className} flex flex-col justify-center items-center ${
|
className={cn(
|
||||||
variants[variant][isActive ? "active" : "inactive"]
|
className,
|
||||||
}`}
|
"flex flex-col justify-center items-center",
|
||||||
|
variants[variant][isActive ? "active" : "inactive"],
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
className={`size-5 md:m-[6px] ${isActive ? "text-white" : "text-secondary-foreground"}`}
|
className={`size-5 md:m-[6px] ${isActive ? "text-white" : "text-secondary-foreground"}`}
|
||||||
|
|||||||
@ -1,29 +1,58 @@
|
|||||||
import {
|
import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||||
CameraGroupConfig,
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
FrigateConfig,
|
|
||||||
GROUP_ICONS,
|
|
||||||
} from "@/types/frigateConfig";
|
|
||||||
import { isDesktop } from "react-device-detect";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { MdHome } from "react-icons/md";
|
import { MdHome } from "react-icons/md";
|
||||||
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
|
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { getIconForGroup } from "@/utils/iconUtil";
|
import { LuPencil, LuPlus } from "react-icons/lu";
|
||||||
import { LuPencil, LuPlus, LuTrash } from "react-icons/lu";
|
|
||||||
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
|
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
|
||||||
|
import { Drawer, DrawerContent } from "../ui/drawer";
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
|
import { Separator } from "../ui/separator";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuItem,
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "../ui/alert-dialog";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import FilterSwitch from "./FilterSwitch";
|
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 = {
|
type CameraGroupSelectorProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -52,7 +81,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
|
|
||||||
// groups
|
// groups
|
||||||
|
|
||||||
const [group, setGroup] = usePersistedOverlayState(
|
const [group, setGroup, deleteGroup] = usePersistedOverlayState(
|
||||||
"cameraGroup",
|
"cameraGroup",
|
||||||
"default" as string,
|
"default" as string,
|
||||||
);
|
);
|
||||||
@ -71,70 +100,93 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
|
|
||||||
const [addGroup, setAddGroup] = useState(false);
|
const [addGroup, setAddGroup] = useState(false);
|
||||||
|
|
||||||
|
const Scroller = isMobile ? ScrollArea : "div";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={`flex items-center justify-start gap-2 ${className ?? ""} ${isDesktop ? "flex-col" : ""}`}
|
|
||||||
>
|
|
||||||
<NewGroupDialog
|
<NewGroupDialog
|
||||||
open={addGroup}
|
open={addGroup}
|
||||||
setOpen={setAddGroup}
|
setOpen={setAddGroup}
|
||||||
currentGroups={groups}
|
currentGroups={groups}
|
||||||
|
activeGroup={group}
|
||||||
|
setGroup={setGroup}
|
||||||
|
deleteGroup={deleteGroup}
|
||||||
/>
|
/>
|
||||||
|
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
|
||||||
<Tooltip open={tooltip == "default"}>
|
<div
|
||||||
<TooltipTrigger asChild>
|
className={cn(
|
||||||
<Button
|
"flex items-center justify-start gap-2",
|
||||||
className={
|
className,
|
||||||
group == "default"
|
isDesktop ? "flex-col" : "whitespace-nowrap",
|
||||||
? "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"
|
>
|
||||||
}
|
<Tooltip open={tooltip == "default"}>
|
||||||
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}>
|
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className={
|
className={
|
||||||
group == name
|
group == "default"
|
||||||
? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
|
? "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"
|
size="xs"
|
||||||
onClick={() => setGroup(name, group != "default")}
|
onClick={() => (group ? setGroup("default", true) : null)}
|
||||||
onMouseEnter={() => (isDesktop ? showTooltip(name) : null)}
|
onMouseEnter={() => (isDesktop ? showTooltip("default") : null)}
|
||||||
onMouseLeave={() => (isDesktop ? showTooltip(undefined) : null)}
|
onMouseLeave={() => (isDesktop ? showTooltip(undefined) : null)}
|
||||||
>
|
>
|
||||||
{getIconForGroup(config.icon)}
|
<MdHome className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="capitalize" side="right">
|
<TooltipPortal>
|
||||||
{name}
|
<TooltipContent className="capitalize" side="right">
|
||||||
</TooltipContent>
|
All Cameras
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
{groups.map(([name, config]) => {
|
||||||
})}
|
return (
|
||||||
{isDesktop && (
|
<Tooltip key={name} open={tooltip == name}>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
className="text-muted-foreground bg-secondary"
|
<Button
|
||||||
size="xs"
|
className={
|
||||||
onClick={() => setAddGroup(true)}
|
group == name
|
||||||
>
|
? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
|
||||||
<LuPlus className="size-4 text-primary" />
|
: "text-secondary-foreground bg-secondary"
|
||||||
</Button>
|
}
|
||||||
)}
|
size="xs"
|
||||||
</div>
|
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;
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
currentGroups: [string, CameraGroupConfig][];
|
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 } =
|
const { data: config, mutate: updateConfig } =
|
||||||
useSWR<FrigateConfig>("config");
|
useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
const birdseyeConfig = useMemo(() => config?.birdseye, [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");
|
cameras: z.array(z.string()).min(2, {
|
||||||
const [newTitle, setNewTitle] = useState("");
|
message: "You must select at least two cameras.",
|
||||||
const [icon, setIcon] = useState("");
|
}),
|
||||||
const [cameras, setCameras] = useState<string[]>([]);
|
icon: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "You must select an icon." })
|
||||||
|
.refine((value) => Object.keys(LuIcons).includes(value), {
|
||||||
|
message: "Invalid icon",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
// validation
|
const onSubmit = useCallback(
|
||||||
|
async (values: z.infer<typeof formSchema>) => {
|
||||||
const [error, setError] = useState("");
|
if (!values) {
|
||||||
|
return;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Dialog
|
<Form {...form}>
|
||||||
open={open}
|
<form
|
||||||
onOpenChange={(open) => {
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
setEditState("none");
|
className="mt-2 space-y-6 overflow-y-auto"
|
||||||
setNewTitle("");
|
>
|
||||||
setIcon("");
|
<FormField
|
||||||
setCameras([]);
|
control={form.control}
|
||||||
setOpen(open);
|
name="name"
|
||||||
}}
|
render={({ field }) => (
|
||||||
>
|
<FormItem>
|
||||||
<DialogContent className="min-w-0 w-96">
|
<FormLabel>Name</FormLabel>
|
||||||
<DialogTitle>Camera Groups</DialogTitle>
|
<FormControl>
|
||||||
{currentGroups.map((group) => (
|
<Input
|
||||||
<div key={group[0]} className="flex justify-between items-center">
|
className="w-full p-2 border border-input bg-background hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||||
{group[0]}
|
placeholder="Enter a name..."
|
||||||
<div className="flex justify-center gap-1">
|
disabled={editingGroup !== undefined}
|
||||||
<Button
|
{...field}
|
||||||
className="bg-transparent"
|
/>
|
||||||
size="icon"
|
</FormControl>
|
||||||
onClick={() => {
|
<FormMessage />
|
||||||
setNewTitle(group[0]);
|
</FormItem>
|
||||||
setIcon(group[1].icon);
|
)}
|
||||||
setCameras(group[1].cameras);
|
/>
|
||||||
setEditState("edit");
|
|
||||||
}}
|
<Separator className="flex my-2 bg-secondary" />
|
||||||
>
|
<div className="max-h-[25dvh] md:max-h-[40dvh] overflow-y-auto">
|
||||||
<LuPencil />
|
<FormField
|
||||||
</Button>
|
control={form.control}
|
||||||
<Button
|
name="cameras"
|
||||||
className="text-destructive bg-transparent"
|
render={({ field }) => (
|
||||||
size="icon"
|
<FormItem>
|
||||||
onClick={() => onDeleteGroup(group[0])}
|
<FormLabel>Cameras</FormLabel>
|
||||||
>
|
<FormDescription>
|
||||||
<LuTrash />
|
Select cameras for this group.
|
||||||
</Button>
|
</FormDescription>
|
||||||
</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>
|
|
||||||
{[
|
{[
|
||||||
...(birdseyeConfig?.enabled ? ["birdseye"] : []),
|
...(birdseyeConfig?.enabled ? ["birdseye"] : []),
|
||||||
...Object.keys(config?.cameras ?? {}),
|
...Object.keys(config?.cameras ?? {}),
|
||||||
].map((camera) => (
|
].map((camera) => (
|
||||||
<FilterSwitch
|
<FormControl key={camera}>
|
||||||
key={camera}
|
<FilterSwitch
|
||||||
isChecked={cameras.includes(camera)}
|
isChecked={field.value && field.value.includes(camera)}
|
||||||
label={camera.replaceAll("_", " ")}
|
label={camera.replaceAll("_", " ")}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
if (checked) {
|
const updatedCameras = checked
|
||||||
setCameras([...cameras, camera]);
|
? [...(field.value || []), camera]
|
||||||
} else {
|
: (field.value || []).filter((c) => c !== camera);
|
||||||
const index = cameras.indexOf(camera);
|
form.setValue("cameras", updatedCameras);
|
||||||
setCameras([
|
}}
|
||||||
...cameras.slice(0, index),
|
/>
|
||||||
...cameras.slice(index + 1),
|
</FormControl>
|
||||||
]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
<FormMessage />
|
||||||
</DropdownMenu>
|
</FormItem>
|
||||||
{error && <div className="text-danger">{error}</div>}
|
)}
|
||||||
<Button variant="select" onClick={onCreateGroup}>
|
/>
|
||||||
Submit
|
</div>
|
||||||
</Button>
|
|
||||||
</>
|
<Separator className="flex my-2 bg-secondary" />
|
||||||
)}
|
<FormField
|
||||||
</DialogContent>
|
control={form.control}
|
||||||
</Dialog>
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,24 +3,27 @@ import { Label } from "../ui/label";
|
|||||||
|
|
||||||
type FilterSwitchProps = {
|
type FilterSwitchProps = {
|
||||||
label: string;
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
isChecked: boolean;
|
isChecked: boolean;
|
||||||
onCheckedChange: (checked: boolean) => void;
|
onCheckedChange: (checked: boolean) => void;
|
||||||
};
|
};
|
||||||
export default function FilterSwitch({
|
export default function FilterSwitch({
|
||||||
label,
|
label,
|
||||||
|
disabled = false,
|
||||||
isChecked,
|
isChecked,
|
||||||
onCheckedChange,
|
onCheckedChange,
|
||||||
}: FilterSwitchProps) {
|
}: FilterSwitchProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-center gap-1">
|
<div className="flex justify-between items-center gap-1">
|
||||||
<Label
|
<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}
|
htmlFor={label}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id={label}
|
id={label}
|
||||||
|
disabled={disabled}
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onCheckedChange={onCheckedChange}
|
onCheckedChange={onCheckedChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
import { ReviewFilter, ReviewSummary } from "@/types/review";
|
import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review";
|
||||||
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
||||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||||
import {
|
import {
|
||||||
@ -49,19 +49,21 @@ const DEFAULT_REVIEW_FILTERS: ReviewFilters[] = [
|
|||||||
|
|
||||||
type ReviewFilterGroupProps = {
|
type ReviewFilterGroupProps = {
|
||||||
filters?: ReviewFilters[];
|
filters?: ReviewFilters[];
|
||||||
|
currentSeverity?: ReviewSeverity;
|
||||||
reviewSummary?: ReviewSummary;
|
reviewSummary?: ReviewSummary;
|
||||||
filter?: ReviewFilter;
|
filter?: ReviewFilter;
|
||||||
onUpdateFilter: (filter: ReviewFilter) => void;
|
|
||||||
motionOnly: boolean;
|
motionOnly: boolean;
|
||||||
|
onUpdateFilter: (filter: ReviewFilter) => void;
|
||||||
setMotionOnly: React.Dispatch<React.SetStateAction<boolean>>;
|
setMotionOnly: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ReviewFilterGroup({
|
export default function ReviewFilterGroup({
|
||||||
filters = DEFAULT_REVIEW_FILTERS,
|
filters = DEFAULT_REVIEW_FILTERS,
|
||||||
|
currentSeverity,
|
||||||
reviewSummary,
|
reviewSummary,
|
||||||
filter,
|
filter,
|
||||||
onUpdateFilter,
|
|
||||||
motionOnly,
|
motionOnly,
|
||||||
|
onUpdateFilter,
|
||||||
setMotionOnly,
|
setMotionOnly,
|
||||||
}: ReviewFilterGroupProps) {
|
}: ReviewFilterGroupProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
@ -179,6 +181,11 @@ export default function ReviewFilterGroup({
|
|||||||
<GeneralFilterButton
|
<GeneralFilterButton
|
||||||
allLabels={filterValues.labels}
|
allLabels={filterValues.labels}
|
||||||
selectedLabels={filter?.labels}
|
selectedLabels={filter?.labels}
|
||||||
|
currentSeverity={currentSeverity}
|
||||||
|
showAll={filter?.showAll == true}
|
||||||
|
setShowAll={(showAll) => {
|
||||||
|
onUpdateFilter({ ...filter, showAll });
|
||||||
|
}}
|
||||||
updateLabelFilter={(newLabels) => {
|
updateLabelFilter={(newLabels) => {
|
||||||
onUpdateFilter({ ...filter, labels: newLabels });
|
onUpdateFilter({ ...filter, labels: newLabels });
|
||||||
}}
|
}}
|
||||||
@ -188,6 +195,7 @@ export default function ReviewFilterGroup({
|
|||||||
<MobileReviewSettingsDrawer
|
<MobileReviewSettingsDrawer
|
||||||
features={mobileSettingsFeatures}
|
features={mobileSettingsFeatures}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
|
currentSeverity={currentSeverity}
|
||||||
reviewSummary={reviewSummary}
|
reviewSummary={reviewSummary}
|
||||||
onUpdateFilter={onUpdateFilter}
|
onUpdateFilter={onUpdateFilter}
|
||||||
// not applicable as exports are not used
|
// not applicable as exports are not used
|
||||||
@ -248,7 +256,7 @@ export function CamerasFilterButton({
|
|||||||
<DropdownMenuSeparator />
|
<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
|
<FilterSwitch
|
||||||
isChecked={currentCameras == undefined}
|
isChecked={currentCameras == undefined}
|
||||||
label="All Cameras"
|
label="All Cameras"
|
||||||
@ -477,11 +485,17 @@ function CalendarFilterButton({
|
|||||||
type GeneralFilterButtonProps = {
|
type GeneralFilterButtonProps = {
|
||||||
allLabels: string[];
|
allLabels: string[];
|
||||||
selectedLabels: string[] | undefined;
|
selectedLabels: string[] | undefined;
|
||||||
|
currentSeverity?: ReviewSeverity;
|
||||||
|
showAll: boolean;
|
||||||
|
setShowAll: (showAll: boolean) => void;
|
||||||
updateLabelFilter: (labels: string[] | undefined) => void;
|
updateLabelFilter: (labels: string[] | undefined) => void;
|
||||||
};
|
};
|
||||||
function GeneralFilterButton({
|
function GeneralFilterButton({
|
||||||
allLabels,
|
allLabels,
|
||||||
selectedLabels,
|
selectedLabels,
|
||||||
|
currentSeverity,
|
||||||
|
showAll,
|
||||||
|
setShowAll,
|
||||||
updateLabelFilter,
|
updateLabelFilter,
|
||||||
}: GeneralFilterButtonProps) {
|
}: GeneralFilterButtonProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -510,6 +524,9 @@ function GeneralFilterButton({
|
|||||||
allLabels={allLabels}
|
allLabels={allLabels}
|
||||||
selectedLabels={selectedLabels}
|
selectedLabels={selectedLabels}
|
||||||
currentLabels={currentLabels}
|
currentLabels={currentLabels}
|
||||||
|
currentSeverity={currentSeverity}
|
||||||
|
showAll={showAll}
|
||||||
|
setShowAll={setShowAll}
|
||||||
updateLabelFilter={updateLabelFilter}
|
updateLabelFilter={updateLabelFilter}
|
||||||
setCurrentLabels={setCurrentLabels}
|
setCurrentLabels={setCurrentLabels}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
@ -557,6 +574,9 @@ type GeneralFilterContentProps = {
|
|||||||
allLabels: string[];
|
allLabels: string[];
|
||||||
selectedLabels: string[] | undefined;
|
selectedLabels: string[] | undefined;
|
||||||
currentLabels: string[] | undefined;
|
currentLabels: string[] | undefined;
|
||||||
|
currentSeverity?: ReviewSeverity;
|
||||||
|
showAll?: boolean;
|
||||||
|
setShowAll?: (showAll: boolean) => void;
|
||||||
updateLabelFilter: (labels: string[] | undefined) => void;
|
updateLabelFilter: (labels: string[] | undefined) => void;
|
||||||
setCurrentLabels: (labels: string[] | undefined) => void;
|
setCurrentLabels: (labels: string[] | undefined) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -565,13 +585,35 @@ export function GeneralFilterContent({
|
|||||||
allLabels,
|
allLabels,
|
||||||
selectedLabels,
|
selectedLabels,
|
||||||
currentLabels,
|
currentLabels,
|
||||||
|
currentSeverity,
|
||||||
|
showAll,
|
||||||
|
setShowAll,
|
||||||
updateLabelFilter,
|
updateLabelFilter,
|
||||||
setCurrentLabels,
|
setCurrentLabels,
|
||||||
onClose,
|
onClose,
|
||||||
}: GeneralFilterContentProps) {
|
}: GeneralFilterContentProps) {
|
||||||
return (
|
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">
|
<div className="flex justify-between items-center my-2.5">
|
||||||
<Label
|
<Label
|
||||||
className="mx-2 text-primary cursor-pointer"
|
className="mx-2 text-primary cursor-pointer"
|
||||||
|
|||||||
@ -37,10 +37,6 @@ export function ThresholdBarGraph({
|
|||||||
|
|
||||||
const formatTime = useCallback(
|
const formatTime = useCallback(
|
||||||
(val: unknown) => {
|
(val: unknown) => {
|
||||||
if (val == 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(updateTimes[Math.round(val as number) - 1] * 1000);
|
const date = new Date(updateTimes[Math.round(val as number) - 1] * 1000);
|
||||||
return date.toLocaleTimeString([], {
|
return date.toLocaleTimeString([], {
|
||||||
hour12: config?.ui.time_format != "24hour",
|
hour12: config?.ui.time_format != "24hour",
|
||||||
@ -110,7 +106,7 @@ export function ThresholdBarGraph({
|
|||||||
tickAmount: isMobileOnly ? 3 : 4,
|
tickAmount: isMobileOnly ? 3 : 4,
|
||||||
tickPlacement: "on",
|
tickPlacement: "on",
|
||||||
labels: {
|
labels: {
|
||||||
offsetX: -18,
|
rotate: 0,
|
||||||
formatter: formatTime,
|
formatter: formatTime,
|
||||||
},
|
},
|
||||||
axisBorder: {
|
axisBorder: {
|
||||||
@ -149,8 +145,8 @@ export function ThresholdBarGraph({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getUnitSize = (MB: number) => {
|
const getUnitSize = (MB: number) => {
|
||||||
if (isNaN(MB) || MB < 0) return "Invalid number";
|
if (MB === null || isNaN(MB) || MB < 0) return "Invalid number";
|
||||||
if (MB < 1024) return `${MB} MiB`;
|
if (MB < 1024) return `${MB.toFixed(2)} MiB`;
|
||||||
if (MB < 1048576) return `${(MB / 1024).toFixed(2)} GiB`;
|
if (MB < 1048576) return `${(MB / 1024).toFixed(2)} GiB`;
|
||||||
|
|
||||||
return `${(MB / 1048576).toFixed(2)} TiB`;
|
return `${(MB / 1048576).toFixed(2)} TiB`;
|
||||||
@ -301,10 +297,6 @@ export function CameraLineGraph({
|
|||||||
|
|
||||||
const formatTime = useCallback(
|
const formatTime = useCallback(
|
||||||
(val: unknown) => {
|
(val: unknown) => {
|
||||||
if (val == 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(updateTimes[Math.round(val as number)] * 1000);
|
const date = new Date(updateTimes[Math.round(val as number)] * 1000);
|
||||||
return date.toLocaleTimeString([], {
|
return date.toLocaleTimeString([], {
|
||||||
hour12: config?.ui.time_format != "24hour",
|
hour12: config?.ui.time_format != "24hour",
|
||||||
@ -352,7 +344,7 @@ export function CameraLineGraph({
|
|||||||
tickAmount: isMobileOnly ? 3 : 4,
|
tickAmount: isMobileOnly ? 3 : 4,
|
||||||
tickPlacement: "on",
|
tickPlacement: "on",
|
||||||
labels: {
|
labels: {
|
||||||
offsetX: isMobileOnly ? -18 : 0,
|
rotate: 0,
|
||||||
formatter: formatTime,
|
formatter: formatTime,
|
||||||
},
|
},
|
||||||
axisBorder: {
|
axisBorder: {
|
||||||
|
|||||||
22
web/src/components/icons/FrigatePlusIcon.tsx
Normal file
22
web/src/components/icons/FrigatePlusIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
web/src/components/icons/IconPicker.tsx
Normal file
154
web/src/components/icons/IconPicker.tsx
Normal 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 })}</>;
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { LogSeverity } from "@/types/log";
|
import { LogSeverity } from "@/types/log";
|
||||||
import { ReactNode, useMemo, useRef } from "react";
|
import { ReactNode, useMemo, useRef } from "react";
|
||||||
import { CSSTransition } from "react-transition-group";
|
import { CSSTransition } from "react-transition-group";
|
||||||
@ -32,7 +33,10 @@ export default function Chip({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={nodeRef}
|
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}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { isSafari } from "react-device-detect";
|
import { isSafari } from "react-device-detect";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function ImageLoadingIndicator({
|
export default function ImageLoadingIndicator({
|
||||||
className,
|
className,
|
||||||
@ -13,8 +14,8 @@ export default function ImageLoadingIndicator({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return isSafari ? (
|
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)} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { LuLoader2 } from "react-icons/lu";
|
import { LuLoader2 } from "react-icons/lu";
|
||||||
|
|
||||||
export default function ActivityIndicator({ className = "w-full", size = 30 }) {
|
export default function ActivityIndicator({ className = "w-full", size = 30 }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-center ${className}`}
|
className={cn("flex items-center justify-center", className)}
|
||||||
aria-label="Loading…"
|
aria-label="Loading…"
|
||||||
>
|
>
|
||||||
<LuLoader2 className="animate-spin" size={size} />
|
<LuLoader2 className="animate-spin" size={size} />
|
||||||
|
|||||||
@ -3,22 +3,35 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { VscAccount } from "react-icons/vsc";
|
import { VscAccount } from "react-icons/vsc";
|
||||||
|
|
||||||
export default function AccountSettings() {
|
type AccountSettingsProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
export default function AccountSettings({ className }: AccountSettingsProps) {
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<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]" />
|
<VscAccount className="size-5 md:m-[6px]" />
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipPortal>
|
||||||
<p>Account</p>
|
<TooltipContent side="right">
|
||||||
</TooltipContent>
|
<p>Account</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,11 +65,12 @@ import {
|
|||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "../ui/dialog";
|
} from "../ui/dialog";
|
||||||
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
type GeneralSettings = {
|
type GeneralSettingsProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
export default function GeneralSettings({ className }: GeneralSettings) {
|
export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||||
const { theme, colorScheme, setTheme, setColorScheme } = useTheme();
|
const { theme, colorScheme, setTheme, setColorScheme } = useTheme();
|
||||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||||
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
|
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
|
||||||
@ -124,9 +125,11 @@ export default function GeneralSettings({ className }: GeneralSettings) {
|
|||||||
<LuSettings className="size-5 md:m-[6px]" />
|
<LuSettings className="size-5 md:m-[6px]" />
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipPortal>
|
||||||
<p>Settings</p>
|
<TooltipContent side="right">
|
||||||
</TooltipContent>
|
<p>Settings</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</a>
|
</a>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
@ -139,7 +142,7 @@ export default function GeneralSettings({ className }: GeneralSettings) {
|
|||||||
<DropdownMenuLabel>System</DropdownMenuLabel>
|
<DropdownMenuLabel>System</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
|
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
|
||||||
<Link to="/system">
|
<Link to="/system#general">
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className={
|
className={
|
||||||
isDesktop
|
isDesktop
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import {
|
|||||||
StatusBarMessagesContext,
|
StatusBarMessagesContext,
|
||||||
StatusMessage,
|
StatusMessage,
|
||||||
} from "@/context/statusbar-provider";
|
} from "@/context/statusbar-provider";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Bottombar() {
|
function Bottombar() {
|
||||||
const navItems = useNavigation("secondary");
|
const navItems = useNavigation("secondary");
|
||||||
@ -20,16 +22,19 @@ function Bottombar() {
|
|||||||
return (
|
return (
|
||||||
<div className="absolute h-16 inset-x-4 bottom-0 flex flex-row items-center justify-between">
|
<div className="absolute h-16 inset-x-4 bottom-0 flex flex-row items-center justify-between">
|
||||||
{navItems.map((item) => (
|
{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 />
|
<GeneralSettings className="p-2" />
|
||||||
<AccountSettings />
|
<AccountSettings className="p-2" />
|
||||||
<StatusAlertNav />
|
<StatusAlertNav className="p-2" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusAlertNav() {
|
type StatusAlertNavProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
function StatusAlertNav({ className }: StatusAlertNavProps) {
|
||||||
const { data: initialStats } = useSWR<FrigateStats>("stats", {
|
const { data: initialStats } = useSWR<FrigateStats>("stats", {
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
});
|
});
|
||||||
@ -51,7 +56,13 @@ function StatusAlertNav() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearMessages("stats");
|
clearMessages("stats");
|
||||||
potentialProblems.forEach((problem) => {
|
potentialProblems.forEach((problem) => {
|
||||||
addMessage("stats", problem.text, problem.color);
|
addMessage(
|
||||||
|
"stats",
|
||||||
|
problem.text,
|
||||||
|
problem.color,
|
||||||
|
undefined,
|
||||||
|
problem.relevantLink,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}, [potentialProblems, addMessage, clearMessages]);
|
}, [potentialProblems, addMessage, clearMessages]);
|
||||||
|
|
||||||
@ -64,18 +75,31 @@ function StatusAlertNav() {
|
|||||||
<DrawerTrigger>
|
<DrawerTrigger>
|
||||||
<IoIosWarning className="size-5 text-danger" />
|
<IoIosWarning className="size-5 text-danger" />
|
||||||
</DrawerTrigger>
|
</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">
|
<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]) => (
|
{Object.entries(messages).map(([key, messageArray]) => (
|
||||||
<div key={key} className="w-full flex items-center gap-2">
|
<div key={key} className="w-full flex items-center gap-2">
|
||||||
{messageArray.map(({ id, text, color }: StatusMessage) => (
|
{messageArray.map(({ id, text, color, link }: StatusMessage) => {
|
||||||
<div key={id} className="flex items-center text-xs gap-2">
|
const message = (
|
||||||
<IoIosWarning
|
<div key={id} className="flex items-center text-xs gap-2">
|
||||||
className={`size-5 ${color || "text-danger"}`}
|
<IoIosWarning
|
||||||
/>
|
className={`size-5 ${color || "text-danger"}`}
|
||||||
{text}
|
/>
|
||||||
</div>
|
{text}
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (link) {
|
||||||
|
return <Link to={link}>{message}</Link>;
|
||||||
|
} else {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { isDesktop } from "react-device-detect";
|
|||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
import { NavData } from "@/types/navigation";
|
import { NavData } from "@/types/navigation";
|
||||||
import { IconType } from "react-icons";
|
import { IconType } from "react-icons";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
primary: {
|
primary: {
|
||||||
@ -42,9 +43,11 @@ export default function NavItem({
|
|||||||
to={item.url}
|
to={item.url}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex flex-col justify-center items-center rounded-lg ${className ?? ""} ${
|
cn(
|
||||||
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"]
|
"flex flex-col justify-center items-center rounded-lg",
|
||||||
}`
|
className,
|
||||||
|
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon className="size-5 md:m-[6px]" />
|
<Icon className="size-5 md:m-[6px]" />
|
||||||
|
|||||||
@ -121,6 +121,14 @@ export default function ExportDialog({
|
|||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
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");
|
setMode("select");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { ExportContent } from "./ExportDialog";
|
|||||||
import { ExportMode } from "@/types/filter";
|
import { ExportMode } from "@/types/filter";
|
||||||
import ReviewActivityCalendar from "./ReviewActivityCalendar";
|
import ReviewActivityCalendar from "./ReviewActivityCalendar";
|
||||||
import { SelectSeparator } from "../ui/select";
|
import { SelectSeparator } from "../ui/select";
|
||||||
import { ReviewFilter, ReviewSummary } from "@/types/review";
|
import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review";
|
||||||
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
||||||
import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
|
import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -31,6 +31,7 @@ type MobileReviewSettingsDrawerProps = {
|
|||||||
features?: DrawerFeatures[];
|
features?: DrawerFeatures[];
|
||||||
camera: string;
|
camera: string;
|
||||||
filter?: ReviewFilter;
|
filter?: ReviewFilter;
|
||||||
|
currentSeverity?: ReviewSeverity;
|
||||||
latestTime: number;
|
latestTime: number;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
range?: TimeRange;
|
range?: TimeRange;
|
||||||
@ -44,6 +45,7 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
features = DEFAULT_DRAWER_FEATURES,
|
features = DEFAULT_DRAWER_FEATURES,
|
||||||
camera,
|
camera,
|
||||||
filter,
|
filter,
|
||||||
|
currentSeverity,
|
||||||
latestTime,
|
latestTime,
|
||||||
currentTime,
|
currentTime,
|
||||||
range,
|
range,
|
||||||
@ -109,6 +111,9 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
const cameras = filter?.cameras || Object.keys(config.cameras);
|
const cameras = filter?.cameras || Object.keys(config.cameras);
|
||||||
|
|
||||||
cameras.forEach((camera) => {
|
cameras.forEach((camera) => {
|
||||||
|
if (camera == "birdseye") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const cameraConfig = config.cameras[camera];
|
const cameraConfig = config.cameras[camera];
|
||||||
cameraConfig.objects.track.forEach((label) => {
|
cameraConfig.objects.track.forEach((label) => {
|
||||||
labels.add(label);
|
labels.add(label);
|
||||||
@ -138,7 +143,10 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
{features.includes("export") && (
|
{features.includes("export") && (
|
||||||
<Button
|
<Button
|
||||||
className="w-full flex justify-center items-center gap-2"
|
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" />
|
<FaArrowDown className="p-1 fill-secondary bg-secondary-foreground rounded-md" />
|
||||||
Export
|
Export
|
||||||
@ -257,6 +265,11 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
allLabels={allLabels}
|
allLabels={allLabels}
|
||||||
selectedLabels={filter?.labels}
|
selectedLabels={filter?.labels}
|
||||||
currentLabels={currentLabels}
|
currentLabels={currentLabels}
|
||||||
|
currentSeverity={currentSeverity}
|
||||||
|
showAll={filter?.showAll == true}
|
||||||
|
setShowAll={(showAll) => {
|
||||||
|
onUpdateFilter({ ...filter, showAll });
|
||||||
|
}}
|
||||||
setCurrentLabels={setCurrentLabels}
|
setCurrentLabels={setCurrentLabels}
|
||||||
updateLabelFilter={(newLabels) =>
|
updateLabelFilter={(newLabels) =>
|
||||||
onUpdateFilter({ ...filter, labels: newLabels })
|
onUpdateFilter({ ...filter, labels: newLabels })
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
|
|||||||
import JSMpegPlayer from "./JSMpegPlayer";
|
import JSMpegPlayer from "./JSMpegPlayer";
|
||||||
import MSEPlayer from "./MsePlayer";
|
import MSEPlayer from "./MsePlayer";
|
||||||
import { LivePlayerMode } from "@/types/live";
|
import { LivePlayerMode } from "@/types/live";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type LivePlayerProps = {
|
type LivePlayerProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -57,7 +58,10 @@ export default function BirdseyeLivePlayer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative flex justify-center w-full cursor-pointer ${className ?? ""}`}
|
className={cn(
|
||||||
|
"relative flex justify-center w-full cursor-pointer",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
onClick={onClick}
|
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 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>
|
||||||
|
|||||||
@ -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 Hls from "hls.js";
|
||||||
import { isAndroid, isDesktop, isMobile } from "react-device-detect";
|
import { isAndroid, isDesktop, isMobile } from "react-device-detect";
|
||||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||||
import VideoControls from "./VideoControls";
|
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
|
// Android native hls does not seek correctly
|
||||||
const USE_NATIVE_HLS = !isAndroid;
|
const USE_NATIVE_HLS = !isAndroid;
|
||||||
@ -21,6 +32,8 @@ type HlsVideoPlayerProps = {
|
|||||||
onPlayerLoaded?: () => void;
|
onPlayerLoaded?: () => void;
|
||||||
onTimeUpdate?: (time: number) => void;
|
onTimeUpdate?: (time: number) => void;
|
||||||
onPlaying?: () => void;
|
onPlaying?: () => void;
|
||||||
|
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||||
|
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
|
||||||
};
|
};
|
||||||
export default function HlsVideoPlayer({
|
export default function HlsVideoPlayer({
|
||||||
videoRef,
|
videoRef,
|
||||||
@ -31,13 +44,29 @@ export default function HlsVideoPlayer({
|
|||||||
onPlayerLoaded,
|
onPlayerLoaded,
|
||||||
onTimeUpdate,
|
onTimeUpdate,
|
||||||
onPlaying,
|
onPlaying,
|
||||||
|
setFullResolution,
|
||||||
|
onUploadFrame,
|
||||||
}: HlsVideoPlayerProps) {
|
}: HlsVideoPlayerProps) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
// playback
|
// playback
|
||||||
|
|
||||||
const hlsRef = useRef<Hls>();
|
const hlsRef = useRef<Hls>();
|
||||||
const [useHlsCompat, setUseHlsCompat] = useState(false);
|
const [useHlsCompat, setUseHlsCompat] = useState(false);
|
||||||
const [loadedMetadata, setLoadedMetadata] = 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(() => {
|
useEffect(() => {
|
||||||
if (!videoRef.current) {
|
if (!videoRef.current) {
|
||||||
return;
|
return;
|
||||||
@ -116,10 +145,15 @@ export default function HlsVideoPlayer({
|
|||||||
className="absolute bottom-5 left-1/2 -translate-x-1/2 z-50"
|
className="absolute bottom-5 left-1/2 -translate-x-1/2 z-50"
|
||||||
video={videoRef.current}
|
video={videoRef.current}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
show={visible && controls}
|
show={visible && (controls || controlsOpen)}
|
||||||
muted={muted}
|
muted={muted}
|
||||||
volume={volume}
|
volume={volume}
|
||||||
controlsOpen={controlsOpen}
|
features={{
|
||||||
|
volume: true,
|
||||||
|
seek: true,
|
||||||
|
playbackRate: true,
|
||||||
|
plusUpload: config?.plus?.enabled == true,
|
||||||
|
}}
|
||||||
setControlsOpen={setControlsOpen}
|
setControlsOpen={setControlsOpen}
|
||||||
setMuted={setMuted}
|
setMuted={setMuted}
|
||||||
playbackRate={videoRef.current?.playbackRate ?? 1}
|
playbackRate={videoRef.current?.playbackRate ?? 1}
|
||||||
@ -147,6 +181,21 @@ export default function HlsVideoPlayer({
|
|||||||
onSetPlaybackRate={(rate) =>
|
onSetPlaybackRate={(rate) =>
|
||||||
videoRef.current ? (videoRef.current.playbackRate = rate) : null
|
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
|
<TransformComponent
|
||||||
wrapperStyle={{
|
wrapperStyle={{
|
||||||
@ -193,7 +242,7 @@ export default function HlsVideoPlayer({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onLoadedData={onPlayerLoaded}
|
onLoadedData={onPlayerLoaded}
|
||||||
onLoadedMetadata={() => setLoadedMetadata(true)}
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
onEnded={onClipEnded}
|
onEnded={onClipEnded}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@ -6,9 +6,14 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import MSEPlayer from "./MsePlayer";
|
import MSEPlayer from "./MsePlayer";
|
||||||
import JSMpegPlayer from "./JSMpegPlayer";
|
import JSMpegPlayer from "./JSMpegPlayer";
|
||||||
import { MdCircle } from "react-icons/md";
|
import { MdCircle } from "react-icons/md";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
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 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 = {
|
type LivePlayerProps = {
|
||||||
cameraRef?: (ref: HTMLDivElement | null) => void;
|
cameraRef?: (ref: HTMLDivElement | null) => void;
|
||||||
@ -22,6 +27,7 @@ type LivePlayerProps = {
|
|||||||
iOSCompatFullScreen?: boolean;
|
iOSCompatFullScreen?: boolean;
|
||||||
pip?: boolean;
|
pip?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LivePlayer({
|
export default function LivePlayer({
|
||||||
@ -36,10 +42,12 @@ export default function LivePlayer({
|
|||||||
iOSCompatFullScreen = false,
|
iOSCompatFullScreen = false,
|
||||||
pip,
|
pip,
|
||||||
onClick,
|
onClick,
|
||||||
|
setFullResolution,
|
||||||
}: LivePlayerProps) {
|
}: LivePlayerProps) {
|
||||||
// camera activity
|
// camera activity
|
||||||
|
|
||||||
const { activeMotion, activeTracking } = useCameraActivity(cameraConfig);
|
const { activeMotion, activeTracking, objects } =
|
||||||
|
useCameraActivity(cameraConfig);
|
||||||
|
|
||||||
const cameraActive = useMemo(
|
const cameraActive = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -110,11 +118,12 @@ export default function LivePlayer({
|
|||||||
player = (
|
player = (
|
||||||
<MSEPlayer
|
<MSEPlayer
|
||||||
className={`rounded-lg md:rounded-2xl size-full ${liveReady ? "" : "hidden"}`}
|
className={`rounded-lg md:rounded-2xl size-full ${liveReady ? "" : "hidden"}`}
|
||||||
camera={cameraConfig.name}
|
camera={cameraConfig.live.stream_name}
|
||||||
playbackEnabled={cameraActive}
|
playbackEnabled={cameraActive}
|
||||||
audioEnabled={playAudio}
|
audioEnabled={playAudio}
|
||||||
onPlaying={() => setLiveReady(true)}
|
onPlaying={() => setLiveReady(true)}
|
||||||
pip={pip}
|
pip={pip}
|
||||||
|
setFullResolution={setFullResolution}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -129,7 +138,7 @@ export default function LivePlayer({
|
|||||||
player = (
|
player = (
|
||||||
<JSMpegPlayer
|
<JSMpegPlayer
|
||||||
className="size-full flex justify-center rounded-lg md:rounded-2xl overflow-hidden"
|
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}
|
width={cameraConfig.detect.width}
|
||||||
height={cameraConfig.detect.height}
|
height={cameraConfig.detect.height}
|
||||||
/>
|
/>
|
||||||
@ -142,17 +151,65 @@ export default function LivePlayer({
|
|||||||
<div
|
<div
|
||||||
ref={cameraRef}
|
ref={cameraRef}
|
||||||
data-camera={cameraConfig.name}
|
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
|
activeTracking
|
||||||
? "outline-severity_alert outline-3 rounded-lg md:rounded-2xl shadow-severity_alert"
|
? "outline-severity_alert outline-3 rounded-lg md:rounded-2xl shadow-severity_alert"
|
||||||
: "outline-0 outline-background"
|
: "outline-0 outline-background",
|
||||||
} transition-all duration-500 ${className}`}
|
"transition-all duration-500",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
onClick={onClick}
|
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 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>
|
<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}
|
{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
|
<div
|
||||||
className={`absolute inset-0 w-full ${
|
className={`absolute inset-0 w-full ${
|
||||||
showStillWithoutActivity && !liveReady ? "visible" : "invisible"
|
showStillWithoutActivity && !liveReady ? "visible" : "invisible"
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
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 = {
|
type MSEPlayerProps = {
|
||||||
camera: string;
|
camera: string;
|
||||||
@ -8,6 +16,7 @@ type MSEPlayerProps = {
|
|||||||
audioEnabled?: boolean;
|
audioEnabled?: boolean;
|
||||||
pip?: boolean;
|
pip?: boolean;
|
||||||
onPlaying?: () => void;
|
onPlaying?: () => void;
|
||||||
|
setFullResolution?: React.Dispatch<SetStateAction<VideoResolutionType>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function MSEPlayer({
|
function MSEPlayer({
|
||||||
@ -17,6 +26,7 @@ function MSEPlayer({
|
|||||||
audioEnabled = false,
|
audioEnabled = false,
|
||||||
pip = false,
|
pip = false,
|
||||||
onPlaying,
|
onPlaying,
|
||||||
|
setFullResolution,
|
||||||
}: MSEPlayerProps) {
|
}: MSEPlayerProps) {
|
||||||
let connectTS: number = 0;
|
let connectTS: number = 0;
|
||||||
|
|
||||||
@ -50,6 +60,15 @@ function MSEPlayer({
|
|||||||
return `${baseUrl.replace(/^http/, "ws")}live/mse/api/ws?src=${camera}`;
|
return `${baseUrl.replace(/^http/, "ws")}live/mse/api/ws?src=${camera}`;
|
||||||
}, [camera]);
|
}, [camera]);
|
||||||
|
|
||||||
|
const handleLoadedMetadata = useCallback(() => {
|
||||||
|
if (videoRef.current && setFullResolution) {
|
||||||
|
setFullResolution({
|
||||||
|
width: videoRef.current.videoWidth,
|
||||||
|
height: videoRef.current.videoHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [setFullResolution]);
|
||||||
|
|
||||||
const play = () => {
|
const play = () => {
|
||||||
const currentVideo = videoRef.current;
|
const currentVideo = videoRef.current;
|
||||||
|
|
||||||
@ -196,8 +215,7 @@ function MSEPlayer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// eslint-disable-next-line no-console
|
// no-op
|
||||||
console.debug(e);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -214,8 +232,7 @@ function MSEPlayer({
|
|||||||
try {
|
try {
|
||||||
sb?.appendBuffer(data);
|
sb?.appendBuffer(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// eslint-disable-next-line no-console
|
// no-op
|
||||||
console.debug(e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -286,6 +303,7 @@ function MSEPlayer({
|
|||||||
playsInline
|
playsInline
|
||||||
preload="auto"
|
preload="auto"
|
||||||
onLoadedData={onPlaying}
|
onLoadedData={onPlaying}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
muted={!audioEnabled}
|
muted={!audioEnabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { baseUrl } from "@/api/baseUrl";
|
|||||||
import { isAndroid, isChrome, isMobile } from "react-device-detect";
|
import { isAndroid, isChrome, isMobile } from "react-device-detect";
|
||||||
import { TimeRange } from "@/types/timeline";
|
import { TimeRange } from "@/types/timeline";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type PreviewPlayerProps = {
|
type PreviewPlayerProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -238,7 +239,11 @@ function PreviewVideoPlayer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@ -476,7 +481,11 @@ function PreviewFramesPlayer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import useContextMenu from "@/hooks/use-contextmenu";
|
|||||||
import ActivityIndicator from "../indicators/activity-indicator";
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
import { TimeRange } from "@/types/timeline";
|
import { TimeRange } from "@/types/timeline";
|
||||||
import { NoThumbSlider } from "../ui/slider";
|
import { NoThumbSlider } from "../ui/slider";
|
||||||
|
import { PREVIEW_FPS, PREVIEW_PADDING } from "@/types/preview";
|
||||||
|
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||||
|
|
||||||
type PreviewPlayerProps = {
|
type PreviewPlayerProps = {
|
||||||
review: ReviewSegment;
|
review: ReviewSegment;
|
||||||
@ -262,7 +264,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
.filter(
|
.filter(
|
||||||
(item) => item !== undefined && !item.includes("-verified"),
|
(item) => item !== undefined && !item.includes("-verified"),
|
||||||
)
|
)
|
||||||
.map((text) => text.charAt(0).toUpperCase() + text.substring(1))
|
.map((text) => capitalizeFirstLetter(text))
|
||||||
.sort()
|
.sort()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
.replaceAll("-verified", "")}
|
.replaceAll("-verified", "")}
|
||||||
@ -337,7 +339,6 @@ function PreviewContent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const PREVIEW_PADDING = 16;
|
|
||||||
type VideoPreviewProps = {
|
type VideoPreviewProps = {
|
||||||
relevantPreview: Preview;
|
relevantPreview: Preview;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
@ -398,7 +399,7 @@ export function VideoPreview({
|
|||||||
setManualPlayback(true);
|
setManualPlayback(true);
|
||||||
} else {
|
} else {
|
||||||
playerRef.current.currentTime = playerStartTime;
|
playerRef.current.currentTime = playerStartTime;
|
||||||
playerRef.current.playbackRate = 8;
|
playerRef.current.playbackRate = PREVIEW_FPS;
|
||||||
}
|
}
|
||||||
|
|
||||||
// we know that these deps are correct
|
// we know that these deps are correct
|
||||||
@ -430,6 +431,11 @@ export function VideoPreview({
|
|||||||
setReviewed();
|
setReviewed();
|
||||||
|
|
||||||
if (loop && playerRef.current) {
|
if (loop && playerRef.current) {
|
||||||
|
if (manualPlayback) {
|
||||||
|
setManualPlayback(false);
|
||||||
|
setTimeout(() => setManualPlayback(true), 100);
|
||||||
|
}
|
||||||
|
|
||||||
playerRef.current.currentTime = playerStartTime;
|
playerRef.current.currentTime = playerStartTime;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -470,7 +476,7 @@ export function VideoPreview({
|
|||||||
playerRef.current.currentTime = playerStartTime + counter;
|
playerRef.current.currentTime = playerStartTime + counter;
|
||||||
counter += 1;
|
counter += 1;
|
||||||
}
|
}
|
||||||
}, 125);
|
}, 1000 / PREVIEW_FPS);
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
|
|
||||||
// we know that these deps are correct
|
// we know that these deps are correct
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { isSafari } from "react-device-detect";
|
import { isSafari } from "react-device-detect";
|
||||||
import { LuPause, LuPlay } from "react-icons/lu";
|
import { LuPause, LuPlay } from "react-icons/lu";
|
||||||
import {
|
import {
|
||||||
@ -18,17 +18,31 @@ import {
|
|||||||
} from "react-icons/md";
|
} from "react-icons/md";
|
||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import { VolumeSlider } from "../ui/slider";
|
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 = {
|
type VideoControls = {
|
||||||
volume?: boolean;
|
volume?: boolean;
|
||||||
seek?: boolean;
|
seek?: boolean;
|
||||||
playbackRate?: boolean;
|
playbackRate?: boolean;
|
||||||
|
plusUpload?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONTROLS_DEFAULT: VideoControls = {
|
const CONTROLS_DEFAULT: VideoControls = {
|
||||||
volume: true,
|
volume: true,
|
||||||
seek: true,
|
seek: true,
|
||||||
playbackRate: true,
|
playbackRate: true,
|
||||||
|
plusUpload: false,
|
||||||
};
|
};
|
||||||
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
||||||
|
|
||||||
@ -40,7 +54,6 @@ type VideoControlsProps = {
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
volume?: number;
|
volume?: number;
|
||||||
controlsOpen?: boolean;
|
|
||||||
playbackRates?: number[];
|
playbackRates?: number[];
|
||||||
playbackRate: number;
|
playbackRate: number;
|
||||||
hotKeys?: boolean;
|
hotKeys?: boolean;
|
||||||
@ -49,6 +62,7 @@ type VideoControlsProps = {
|
|||||||
onPlayPause: (play: boolean) => void;
|
onPlayPause: (play: boolean) => void;
|
||||||
onSeek: (diff: number) => void;
|
onSeek: (diff: number) => void;
|
||||||
onSetPlaybackRate: (rate: number) => void;
|
onSetPlaybackRate: (rate: number) => void;
|
||||||
|
onUploadFrame?: () => void;
|
||||||
};
|
};
|
||||||
export default function VideoControls({
|
export default function VideoControls({
|
||||||
className,
|
className,
|
||||||
@ -58,7 +72,6 @@ export default function VideoControls({
|
|||||||
show,
|
show,
|
||||||
muted,
|
muted,
|
||||||
volume,
|
volume,
|
||||||
controlsOpen,
|
|
||||||
playbackRates = PLAYBACK_RATE_DEFAULT,
|
playbackRates = PLAYBACK_RATE_DEFAULT,
|
||||||
playbackRate,
|
playbackRate,
|
||||||
hotKeys = true,
|
hotKeys = true,
|
||||||
@ -67,6 +80,7 @@ export default function VideoControls({
|
|||||||
onPlayPause,
|
onPlayPause,
|
||||||
onSeek,
|
onSeek,
|
||||||
onSetPlaybackRate,
|
onSetPlaybackRate,
|
||||||
|
onUploadFrame,
|
||||||
}: VideoControlsProps) {
|
}: VideoControlsProps) {
|
||||||
const onReplay = useCallback(
|
const onReplay = useCallback(
|
||||||
(e: React.MouseEvent<SVGElement>) => {
|
(e: React.MouseEvent<SVGElement>) => {
|
||||||
@ -148,7 +162,10 @@ export default function VideoControls({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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 && (
|
{video && features.volume && (
|
||||||
<div className="flex justify-normal items-center gap-2 cursor-pointer">
|
<div className="flex justify-normal items-center gap-2 cursor-pointer">
|
||||||
@ -189,7 +206,6 @@ export default function VideoControls({
|
|||||||
)}
|
)}
|
||||||
{features.playbackRate && (
|
{features.playbackRate && (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={controlsOpen == true}
|
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (setControlsOpen) {
|
if (setControlsOpen) {
|
||||||
setControlsOpen(open);
|
setControlsOpen(open);
|
||||||
@ -214,6 +230,84 @@ export default function VideoControls({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
{features.plusUpload && onUploadFrame && (
|
||||||
|
<FrigatePlusUploadButton
|
||||||
|
video={video}
|
||||||
|
onClose={() => {
|
||||||
|
if (setControlsOpen) {
|
||||||
|
setControlsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onOpen={() => {
|
||||||
|
onPlayPause(false);
|
||||||
|
|
||||||
|
if (setControlsOpen) {
|
||||||
|
setControlsOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onUploadFrame={onUploadFrame}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -101,7 +101,7 @@ export class DynamicVideoController {
|
|||||||
this.playerController.pause();
|
this.playerController.pause();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`seek time is 0`);
|
// no op
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,9 @@ import { DynamicVideoController } from "./DynamicVideoController";
|
|||||||
import HlsVideoPlayer from "../HlsVideoPlayer";
|
import HlsVideoPlayer from "../HlsVideoPlayer";
|
||||||
import { TimeRange } from "@/types/timeline";
|
import { TimeRange } from "@/types/timeline";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
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.
|
* Dynamically switches between video playback and scrubbing preview player.
|
||||||
@ -24,6 +27,7 @@ type DynamicVideoPlayerProps = {
|
|||||||
onControllerReady: (controller: DynamicVideoController) => void;
|
onControllerReady: (controller: DynamicVideoController) => void;
|
||||||
onTimestampUpdate?: (timestamp: number) => void;
|
onTimestampUpdate?: (timestamp: number) => void;
|
||||||
onClipEnded?: () => void;
|
onClipEnded?: () => void;
|
||||||
|
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||||
};
|
};
|
||||||
export default function DynamicVideoPlayer({
|
export default function DynamicVideoPlayer({
|
||||||
className,
|
className,
|
||||||
@ -36,6 +40,7 @@ export default function DynamicVideoPlayer({
|
|||||||
onControllerReady,
|
onControllerReady,
|
||||||
onTimestampUpdate,
|
onTimestampUpdate,
|
||||||
onClipEnded,
|
onClipEnded,
|
||||||
|
setFullResolution,
|
||||||
}: DynamicVideoPlayerProps) {
|
}: DynamicVideoPlayerProps) {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
@ -124,6 +129,18 @@ export default function DynamicVideoPlayer({
|
|||||||
[controller, onTimestampUpdate, isScrubbing, isLoading],
|
[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
|
// state of playback player
|
||||||
|
|
||||||
const recordingParams = useMemo(() => {
|
const recordingParams = useMemo(() => {
|
||||||
@ -182,9 +199,14 @@ export default function DynamicVideoPlayer({
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setNoRecording(false);
|
setNoRecording(false);
|
||||||
}}
|
}}
|
||||||
|
setFullResolution={setFullResolution}
|
||||||
|
onUploadFrame={onUploadFrameToPlus}
|
||||||
/>
|
/>
|
||||||
<PreviewPlayer
|
<PreviewPlayer
|
||||||
className={`${isScrubbing || isLoading ? "visible" : "hidden"} ${className}`}
|
className={cn(
|
||||||
|
className,
|
||||||
|
isScrubbing || isLoading ? "visible" : "hidden",
|
||||||
|
)}
|
||||||
camera={camera}
|
camera={camera}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
cameraPreviews={cameraPreviews}
|
cameraPreviews={cameraPreviews}
|
||||||
|
|||||||
@ -1,39 +1,91 @@
|
|||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { 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() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Heading as="h2">Settings</Heading>
|
<div className="flex flex-col md:flex-row size-full">
|
||||||
<div className="flex items-center space-x-2 mt-5">
|
<Toaster position="top-center" closeButton={true} />
|
||||||
<Switch id="lowdata" checked={false} onCheckedChange={() => {}} />
|
<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">
|
||||||
<Label htmlFor="lowdata">Low Data Mode (this device only)</Label>
|
<Heading as="h3" className="my-2">
|
||||||
</div>
|
General Settings
|
||||||
|
</Heading>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 mt-5">
|
<div className="flex flex-col w-full space-y-6">
|
||||||
<Select>
|
<div className="mt-2 space-y-6">
|
||||||
<SelectTrigger className="w-[180px]">
|
<div className="space-y-0.5">
|
||||||
<SelectValue placeholder="Another General Option" />
|
<div className="text-md">Stored Layouts</div>
|
||||||
</SelectTrigger>
|
<div className="text-sm text-muted-foreground my-2">
|
||||||
<SelectContent>
|
<p>
|
||||||
<SelectGroup>
|
The layout of cameras in a camera group can be
|
||||||
<SelectLabel>Live Mode</SelectLabel>
|
dragged/resized. The positions are stored in your browser's
|
||||||
<SelectItem value="jsmpeg">JSMpeg</SelectItem>
|
local storage.
|
||||||
<SelectItem value="mse">MSE</SelectItem>
|
</p>
|
||||||
<SelectItem value="webrtc">WebRTC</SelectItem>
|
</div>
|
||||||
</SelectGroup>
|
</div>
|
||||||
</SelectContent>
|
<div className="flex flex-row justify-start items-center gap-2">
|
||||||
</Select>
|
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -171,13 +171,19 @@ export default function MasksAndZones({
|
|||||||
setActivePolygonIndex(undefined);
|
setActivePolygonIndex(undefined);
|
||||||
setHoveredPolygonIndex(null);
|
setHoveredPolygonIndex(null);
|
||||||
setUnsavedChanges(false);
|
setUnsavedChanges(false);
|
||||||
|
document.title = "Mask and Zone Editor - Frigate";
|
||||||
}, [allPolygons, setUnsavedChanges]);
|
}, [allPolygons, setUnsavedChanges]);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
setAllPolygons([...(editingPolygons ?? [])]);
|
setAllPolygons([...(editingPolygons ?? [])]);
|
||||||
setHoveredPolygonIndex(null);
|
setHoveredPolygonIndex(null);
|
||||||
setUnsavedChanges(false);
|
setUnsavedChanges(false);
|
||||||
addMessage("masks_zones", "Restart required (masks/zones changed)");
|
addMessage(
|
||||||
|
"masks_zones",
|
||||||
|
"Restart required (masks/zones changed)",
|
||||||
|
undefined,
|
||||||
|
"masks_zones",
|
||||||
|
);
|
||||||
}, [editingPolygons, setUnsavedChanges, addMessage]);
|
}, [editingPolygons, setUnsavedChanges, addMessage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -353,6 +359,10 @@ export default function MasksAndZones({
|
|||||||
}
|
}
|
||||||
}, [selectedCamera]);
|
}, [selectedCamera]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Mask and Zone Editor - Frigate";
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!cameraConfig && !selectedCamera) {
|
if (!cameraConfig && !selectedCamera) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
@ -361,7 +371,7 @@ export default function MasksAndZones({
|
|||||||
<>
|
<>
|
||||||
{cameraConfig && editingPolygons && (
|
{cameraConfig && editingPolygons && (
|
||||||
<div className="flex flex-col md:flex-row size-full">
|
<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">
|
<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" && (
|
{editPane == "zone" && (
|
||||||
<ZoneEditPane
|
<ZoneEditPane
|
||||||
@ -624,6 +634,7 @@ export default function MasksAndZones({
|
|||||||
scaledHeight &&
|
scaledHeight &&
|
||||||
editingPolygons ? (
|
editingPolygons ? (
|
||||||
<PolygonCanvas
|
<PolygonCanvas
|
||||||
|
containerRef={containerRef}
|
||||||
camera={cameraConfig.name}
|
camera={cameraConfig.name}
|
||||||
width={scaledWidth}
|
width={scaledWidth}
|
||||||
height={scaledHeight}
|
height={scaledHeight}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import Heading from "../ui/heading";
|
|||||||
import { Separator } from "../ui/separator";
|
import { Separator } from "../ui/separator";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
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 { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@ -173,13 +173,17 @@ export default function MotionMaskEditPane({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Edit Motion Mask - Frigate";
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!polygon) {
|
if (!polygon) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" closeButton={true} />
|
||||||
<Heading as="h3" className="my-2">
|
<Heading as="h3" className="my-2">
|
||||||
{polygon.name.length ? "Edit" : "New"} Motion Mask
|
{polygon.name.length ? "Edit" : "New"} Motion Mask
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|||||||
@ -153,19 +153,28 @@ export default function MotionTuner({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (changedValue) {
|
if (changedValue) {
|
||||||
addMessage("motion_tuner", "Unsaved motion tuner changes");
|
addMessage(
|
||||||
|
"motion_tuner",
|
||||||
|
"Unsaved motion tuner changes",
|
||||||
|
undefined,
|
||||||
|
"motion_tuner",
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
clearMessages("motion_tuner");
|
clearMessages("motion_tuner");
|
||||||
}
|
}
|
||||||
}, [changedValue, addMessage, clearMessages]);
|
}, [changedValue, addMessage, clearMessages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Motion Tuner - Frigate";
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!cameraConfig && !selectedCamera) {
|
if (!cameraConfig && !selectedCamera) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col md:flex-row size-full">
|
<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">
|
<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">
|
<Heading as="h3" className="my-2">
|
||||||
Motion Detection Tuner
|
Motion Detection Tuner
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
|
import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@ -235,13 +235,17 @@ export default function ObjectMaskEditPane({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Edit Object Mask - Frigate";
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!polygon) {
|
if (!polygon) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" closeButton={true} />
|
||||||
<Heading as="h3" className="my-2">
|
<Heading as="h3" className="my-2">
|
||||||
{polygon.name.length ? "Edit" : "New"} Object Mask
|
{polygon.name.length ? "Edit" : "New"} Object Mask
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|||||||
@ -1,31 +1,284 @@
|
|||||||
import { useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import DebugCameraImage from "../camera/DebugCameraImage";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
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 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 = {
|
type ObjectSettingsProps = {
|
||||||
selectedCamera?: string;
|
selectedCamera?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Options = { [key: string]: boolean };
|
||||||
|
|
||||||
|
const emptyObject = Object.freeze({});
|
||||||
|
|
||||||
export default function ObjectSettings({
|
export default function ObjectSettings({
|
||||||
selectedCamera,
|
selectedCamera,
|
||||||
}: ObjectSettingsProps) {
|
}: ObjectSettingsProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
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(() => {
|
const cameraConfig = useMemo(() => {
|
||||||
if (config && selectedCamera) {
|
if (config && selectedCamera) {
|
||||||
return config.cameras[selectedCamera];
|
return config.cameras[selectedCamera];
|
||||||
}
|
}
|
||||||
}, [config, 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) {
|
if (!cameraConfig) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-50">
|
<div className="flex flex-col md:flex-row size-full">
|
||||||
<DebugCameraImage cameraConfig={cameraConfig} className="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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 PolygonDrawer from "./PolygonDrawer";
|
||||||
import { Stage, Layer, Image } from "react-konva";
|
import { Stage, Layer, Image } from "react-konva";
|
||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
@ -7,6 +7,7 @@ import { Polygon, PolygonType } from "@/types/canvas";
|
|||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
|
|
||||||
type PolygonCanvasProps = {
|
type PolygonCanvasProps = {
|
||||||
|
containerRef: RefObject<HTMLDivElement>;
|
||||||
camera: string;
|
camera: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@ -18,6 +19,7 @@ type PolygonCanvasProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function PolygonCanvas({
|
export function PolygonCanvas({
|
||||||
|
containerRef,
|
||||||
camera,
|
camera,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@ -55,10 +57,6 @@ export function PolygonCanvas({
|
|||||||
};
|
};
|
||||||
}, [videoElement]);
|
}, [videoElement]);
|
||||||
|
|
||||||
const getMousePos = (stage: Konva.Stage) => {
|
|
||||||
return [stage.getPointerPosition()!.x, stage.getPointerPosition()!.y];
|
|
||||||
};
|
|
||||||
|
|
||||||
const addPointToPolygon = (polygon: Polygon, newPoint: number[]) => {
|
const addPointToPolygon = (polygon: Polygon, newPoint: number[]) => {
|
||||||
const points = polygon.points;
|
const points = polygon.points;
|
||||||
const pointsOrder = polygon.pointsOrder;
|
const pointsOrder = polygon.pointsOrder;
|
||||||
@ -99,37 +97,6 @@ export function PolygonCanvas({
|
|||||||
return { updatedPoints, updatedPointsOrder };
|
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>) => {
|
const handleMouseDown = (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
|
||||||
if (activePolygonIndex === undefined || !polygons) {
|
if (activePolygonIndex === undefined || !polygons) {
|
||||||
return;
|
return;
|
||||||
@ -138,11 +105,13 @@ export function PolygonCanvas({
|
|||||||
const updatedPolygons = [...polygons];
|
const updatedPolygons = [...polygons];
|
||||||
const activePolygon = updatedPolygons[activePolygonIndex];
|
const activePolygon = updatedPolygons[activePolygonIndex];
|
||||||
const stage = e.target.getStage()!;
|
const stage = e.target.getStage()!;
|
||||||
const mousePos = getMousePos(stage);
|
const mousePos = stage.getPointerPosition() ?? { x: 0, y: 0 };
|
||||||
|
const intersection = stage.getIntersection(mousePos);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
activePolygon.points.length >= 3 &&
|
activePolygon.points.length >= 3 &&
|
||||||
isMouseOverFirstPoint(activePolygon, mousePos)
|
intersection?.getClassName() == "Circle" &&
|
||||||
|
intersection?.name() == "point-0"
|
||||||
) {
|
) {
|
||||||
// Close the polygon
|
// Close the polygon
|
||||||
updatedPolygons[activePolygonIndex] = {
|
updatedPolygons[activePolygonIndex] = {
|
||||||
@ -152,12 +121,13 @@ export function PolygonCanvas({
|
|||||||
setPolygons(updatedPolygons);
|
setPolygons(updatedPolygons);
|
||||||
} else {
|
} else {
|
||||||
if (
|
if (
|
||||||
!activePolygon.isFinished &&
|
(!activePolygon.isFinished &&
|
||||||
!isMouseOverAnyPoint(activePolygon, mousePos)
|
intersection?.getClassName() !== "Circle") ||
|
||||||
|
(activePolygon.isFinished && intersection?.name() == "unfilled-line")
|
||||||
) {
|
) {
|
||||||
const { updatedPoints, updatedPointsOrder } = addPointToPolygon(
|
const { updatedPoints, updatedPointsOrder } = addPointToPolygon(
|
||||||
activePolygon,
|
activePolygon,
|
||||||
mousePos,
|
[mousePos.x, mousePos.y],
|
||||||
);
|
);
|
||||||
|
|
||||||
updatedPolygons[activePolygonIndex] = {
|
updatedPolygons[activePolygonIndex] = {
|
||||||
@ -168,62 +138,6 @@ export function PolygonCanvas({
|
|||||||
setPolygons(updatedPolygons);
|
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 = (
|
const handlePointDragMove = (
|
||||||
@ -237,7 +151,8 @@ export function PolygonCanvas({
|
|||||||
const activePolygon = updatedPolygons[activePolygonIndex];
|
const activePolygon = updatedPolygons[activePolygonIndex];
|
||||||
const stage = e.target.getStage();
|
const stage = e.target.getStage();
|
||||||
if (stage) {
|
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];
|
const pos = [e.target._lastPos!.x, e.target._lastPos!.y];
|
||||||
if (pos[0] < 0) pos[0] = 0;
|
if (pos[0] < 0) pos[0] = 0;
|
||||||
if (pos[1] < 0) pos[1] = 0;
|
if (pos[1] < 0) pos[1] = 0;
|
||||||
@ -272,26 +187,17 @@ export function PolygonCanvas({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStageMouseOver = (
|
const handleStageMouseOver = () => {
|
||||||
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
|
|
||||||
) => {
|
|
||||||
if (activePolygonIndex === undefined || !polygons) {
|
if (activePolygonIndex === undefined || !polygons) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedPolygons = [...polygons];
|
const updatedPolygons = [...polygons];
|
||||||
const activePolygon = updatedPolygons[activePolygonIndex];
|
const activePolygon = updatedPolygons[activePolygonIndex];
|
||||||
const stage = e.target.getStage()!;
|
|
||||||
const mousePos = getMousePos(stage);
|
|
||||||
|
|
||||||
if (
|
if (containerRef.current && !activePolygon.isFinished) {
|
||||||
activePolygon.isFinished ||
|
containerRef.current.style.cursor = "crosshair";
|
||||||
isMouseOverAnyPoint(activePolygon, mousePos) ||
|
}
|
||||||
isMouseOverFirstPoint(activePolygon, mousePos)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
e.target.getStage()!.container().style.cursor = "crosshair";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -336,6 +242,7 @@ export function PolygonCanvas({
|
|||||||
selectedZoneMask.includes(polygon.type)) &&
|
selectedZoneMask.includes(polygon.type)) &&
|
||||||
index !== activePolygonIndex && (
|
index !== activePolygonIndex && (
|
||||||
<PolygonDrawer
|
<PolygonDrawer
|
||||||
|
stageRef={stageRef}
|
||||||
key={index}
|
key={index}
|
||||||
points={polygon.points}
|
points={polygon.points}
|
||||||
isActive={index === activePolygonIndex}
|
isActive={index === activePolygonIndex}
|
||||||
@ -344,10 +251,6 @@ export function PolygonCanvas({
|
|||||||
color={polygon.color}
|
color={polygon.color}
|
||||||
handlePointDragMove={handlePointDragMove}
|
handlePointDragMove={handlePointDragMove}
|
||||||
handleGroupDragEnd={handleGroupDragEnd}
|
handleGroupDragEnd={handleGroupDragEnd}
|
||||||
handleMouseOverStartPoint={handleMouseOverStartPoint}
|
|
||||||
handleMouseOutStartPoint={handleMouseOutStartPoint}
|
|
||||||
handleMouseOverAnyPoint={handleMouseOverAnyPoint}
|
|
||||||
handleMouseOutAnyPoint={handleMouseOutAnyPoint}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@ -356,6 +259,7 @@ export function PolygonCanvas({
|
|||||||
(selectedZoneMask === undefined ||
|
(selectedZoneMask === undefined ||
|
||||||
selectedZoneMask.includes(polygons[activePolygonIndex].type)) && (
|
selectedZoneMask.includes(polygons[activePolygonIndex].type)) && (
|
||||||
<PolygonDrawer
|
<PolygonDrawer
|
||||||
|
stageRef={stageRef}
|
||||||
key={activePolygonIndex}
|
key={activePolygonIndex}
|
||||||
points={polygons[activePolygonIndex].points}
|
points={polygons[activePolygonIndex].points}
|
||||||
isActive={true}
|
isActive={true}
|
||||||
@ -364,10 +268,6 @@ export function PolygonCanvas({
|
|||||||
color={polygons[activePolygonIndex].color}
|
color={polygons[activePolygonIndex].color}
|
||||||
handlePointDragMove={handlePointDragMove}
|
handlePointDragMove={handlePointDragMove}
|
||||||
handleGroupDragEnd={handleGroupDragEnd}
|
handleGroupDragEnd={handleGroupDragEnd}
|
||||||
handleMouseOverStartPoint={handleMouseOverStartPoint}
|
|
||||||
handleMouseOutStartPoint={handleMouseOutStartPoint}
|
|
||||||
handleMouseOverAnyPoint={handleMouseOverAnyPoint}
|
|
||||||
handleMouseOutAnyPoint={handleMouseOutAnyPoint}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Layer>
|
</Layer>
|
||||||
|
|||||||
@ -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 { Line, Circle, Group } from "react-konva";
|
||||||
import {
|
import {
|
||||||
minMax,
|
minMax,
|
||||||
@ -11,6 +18,7 @@ import Konva from "konva";
|
|||||||
import { Vector2d } from "konva/lib/types";
|
import { Vector2d } from "konva/lib/types";
|
||||||
|
|
||||||
type PolygonDrawerProps = {
|
type PolygonDrawerProps = {
|
||||||
|
stageRef: RefObject<Konva.Stage>;
|
||||||
points: number[][];
|
points: number[][];
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isHovered: boolean;
|
isHovered: boolean;
|
||||||
@ -18,21 +26,10 @@ type PolygonDrawerProps = {
|
|||||||
color: number[];
|
color: number[];
|
||||||
handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
|
handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
|
||||||
handleGroupDragEnd: (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({
|
export default function PolygonDrawer({
|
||||||
|
stageRef,
|
||||||
points,
|
points,
|
||||||
isActive,
|
isActive,
|
||||||
isHovered,
|
isHovered,
|
||||||
@ -40,31 +37,41 @@ export default function PolygonDrawer({
|
|||||||
color,
|
color,
|
||||||
handlePointDragMove,
|
handlePointDragMove,
|
||||||
handleGroupDragEnd,
|
handleGroupDragEnd,
|
||||||
handleMouseOverStartPoint,
|
|
||||||
handleMouseOutStartPoint,
|
|
||||||
handleMouseOverAnyPoint,
|
|
||||||
handleMouseOutAnyPoint,
|
|
||||||
}: PolygonDrawerProps) {
|
}: PolygonDrawerProps) {
|
||||||
const vertexRadius = 6;
|
const vertexRadius = 6;
|
||||||
const flattenedPoints = useMemo(() => flattenPoints(points), [points]);
|
const flattenedPoints = useMemo(() => flattenPoints(points), [points]);
|
||||||
const [stage, setStage] = useState<Konva.Stage>();
|
|
||||||
const [minMaxX, setMinMaxX] = useState([0, 0]);
|
const [minMaxX, setMinMaxX] = useState([0, 0]);
|
||||||
const [minMaxY, setMinMaxY] = useState([0, 0]);
|
const [minMaxY, setMinMaxY] = useState([0, 0]);
|
||||||
const groupRef = useRef<Konva.Group>(null);
|
const groupRef = useRef<Konva.Group>(null);
|
||||||
|
const [cursor, setCursor] = useState("default");
|
||||||
|
|
||||||
const handleGroupMouseOver = (
|
const handleMouseOverPoint = (
|
||||||
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
|
e: KonvaEventObject<MouseEvent | TouchEvent>,
|
||||||
) => {
|
) => {
|
||||||
if (!isFinished) return;
|
if (!e.target) return;
|
||||||
e.target.getStage()!.container().style.cursor = "move";
|
|
||||||
setStage(e.target.getStage()!);
|
if (!isFinished && points.length >= 3 && e.target.name() === "point-0") {
|
||||||
|
e.target.scale({ x: 2, y: 2 });
|
||||||
|
setCursor("crosshair");
|
||||||
|
} else {
|
||||||
|
setCursor("move");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGroupMouseOut = (
|
const handleMouseOutPoint = (
|
||||||
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
|
e: KonvaEventObject<MouseEvent | TouchEvent>,
|
||||||
) => {
|
) => {
|
||||||
if (!e.target || !isFinished) return;
|
if (!e.target) return;
|
||||||
e.target.getStage()!.container().style.cursor = "default";
|
|
||||||
|
if (isFinished) {
|
||||||
|
setCursor("default");
|
||||||
|
} else {
|
||||||
|
setCursor("crosshair");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.name() === "point-0") {
|
||||||
|
e.target.scale({ x: 1, y: 1 });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGroupDragStart = () => {
|
const handleGroupDragStart = () => {
|
||||||
@ -75,13 +82,13 @@ export default function PolygonDrawer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const groupDragBound = (pos: Vector2d) => {
|
const groupDragBound = (pos: Vector2d) => {
|
||||||
if (!stage) {
|
if (!stageRef.current) {
|
||||||
return pos;
|
return pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { x, y } = pos;
|
let { x, y } = pos;
|
||||||
const sw = stage.width();
|
const sw = stageRef.current.width();
|
||||||
const sh = stage.height();
|
const sh = stageRef.current.height();
|
||||||
|
|
||||||
if (minMaxY[0] + y < 0) y = -1 * minMaxY[0];
|
if (minMaxY[0] + y < 0) y = -1 * minMaxY[0];
|
||||||
if (minMaxX[0] + x < 0) x = -1 * minMaxX[0];
|
if (minMaxX[0] + x < 0) x = -1 * minMaxX[0];
|
||||||
@ -98,6 +105,14 @@ export default function PolygonDrawer({
|
|||||||
[color],
|
[color],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!stageRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stageRef.current.container().style.cursor = cursor;
|
||||||
|
}, [stageRef, cursor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
name="polygon"
|
name="polygon"
|
||||||
@ -106,55 +121,62 @@ export default function PolygonDrawer({
|
|||||||
onDragStart={isActive ? handleGroupDragStart : undefined}
|
onDragStart={isActive ? handleGroupDragStart : undefined}
|
||||||
onDragEnd={isActive ? handleGroupDragEnd : undefined}
|
onDragEnd={isActive ? handleGroupDragEnd : undefined}
|
||||||
dragBoundFunc={isActive ? groupDragBound : undefined}
|
dragBoundFunc={isActive ? groupDragBound : undefined}
|
||||||
onMouseOver={isActive ? handleGroupMouseOver : undefined}
|
|
||||||
onTouchStart={isActive ? handleGroupMouseOver : undefined}
|
|
||||||
onMouseOut={isActive ? handleGroupMouseOut : undefined}
|
|
||||||
>
|
>
|
||||||
<Line
|
<Line
|
||||||
|
name="filled-line"
|
||||||
points={flattenedPoints}
|
points={flattenedPoints}
|
||||||
stroke={colorString(true)}
|
stroke={colorString(true)}
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
|
hitStrokeWidth={12}
|
||||||
closed={isFinished}
|
closed={isFinished}
|
||||||
fill={colorString(isActive || isHovered ? true : false)}
|
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) => {
|
{points.map((point, index) => {
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const x = point[0];
|
const x = point[0];
|
||||||
const y = point[1];
|
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 (
|
return (
|
||||||
<Circle
|
<Circle
|
||||||
key={index}
|
key={index}
|
||||||
|
name={`point-${index}`}
|
||||||
x={x}
|
x={x}
|
||||||
y={y}
|
y={y}
|
||||||
radius={vertexRadius}
|
radius={vertexRadius}
|
||||||
stroke={colorString(true)}
|
stroke={colorString(true)}
|
||||||
fill="#ffffff"
|
fill="#ffffff"
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
|
hitStrokeWidth={index === 0 ? 12 : 9}
|
||||||
|
onMouseOver={handleMouseOverPoint}
|
||||||
|
onMouseOut={handleMouseOutPoint}
|
||||||
draggable={isActive}
|
draggable={isActive}
|
||||||
onDragMove={isActive ? handlePointDragMove : undefined}
|
onDragMove={isActive ? handlePointDragMove : undefined}
|
||||||
dragBoundFunc={(pos) => {
|
dragBoundFunc={(pos) => {
|
||||||
if (stage) {
|
if (stageRef.current) {
|
||||||
return dragBoundFunc(
|
return dragBoundFunc(
|
||||||
stage.width(),
|
stageRef.current.width(),
|
||||||
stage.height(),
|
stageRef.current.height(),
|
||||||
vertexRadius,
|
vertexRadius,
|
||||||
pos,
|
pos,
|
||||||
);
|
);
|
||||||
@ -162,8 +184,6 @@ export default function PolygonDrawer({
|
|||||||
return pos;
|
return pos;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
{...startPointAttr}
|
|
||||||
{...otherPointsAttr}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export default function PolygonEditControls({
|
|||||||
...activePolygon.pointsOrder.slice(0, lastPointOrderIndex),
|
...activePolygon.pointsOrder.slice(0, lastPointOrderIndex),
|
||||||
...activePolygon.pointsOrder.slice(lastPointOrderIndex + 1),
|
...activePolygon.pointsOrder.slice(lastPointOrderIndex + 1),
|
||||||
],
|
],
|
||||||
isFinished: false,
|
isFinished: activePolygon.isFinished && activePolygon.points.length > 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
setPolygons(updatedPolygons);
|
setPolygons(updatedPolygons);
|
||||||
|
|||||||
@ -202,7 +202,7 @@ export default function PolygonItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" closeButton={true} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@ -308,13 +308,17 @@ export default function ZoneEditPane({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Edit Zone - Frigate";
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!polygon) {
|
if (!polygon) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" closeButton={true} />
|
||||||
<Heading as="h3" className="my-2">
|
<Heading as="h3" className="my-2">
|
||||||
{polygon.name.length ? "Edit" : "New"} Zone
|
{polygon.name.length ? "Edit" : "New"} Zone
|
||||||
</Heading>
|
</Heading>
|
||||||
@ -551,14 +555,6 @@ export function ZoneObjectSelector({
|
|||||||
|
|
||||||
const labels = new Set<string>();
|
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) => {
|
cameraConfig.objects.track.forEach((label) => {
|
||||||
if (!ATTRIBUTE_LABELS.includes(label)) {
|
if (!ATTRIBUTE_LABELS.includes(label)) {
|
||||||
labels.add(label);
|
labels.add(label);
|
||||||
|
|||||||
@ -38,12 +38,8 @@ export function MotionSegment({
|
|||||||
dense,
|
dense,
|
||||||
}: MotionSegmentProps) {
|
}: MotionSegmentProps) {
|
||||||
const severityType = "all";
|
const severityType = "all";
|
||||||
const {
|
const { getSeverity, getReviewed, displaySeverityType } =
|
||||||
getSeverity,
|
useEventSegmentUtils(segmentDuration, events, severityType);
|
||||||
getReviewed,
|
|
||||||
displaySeverityType,
|
|
||||||
shouldShowRoundedCorners,
|
|
||||||
} = useEventSegmentUtils(segmentDuration, events, severityType);
|
|
||||||
|
|
||||||
const { interpolateMotionAudioData } = useMotionSegmentUtils(
|
const { interpolateMotionAudioData } = useMotionSegmentUtils(
|
||||||
segmentDuration,
|
segmentDuration,
|
||||||
@ -68,11 +64,6 @@ export function MotionSegment({
|
|||||||
[getReviewed, segmentTime],
|
[getReviewed, segmentTime],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { roundTopSecondary, roundBottomSecondary } = useMemo(
|
|
||||||
() => shouldShowRoundedCorners(segmentTime),
|
|
||||||
[shouldShowRoundedCorners, segmentTime],
|
|
||||||
);
|
|
||||||
|
|
||||||
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
|
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
|
||||||
const segmentKey = useMemo(() => segmentTime, [segmentTime]);
|
const segmentKey = useMemo(() => segmentTime, [segmentTime]);
|
||||||
|
|
||||||
@ -152,16 +143,16 @@ export function MotionSegment({
|
|||||||
const animationClassesFirstHalf = `motion-segment ${firstHalfSegmentWidth > 0 ? "hidden" : ""}
|
const animationClassesFirstHalf = `motion-segment ${firstHalfSegmentWidth > 0 ? "hidden" : ""}
|
||||||
zoom-in-[0.2] ${firstHalfSegmentWidth < 5 ? "duration-200" : "duration-1000"}`;
|
zoom-in-[0.2] ${firstHalfSegmentWidth < 5 ? "duration-200" : "duration-1000"}`;
|
||||||
|
|
||||||
const severityColors: { [key: number]: string } = {
|
const severityColorsBg: { [key: number]: string } = {
|
||||||
1: reviewed
|
1: reviewed
|
||||||
? "from-severity_significant_motion-dimmed/50 to-severity_significant_motion/50"
|
? "from-severity_significant_motion-dimmed/10 to-severity_significant_motion/10"
|
||||||
: "from-severity_significant_motion-dimmed to-severity_significant_motion",
|
: "from-severity_significant_motion-dimmed/20 to-severity_significant_motion/20",
|
||||||
2: reviewed
|
2: reviewed
|
||||||
? "from-severity_detection-dimmed/50 to-severity_detection/50"
|
? "from-severity_detection-dimmed/10 to-severity_detection/10"
|
||||||
: "from-severity_detection-dimmed to-severity_detection",
|
: "from-severity_detection-dimmed/20 to-severity_detection/20",
|
||||||
3: reviewed
|
3: reviewed
|
||||||
? "from-severity_alert-dimmed/50 to-severity_alert/50"
|
? "from-severity_alert-dimmed/10 to-severity_alert/10"
|
||||||
: "from-severity_alert-dimmed to-severity_alert",
|
: "from-severity_alert-dimmed/20 to-severity_alert/20",
|
||||||
};
|
};
|
||||||
|
|
||||||
const segmentClick = useCallback(() => {
|
const segmentClick = useCallback(() => {
|
||||||
@ -179,7 +170,7 @@ export function MotionSegment({
|
|||||||
<div
|
<div
|
||||||
key={segmentKey}
|
key={segmentKey}
|
||||||
data-segment-id={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}
|
onClick={segmentClick}
|
||||||
onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
|
onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
|
||||||
>
|
>
|
||||||
@ -219,7 +210,7 @@ export function MotionSegment({
|
|||||||
<div
|
<div
|
||||||
key={`${segmentKey}_motion_data_1`}
|
key={`${segmentKey}_motion_data_1`}
|
||||||
data-motion-value={secondHalfSegmentWidth}
|
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={{
|
style={{
|
||||||
width: secondHalfSegmentWidth || 1,
|
width: secondHalfSegmentWidth || 1,
|
||||||
}}
|
}}
|
||||||
@ -232,7 +223,7 @@ export function MotionSegment({
|
|||||||
<div
|
<div
|
||||||
key={`${segmentKey}_motion_data_2`}
|
key={`${segmentKey}_motion_data_2`}
|
||||||
data-motion-value={firstHalfSegmentWidth}
|
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={{
|
style={{
|
||||||
width: firstHalfSegmentWidth || 1,
|
width: firstHalfSegmentWidth || 1,
|
||||||
}}
|
}}
|
||||||
@ -240,29 +231,6 @@ export function MotionSegment({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
import { SummarySegment } from "./SummarySegment";
|
import { SummarySegment } from "./SummarySegment";
|
||||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||||
import { isMobile } from "react-device-detect";
|
|
||||||
|
|
||||||
export type SummaryTimelineProps = {
|
export type SummaryTimelineProps = {
|
||||||
reviewTimelineRef: RefObject<HTMLDivElement>;
|
reviewTimelineRef: RefObject<HTMLDivElement>;
|
||||||
@ -188,7 +187,7 @@ export function SummaryTimeline({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
let clientY;
|
let clientY;
|
||||||
if (isMobile && e.nativeEvent instanceof TouchEvent) {
|
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
|
||||||
clientY = e.nativeEvent.touches[0].clientY;
|
clientY = e.nativeEvent.touches[0].clientY;
|
||||||
} else if (e.nativeEvent instanceof MouseEvent) {
|
} else if (e.nativeEvent instanceof MouseEvent) {
|
||||||
clientY = e.nativeEvent.clientY;
|
clientY = e.nativeEvent.clientY;
|
||||||
@ -239,7 +238,7 @@ export function SummaryTimeline({
|
|||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
|
|
||||||
let clientY;
|
let clientY;
|
||||||
if (isMobile && e.nativeEvent instanceof TouchEvent) {
|
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
|
||||||
clientY = e.nativeEvent.touches[0].clientY;
|
clientY = e.nativeEvent.touches[0].clientY;
|
||||||
} else if (e.nativeEvent instanceof MouseEvent) {
|
} else if (e.nativeEvent instanceof MouseEvent) {
|
||||||
clientY = e.nativeEvent.clientY;
|
clientY = e.nativeEvent.clientY;
|
||||||
@ -277,7 +276,7 @@ export function SummaryTimeline({
|
|||||||
}
|
}
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
let clientY;
|
let clientY;
|
||||||
if (isMobile && e instanceof TouchEvent) {
|
if ("TouchEvent" in window && e instanceof TouchEvent) {
|
||||||
clientY = e.touches[0].clientY;
|
clientY = e.touches[0].clientY;
|
||||||
} else if (e instanceof MouseEvent) {
|
} else if (e instanceof MouseEvent) {
|
||||||
clientY = e.clientY;
|
clientY = e.clientY;
|
||||||
|
|||||||
@ -1,29 +1,36 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
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<
|
const PopoverContent = React.forwardRef<
|
||||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
|
||||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
container?: HTMLElement | null;
|
||||||
<PopoverPrimitive.Portal>
|
}
|
||||||
<PopoverPrimitive.Content
|
>(
|
||||||
ref={ref}
|
(
|
||||||
align={align}
|
{ className, container, align = "center", sideOffset = 4, ...props },
|
||||||
sideOffset={sideOffset}
|
ref,
|
||||||
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",
|
<PopoverPrimitive.Portal container={container}>
|
||||||
className
|
<PopoverPrimitive.Content
|
||||||
)}
|
ref={ref}
|
||||||
{...props}
|
align={align}
|
||||||
/>
|
sideOffset={sideOffset}
|
||||||
</PopoverPrimitive.Portal>
|
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",
|
||||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent }
|
export { Popover, PopoverTrigger, PopoverContent };
|
||||||
|
|||||||
@ -18,6 +18,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary",
|
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary",
|
||||||
cancelButton:
|
cancelButton:
|
||||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
closeButton:
|
||||||
|
"group-[.toast]:bg-secondary border-primary border-[1px]",
|
||||||
success:
|
success:
|
||||||
"group toast group-[.toaster]:bg-success group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
"group toast group-[.toaster]:bg-success group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
error:
|
error:
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export type StatusMessage = {
|
|||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
link?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StatusMessagesState = {
|
export type StatusMessagesState = {
|
||||||
@ -27,6 +28,7 @@ type StatusBarMessagesContextValue = {
|
|||||||
message: string,
|
message: string,
|
||||||
color?: string,
|
color?: string,
|
||||||
messageId?: string,
|
messageId?: string,
|
||||||
|
link?: string,
|
||||||
) => string;
|
) => string;
|
||||||
removeMessage: (key: string, messageId: string) => void;
|
removeMessage: (key: string, messageId: string) => void;
|
||||||
clearMessages: (key: string) => void;
|
clearMessages: (key: string) => void;
|
||||||
@ -43,14 +45,20 @@ export function StatusBarMessagesProvider({
|
|||||||
const messages = useMemo(() => messagesState, [messagesState]);
|
const messages = useMemo(() => messagesState, [messagesState]);
|
||||||
|
|
||||||
const addMessage = useCallback(
|
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 id = messageId || Date.now().toString();
|
||||||
const msgColor = color || "text-danger";
|
const msgColor = color || "text-danger";
|
||||||
setMessagesState((prevMessages) => ({
|
setMessagesState((prevMessages) => ({
|
||||||
...prevMessages,
|
...prevMessages,
|
||||||
[key]: [
|
[key]: [
|
||||||
...(prevMessages[key] || []),
|
...(prevMessages[key] || []),
|
||||||
{ id, text: message, color: msgColor },
|
{ id, text: message, color: msgColor, link },
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
return id;
|
return id;
|
||||||
|
|||||||
@ -1,62 +1,123 @@
|
|||||||
import { useFrigateEvents, useMotionActivity } from "@/api/ws";
|
import {
|
||||||
import { CameraConfig } from "@/types/frigateConfig";
|
useFrigateEvents,
|
||||||
|
useInitialCameraState,
|
||||||
|
useMotionActivity,
|
||||||
|
} from "@/api/ws";
|
||||||
|
import { ATTRIBUTE_LABELS, CameraConfig } from "@/types/frigateConfig";
|
||||||
import { MotionData, ReviewSegment } from "@/types/review";
|
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 { useTimelineUtils } from "./use-timeline-utils";
|
||||||
|
import { ObjectType } from "@/types/ws";
|
||||||
|
import useDeepMemo from "./use-deep-memo";
|
||||||
|
import { isEqual } from "lodash";
|
||||||
|
|
||||||
type useCameraActivityReturn = {
|
type useCameraActivityReturn = {
|
||||||
activeTracking: boolean;
|
activeTracking: boolean;
|
||||||
activeMotion: boolean;
|
activeMotion: boolean;
|
||||||
|
objects: ObjectType[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCameraActivity(
|
export function useCameraActivity(
|
||||||
camera: CameraConfig,
|
camera: CameraConfig,
|
||||||
): useCameraActivityReturn {
|
): 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(
|
const hasActiveObjects = useMemo(
|
||||||
() => activeObjects.length > 0,
|
() => objects.filter((obj) => !obj.stationary).length > 0,
|
||||||
[activeObjects],
|
[objects],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { payload: detectingMotion } = useMotionActivity(camera.name);
|
const { payload: detectingMotion } = useMotionActivity(camera.name);
|
||||||
const { payload: event } = useFrigateEvents();
|
const { payload: event } = useFrigateEvents();
|
||||||
|
const updatedEvent = useDeepMemo(event);
|
||||||
|
|
||||||
|
const handleSetObjects = useCallback(
|
||||||
|
(newObjects: ObjectType[]) => {
|
||||||
|
if (!isEqual(objects, newObjects)) {
|
||||||
|
setObjects(newObjects);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[objects],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!event) {
|
if (!updatedEvent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.after.camera != camera.name) {
|
if (updatedEvent.after.camera !== camera.name) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventIndex = activeObjects.indexOf(event.after.id);
|
const updatedEventIndex = objects.findIndex(
|
||||||
|
(obj) => obj.id === updatedEvent.after.id,
|
||||||
|
);
|
||||||
|
|
||||||
if (event.type == "end") {
|
let newObjects: ObjectType[] = [...objects];
|
||||||
if (eventIndex != -1) {
|
|
||||||
const newActiveObjects = [...activeObjects];
|
if (updatedEvent.type === "end") {
|
||||||
newActiveObjects.splice(eventIndex, 1);
|
if (updatedEventIndex !== -1) {
|
||||||
setActiveObjects(newActiveObjects);
|
newObjects.splice(updatedEventIndex, 1);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (eventIndex == -1) {
|
if (updatedEventIndex === -1) {
|
||||||
// add unknown event to list if not stationary
|
// add unknown updatedEvent to list if not stationary
|
||||||
if (!event.after.stationary) {
|
if (!updatedEvent.after.stationary) {
|
||||||
const newActiveObjects = [...activeObjects, event.after.id];
|
const newActiveObject: ObjectType = {
|
||||||
setActiveObjects(newActiveObjects);
|
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 {
|
} else {
|
||||||
// remove known event from list if it has become stationary
|
const newObjects = [...objects];
|
||||||
if (event.after.stationary) {
|
|
||||||
activeObjects.splice(eventIndex, 1);
|
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 {
|
return {
|
||||||
activeTracking: hasActiveObjects,
|
activeTracking: hasActiveObjects,
|
||||||
activeMotion: detectingMotion == "ON",
|
activeMotion: detectingMotion
|
||||||
|
? detectingMotion === "ON"
|
||||||
|
: initialCameraState?.motion === true,
|
||||||
|
objects,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
57
web/src/hooks/use-camera-previews.ts
Normal file
57
web/src/hooks/use-camera-previews.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { isMobile } from "react-device-detect";
|
|
||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
import scrollIntoView from "scroll-into-view-if-needed";
|
||||||
import { useTimelineUtils } from "./use-timeline-utils";
|
import { useTimelineUtils } from "./use-timeline-utils";
|
||||||
|
|
||||||
@ -88,7 +87,7 @@ function useDraggableElement({
|
|||||||
const getClientYPosition = useCallback(
|
const getClientYPosition = useCallback(
|
||||||
(e: MouseEvent | TouchEvent) => {
|
(e: MouseEvent | TouchEvent) => {
|
||||||
let clientY;
|
let clientY;
|
||||||
if (isMobile && e instanceof TouchEvent) {
|
if ("TouchEvent" in window && e instanceof TouchEvent) {
|
||||||
clientY = e.touches[0].clientY;
|
clientY = e.touches[0].clientY;
|
||||||
} else if (e instanceof MouseEvent) {
|
} else if (e instanceof MouseEvent) {
|
||||||
clientY = e.clientY;
|
clientY = e.clientY;
|
||||||
@ -114,7 +113,7 @@ function useDraggableElement({
|
|||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
|
|
||||||
let clientY;
|
let clientY;
|
||||||
if (isMobile && e.nativeEvent instanceof TouchEvent) {
|
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
|
||||||
clientY = e.nativeEvent.touches[0].clientY;
|
clientY = e.nativeEvent.touches[0].clientY;
|
||||||
} else if (e.nativeEvent instanceof MouseEvent) {
|
} else if (e.nativeEvent instanceof MouseEvent) {
|
||||||
clientY = e.nativeEvent.clientY;
|
clientY = e.nativeEvent.clientY;
|
||||||
|
|||||||
@ -8,7 +8,8 @@ export function useOverlayState<S>(
|
|||||||
): [S | undefined, (value: S, replace?: boolean) => void] {
|
): [S | undefined, (value: S, replace?: boolean) => void] {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const currentLocationState = location.state;
|
|
||||||
|
const currentLocationState = useMemo(() => location.state, [location]);
|
||||||
|
|
||||||
const setOverlayStateValue = useCallback(
|
const setOverlayStateValue = useCallback(
|
||||||
(value: S, replace: boolean = false) => {
|
(value: S, replace: boolean = false) => {
|
||||||
@ -18,7 +19,7 @@ export function useOverlayState<S>(
|
|||||||
},
|
},
|
||||||
// we know that these deps are correct
|
// we know that these deps are correct
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[key, navigate],
|
[key, currentLocationState, navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const overlayStateValue = useMemo<S | undefined>(
|
const overlayStateValue = useMemo<S | undefined>(
|
||||||
@ -32,14 +33,16 @@ export function useOverlayState<S>(
|
|||||||
export function usePersistedOverlayState<S extends string>(
|
export function usePersistedOverlayState<S extends string>(
|
||||||
key: string,
|
key: string,
|
||||||
defaultValue: S | undefined = undefined,
|
defaultValue: S | undefined = undefined,
|
||||||
): [S | undefined, (value: S | undefined, replace?: boolean) => void] {
|
): [
|
||||||
const [persistedValue, setPersistedValue] = usePersistence<S>(
|
S | undefined,
|
||||||
key,
|
(value: S | undefined, replace?: boolean) => void,
|
||||||
defaultValue,
|
() => void,
|
||||||
);
|
] {
|
||||||
|
const [persistedValue, setPersistedValue, , deletePersistedValue] =
|
||||||
|
usePersistence<S>(key, defaultValue);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const currentLocationState = location.state;
|
const currentLocationState = useMemo(() => location.state, [location]);
|
||||||
|
|
||||||
const setOverlayStateValue = useCallback(
|
const setOverlayStateValue = useCallback(
|
||||||
(value: S | undefined, replace: boolean = false) => {
|
(value: S | undefined, replace: boolean = false) => {
|
||||||
@ -50,7 +53,7 @@ export function usePersistedOverlayState<S extends string>(
|
|||||||
},
|
},
|
||||||
// we know that these deps are correct
|
// we know that these deps are correct
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[key, navigate],
|
[key, currentLocationState, navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const overlayStateValue = useMemo<S | undefined>(
|
const overlayStateValue = useMemo<S | undefined>(
|
||||||
@ -61,6 +64,7 @@ export function usePersistedOverlayState<S extends string>(
|
|||||||
return [
|
return [
|
||||||
overlayStateValue ?? persistedValue ?? defaultValue,
|
overlayStateValue ?? persistedValue ?? defaultValue,
|
||||||
setOverlayStateValue,
|
setOverlayStateValue,
|
||||||
|
deletePersistedValue,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
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> = [
|
type usePersistenceReturn<S> = [
|
||||||
value: S | undefined,
|
value: S | undefined,
|
||||||
setValue: (value: S | undefined) => void,
|
setValue: (value: S | undefined) => void,
|
||||||
loaded: boolean,
|
loaded: boolean,
|
||||||
|
deleteValue: () => void,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function usePersistence<S>(
|
export function usePersistence<S>(
|
||||||
@ -26,6 +27,11 @@ export function usePersistence<S>(
|
|||||||
[key],
|
[key],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deleteValue = useCallback(async () => {
|
||||||
|
await delData(key);
|
||||||
|
setInternalValue(defaultValue);
|
||||||
|
}, [key, defaultValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoaded(false);
|
setLoaded(false);
|
||||||
setInternalValue(defaultValue);
|
setInternalValue(defaultValue);
|
||||||
@ -41,5 +47,5 @@ export function usePersistence<S>(
|
|||||||
load();
|
load();
|
||||||
}, [key, defaultValue, setValue]);
|
}, [key, defaultValue, setValue]);
|
||||||
|
|
||||||
return [value, setValue, loaded];
|
return [value, setValue, loaded, deleteValue];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,11 +34,13 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
problems.push({
|
problems.push({
|
||||||
text: `${capitalizeFirstLetter(key)} is very slow (${det["inference_speed"]} ms)`,
|
text: `${capitalizeFirstLetter(key)} is very slow (${det["inference_speed"]} ms)`,
|
||||||
color: "text-danger",
|
color: "text-danger",
|
||||||
|
relevantLink: "/system#general",
|
||||||
});
|
});
|
||||||
} else if (det["inference_speed"] > InferenceThreshold.warning) {
|
} else if (det["inference_speed"] > InferenceThreshold.warning) {
|
||||||
problems.push({
|
problems.push({
|
||||||
text: `${capitalizeFirstLetter(key)} is slow (${det["inference_speed"]} ms)`,
|
text: `${capitalizeFirstLetter(key)} is slow (${det["inference_speed"]} ms)`,
|
||||||
color: "text-orange-400",
|
color: "text-orange-400",
|
||||||
|
relevantLink: "/system#general",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -53,6 +55,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
problems.push({
|
problems.push({
|
||||||
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} is offline`,
|
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} is offline`,
|
||||||
color: "text-danger",
|
color: "text-danger",
|
||||||
|
relevantLink: "logs",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -70,6 +73,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
problems.push({
|
problems.push({
|
||||||
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high FFMPEG CPU usage (${ffmpegAvg}%)`,
|
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high FFMPEG CPU usage (${ffmpegAvg}%)`,
|
||||||
color: "text-danger",
|
color: "text-danger",
|
||||||
|
relevantLink: "/system#cameras",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,6 +81,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
problems.push({
|
problems.push({
|
||||||
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high detect CPU usage (${detectAvg}%)`,
|
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high detect CPU usage (${detectAvg}%)`,
|
||||||
color: "text-danger",
|
color: "text-danger",
|
||||||
|
relevantLink: "/system#cameras",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -158,7 +158,7 @@ function ConfigEditor() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={configRef} className="h-full mt-2" />
|
<div ref={configRef} className="h-full mt-2" />
|
||||||
<Toaster />
|
<Toaster closeButton={true} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import useApiFilter from "@/hooks/use-api-filter";
|
import useApiFilter from "@/hooks/use-api-filter";
|
||||||
|
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
||||||
import { useTimezone } from "@/hooks/use-date-utils";
|
import { useTimezone } from "@/hooks/use-date-utils";
|
||||||
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
|
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { Preview } from "@/types/preview";
|
|
||||||
import { RecordingStartingPoint } from "@/types/record";
|
import { RecordingStartingPoint } from "@/types/record";
|
||||||
import {
|
import {
|
||||||
ReviewFilter,
|
ReviewFilter,
|
||||||
@ -161,7 +161,6 @@ export default function Events() {
|
|||||||
}, [updateSummary]);
|
}, [updateSummary]);
|
||||||
|
|
||||||
// preview videos
|
// preview videos
|
||||||
const [previewKey, setPreviewKey] = useState(0);
|
|
||||||
const previewTimes = useMemo(() => {
|
const previewTimes = useMemo(() => {
|
||||||
if (!reviews || reviews.length == 0) {
|
if (!reviews || reviews.length == 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -170,50 +169,22 @@ export default function Events() {
|
|||||||
const startDate = new Date();
|
const startDate = new Date();
|
||||||
startDate.setMinutes(0, 0, 0);
|
startDate.setMinutes(0, 0, 0);
|
||||||
|
|
||||||
let endDate;
|
const endDate = new Date(reviews.at(-1)?.end_time || 0);
|
||||||
if (previewKey == 0) {
|
endDate.setHours(0, 0, 0, 0);
|
||||||
endDate = new Date(reviews.at(-1)?.end_time || 0);
|
|
||||||
endDate.setHours(0, 0, 0, 0);
|
|
||||||
} else {
|
|
||||||
endDate = new Date();
|
|
||||||
endDate.setMilliseconds(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
start: startDate.getTime() / 1000,
|
after: startDate.getTime() / 1000,
|
||||||
end: endDate.getTime() / 1000,
|
before: endDate.getTime() / 1000,
|
||||||
};
|
};
|
||||||
}, [reviews, previewKey]);
|
}, [reviews]);
|
||||||
const { data: allPreviews } = useSWR<Preview[]>(
|
|
||||||
previewTimes
|
const allPreviews = useCameraPreviews(
|
||||||
? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}`
|
previewTimes ?? { after: 0, before: 0 },
|
||||||
: null,
|
{
|
||||||
{ revalidateOnFocus: false, revalidateOnReconnect: false },
|
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
|
// review status
|
||||||
|
|
||||||
const markAllItemsAsReviewed = useCallback(
|
const markAllItemsAsReviewed = useCallback(
|
||||||
|
|||||||
@ -38,7 +38,13 @@ function Live() {
|
|||||||
// settings
|
// settings
|
||||||
|
|
||||||
const includesBirdseye = useMemo(() => {
|
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");
|
return config.camera_groups[cameraGroup].cameras.includes("birdseye");
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
@ -50,7 +56,12 @@ function Live() {
|
|||||||
return [];
|
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];
|
const group = config.camera_groups[cameraGroup];
|
||||||
return Object.values(config.cameras)
|
return Object.values(config.cameras)
|
||||||
.filter((conf) => conf.enabled && group.cameras.includes(conf.name))
|
.filter((conf) => conf.enabled && group.cameras.includes(conf.name))
|
||||||
@ -78,6 +89,7 @@ function Live() {
|
|||||||
return (
|
return (
|
||||||
<LiveDashboardView
|
<LiveDashboardView
|
||||||
cameras={cameras}
|
cameras={cameras}
|
||||||
|
cameraGroup={cameraGroup}
|
||||||
includeBirdseye={includesBirdseye}
|
includeBirdseye={includesBirdseye}
|
||||||
onSelectCamera={setSelectedCameraName}
|
onSelectCamera={setSelectedCameraName}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { Toaster } from "@/components/ui/sonner";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const logTypes = ["frigate", "go2rtc", "nginx"] as const;
|
const logTypes = ["frigate", "go2rtc", "nginx"] as const;
|
||||||
type LogType = (typeof logTypes)[number];
|
type LogType = (typeof logTypes)[number];
|
||||||
@ -332,7 +333,7 @@ function Logs() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full p-2 flex flex-col">
|
<div className="size-full p-2 flex flex-col">
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" closeButton={true} />
|
||||||
<LogInfoDialog logLine={selectedLog} setLogLine={setSelectedLog} />
|
<LogInfoDialog logLine={selectedLog} setLogLine={setSelectedLog} />
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
@ -472,7 +473,11 @@ function LogLineData({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={startRef}
|
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}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
<div className="h-full p-1 flex items-center gap-2">
|
<div className="h-full p-1 flex items-center gap-2">
|
||||||
|
|||||||
@ -37,9 +37,9 @@ import scrollIntoView from "scroll-into-view-if-needed";
|
|||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const settingsViews = [
|
const settingsViews = [
|
||||||
"general",
|
"general",
|
||||||
"objects",
|
|
||||||
"masks / zones",
|
"masks / zones",
|
||||||
"motion tuner",
|
"motion tuner",
|
||||||
|
"debug",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type SettingsType = (typeof settingsViews)[number];
|
type SettingsType = (typeof settingsViews)[number];
|
||||||
@ -100,6 +100,10 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
}, [tabsRef, pageToggle]);
|
}, [tabsRef, pageToggle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Settings - Frigate";
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full p-2 flex flex-col">
|
<div className="size-full p-2 flex flex-col">
|
||||||
<div className="w-full h-11 relative flex justify-between items-center">
|
<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" />
|
<ScrollBar orientation="horizontal" className="h-0" />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
{(page == "objects" ||
|
{(page == "debug" ||
|
||||||
page == "masks / zones" ||
|
page == "masks / zones" ||
|
||||||
page == "motion tuner") && (
|
page == "motion tuner") && (
|
||||||
<div className="flex items-center gap-2 ml-2 flex-shrink-0">
|
<div className="flex items-center gap-2 ml-2 flex-shrink-0">
|
||||||
@ -151,9 +155,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-col items-start w-full h-full md:h-dvh md:pb-24">
|
<div className="mt-2 flex flex-col items-start w-full h-full md:h-dvh md:pb-24">
|
||||||
{page == "general" && <General />}
|
{page == "general" && <General />}
|
||||||
{page == "objects" && (
|
{page == "debug" && <ObjectSettings selectedCamera={selectedCamera} />}
|
||||||
<ObjectSettings selectedCamera={selectedCamera} />
|
|
||||||
)}
|
|
||||||
{page == "masks / zones" && (
|
{page == "masks / zones" && (
|
||||||
<MasksAndZones
|
<MasksAndZones
|
||||||
selectedCamera={selectedCamera}
|
selectedCamera={selectedCamera}
|
||||||
@ -235,7 +237,7 @@ function CameraSelectButton({
|
|||||||
<DropdownMenuSeparator />
|
<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">
|
<div className="flex flex-col gap-2.5">
|
||||||
{allCameras.map((item) => (
|
{allCameras.map((item) => (
|
||||||
<FilterSwitch
|
<FilterSwitch
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import {
|
|||||||
CamerasFilterButton,
|
CamerasFilterButton,
|
||||||
GeneralFilterContent,
|
GeneralFilterContent,
|
||||||
} from "@/components/filter/ReviewFilterGroup";
|
} from "@/components/filter/ReviewFilterGroup";
|
||||||
|
import Chip from "@/components/indicators/Chip";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -23,10 +25,17 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { DualThumbSlider } from "@/components/ui/slider";
|
import { DualThumbSlider } from "@/components/ui/slider";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
|
import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { getIconForLabel } from "@/utils/iconUtil";
|
||||||
|
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||||
import axios from "axios";
|
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 { isMobile } from "react-device-detect";
|
||||||
import {
|
import {
|
||||||
FaList,
|
FaList,
|
||||||
@ -36,6 +45,9 @@ import {
|
|||||||
} from "react-icons/fa";
|
} from "react-icons/fa";
|
||||||
import { PiSlidersHorizontalFill } from "react-icons/pi";
|
import { PiSlidersHorizontalFill } from "react-icons/pi";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import useSWRInfinite from "swr/infinite";
|
||||||
|
|
||||||
|
const API_LIMIT = 100;
|
||||||
|
|
||||||
export default function SubmitPlus() {
|
export default function SubmitPlus() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
@ -56,21 +68,93 @@ export default function SubmitPlus() {
|
|||||||
|
|
||||||
// data
|
// data
|
||||||
|
|
||||||
const { data: events, mutate: refresh } = useSWR<Event[]>([
|
const eventFetcher = useCallback((key: string) => {
|
||||||
"events",
|
const [path, params] = Array.isArray(key) ? key : [key, undefined];
|
||||||
{
|
return axios.get(path, { params }).then((res) => res.data);
|
||||||
limit: 100,
|
}, []);
|
||||||
in_progress: 0,
|
|
||||||
is_submitted: 0,
|
const getKey = useCallback(
|
||||||
cameras: selectedCameras ? selectedCameras.join(",") : null,
|
(index: number, prevData: Event[]) => {
|
||||||
labels: selectedLabels ? selectedLabels.join(",") : null,
|
if (index > 0) {
|
||||||
min_score: scoreRange ? scoreRange[0] : null,
|
const lastDate = prevData[prevData.length - 1].start_time;
|
||||||
max_score: scoreRange ? scoreRange[1] : null,
|
return [
|
||||||
sort: sort ? sort : null,
|
"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>();
|
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(() => {
|
const grow = useMemo(() => {
|
||||||
if (!config || !upload) {
|
if (!config || !upload) {
|
||||||
return "";
|
return "";
|
||||||
@ -102,18 +186,35 @@ export default function SubmitPlus() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
refresh(
|
refresh(
|
||||||
(data: Event[] | undefined) => {
|
(data: Event[][] | undefined) => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return 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) {
|
if (index == -1) {
|
||||||
return data;
|
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 },
|
{ revalidate: false, populateCache: true },
|
||||||
);
|
);
|
||||||
@ -141,7 +242,7 @@ export default function SubmitPlus() {
|
|||||||
open={upload != undefined}
|
open={upload != undefined}
|
||||||
onOpenChange={(open) => (!open ? setUpload(undefined) : null)}
|
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>
|
<DialogHeader>
|
||||||
<DialogTitle>Submit To Frigate+</DialogTitle>
|
<DialogTitle>Submit To Frigate+</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@ -174,17 +275,47 @@ export default function SubmitPlus() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{events?.map((event) => {
|
{events?.map((event, eIdx) => {
|
||||||
if (event.data.type != "object") {
|
if (event.data.type != "object") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastRow = eIdx == events.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
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)}
|
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
|
<img
|
||||||
className="aspect-video h-full object-contain rounded-lg md:rounded-2xl"
|
className="aspect-video h-full object-contain rounded-lg md:rounded-2xl"
|
||||||
src={`${baseUrl}api/events/${event.id}/snapshot.jpg`}
|
src={`${baseUrl}api/events/${event.id}/snapshot.jpg`}
|
||||||
@ -193,6 +324,8 @@ export default function SubmitPlus() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{isValidating && <ActivityIndicator />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import { FaVideo } from "react-icons/fa";
|
|||||||
import Logo from "@/components/Logo";
|
import Logo from "@/components/Logo";
|
||||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||||
import CameraMetrics from "@/views/system/CameraMetrics";
|
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;
|
const metrics = ["general", "storage", "cameras"] as const;
|
||||||
type SystemMetric = (typeof metrics)[number];
|
type SystemMetric = (typeof metrics)[number];
|
||||||
@ -18,12 +20,18 @@ type SystemMetric = (typeof metrics)[number];
|
|||||||
function System() {
|
function System() {
|
||||||
// stats page
|
// stats page
|
||||||
|
|
||||||
const [page, setPage] = useState<SystemMetric>("general");
|
const [page, setPage] = useHashState<SystemMetric>();
|
||||||
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
const [pageToggle, setPageToggle] = useOptimisticState(
|
||||||
|
page ?? "general",
|
||||||
|
setPage,
|
||||||
|
100,
|
||||||
|
);
|
||||||
const [lastUpdated, setLastUpdated] = useState<number>(Date.now() / 1000);
|
const [lastUpdated, setLastUpdated] = useState<number>(Date.now() / 1000);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${pageToggle[0].toUpperCase()}${pageToggle.substring(1)} Stats - Frigate`;
|
if (pageToggle) {
|
||||||
|
document.title = `${capitalizeFirstLetter(pageToggle)} Stats - Frigate`;
|
||||||
|
}
|
||||||
}, [pageToggle]);
|
}, [pageToggle]);
|
||||||
|
|
||||||
// stats collection
|
// stats collection
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import SummaryTimeline from "@/components/timeline/SummaryTimeline";
|
import SummaryTimeline from "@/components/timeline/SummaryTimeline";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
|
import IconPicker, { IconElement } from "@/components/icons/IconPicker";
|
||||||
|
|
||||||
// Color data
|
// Color data
|
||||||
const colors = [
|
const colors = [
|
||||||
@ -207,6 +208,8 @@ function UIPlayground() {
|
|||||||
const [isEventsReviewTimeline, setIsEventsReviewTimeline] = useState(true);
|
const [isEventsReviewTimeline, setIsEventsReviewTimeline] = useState(true);
|
||||||
const birdseyeConfig = config?.birdseye;
|
const birdseyeConfig = config?.birdseye;
|
||||||
|
|
||||||
|
const [selectedIcon, setSelectedIcon] = useState<IconElement>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full h-full">
|
<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">
|
<div className="flex-1 content-start gap-2 overflow-y-auto no-scrollbar mt-4 mr-5">
|
||||||
<Heading as="h2">UI Playground</Heading>
|
<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">
|
<Heading as="h4" className="my-5">
|
||||||
Scrubber
|
Scrubber
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { IconName } from "@/components/icons/IconPicker";
|
||||||
import { LivePlayerMode } from "./live";
|
import { LivePlayerMode } from "./live";
|
||||||
|
|
||||||
export interface UiConfig {
|
export interface UiConfig {
|
||||||
@ -222,11 +223,9 @@ export interface CameraConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GROUP_ICONS = ["car", "cat", "dog", "leaf"] as const;
|
|
||||||
|
|
||||||
export type CameraGroupConfig = {
|
export type CameraGroupConfig = {
|
||||||
cameras: string[];
|
cameras: string[];
|
||||||
icon: (typeof GROUP_ICONS)[number];
|
icon: IconName;
|
||||||
order: number;
|
order: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -324,6 +323,7 @@ export interface FrigateConfig {
|
|||||||
model_type: string;
|
model_type: string;
|
||||||
path: string | null;
|
path: string | null;
|
||||||
width: number;
|
width: number;
|
||||||
|
colormap: { [key: string]: [number, number, number] };
|
||||||
};
|
};
|
||||||
|
|
||||||
motion: Record<string, unknown> | null;
|
motion: Record<string, unknown> | null;
|
||||||
|
|||||||
@ -1 +1,5 @@
|
|||||||
export type LivePlayerMode = "webrtc" | "mse" | "jsmpeg" | "debug";
|
export type LivePlayerMode = "webrtc" | "mse" | "jsmpeg" | "debug";
|
||||||
|
export type VideoResolutionType = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { REVIEW_PADDING } from "./review";
|
||||||
|
|
||||||
export type Preview = {
|
export type Preview = {
|
||||||
camera: string;
|
camera: string;
|
||||||
src: string;
|
src: string;
|
||||||
@ -5,3 +7,6 @@ export type Preview = {
|
|||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PREVIEW_FPS = 8;
|
||||||
|
export const PREVIEW_PADDING = REVIEW_PADDING * PREVIEW_FPS;
|
||||||
|
|||||||
@ -38,3 +38,6 @@ export type RecordingStartingPoint = {
|
|||||||
startTime: number;
|
startTime: number;
|
||||||
severity: ReviewSeverity;
|
severity: ReviewSeverity;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ASPECT_VERTICAL_LAYOUT = 1.5;
|
||||||
|
export const ASPECT_WIDE_LAYOUT = 2;
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export type ReviewFilter = {
|
|||||||
before?: number;
|
before?: number;
|
||||||
after?: number;
|
after?: number;
|
||||||
showReviewed?: 0 | 1;
|
showReviewed?: 0 | 1;
|
||||||
|
showAll?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReviewSummaryDay = {
|
type ReviewSummaryDay = {
|
||||||
@ -48,3 +49,5 @@ export type MotionData = {
|
|||||||
audio?: number;
|
audio?: number;
|
||||||
camera: string;
|
camera: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const REVIEW_PADDING = 2;
|
||||||
|
|||||||
@ -62,6 +62,7 @@ export type StorageStats = {
|
|||||||
export type PotentialProblem = {
|
export type PotentialProblem = {
|
||||||
text: string;
|
text: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
relevantLink?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Vainfo = {
|
export type Vainfo = {
|
||||||
|
|||||||
@ -41,4 +41,19 @@ export interface FrigateEvent {
|
|||||||
after: FrigateObjectState;
|
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";
|
export type ToggleableSetting = "ON" | "OFF";
|
||||||
|
|||||||
@ -1,37 +1,24 @@
|
|||||||
|
import { IconName } from "@/components/icons/IconPicker";
|
||||||
import { BsPersonWalking } from "react-icons/bs";
|
import { BsPersonWalking } from "react-icons/bs";
|
||||||
import {
|
import {
|
||||||
FaAmazon,
|
FaAmazon,
|
||||||
|
FaBicycle,
|
||||||
|
FaBus,
|
||||||
FaCarSide,
|
FaCarSide,
|
||||||
FaCat,
|
FaCat,
|
||||||
FaCheckCircle,
|
FaCheckCircle,
|
||||||
FaCircle,
|
|
||||||
FaDog,
|
FaDog,
|
||||||
FaFedex,
|
FaFedex,
|
||||||
FaFire,
|
FaFire,
|
||||||
FaLeaf,
|
|
||||||
FaUps,
|
FaUps,
|
||||||
} from "react-icons/fa";
|
} from "react-icons/fa";
|
||||||
|
import { GiHummingbird } from "react-icons/gi";
|
||||||
import { LuBox, LuLassoSelect } from "react-icons/lu";
|
import { LuBox, LuLassoSelect } from "react-icons/lu";
|
||||||
|
import * as LuIcons from "react-icons/lu";
|
||||||
import { MdRecordVoiceOver } from "react-icons/md";
|
import { MdRecordVoiceOver } from "react-icons/md";
|
||||||
|
|
||||||
export function getIconTypeForGroup(icon: string) {
|
export function isValidIconName(value: string): value is IconName {
|
||||||
switch (icon) {
|
return Object.keys(LuIcons).includes(value as IconName);
|
||||||
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 getIconForLabel(label: string, className?: string) {
|
export function getIconForLabel(label: string, className?: string) {
|
||||||
@ -40,10 +27,18 @@ export function getIconForLabel(label: string, className?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (label) {
|
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 "car":
|
||||||
|
case "vehicle":
|
||||||
return <FaCarSide key={label} className={className} />;
|
return <FaCarSide key={label} className={className} />;
|
||||||
case "cat":
|
case "cat":
|
||||||
return <FaCat key={label} className={className} />;
|
return <FaCat key={label} className={className} />;
|
||||||
|
case "animal":
|
||||||
case "bark":
|
case "bark":
|
||||||
case "dog":
|
case "dog":
|
||||||
return <FaDog key={label} className={className} />;
|
return <FaDog key={label} className={className} />;
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { Preview } from "@/types/preview";
|
import { Preview } from "@/types/preview";
|
||||||
import {
|
import {
|
||||||
MotionData,
|
MotionData,
|
||||||
|
REVIEW_PADDING,
|
||||||
ReviewFilter,
|
ReviewFilter,
|
||||||
ReviewSegment,
|
ReviewSegment,
|
||||||
ReviewSeverity,
|
ReviewSeverity,
|
||||||
@ -44,6 +45,8 @@ import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity";
|
|||||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
import scrollIntoView from "scroll-into-view-if-needed";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type EventViewProps = {
|
type EventViewProps = {
|
||||||
reviews?: ReviewSegment[];
|
reviews?: ReviewSegment[];
|
||||||
@ -175,7 +178,7 @@ export default function EventView({
|
|||||||
} else {
|
} else {
|
||||||
onOpenRecording({
|
onOpenRecording({
|
||||||
camera: review.camera,
|
camera: review.camera,
|
||||||
startTime: review.start_time,
|
startTime: review.start_time - REVIEW_PADDING,
|
||||||
severity: review.severity,
|
severity: review.severity,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -193,10 +196,31 @@ export default function EventView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
axios.post(
|
axios
|
||||||
`export/${review.camera}/start/${review.start_time}/end/${review.end_time}`,
|
.post(
|
||||||
{ playback: "realtime" },
|
`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],
|
[reviewItems],
|
||||||
);
|
);
|
||||||
@ -214,6 +238,7 @@ export default function EventView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-2 flex flex-col size-full">
|
<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">
|
<div className="h-11 mb-2 pl-3 pr-2 relative flex justify-between items-center">
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
|
<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", "date", "motionOnly"]
|
||||||
: ["cameras", "reviewed", "date", "general"]
|
: ["cameras", "reviewed", "date", "general"]
|
||||||
}
|
}
|
||||||
|
currentSeverity={severityToggle}
|
||||||
reviewSummary={reviewSummary}
|
reviewSummary={reviewSummary}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
onUpdateFilter={updateFilter}
|
onUpdateFilter={updateFilter}
|
||||||
@ -369,7 +395,13 @@ function DetectionReview({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = reviewItems[severity];
|
let current;
|
||||||
|
|
||||||
|
if (filter?.showAll) {
|
||||||
|
current = reviewItems.all;
|
||||||
|
} else {
|
||||||
|
current = reviewItems[severity];
|
||||||
|
}
|
||||||
|
|
||||||
if (!current || current.length == 0) {
|
if (!current || current.length == 0) {
|
||||||
return [];
|
return [];
|
||||||
@ -512,7 +544,7 @@ function DetectionReview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const element = contentRef.current?.querySelector(
|
const element = contentRef.current?.querySelector(
|
||||||
`[data-start="${startTime}"]`,
|
`[data-start="${startTime + REVIEW_PADDING}"]`,
|
||||||
);
|
);
|
||||||
if (element) {
|
if (element) {
|
||||||
scrollIntoView(element, {
|
scrollIntoView(element, {
|
||||||
@ -797,6 +829,11 @@ function MotionReview({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nextTimestamp >= timeRange.before - 4) {
|
||||||
|
setPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handleTimeout = () => {
|
const handleTimeout = () => {
|
||||||
setCurrentTime(nextTimestamp);
|
setCurrentTime(nextTimestamp);
|
||||||
timeoutIdRef.current = setTimeout(handleTimeout, 500 / playbackRate);
|
timeoutIdRef.current = setTimeout(handleTimeout, 500 / playbackRate);
|
||||||
@ -810,7 +847,7 @@ function MotionReview({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [playing, playbackRate, nextTimestamp]);
|
}, [playing, playbackRate, nextTimestamp, setPlaying, timeRange]);
|
||||||
|
|
||||||
const { alignStartDateToTimeline } = useTimelineUtils({
|
const { alignStartDateToTimeline } = useTimelineUtils({
|
||||||
segmentDuration,
|
segmentDuration,
|
||||||
@ -954,37 +991,34 @@ function MotionReview({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!scrubbing && (
|
<VideoControls
|
||||||
<VideoControls
|
className="absolute bottom-16 left-1/2 -translate-x-1/2 bg-secondary"
|
||||||
className="absolute bottom-16 left-1/2 -translate-x-1/2 bg-secondary"
|
features={{
|
||||||
features={{
|
volume: false,
|
||||||
volume: false,
|
seek: true,
|
||||||
seek: true,
|
playbackRate: true,
|
||||||
playbackRate: true,
|
}}
|
||||||
}}
|
isPlaying={playing}
|
||||||
isPlaying={playing}
|
show={!scrubbing || controlsOpen}
|
||||||
playbackRates={[4, 8, 12, 16]}
|
playbackRates={[4, 8, 12, 16]}
|
||||||
playbackRate={playbackRate}
|
playbackRate={playbackRate}
|
||||||
controlsOpen={controlsOpen}
|
setControlsOpen={setControlsOpen}
|
||||||
setControlsOpen={setControlsOpen}
|
onPlayPause={setPlaying}
|
||||||
onPlayPause={setPlaying}
|
onSeek={(diff) => {
|
||||||
onSeek={(diff) => {
|
const wasPlaying = playing;
|
||||||
const wasPlaying = playing;
|
|
||||||
|
|
||||||
if (wasPlaying) {
|
if (wasPlaying) {
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentTime(currentTime + diff);
|
setCurrentTime(currentTime + diff);
|
||||||
|
|
||||||
if (wasPlaying) {
|
if (wasPlaying) {
|
||||||
setTimeout(() => setPlaying(true), 100);
|
setTimeout(() => setPlaying(true), 100);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSetPlaybackRate={setPlaybackRate}
|
onSetPlaybackRate={setPlaybackRate}
|
||||||
show={currentTime < timeRange.before - 4}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { Preview } from "@/types/preview";
|
import { Preview } from "@/types/preview";
|
||||||
import {
|
import {
|
||||||
MotionData,
|
MotionData,
|
||||||
|
REVIEW_PADDING,
|
||||||
ReviewFilter,
|
ReviewFilter,
|
||||||
ReviewSegment,
|
ReviewSegment,
|
||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
@ -40,6 +41,8 @@ import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSetting
|
|||||||
import Logo from "@/components/Logo";
|
import Logo from "@/components/Logo";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { FaVideo } from "react-icons/fa";
|
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;
|
const SEGMENT_DURATION = 30;
|
||||||
|
|
||||||
@ -188,9 +191,18 @@ export function RecordingView({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [currentTime, scrubbing]);
|
}, [currentTime, scrubbing]);
|
||||||
|
|
||||||
|
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const onSelectCamera = useCallback(
|
const onSelectCamera = useCallback(
|
||||||
(newCam: string) => {
|
(newCam: string) => {
|
||||||
setMainCamera(newCam);
|
setMainCamera(newCam);
|
||||||
|
setFullResolution({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
setPlaybackStart(currentTime);
|
setPlaybackStart(currentTime);
|
||||||
},
|
},
|
||||||
[currentTime],
|
[currentTime],
|
||||||
@ -204,6 +216,10 @@ export function RecordingView({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cam == mainCamera && fullResolution.width && fullResolution.height) {
|
||||||
|
return fullResolution.width / fullResolution.height;
|
||||||
|
}
|
||||||
|
|
||||||
const camera = config.cameras[cam];
|
const camera = config.cameras[cam];
|
||||||
|
|
||||||
if (!camera) {
|
if (!camera) {
|
||||||
@ -212,7 +228,7 @@ export function RecordingView({
|
|||||||
|
|
||||||
return camera.detect.width / camera.detect.height;
|
return camera.detect.width / camera.detect.height;
|
||||||
},
|
},
|
||||||
[config],
|
[config, fullResolution, mainCamera],
|
||||||
);
|
);
|
||||||
|
|
||||||
const mainCameraAspect = useMemo(() => {
|
const mainCameraAspect = useMemo(() => {
|
||||||
@ -220,9 +236,9 @@ export function RecordingView({
|
|||||||
|
|
||||||
if (!aspectRatio) {
|
if (!aspectRatio) {
|
||||||
return "normal";
|
return "normal";
|
||||||
} else if (aspectRatio > 2) {
|
} else if (aspectRatio > ASPECT_WIDE_LAYOUT) {
|
||||||
return "wide";
|
return "wide";
|
||||||
} else if (aspectRatio < 16 / 9) {
|
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
|
||||||
return "tall";
|
return "tall";
|
||||||
} else {
|
} else {
|
||||||
return "normal";
|
return "normal";
|
||||||
@ -245,7 +261,7 @@ export function RecordingView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="size-full pt-2 flex flex-col">
|
<div ref={contentRef} className="size-full pt-2 flex flex-col">
|
||||||
<Toaster />
|
<Toaster closeButton={true} />
|
||||||
<div
|
<div
|
||||||
className={`w-full h-11 mb-2 px-2 relative flex items-center justify-between`}
|
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;
|
mainControllerRef.current = controller;
|
||||||
}}
|
}}
|
||||||
isScrubbing={scrubbing || exportMode == "timeline"}
|
isScrubbing={scrubbing || exportMode == "timeline"}
|
||||||
|
setFullResolution={setFullResolution}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isDesktop && (
|
{isDesktop && (
|
||||||
@ -558,7 +575,7 @@ function Timeline({
|
|||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setScrubbing(true);
|
setScrubbing(true);
|
||||||
setCurrentTime(review.start_time);
|
setCurrentTime(review.start_time - REVIEW_PADDING);
|
||||||
setScrubbing(false);
|
setScrubbing(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
528
web/src/views/live/DraggableGridLayout.tsx
Normal file
528
web/src/views/live/DraggableGridLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -20,6 +20,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|||||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import { CameraConfig } from "@/types/frigateConfig";
|
import { CameraConfig } from "@/types/frigateConfig";
|
||||||
|
import { VideoResolutionType } from "@/types/live";
|
||||||
import { CameraPtzInfo } from "@/types/ptz";
|
import { CameraPtzInfo } from "@/types/ptz";
|
||||||
import { RecordingStartingPoint } from "@/types/record";
|
import { RecordingStartingPoint } from "@/types/record";
|
||||||
import React, {
|
import React, {
|
||||||
@ -97,7 +98,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
|
|||||||
|
|
||||||
let clientX;
|
let clientX;
|
||||||
let clientY;
|
let clientY;
|
||||||
if (isMobile && e.nativeEvent instanceof TouchEvent) {
|
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
|
||||||
clientX = e.nativeEvent.touches[0].clientX;
|
clientX = e.nativeEvent.touches[0].clientX;
|
||||||
clientY = e.nativeEvent.touches[0].clientY;
|
clientY = e.nativeEvent.touches[0].clientY;
|
||||||
} else if (e.nativeEvent instanceof MouseEvent) {
|
} else if (e.nativeEvent instanceof MouseEvent) {
|
||||||
@ -149,14 +150,24 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
|
|||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const [pip, setPip] = useState(false);
|
const [pip, setPip] = useState(false);
|
||||||
|
|
||||||
|
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const growClassName = useMemo(() => {
|
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 (isMobile) {
|
||||||
if (isPortrait) {
|
if (isPortrait) {
|
||||||
return "absolute left-2 right-2 top-[50%] -translate-y-[50%]";
|
return "absolute left-2 right-2 top-[50%] -translate-y-[50%]";
|
||||||
} else {
|
} else {
|
||||||
if (aspect > 16 / 9) {
|
if (aspect > 1.5) {
|
||||||
return "p-2 absolute left-0 top-[50%] -translate-y-[50%]";
|
return "p-2 absolute left-0 top-[50%] -translate-y-[50%]";
|
||||||
} else {
|
} else {
|
||||||
return "p-2 absolute top-2 bottom-2 left-[50%] -translate-x-[50%]";
|
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 (fullscreen) {
|
||||||
if (aspect > 16 / 9) {
|
if (aspect > 1.5) {
|
||||||
return "absolute inset-x-2 top-[50%] -translate-y-[50%]";
|
return "absolute inset-x-2 top-[50%] -translate-y-[50%]";
|
||||||
} else {
|
} else {
|
||||||
return "absolute inset-y-2 left-[50%] -translate-x-[50%]";
|
return "absolute inset-y-2 left-[50%] -translate-x-[50%]";
|
||||||
@ -173,7 +184,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
|
|||||||
} else {
|
} else {
|
||||||
return "absolute top-2 bottom-2 left-[50%] -translate-x-[50%]";
|
return "absolute top-2 bottom-2 left-[50%] -translate-x-[50%]";
|
||||||
}
|
}
|
||||||
}, [camera, fullscreen, isPortrait]);
|
}, [camera, fullscreen, isPortrait, fullResolution]);
|
||||||
|
|
||||||
const preferredLiveMode = useMemo(() => {
|
const preferredLiveMode = useMemo(() => {
|
||||||
if (isSafari || mic) {
|
if (isSafari || mic) {
|
||||||
@ -188,8 +199,12 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
|
|||||||
}, [windowWidth, windowHeight]);
|
}, [windowWidth, windowHeight]);
|
||||||
|
|
||||||
const cameraAspectRatio = useMemo(() => {
|
const cameraAspectRatio = useMemo(() => {
|
||||||
return camera.detect.width / camera.detect.height;
|
if (fullResolution.width && fullResolution.height) {
|
||||||
}, [camera]);
|
return fullResolution.width / fullResolution.height;
|
||||||
|
} else {
|
||||||
|
return camera.detect.width / camera.detect.height;
|
||||||
|
}
|
||||||
|
}, [camera, fullResolution]);
|
||||||
|
|
||||||
const aspectRatio = useMemo<number>(() => {
|
const aspectRatio = useMemo<number>(() => {
|
||||||
if (isMobile || fullscreen) {
|
if (isMobile || fullscreen) {
|
||||||
@ -347,6 +362,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
|
|||||||
iOSCompatFullScreen={isIOS}
|
iOSCompatFullScreen={isIOS}
|
||||||
preferredLiveMode={preferredLiveMode}
|
preferredLiveMode={preferredLiveMode}
|
||||||
pip={pip}
|
pip={pip}
|
||||||
|
setFullResolution={setFullResolution}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{camera.onvif.host != "" && (
|
{camera.onvif.host != "" && (
|
||||||
@ -529,7 +545,7 @@ function PtzControlPanel({
|
|||||||
<BsThreeDotsVertical />
|
<BsThreeDotsVertical />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent className="max-h-[40dvh] overflow-y-auto">
|
||||||
{ptz?.presets.map((preset) => {
|
{ptz?.presets.map((preset) => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@ -12,16 +12,27 @@ import { usePersistence } from "@/hooks/use-persistence";
|
|||||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { ReviewSegment } from "@/types/review";
|
import { ReviewSegment } from "@/types/review";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 useSWR from "swr";
|
||||||
|
import DraggableGridLayout from "./DraggableGridLayout";
|
||||||
|
import { IoClose } from "react-icons/io5";
|
||||||
|
import { LuMove } from "react-icons/lu";
|
||||||
|
|
||||||
type LiveDashboardViewProps = {
|
type LiveDashboardViewProps = {
|
||||||
cameras: CameraConfig[];
|
cameras: CameraConfig[];
|
||||||
|
cameraGroup?: string;
|
||||||
includeBirdseye: boolean;
|
includeBirdseye: boolean;
|
||||||
onSelectCamera: (camera: string) => void;
|
onSelectCamera: (camera: string) => void;
|
||||||
};
|
};
|
||||||
export default function LiveDashboardView({
|
export default function LiveDashboardView({
|
||||||
cameras,
|
cameras,
|
||||||
|
cameraGroup,
|
||||||
includeBirdseye,
|
includeBirdseye,
|
||||||
onSelectCamera,
|
onSelectCamera,
|
||||||
}: LiveDashboardViewProps) {
|
}: LiveDashboardViewProps) {
|
||||||
@ -29,11 +40,14 @@ export default function LiveDashboardView({
|
|||||||
|
|
||||||
// layout
|
// layout
|
||||||
|
|
||||||
const [layout, setLayout] = usePersistence<"grid" | "list">(
|
const [mobileLayout, setMobileLayout] = usePersistence<"grid" | "list">(
|
||||||
"live-layout",
|
"live-layout",
|
||||||
isDesktop ? "grid" : "list",
|
isDesktop ? "grid" : "list",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [isEditMode, setIsEditMode] = useState<boolean>(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// recent events
|
// recent events
|
||||||
const { payload: eventUpdate } = useFrigateReviews();
|
const { payload: eventUpdate } = useFrigateReviews();
|
||||||
const { data: allEvents, mutate: updateEvents } = useSWR<ReviewSegment[]>([
|
const { data: allEvents, mutate: updateEvents } = useSWR<ReviewSegment[]>([
|
||||||
@ -140,35 +154,52 @@ export default function LiveDashboardView({
|
|||||||
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full p-2 overflow-y-auto">
|
<div className="size-full p-2 overflow-y-auto" ref={containerRef}>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="h-11 relative flex items-center justify-between">
|
<div className="h-11 relative flex items-center justify-between">
|
||||||
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
|
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
|
||||||
<CameraGroupSelector />
|
<div className="max-w-[45%]">
|
||||||
<div className="flex items-center gap-1">
|
<CameraGroupSelector />
|
||||||
<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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -185,41 +216,56 @@ export default function LiveDashboardView({
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
{!cameraGroup || cameraGroup == "default" || isMobileOnly ? (
|
||||||
className={`mt-2 px-2 grid ${layout == "grid" ? "grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" : ""} gap-2 md:gap-4`}
|
<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
|
{includeBirdseye && birdseyeConfig?.enabled && (
|
||||||
birdseyeConfig={birdseyeConfig}
|
<BirdseyeLivePlayer
|
||||||
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
|
birdseyeConfig={birdseyeConfig}
|
||||||
onClick={() => onSelectCamera("birdseye")}
|
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)}
|
|
||||||
/>
|
/>
|
||||||
);
|
)}
|
||||||
})}
|
{cameras.map((camera) => {
|
||||||
</div>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user