forked from Github/frigate
Compare commits
33 Commits
v0.14.0-be
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4558919ba7 | ||
|
|
88046ebd15 | ||
|
|
abc1ecfb60 | ||
|
|
9bbb88cdcb | ||
|
|
c867d90f50 | ||
|
|
3410290b45 | ||
|
|
b34be991bd | ||
|
|
73755e9777 | ||
|
|
c871bebee6 | ||
|
|
a60ffe06ac | ||
|
|
9f81ce2876 | ||
|
|
5c33cdba4e | ||
|
|
e9cdef9f25 | ||
|
|
d01457e64d | ||
|
|
c72d304515 | ||
|
|
10c1f7ead4 | ||
|
|
7b57a66d45 | ||
|
|
767033e4d8 | ||
|
|
e6790d9a6a | ||
|
|
4bca405e29 | ||
|
|
bdda89b5e2 | ||
|
|
2cbc336bc0 | ||
|
|
6c107883b5 | ||
|
|
4635e64b2e | ||
|
|
5b60785cca | ||
|
|
ef304e6f7f | ||
|
|
24770148a7 | ||
|
|
ba6fc0fdb3 | ||
|
|
89a478ce0a | ||
|
|
f1bb797fe0 | ||
|
|
e208241eea | ||
|
|
02af1b0ac7 | ||
|
|
3c12872a56 |
12
.github/DISCUSSION_TEMPLATE/camera-support.yml
vendored
12
.github/DISCUSSION_TEMPLATE/camera-support.yml
vendored
@@ -69,14 +69,14 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: coral
|
||||
id: object-detector
|
||||
attributes:
|
||||
label: Coral version
|
||||
label: Object Detector
|
||||
options:
|
||||
- USB
|
||||
- PCIe
|
||||
- M.2
|
||||
- Dev Board
|
||||
- Coral
|
||||
- OpenVino
|
||||
- TensorRT
|
||||
- RKNN
|
||||
- Other
|
||||
- CPU (no coral)
|
||||
validations:
|
||||
|
||||
12
.github/DISCUSSION_TEMPLATE/config-support.yml
vendored
12
.github/DISCUSSION_TEMPLATE/config-support.yml
vendored
@@ -61,14 +61,14 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: coral
|
||||
id: object-detector
|
||||
attributes:
|
||||
label: Coral version
|
||||
label: Object Detector
|
||||
options:
|
||||
- USB
|
||||
- PCIe
|
||||
- M.2
|
||||
- Dev Board
|
||||
- Coral
|
||||
- OpenVino
|
||||
- TensorRT
|
||||
- RKNN
|
||||
- Other
|
||||
- CPU (no coral)
|
||||
validations:
|
||||
|
||||
12
.github/DISCUSSION_TEMPLATE/detector-support.yml
vendored
12
.github/DISCUSSION_TEMPLATE/detector-support.yml
vendored
@@ -63,14 +63,14 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: coral
|
||||
id: object-detector
|
||||
attributes:
|
||||
label: Coral version
|
||||
label: Object Detector
|
||||
options:
|
||||
- USB
|
||||
- PCIe
|
||||
- M.2
|
||||
- Dev Board
|
||||
- Coral
|
||||
- OpenVino
|
||||
- TensorRT
|
||||
- RKNN
|
||||
- Other
|
||||
- CPU (no coral)
|
||||
validations:
|
||||
|
||||
12
.github/DISCUSSION_TEMPLATE/general-support.yml
vendored
12
.github/DISCUSSION_TEMPLATE/general-support.yml
vendored
@@ -69,14 +69,14 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: coral
|
||||
id: object-detector
|
||||
attributes:
|
||||
label: Coral version
|
||||
label: Object Detector
|
||||
options:
|
||||
- USB
|
||||
- PCIe
|
||||
- M.2
|
||||
- Dev Board
|
||||
- Coral
|
||||
- OpenVino
|
||||
- TensorRT
|
||||
- RKNN
|
||||
- Other
|
||||
- CPU (no coral)
|
||||
validations:
|
||||
|
||||
@@ -33,7 +33,7 @@ RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/cache/apt \
|
||||
FROM scratch AS go2rtc
|
||||
ARG TARGETARCH
|
||||
WORKDIR /rootfs/usr/local/go2rtc/bin
|
||||
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.2/go2rtc_linux_${TARGETARCH}" go2rtc
|
||||
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.4/go2rtc_linux_${TARGETARCH}" go2rtc
|
||||
|
||||
FROM wget AS tempio
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
scikit-build == 0.17.*
|
||||
scikit-build == 0.18.*
|
||||
nvidia-pyindex
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Header used to validate reverse proxy trust
|
||||
proxy_set_header X-Proxy-Secret $http_x_proxy_secret;
|
||||
|
||||
# these headers will be copied to the /auth request and are available
|
||||
# to be mapped in the config to Frigate's remote-user header
|
||||
|
||||
@@ -19,4 +22,4 @@ proxy_set_header X-authentik-username $http_x_authentik_username;
|
||||
proxy_set_header X-authentik-groups $http_x_authentik_groups;
|
||||
proxy_set_header X-authentik-email $http_x_authentik_email;
|
||||
proxy_set_header X-authentik-name $http_x_authentik_name;
|
||||
proxy_set_header X-authentik-uid $http_x_authentik_uid;
|
||||
proxy_set_header X-authentik-uid $http_x_authentik_uid;
|
||||
|
||||
@@ -23,6 +23,6 @@ try:
|
||||
except FileNotFoundError:
|
||||
config: dict[str, any] = {}
|
||||
|
||||
tls_config: dict[str, any] = config.get("tls", {})
|
||||
tls_config: dict[str, any] = config.get("tls", {"enabled": True})
|
||||
|
||||
print(json.dumps(tls_config))
|
||||
|
||||
@@ -120,7 +120,7 @@ NOTE: The folder that is mapped from the host needs to be the folder that contai
|
||||
|
||||
## Custom go2rtc version
|
||||
|
||||
Frigate currently includes go2rtc v1.9.2, there may be certain cases where you want to run a different version of go2rtc.
|
||||
Frigate currently includes go2rtc v1.9.4, there may be certain cases where you want to run a different version of go2rtc.
|
||||
|
||||
To do this:
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ go2rtc:
|
||||
- rtspx://192.168.1.1:7441/abcdefghijk
|
||||
```
|
||||
|
||||
[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#source-rtsp)
|
||||
[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.9.4#source-rtsp)
|
||||
|
||||
In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record if used directly with unifi protect.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ title: Restream
|
||||
|
||||
Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://<frigate_host>:8554/<camera_name>`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate.
|
||||
|
||||
Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.2) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#configuration) for more advanced configurations and features.
|
||||
Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.4) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.4#configuration) for more advanced configurations and features.
|
||||
|
||||
:::note
|
||||
|
||||
@@ -134,7 +134,7 @@ cameras:
|
||||
|
||||
## Advanced Restream Configurations
|
||||
|
||||
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
|
||||
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.4#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
|
||||
|
||||
NOTE: The output will need to be passed with two curly braces `{{output}}`
|
||||
|
||||
|
||||
@@ -69,15 +69,6 @@ Sometimes you want to limit a zone to specific object types to have more granula
|
||||
```yaml
|
||||
cameras:
|
||||
name_of_your_camera:
|
||||
record:
|
||||
events:
|
||||
required_zones:
|
||||
- entire_yard
|
||||
- front_yard_street
|
||||
snapshots:
|
||||
required_zones:
|
||||
- entire_yard
|
||||
- front_yard_street
|
||||
zones:
|
||||
entire_yard:
|
||||
coordinates: ... (everywhere you want a person)
|
||||
|
||||
@@ -13,7 +13,7 @@ Use of the bundled go2rtc is optional. You can still configure FFmpeg to connect
|
||||
|
||||
# Setup a go2rtc stream
|
||||
|
||||
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. If you set the stream name under go2rtc to match the name of your camera, it will automatically be mapped and you will get additional live view options for the camera. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#module-streams), not just rtsp.
|
||||
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. If you set the stream name under go2rtc to match the name of your camera, it will automatically be mapped and you will get additional live view options for the camera. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.4#module-streams), not just rtsp.
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
@@ -26,7 +26,7 @@ The easiest live view to get working is MSE. After adding this to the config, re
|
||||
|
||||
### What if my video doesn't play?
|
||||
|
||||
If you are unable to see your video feed, first check the go2rtc logs in the Frigate UI under Logs in the sidebar. If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log. If you do not see any errors, then the video codec of the stream may not be supported in your browser. If your camera stream is set to H265, try switching to H264. You can see more information about [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#codecs-madness) in the go2rtc documentation. If you are not able to switch your camera settings from H265 to H264 or your stream is a different format such as MJPEG, you can use go2rtc to re-encode the video using the [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. Here is an example of a config that will re-encode the stream to H264 without hardware acceleration:
|
||||
If you are unable to see your video feed, first check the go2rtc logs in the Frigate UI under Logs in the sidebar. If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log. If you do not see any errors, then the video codec of the stream may not be supported in your browser. If your camera stream is set to H265, try switching to H264. You can see more information about [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.9.4#codecs-madness) in the go2rtc documentation. If you are not able to switch your camera settings from H265 to H264 or your stream is a different format such as MJPEG, you can use go2rtc to re-encode the video using the [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.9.4#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. Here is an example of a config that will re-encode the stream to H264 without hardware acceleration:
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
|
||||
@@ -92,6 +92,61 @@ Message published for each changed event. The first message is published when th
|
||||
}
|
||||
```
|
||||
|
||||
### `frigate/reviews`
|
||||
|
||||
Message published for each changed review item. The first message is published when the `detection` or `alert` is initiated. When additional objects are detected or when a zone change occurs, it will publish a, `update` message with the same id. When the review activity has ended a final `end` message is published.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "update", // new, update, end
|
||||
"before": {
|
||||
"id": "1718987129.308396-fqk5ka", // review_id
|
||||
"camera": "front_cam",
|
||||
"start_time": 1718987129.308396,
|
||||
"end_time": null,
|
||||
"severity": "detection",
|
||||
"thumb_path": "/media/frigate/clips/review/thumb-front_cam-1718987129.308396-fqk5ka.webp",
|
||||
"data": {
|
||||
"detections": [ // list of event IDs
|
||||
"1718987128.947436-g92ztx",
|
||||
"1718987148.879516-d7oq7r",
|
||||
"1718987126.934663-q5ywpt"
|
||||
],
|
||||
"objects": [
|
||||
"person",
|
||||
"car"
|
||||
],
|
||||
"sub_labels": [],
|
||||
"zones": [],
|
||||
"audio": []
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
"id": "1718987129.308396-fqk5ka",
|
||||
"camera": "front_cam",
|
||||
"start_time": 1718987129.308396,
|
||||
"end_time": null,
|
||||
"severity": "alert",
|
||||
"thumb_path": "/media/frigate/clips/review/thumb-front_cam-1718987129.308396-fqk5ka.webp",
|
||||
"data": {
|
||||
"detections": [
|
||||
"1718987128.947436-g92ztx",
|
||||
"1718987148.879516-d7oq7r",
|
||||
"1718987126.934663-q5ywpt"
|
||||
],
|
||||
"objects": [
|
||||
"person",
|
||||
"car"
|
||||
],
|
||||
"sub_labels": [],
|
||||
"zones": [
|
||||
"front_yard"
|
||||
],
|
||||
"audio": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `frigate/stats`
|
||||
|
||||
Same data available at `/api/stats` published at a configurable interval.
|
||||
|
||||
@@ -22,7 +22,7 @@ module.exports = {
|
||||
{
|
||||
type: "link",
|
||||
label: "Go2RTC Configuration Reference",
|
||||
href: "https://github.com/AlexxIT/go2rtc/tree/v1.9.2#configuration",
|
||||
href: "https://github.com/AlexxIT/go2rtc/tree/v1.9.4#configuration",
|
||||
},
|
||||
],
|
||||
Detectors: [
|
||||
|
||||
@@ -484,6 +484,10 @@ def logs(service: str):
|
||||
if len(cleanLine) < 10:
|
||||
continue
|
||||
|
||||
# handle cases where S6 does not include date in log line
|
||||
if " " not in cleanLine:
|
||||
cleanLine = f"{datetime.now()} {cleanLine}"
|
||||
|
||||
if dateEnd == 0:
|
||||
dateEnd = cleanLine.index(" ")
|
||||
keyLength = dateEnd - (6 if service_location == "frigate" else 0)
|
||||
|
||||
@@ -193,7 +193,7 @@ def auth():
|
||||
# or use anonymous if none are specified
|
||||
if proxy_config.header_map.user is not None:
|
||||
upstream_user_header_value = request.headers.get(
|
||||
current_app.frigate_config.auth.header_map.user,
|
||||
proxy_config.header_map.user,
|
||||
type=str,
|
||||
default="anonymous",
|
||||
)
|
||||
|
||||
@@ -105,6 +105,7 @@ def latest_frame(camera_name):
|
||||
"regions": request.args.get("regions", type=int),
|
||||
}
|
||||
resize_quality = request.args.get("quality", default=70, type=int)
|
||||
extension = os.path.splitext(request.path)[1][1:]
|
||||
|
||||
if camera_name in current_app.frigate_config.cameras:
|
||||
frame = current_app.detected_frames_processor.get_current_frame(
|
||||
@@ -147,10 +148,10 @@ def latest_frame(camera_name):
|
||||
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
|
||||
|
||||
ret, img = cv2.imencode(
|
||||
".webp", frame, [int(cv2.IMWRITE_WEBP_QUALITY), resize_quality]
|
||||
f".{extension}", frame, [int(cv2.IMWRITE_WEBP_QUALITY), resize_quality]
|
||||
)
|
||||
response = make_response(img.tobytes())
|
||||
response.headers["Content-Type"] = "image/webp"
|
||||
response.headers["Content-Type"] = f"image/{extension}"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
elif camera_name == "birdseye" and current_app.frigate_config.birdseye.restream:
|
||||
@@ -165,10 +166,10 @@ def latest_frame(camera_name):
|
||||
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
|
||||
|
||||
ret, img = cv2.imencode(
|
||||
".webp", frame, [int(cv2.IMWRITE_WEBP_QUALITY), resize_quality]
|
||||
f".{extension}", frame, [int(cv2.IMWRITE_WEBP_QUALITY), resize_quality]
|
||||
)
|
||||
response = make_response(img.tobytes())
|
||||
response.headers["Content-Type"] = "image/webp"
|
||||
response.headers["Content-Type"] = f"image/{extension}"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
else:
|
||||
|
||||
@@ -41,12 +41,12 @@ class Rknn(DetectionApi):
|
||||
if model_props["preset"]:
|
||||
config.model.model_type = model_props["model_type"]
|
||||
|
||||
if model_props["model_type"] == ModelTypeEnum.yolonas:
|
||||
logger.info(
|
||||
"You are using yolo-nas with weights from DeciAI. "
|
||||
"These weights are subject to their license and can't be used commercially. "
|
||||
"For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html"
|
||||
)
|
||||
if model_props["model_type"] == ModelTypeEnum.yolonas:
|
||||
logger.info(
|
||||
"You are using yolo-nas with weights from DeciAI. "
|
||||
"These weights are subject to their license and can't be used commercially. "
|
||||
"For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html"
|
||||
)
|
||||
|
||||
from rknnlite.api import RKNNLite
|
||||
|
||||
|
||||
@@ -174,10 +174,13 @@ def move_preview_frames(loc: str):
|
||||
preview_holdover = os.path.join(CLIPS_DIR, "preview_restart_cache")
|
||||
preview_cache = os.path.join(CACHE_DIR, "preview_frames")
|
||||
|
||||
if loc == "clips":
|
||||
shutil.move(preview_cache, preview_holdover)
|
||||
elif loc == "cache":
|
||||
if not os.path.exists(preview_holdover):
|
||||
return
|
||||
try:
|
||||
if loc == "clips":
|
||||
shutil.move(preview_cache, preview_holdover)
|
||||
elif loc == "cache":
|
||||
if not os.path.exists(preview_holdover):
|
||||
return
|
||||
|
||||
shutil.move(preview_holdover, preview_cache)
|
||||
shutil.move(preview_holdover, preview_cache)
|
||||
except shutil.Error:
|
||||
logger.error("Failed to restore preview cache.")
|
||||
|
||||
@@ -219,13 +219,16 @@ class PreviewRecorder:
|
||||
os.unlink(os.path.join(PREVIEW_CACHE_DIR, file))
|
||||
continue
|
||||
|
||||
file_time = file.split("-")[1][: -(len(PREVIEW_FRAME_TYPE) + 1)]
|
||||
try:
|
||||
file_time = file.split("-")[-1][: -(len(PREVIEW_FRAME_TYPE) + 1)]
|
||||
|
||||
if not file_time:
|
||||
if not file_time:
|
||||
continue
|
||||
|
||||
ts = float(file_time)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
ts = float(file_time)
|
||||
|
||||
if self.start_time == 0:
|
||||
self.start_time = ts
|
||||
|
||||
|
||||
@@ -213,18 +213,21 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
),
|
||||
)
|
||||
|
||||
def end_segment(self, segment: PendingReviewSegment) -> None:
|
||||
def end_segment(
|
||||
self,
|
||||
segment: PendingReviewSegment,
|
||||
prev_data: dict[str, any],
|
||||
) -> None:
|
||||
"""End segment."""
|
||||
final_data = segment.get_data(ended=True)
|
||||
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, final_data)
|
||||
end_data = {k.name: v for k, v in final_data.items()}
|
||||
self.requestor.send_data(
|
||||
"reviews",
|
||||
json.dumps(
|
||||
{
|
||||
"type": "end",
|
||||
"before": end_data,
|
||||
"after": end_data,
|
||||
"before": {k.name: v for k, v in prev_data.items()},
|
||||
"after": {k.name: v for k, v in final_data.items()},
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -309,9 +312,9 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
if segment.severity == SeverityEnum.alert and frame_time > (
|
||||
segment.last_update + THRESHOLD_ALERT_ACTIVITY
|
||||
):
|
||||
self.end_segment(segment)
|
||||
self.end_segment(segment, prev_data)
|
||||
elif frame_time > (segment.last_update + THRESHOLD_DETECTION_ACTIVITY):
|
||||
self.end_segment(segment)
|
||||
self.end_segment(segment, prev_data)
|
||||
|
||||
def check_if_new_segment(
|
||||
self,
|
||||
|
||||
5
web/.vscode/extensions.json
vendored
Normal file
5
web/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
@@ -1,30 +1,25 @@
|
||||
# React + TypeScript + Vite
|
||||
This is the Frigate frontend which connects to and provides a User Interface to the Python backend.
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
# Web Development
|
||||
|
||||
Currently, two official plugins are available:
|
||||
## Installing Web Dependencies Via NPM
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
Within `/web`, run:
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
## Running development frontend
|
||||
|
||||
Within `/web`, run:
|
||||
|
||||
```bash
|
||||
PROXY_HOST=<ip_address:port> npm run dev
|
||||
```
|
||||
|
||||
The Proxy Host can point to your existing Frigate instance. Otherwise defaults to `localhost:5000` if running Frigate on the same machine.
|
||||
|
||||
## Extensions
|
||||
Install these IDE extensions for an improved development experience:
|
||||
- eslint
|
||||
|
||||
@@ -93,6 +93,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
autoFocus
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useApiHost } from "@/api";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
|
||||
type CameraImageProps = {
|
||||
className?: string;
|
||||
@@ -27,18 +28,29 @@ export default function CameraImage({
|
||||
const enabled = config ? config.cameras[camera].enabled : "True";
|
||||
const [isPortraitImage, setIsPortraitImage] = useState(false);
|
||||
|
||||
const [{ width: containerWidth, height: containerHeight }] =
|
||||
useResizeObserver(containerRef);
|
||||
|
||||
const requestHeight = useMemo(() => {
|
||||
if (!config || containerHeight == 0) {
|
||||
return 360;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
config.cameras[camera].detect.height,
|
||||
Math.round(containerHeight * (isDesktop ? 1.1 : 1.25)),
|
||||
);
|
||||
}, [config, camera, containerHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config || !imgRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
imgRef.current.src = `${apiHost}api/${name}/latest.jpg${
|
||||
searchParams ? `?${searchParams}` : ""
|
||||
imgRef.current.src = `${apiHost}api/${name}/latest.webp?h=${requestHeight}${
|
||||
searchParams ? `&${searchParams}` : ""
|
||||
}`;
|
||||
}, [apiHost, name, imgRef, searchParams, config]);
|
||||
|
||||
const [{ width: containerWidth, height: containerHeight }] =
|
||||
useResizeObserver(containerRef);
|
||||
}, [apiHost, name, imgRef, searchParams, requestHeight, config]);
|
||||
|
||||
return (
|
||||
<div className={className} ref={containerRef}>
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function CameraImage({
|
||||
if (!config || scaledHeight === 0 || !canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
img.src = `${apiHost}api/${name}/latest.jpg?h=${scaledHeight}${
|
||||
img.src = `${apiHost}api/${name}/latest.webp?h=${scaledHeight}${
|
||||
searchParams ? `&${searchParams}` : ""
|
||||
}`;
|
||||
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
|
||||
|
||||
@@ -49,8 +49,14 @@ export default function ExportCard({
|
||||
|
||||
useKeyboardListener(
|
||||
editName != undefined ? ["Enter"] : [],
|
||||
(_, down, repeat) => {
|
||||
if (down && !repeat && editName && editName.update.length > 0) {
|
||||
(key, modifiers) => {
|
||||
if (
|
||||
key == "Enter" &&
|
||||
modifiers.down &&
|
||||
!modifiers.repeat &&
|
||||
editName &&
|
||||
editName.update.length > 0
|
||||
) {
|
||||
onRename(exportedRecording.id, editName.update);
|
||||
setEditName(undefined);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { ReviewSegment } from "@/types/review";
|
||||
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { isDesktop, isIOS, isSafari } from "react-device-detect";
|
||||
import useSWR from "swr";
|
||||
@@ -54,9 +54,13 @@ export default function ReviewCard({
|
||||
}, [event]);
|
||||
|
||||
const onExport = useCallback(async () => {
|
||||
const endTime = event.end_time
|
||||
? event.end_time + REVIEW_PADDING
|
||||
: Date.now() / 1000;
|
||||
|
||||
axios
|
||||
.post(
|
||||
`export/${event.camera}/start/${event.start_time}/end/${event.end_time}`,
|
||||
`export/${event.camera}/start/${event.start_time + REVIEW_PADDING}/end/${endTime}`,
|
||||
{ playback: "realtime" },
|
||||
)
|
||||
.then((response) => {
|
||||
|
||||
@@ -468,7 +468,7 @@ export function CameraGroupRow({
|
||||
|
||||
{isMobile && (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu modal={!isDesktop}>
|
||||
<DropdownMenuTrigger>
|
||||
<HiOutlineDotsVertical className="size-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { cn } from "@/lib/utils";
|
||||
// @ts-expect-error we know this doesn't have types
|
||||
import JSMpeg from "@cycjimmy/jsmpeg-player";
|
||||
import React, { useEffect, useMemo, useRef, useId } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useId, useState } from "react";
|
||||
|
||||
type JSMpegPlayerProps = {
|
||||
className?: string;
|
||||
@@ -23,7 +24,10 @@ export default function JSMpegPlayer({
|
||||
}: JSMpegPlayerProps) {
|
||||
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
|
||||
const playerRef = useRef<HTMLDivElement | null>(null);
|
||||
const videoRef = useRef(null);
|
||||
const internalContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const onPlayingRef = useRef(onPlaying);
|
||||
const [showCanvas, setShowCanvas] = useState(false);
|
||||
|
||||
const selectedContainerRef = useMemo(
|
||||
() => containerRef ?? internalContainerRef,
|
||||
@@ -82,11 +86,15 @@ export default function JSMpegPlayer({
|
||||
const uniqueId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!playerRef.current) {
|
||||
onPlayingRef.current = onPlaying;
|
||||
}, [onPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playerRef.current || videoRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const video = new JSMpeg.VideoElement(
|
||||
videoRef.current = new JSMpeg.VideoElement(
|
||||
playerRef.current,
|
||||
url,
|
||||
{ canvas: `#${CSS.escape(uniqueId)}` },
|
||||
@@ -95,26 +103,17 @@ export default function JSMpegPlayer({
|
||||
audio: false,
|
||||
videoBufferSize: 1024 * 1024 * 4,
|
||||
onPlay: () => {
|
||||
onPlaying?.();
|
||||
setShowCanvas(true);
|
||||
onPlayingRef.current?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (playerRef.current) {
|
||||
try {
|
||||
video.destroy();
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (e) {}
|
||||
playerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [url, uniqueId, onPlaying]);
|
||||
}, [url, uniqueId]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="size-full" ref={internalContainerRef}>
|
||||
<div ref={playerRef} className="jsmpeg">
|
||||
<div ref={playerRef} className={cn("jsmpeg", !showCanvas && "hidden")}>
|
||||
<canvas
|
||||
id={uniqueId}
|
||||
style={{
|
||||
|
||||
@@ -2,7 +2,7 @@ import WebRtcPlayer from "./WebRTCPlayer";
|
||||
import { CameraConfig } from "@/types/frigateConfig";
|
||||
import AutoUpdatingCameraImage from "../camera/AutoUpdatingCameraImage";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import MSEPlayer from "./MsePlayer";
|
||||
import JSMpegPlayer from "./JSMpegPlayer";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
@@ -18,6 +18,7 @@ import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import Chip from "../indicators/Chip";
|
||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TbExclamationCircle } from "react-icons/tb";
|
||||
|
||||
type LivePlayerProps = {
|
||||
cameraRef?: (ref: HTMLDivElement | null) => void;
|
||||
@@ -125,6 +126,10 @@ export default function LivePlayer({
|
||||
setLiveReady(false);
|
||||
}, [preferredLiveMode]);
|
||||
|
||||
const playerIsPlaying = useCallback(() => {
|
||||
setLiveReady(true);
|
||||
}, []);
|
||||
|
||||
if (!cameraConfig) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
@@ -141,7 +146,7 @@ export default function LivePlayer({
|
||||
audioEnabled={playAudio}
|
||||
microphoneEnabled={micEnabled}
|
||||
iOSCompatFullScreen={iOSCompatFullScreen}
|
||||
onPlaying={() => setLiveReady(true)}
|
||||
onPlaying={playerIsPlaying}
|
||||
pip={pip}
|
||||
onError={onError}
|
||||
/>
|
||||
@@ -154,7 +159,7 @@ export default function LivePlayer({
|
||||
camera={cameraConfig.live.stream_name}
|
||||
playbackEnabled={cameraActive}
|
||||
audioEnabled={playAudio}
|
||||
onPlaying={() => setLiveReady(true)}
|
||||
onPlaying={playerIsPlaying}
|
||||
pip={pip}
|
||||
setFullResolution={setFullResolution}
|
||||
onError={onError}
|
||||
@@ -177,7 +182,7 @@ export default function LivePlayer({
|
||||
width={cameraConfig.detect.width}
|
||||
height={cameraConfig.detect.height}
|
||||
containerRef={containerRef}
|
||||
onPlaying={() => setLiveReady(true)}
|
||||
onPlaying={playerIsPlaying}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -271,6 +276,16 @@ export default function LivePlayer({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{offline && !showStillWithoutActivity && (
|
||||
<div className="flex size-full flex-col items-center">
|
||||
<p className="mb-5">
|
||||
{capitalizeFirstLetter(cameraConfig.name)} is offline
|
||||
</p>
|
||||
<TbExclamationCircle className="mb-3 size-10" />
|
||||
<p>No frames have been received, check error logs</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute right-2 top-2">
|
||||
{autoLive &&
|
||||
!offline &&
|
||||
@@ -278,7 +293,7 @@ export default function LivePlayer({
|
||||
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
||||
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
|
||||
)}
|
||||
{offline && (
|
||||
{offline && showStillWithoutActivity && (
|
||||
<Chip
|
||||
className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize`}
|
||||
>
|
||||
|
||||
@@ -117,12 +117,12 @@ function MSEPlayer({
|
||||
}, [wsURL]);
|
||||
|
||||
const onDisconnect = useCallback(() => {
|
||||
setWsState(WebSocket.CLOSED);
|
||||
if (wsRef.current) {
|
||||
if (wsRef.current && wsState == WebSocket.OPEN) {
|
||||
setWsState(WebSocket.CLOSED);
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
}, [wsState]);
|
||||
|
||||
const onOpen = () => {
|
||||
setWsState(WebSocket.OPEN);
|
||||
@@ -260,10 +260,14 @@ function MSEPlayer({
|
||||
|
||||
return () => {
|
||||
onDisconnect();
|
||||
if (bufferTimeout) {
|
||||
clearTimeout(bufferTimeout);
|
||||
setBufferTimeout(undefined);
|
||||
}
|
||||
};
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playbackEnabled, onDisconnect, onConnect]);
|
||||
}, [playbackEnabled]);
|
||||
|
||||
// check visibility
|
||||
|
||||
@@ -330,7 +334,7 @@ function MSEPlayer({
|
||||
setTimeout(() => {
|
||||
if (
|
||||
document.visibilityState === "visible" &&
|
||||
wsRef.current != undefined
|
||||
wsRef.current != null
|
||||
) {
|
||||
onError("stalled");
|
||||
}
|
||||
|
||||
@@ -172,6 +172,12 @@ function PreviewVideoPlayer({
|
||||
|
||||
const [firstLoad, setFirstLoad] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (cameraPreviews && cameraPreviews.length > 0) {
|
||||
setFirstLoad(false);
|
||||
}
|
||||
}, [cameraPreviews]);
|
||||
|
||||
const [currentPreview, setCurrentPreview] = useState(initialPreview);
|
||||
|
||||
const onPreviewSeeked = useCallback(() => {
|
||||
@@ -483,6 +489,12 @@ function PreviewFramesPlayer({
|
||||
|
||||
const [firstLoad, setFirstLoad] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewFrames != undefined && previewFrames.length == 0) {
|
||||
setFirstLoad(false);
|
||||
}
|
||||
}, [previewFrames]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller) {
|
||||
return;
|
||||
|
||||
@@ -70,12 +70,6 @@ export default function PreviewThumbnailPlayer({
|
||||
[ignoreClick, review, onClick],
|
||||
);
|
||||
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedLeft: () => (setReviewed ? setReviewed(review) : null),
|
||||
onSwipedRight: () => setPlayback(true),
|
||||
preventScrollOnSwipe: true,
|
||||
});
|
||||
|
||||
const handleSetReviewed = useCallback(() => {
|
||||
if (review.end_time && !review.has_been_reviewed) {
|
||||
review.has_been_reviewed = true;
|
||||
@@ -83,6 +77,15 @@ export default function PreviewThumbnailPlayer({
|
||||
}
|
||||
}, [review, setReviewed]);
|
||||
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedLeft: () => {
|
||||
setPlayback(false);
|
||||
handleSetReviewed();
|
||||
},
|
||||
onSwipedRight: () => setPlayback(true),
|
||||
preventScrollOnSwipe: true,
|
||||
});
|
||||
|
||||
useContextMenu(imgRef, () => {
|
||||
onClick(review, true);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { isMobileOnly, isSafari } from "react-device-detect";
|
||||
import { isDesktop, isMobileOnly, isSafari } from "react-device-detect";
|
||||
import { LuPause, LuPlay } from "react-icons/lu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
MdVolumeOff,
|
||||
MdVolumeUp,
|
||||
} from "react-icons/md";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import useKeyboardListener, {
|
||||
KeyModifiers,
|
||||
} from "@/hooks/use-keyboard-listener";
|
||||
import { VolumeSlider } from "../ui/slider";
|
||||
import FrigatePlusIcon from "../icons/FrigatePlusIcon";
|
||||
import {
|
||||
@@ -137,42 +139,36 @@ export default function VideoControls({
|
||||
}, [volume, muted]);
|
||||
|
||||
const onKeyboardShortcut = useCallback(
|
||||
(key: string, down: boolean, repeat: boolean) => {
|
||||
(key: string, modifiers: KeyModifiers) => {
|
||||
if (!modifiers.down) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "ArrowDown":
|
||||
if (down) {
|
||||
onSeek(-1);
|
||||
}
|
||||
onSeek(-1);
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
if (down) {
|
||||
onSeek(-10);
|
||||
}
|
||||
onSeek(-10);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
if (down) {
|
||||
onSeek(10);
|
||||
}
|
||||
onSeek(10);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
if (down) {
|
||||
onSeek(1);
|
||||
}
|
||||
onSeek(1);
|
||||
break;
|
||||
case "f":
|
||||
if (toggleFullscreen && down && !repeat) {
|
||||
if (toggleFullscreen && !modifiers.repeat) {
|
||||
toggleFullscreen();
|
||||
}
|
||||
break;
|
||||
case "m":
|
||||
if (setMuted && down && !repeat && video) {
|
||||
if (setMuted && !modifiers.repeat && video) {
|
||||
setMuted(!muted);
|
||||
}
|
||||
break;
|
||||
case " ":
|
||||
if (down) {
|
||||
onPlayPause(!isPlaying);
|
||||
}
|
||||
onPlayPause(!isPlaying);
|
||||
break;
|
||||
}
|
||||
},
|
||||
@@ -242,7 +238,7 @@ export default function VideoControls({
|
||||
)}
|
||||
{features.playbackRate && (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
modal={!isDesktop}
|
||||
onOpenChange={(open) => {
|
||||
if (setControlsOpen) {
|
||||
setControlsOpen(open);
|
||||
|
||||
@@ -39,7 +39,7 @@ export function PolygonCanvas({
|
||||
const element = new window.Image();
|
||||
element.width = width;
|
||||
element.height = height;
|
||||
element.src = `${apiHost}api/${camera}/latest.jpg`;
|
||||
element.src = `${apiHost}api/${camera}/latest.webp`;
|
||||
return element;
|
||||
}
|
||||
}, [camera, width, height, apiHost]);
|
||||
|
||||
@@ -19,7 +19,7 @@ import { LuCopy, LuPencil } from "react-icons/lu";
|
||||
import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa";
|
||||
import { BsPersonBoundingBox } from "react-icons/bs";
|
||||
import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import {
|
||||
flattenPoints,
|
||||
parseCoordinates,
|
||||
@@ -266,7 +266,7 @@ export default function PolygonItem({
|
||||
|
||||
{isMobile && (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu modal={!isDesktop}>
|
||||
<DropdownMenuTrigger>
|
||||
<HiOutlineDotsVertical className="size-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
HoverCard,
|
||||
@@ -195,9 +196,48 @@ export function EventSegment({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [startTimestamp]);
|
||||
|
||||
const [segmentRendered, setSegmentRendered] = useState(false);
|
||||
const segmentObserverRef = useRef<IntersectionObserver | null>(null);
|
||||
const segmentRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const segmentObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && !segmentRendered) {
|
||||
setSegmentRendered(true);
|
||||
}
|
||||
},
|
||||
{ threshold: 0 },
|
||||
);
|
||||
|
||||
if (segmentRef.current) {
|
||||
segmentObserver.observe(segmentRef.current);
|
||||
}
|
||||
|
||||
segmentObserverRef.current = segmentObserver;
|
||||
|
||||
return () => {
|
||||
if (segmentObserverRef.current) {
|
||||
segmentObserverRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [segmentRendered]);
|
||||
|
||||
if (!segmentRendered) {
|
||||
return (
|
||||
<div
|
||||
key={segmentKey}
|
||||
ref={segmentRef}
|
||||
data-segment-id={segmentKey}
|
||||
className={`segment ${segmentClasses}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={segmentKey}
|
||||
ref={segmentRef}
|
||||
data-segment-id={segmentKey}
|
||||
className={`segment ${segmentClasses}`}
|
||||
onClick={segmentClick}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useEffect, useCallback, useMemo, useRef, RefObject } from "react";
|
||||
import { useCallback, useMemo, useRef, RefObject } from "react";
|
||||
import MotionSegment from "./MotionSegment";
|
||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||
import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
import ReviewTimeline from "./ReviewTimeline";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
|
||||
|
||||
export type MotionReviewTimelineProps = {
|
||||
@@ -165,42 +164,6 @@ export function MotionReviewTimeline({
|
||||
],
|
||||
);
|
||||
|
||||
const segmentsObserver = useRef<IntersectionObserver | null>(null);
|
||||
useEffect(() => {
|
||||
if (selectedTimelineRef.current && segments && isDesktop) {
|
||||
segmentsObserver.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const segmentId = entry.target.getAttribute("data-segment-id");
|
||||
|
||||
const segmentElements =
|
||||
internalTimelineRef.current?.querySelectorAll(
|
||||
`[data-segment-id="${segmentId}"] .motion-segment`,
|
||||
);
|
||||
segmentElements?.forEach((segmentElement) => {
|
||||
segmentElement.classList.remove("hidden");
|
||||
segmentElement.classList.add("animate-in");
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0 },
|
||||
);
|
||||
|
||||
// Get all segment divs and observe each one
|
||||
const segmentDivs =
|
||||
selectedTimelineRef.current.querySelectorAll(".segment.has-data");
|
||||
segmentDivs.forEach((segmentDiv) => {
|
||||
segmentsObserver.current?.observe(segmentDiv);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
segmentsObserver.current?.disconnect();
|
||||
};
|
||||
}, [selectedTimelineRef, segments]);
|
||||
|
||||
return (
|
||||
<ReviewTimeline
|
||||
timelineRef={selectedTimelineRef}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
|
||||
import { ReviewSegment } from "@/types/review";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
|
||||
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import useTapUtils from "@/hooks/use-tap-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -139,11 +145,6 @@ export function MotionSegment({
|
||||
: ""
|
||||
}`;
|
||||
|
||||
const animationClassesSecondHalf = `motion-segment ${secondHalfSegmentWidth > 0 ? "hidden" : ""}
|
||||
zoom-in-[0.2] ${secondHalfSegmentWidth < 5 ? "duration-200" : "duration-1000"}`;
|
||||
const animationClassesFirstHalf = `motion-segment ${firstHalfSegmentWidth > 0 ? "hidden" : ""}
|
||||
zoom-in-[0.2] ${firstHalfSegmentWidth < 5 ? "duration-200" : "duration-1000"}`;
|
||||
|
||||
const severityColorsBg: { [key: number]: string } = {
|
||||
1: reviewed
|
||||
? "from-severity_significant_motion-dimmed/10 to-severity_significant_motion/10"
|
||||
@@ -162,6 +163,44 @@ export function MotionSegment({
|
||||
}
|
||||
}, [segmentTime, setHandlebarTime]);
|
||||
|
||||
const [segmentRendered, setSegmentRendered] = useState(false);
|
||||
const segmentObserverRef = useRef<IntersectionObserver | null>(null);
|
||||
const segmentRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const segmentObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && !segmentRendered) {
|
||||
setSegmentRendered(true);
|
||||
}
|
||||
},
|
||||
{ threshold: 0 },
|
||||
);
|
||||
|
||||
if (segmentRef.current) {
|
||||
segmentObserver.observe(segmentRef.current);
|
||||
}
|
||||
|
||||
segmentObserverRef.current = segmentObserver;
|
||||
|
||||
return () => {
|
||||
if (segmentObserverRef.current) {
|
||||
segmentObserverRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [segmentRendered]);
|
||||
|
||||
if (!segmentRendered) {
|
||||
return (
|
||||
<div
|
||||
key={segmentKey}
|
||||
ref={segmentRef}
|
||||
data-segment-id={segmentKey}
|
||||
className={`segment ${segmentClasses}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{(((firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0) &&
|
||||
@@ -171,6 +210,7 @@ export function MotionSegment({
|
||||
<div
|
||||
key={segmentKey}
|
||||
data-segment-id={segmentKey}
|
||||
ref={segmentRef}
|
||||
className={cn(
|
||||
"segment",
|
||||
{
|
||||
@@ -221,7 +261,6 @@ export function MotionSegment({
|
||||
key={`${segmentKey}_motion_data_1`}
|
||||
data-motion-value={secondHalfSegmentWidth}
|
||||
className={cn(
|
||||
isDesktop && animationClassesSecondHalf,
|
||||
"h-[2px]",
|
||||
"rounded-full",
|
||||
secondHalfSegmentWidth
|
||||
@@ -241,7 +280,6 @@ export function MotionSegment({
|
||||
key={`${segmentKey}_motion_data_2`}
|
||||
data-motion-value={firstHalfSegmentWidth}
|
||||
className={cn(
|
||||
isDesktop && animationClassesFirstHalf,
|
||||
"h-[2px]",
|
||||
"rounded-full",
|
||||
firstHalfSegmentWidth
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
export type KeyModifiers = {
|
||||
down: boolean;
|
||||
repeat: boolean;
|
||||
ctrl: boolean;
|
||||
};
|
||||
|
||||
export default function useKeyboardListener(
|
||||
keys: string[],
|
||||
listener: (key: string, down: boolean, repeat: boolean) => void,
|
||||
listener: (key: string, modifiers: KeyModifiers) => void,
|
||||
) {
|
||||
const keyDownListener = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
@@ -12,7 +18,11 @@ export default function useKeyboardListener(
|
||||
|
||||
if (keys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
listener(e.key, true, e.repeat);
|
||||
listener(e.key, {
|
||||
down: true,
|
||||
repeat: e.repeat,
|
||||
ctrl: e.ctrlKey || e.metaKey,
|
||||
});
|
||||
}
|
||||
},
|
||||
[keys, listener],
|
||||
@@ -26,7 +36,7 @@ export default function useKeyboardListener(
|
||||
|
||||
if (keys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
listener(e.key, false, false);
|
||||
listener(e.key, { down: false, repeat: false, ctrl: false });
|
||||
}
|
||||
},
|
||||
[keys, listener],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import {
|
||||
useHashState,
|
||||
usePersistedOverlayState,
|
||||
useSearchEffect,
|
||||
} from "@/hooks/use-overlay-state";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
|
||||
@@ -16,11 +17,21 @@ function Live() {
|
||||
// selection
|
||||
|
||||
const [selectedCameraName, setSelectedCameraName] = useHashState();
|
||||
const [cameraGroup] = usePersistedOverlayState(
|
||||
const [cameraGroup, setCameraGroup] = usePersistedOverlayState(
|
||||
"cameraGroup",
|
||||
"default" as string,
|
||||
);
|
||||
|
||||
useSearchEffect("group", (cameraGroup) => {
|
||||
if (config && cameraGroup) {
|
||||
const group = config.camera_groups[cameraGroup];
|
||||
|
||||
if (group) {
|
||||
setCameraGroup(cameraGroup);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// fullscreen
|
||||
|
||||
const mainRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -231,8 +231,8 @@ function Logs() {
|
||||
|
||||
useKeyboardListener(
|
||||
["PageDown", "PageUp", "ArrowDown", "ArrowUp"],
|
||||
(key, down, _) => {
|
||||
if (!down) {
|
||||
(key, modifiers) => {
|
||||
if (!modifiers.down) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,4 +60,4 @@ export type MotionData = {
|
||||
camera: string;
|
||||
};
|
||||
|
||||
export const REVIEW_PADDING = 2;
|
||||
export const REVIEW_PADDING = 4;
|
||||
|
||||
@@ -51,6 +51,7 @@ import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FilterList, LAST_24_HOURS_KEY } from "@/types/filter";
|
||||
import { GiSoundWaves } from "react-icons/gi";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
|
||||
type EventViewProps = {
|
||||
reviewItems?: SegmentedReviewData;
|
||||
@@ -158,6 +159,17 @@ export default function EventView({
|
||||
},
|
||||
[selectedReviews, setSelectedReviews, onOpenRecording, markItemAsReviewed],
|
||||
);
|
||||
const onSelectAllReviews = useCallback(() => {
|
||||
if (!currentReviewItems || currentReviewItems.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedReviews.length < currentReviewItems.length) {
|
||||
setSelectedReviews(currentReviewItems.map((seg) => seg.id));
|
||||
} else {
|
||||
setSelectedReviews([]);
|
||||
}
|
||||
}, [currentReviewItems, selectedReviews]);
|
||||
|
||||
const exportReview = useCallback(
|
||||
(id: string) => {
|
||||
@@ -167,9 +179,13 @@ export default function EventView({
|
||||
return;
|
||||
}
|
||||
|
||||
const endTime = review.end_time
|
||||
? review.end_time + REVIEW_PADDING
|
||||
: Date.now() / 1000;
|
||||
|
||||
axios
|
||||
.post(
|
||||
`export/${review.camera}/start/${review.start_time}/end/${review.end_time}`,
|
||||
`export/${review.camera}/start/${review.start_time - REVIEW_PADDING}/end/${endTime}`,
|
||||
{ playback: "realtime" },
|
||||
)
|
||||
.then((response) => {
|
||||
@@ -372,6 +388,7 @@ export default function EventView({
|
||||
markItemAsReviewed={markItemAsReviewed}
|
||||
markAllItemsAsReviewed={markAllItemsAsReviewed}
|
||||
onSelectReview={onSelectReview}
|
||||
onSelectAllReviews={onSelectAllReviews}
|
||||
pullLatestData={pullLatestData}
|
||||
/>
|
||||
)}
|
||||
@@ -413,6 +430,7 @@ type DetectionReviewProps = {
|
||||
markItemAsReviewed: (review: ReviewSegment) => void;
|
||||
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
|
||||
onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
|
||||
onSelectAllReviews: () => void;
|
||||
pullLatestData: () => void;
|
||||
};
|
||||
function DetectionReview({
|
||||
@@ -430,6 +448,7 @@ function DetectionReview({
|
||||
markItemAsReviewed,
|
||||
markAllItemsAsReviewed,
|
||||
onSelectReview,
|
||||
onSelectAllReviews,
|
||||
pullLatestData,
|
||||
}: DetectionReviewProps) {
|
||||
const reviewTimelineRef = useRef<HTMLDivElement>(null);
|
||||
@@ -576,6 +595,18 @@ function DetectionReview({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [startTime]);
|
||||
|
||||
// keyboard
|
||||
|
||||
useKeyboardListener(["a"], (key, modifiers) => {
|
||||
if (modifiers.repeat || !modifiers.down) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key == "a" && modifiers.ctrl) {
|
||||
onSelectAllReviews();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
||||
@@ -482,12 +482,12 @@ function PtzControlPanel({
|
||||
|
||||
useKeyboardListener(
|
||||
["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "-"],
|
||||
(key, down, repeat) => {
|
||||
if (repeat) {
|
||||
(key, modifiers) => {
|
||||
if (modifiers.repeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!down) {
|
||||
if (!modifiers.down) {
|
||||
sendPtz("STOP");
|
||||
return;
|
||||
}
|
||||
@@ -620,13 +620,16 @@ function PtzControlPanel({
|
||||
</>
|
||||
)}
|
||||
{(ptz?.presets?.length ?? 0) > 0 && (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu modal={!isDesktop}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button>
|
||||
<BsThreeDotsVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="scrollbar-container max-h-[40dvh] overflow-y-auto">
|
||||
<DropdownMenuContent
|
||||
className="scrollbar-container max-h-[40dvh] overflow-y-auto"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{ptz?.presets.map((preset) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -163,10 +163,14 @@ export default function GeneralMetrics({
|
||||
series[key] = { name: key, data: [] };
|
||||
}
|
||||
|
||||
series[key].data.push({
|
||||
x: statsIdx + 1,
|
||||
y: stats.cpu_usages[detStats.pid.toString()].cpu,
|
||||
});
|
||||
const data = stats.cpu_usages[detStats.pid.toString()].cpu;
|
||||
|
||||
if (data != undefined) {
|
||||
series[key].data.push({
|
||||
x: statsIdx + 1,
|
||||
y: data,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return Object.values(series);
|
||||
@@ -300,10 +304,14 @@ export default function GeneralMetrics({
|
||||
series[key] = { name: key, data: [] };
|
||||
}
|
||||
|
||||
series[key].data.push({
|
||||
x: statsIdx + 1,
|
||||
y: stats.cpu_usages[procStats.pid.toString()].cpu,
|
||||
});
|
||||
const data = stats.cpu_usages[procStats.pid.toString()].cpu;
|
||||
|
||||
if (data != undefined) {
|
||||
series[key].data.push({
|
||||
x: statsIdx + 1,
|
||||
y: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user