Compare commits

...

33 Commits

Author SHA1 Message Date
dependabot[bot]
4558919ba7 Update scikit-build requirement in /docker/main
Updates the requirements on [scikit-build](https://github.com/scikit-build/scikit-build) to permit the latest version.
- [Release notes](https://github.com/scikit-build/scikit-build/releases)
- [Changelog](https://github.com/scikit-build/scikit-build/blob/main/CHANGES.rst)
- [Commits](https://github.com/scikit-build/scikit-build/compare/0.17.0...0.18.0)

---
updated-dependencies:
- dependency-name: scikit-build
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-24 11:34:07 +00:00
Nicolas Mowen
88046ebd15 Fix review items (#12126) 2024-06-23 18:52:02 -05:00
Nicolas Mowen
abc1ecfb60 Show correct previous state when updating for end (#12122)
* Show correct previous state when updating for end

* remove log

* Formatting
2024-06-23 08:45:10 -05:00
Nicolas Mowen
9bbb88cdcb Fix left swipe on preview (#12104)
* Fix left swipe

* Simplify
2024-06-21 16:06:40 -05:00
Nicolas Mowen
c867d90f50 Add reviews topic to MQTT docs (#12097) 2024-06-21 11:43:52 -05:00
Nicolas Mowen
3410290b45 Fix preview failing on cameras with - in name (#12093) 2024-06-21 07:33:20 -06:00
Nicolas Mowen
b34be991bd Simplify zone objects example (#12086) 2024-06-20 21:06:42 -05:00
Nicolas Mowen
73755e9777 Auto focus user field for login (#12083) 2024-06-20 11:37:54 -05:00
Nicolas Mowen
c871bebee6 Fix export timing (#12080) 2024-06-20 07:25:02 -06:00
Josh Hawkins
a60ffe06ac Prevent ptz keyboard shortcuts from reopening presets menu (#12079) 2024-06-20 07:24:50 -06:00
Josh Hawkins
9f81ce2876 Only close MSE websocket when it's already open (#12078) 2024-06-20 06:03:14 -06:00
Josh Hawkins
5c33cdba4e Timeline performance improvements (#12070)
* Use intersection observer for timeline segments

* only render when visible
2024-06-19 18:14:32 -05:00
Nicolas Mowen
e9cdef9f25 fix case where camera is disabled and has no previews (#12066)
* fix case where camera is disabled and has no previews

* Maintain slow loading behavior
2024-06-19 12:51:19 -06:00
Nicolas Mowen
d01457e64d Fix showing loading indicator when first loading a camera without previews (#12064) 2024-06-19 08:52:45 -06:00
Nicolas Mowen
c72d304515 Update go2rtc (#12063) 2024-06-19 08:46:23 -06:00
Sam Wright
10c1f7ead4 Update web readme (#12062)
* Update web readme

* Update /web readme

* Apply suggestions from code review

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>

---------

Co-authored-by: Sam Wright <sam@sams-mbp.lan>
Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2024-06-19 08:11:51 -06:00
Josh Hawkins
7b57a66d45 Various UI tweaks (#12061) 2024-06-19 06:09:49 -06:00
Nicolas Mowen
767033e4d8 Treat meta key same as ctrl (#12051) 2024-06-18 20:26:00 -05:00
Nicolas Mowen
e6790d9a6a Add ability to select all on desktop (#12044)
* Add ability to select all review items

* Refactor keybaord listener
2024-06-18 09:32:17 -05:00
Marc Altmann
4bca405e29 fix key error for custom models (#12042) 2024-06-18 07:40:54 -06:00
Nicolas Mowen
bdda89b5e2 Fix case where S6 timestamp is missing (#12040) 2024-06-18 08:07:28 -05:00
Josh Hawkins
2cbc336bc0 Memoize onPlaying and only instantiate one jsmpeg player (#12033) 2024-06-17 17:10:41 -06:00
Josh Hawkins
6c107883b5 Small jsmpeg and mse player fixes (#12032) 2024-06-17 14:54:14 -06:00
Nicolas Mowen
4635e64b2e Use api path to determine type (#12031)
* Use api path to determine type

* Use in both cases

* Fix extension parsing
2024-06-17 14:53:35 -06:00
Nicolas Mowen
5b60785cca Increase resolution for mobile viewing (#12025) 2024-06-17 12:35:36 -05:00
Nicolas Mowen
ef304e6f7f Set image height to make bandwidth usage lower (#12024) 2024-06-17 08:21:51 -06:00
Nicolas Mowen
24770148a7 Don't fail when preview restore fails (#12022)
* Don't fail when preview restore fails

* Cleanup
2024-06-17 08:56:24 -05:00
Nicolas Mowen
ba6fc0fdb3 UI Tweaks (#12002)
* Adjust review padding

* Fix mse check

* Don't fail when cpu property is missing

* ignore lines without any spaces
2024-06-17 06:19:16 -06:00
Josh Hawkins
89a478ce0a Use modal on dropdowns for mobile only (#11993) 2024-06-16 13:58:28 -05:00
Blake Blackshear
f1bb797fe0 enable tls by default if undefined (#11994) 2024-06-16 07:55:28 -05:00
Miguel Angel Nubla
e208241eea Fix X-Proxy-Secret header passthrough (#11984) 2024-06-16 05:53:02 -05:00
Miguel Angel Nubla
02af1b0ac7 Fix header auth (#11985) 2024-06-16 05:52:17 -05:00
Nicolas Mowen
3c12872a56 Update template to include all detector types (#11981)
* Change from coral to general detector dropdown

* Update camera-support.yml

* Update config-support.yml

* Update general-support.yml
2024-06-15 15:26:09 -05:00
48 changed files with 451 additions and 232 deletions

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -1,2 +1,2 @@
scikit-build == 0.17.*
scikit-build == 0.18.*
nvidia-pyindex

View File

@@ -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;

View File

@@ -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))

View File

@@ -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:

View File

@@ -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.

View File

@@ -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}}`

View File

@@ -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)

View File

@@ -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:

View File

@@ -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.

View File

@@ -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: [

View File

@@ -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)

View File

@@ -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",
)

View File

@@ -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:

View File

@@ -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

View File

@@ -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.")

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1,5 @@
{
"recommendations": [
"dbaeumer.vscode-eslint"
]
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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}>

View File

@@ -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]);

View File

@@ -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);
}

View File

@@ -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) => {

View File

@@ -468,7 +468,7 @@ export function CameraGroupRow({
{isMobile && (
<>
<DropdownMenu modal={false}>
<DropdownMenu modal={!isDesktop}>
<DropdownMenuTrigger>
<HiOutlineDotsVertical className="size-5" />
</DropdownMenuTrigger>

View File

@@ -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={{

View File

@@ -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`}
>

View File

@@ -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");
}

View File

@@ -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;

View File

@@ -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);
});

View File

@@ -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);

View File

@@ -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]);

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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

View File

@@ -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],

View File

@@ -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);

View File

@@ -231,8 +231,8 @@ function Logs() {
useKeyboardListener(
["PageDown", "PageUp", "ArrowDown", "ArrowUp"],
(key, down, _) => {
if (!down) {
(key, modifiers) => {
if (!modifiers.down) {
return;
}

View File

@@ -60,4 +60,4 @@ export type MotionData = {
camera: string;
};
export const REVIEW_PADDING = 2;
export const REVIEW_PADDING = 4;

View File

@@ -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

View File

@@ -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

View File

@@ -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,
});
}
}
});
});