forked from Github/frigate
Compare commits
44 Commits
v0.14.0-be
...
v0.14.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da913d8d31 | ||
|
|
88d4b694f8 | ||
|
|
b28cc45510 | ||
|
|
c0b23ca938 | ||
|
|
8e7b83d2f1 | ||
|
|
599dd7eecb | ||
|
|
84348350fe | ||
|
|
7d03d99852 | ||
|
|
7c39b176ac | ||
|
|
4c2e6f75a2 | ||
|
|
81139e8f47 | ||
|
|
cea0596cf5 | ||
|
|
51a1526146 | ||
|
|
b4db07d7a5 | ||
|
|
5c15659a34 | ||
|
|
1bd3285679 | ||
|
|
6de426c697 | ||
|
|
d28ad0f0c8 | ||
|
|
47aecff567 | ||
|
|
524f03a650 | ||
|
|
68e6ffdfef | ||
|
|
29345c429a | ||
|
|
f2c46408c4 | ||
|
|
e5dc476c1e | ||
|
|
eb2363b93d | ||
|
|
7bfebd5b61 | ||
|
|
6addf4d88b | ||
|
|
c56e7e7c6c | ||
|
|
78c15f3020 | ||
|
|
30f0f73a4e | ||
|
|
e9da453190 | ||
|
|
91f62cf8ce | ||
|
|
58dbbd5d29 | ||
|
|
5c90f7dce7 | ||
|
|
b7cf5f4105 | ||
|
|
c850604931 | ||
|
|
82d2910039 | ||
|
|
5066fa369d | ||
|
|
3afd77cbe0 | ||
|
|
093201a1cc | ||
|
|
6102e9e5ea | ||
|
|
91215a1406 | ||
|
|
94b1350c9d | ||
|
|
1129a2aba4 |
@@ -66,6 +66,40 @@ RUN --mount=type=bind,source=docker/main/build_ov_model.py,target=/build_ov_mode
|
||||
&& tar -xvf ssdlite_mobilenet_v2_coco_2018_05_09.tar.gz \
|
||||
&& python3 /build_ov_model.py
|
||||
|
||||
####
|
||||
#
|
||||
# Coral Compatibility
|
||||
#
|
||||
# Builds libusb without udev. Needed for synology and other devices with USB coral
|
||||
####
|
||||
# libUSB - No Udev
|
||||
FROM wget as libusb-build
|
||||
ARG TARGETARCH
|
||||
ARG DEBIAN_FRONTEND
|
||||
ENV CCACHE_DIR /root/.ccache
|
||||
ENV CCACHE_MAXSIZE 2G
|
||||
|
||||
# Build libUSB without udev. Needed for Openvino NCS2 support
|
||||
WORKDIR /opt
|
||||
RUN apt-get update && apt-get install -y unzip build-essential automake libtool ccache pkg-config
|
||||
RUN --mount=type=cache,target=/root/.ccache wget -q https://github.com/libusb/libusb/archive/v1.0.26.zip -O v1.0.26.zip && \
|
||||
unzip v1.0.26.zip && cd libusb-1.0.26 && \
|
||||
./bootstrap.sh && \
|
||||
./configure CC='ccache gcc' CCX='ccache g++' --disable-udev --enable-shared && \
|
||||
make -j $(nproc --all)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends libusb-1.0-0-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /opt/libusb-1.0.26/libusb
|
||||
RUN /bin/mkdir -p '/usr/local/lib' && \
|
||||
/bin/bash ../libtool --mode=install /usr/bin/install -c libusb-1.0.la '/usr/local/lib' && \
|
||||
/bin/mkdir -p '/usr/local/include/libusb-1.0' && \
|
||||
/usr/bin/install -c -m 644 libusb.h '/usr/local/include/libusb-1.0' && \
|
||||
/bin/mkdir -p '/usr/local/lib/pkgconfig' && \
|
||||
cd /opt/libusb-1.0.26/ && \
|
||||
/usr/bin/install -c -m 644 libusb-1.0.pc '/usr/local/lib/pkgconfig' && \
|
||||
ldconfig
|
||||
|
||||
FROM wget AS models
|
||||
|
||||
# Get model and labels
|
||||
@@ -78,7 +112,7 @@ COPY --from=ov-converter /models/ssdlite_mobilenet_v2.bin openvino-model/
|
||||
RUN wget -q https://github.com/openvinotoolkit/open_model_zoo/raw/master/data/dataset_classes/coco_91cl_bkgr.txt -O openvino-model/coco_91cl_bkgr.txt && \
|
||||
sed -i 's/truck/car/g' openvino-model/coco_91cl_bkgr.txt
|
||||
# Get Audio Model and labels
|
||||
RUN wget -qO - https://www.kaggle.com/api/v1/models/google/yamnet/tfLite/classification-tflite/1/download | tar xvz && mv 1.tflite cpu_audio_model.tflite
|
||||
RUN wget -qO - https://www.kaggle.com/api/v1/models/google/yamnet/tfLite/classification-tflite/1/download | tar xvz && mv 1.tflite cpu_audio_model.tflite
|
||||
COPY audio-labelmap.txt .
|
||||
|
||||
|
||||
@@ -135,6 +169,7 @@ RUN pip3 wheel --wheel-dir=/wheels -r /requirements-wheels.txt
|
||||
FROM scratch AS deps-rootfs
|
||||
COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/
|
||||
COPY --from=go2rtc /rootfs/ /
|
||||
COPY --from=libusb-build /usr/local/lib /usr/local/lib
|
||||
COPY --from=tempio /rootfs/ /
|
||||
COPY --from=s6-overlay /rootfs/ /
|
||||
COPY --from=models /rootfs/ /
|
||||
@@ -165,6 +200,8 @@ RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \
|
||||
|
||||
COPY --from=deps-rootfs / /
|
||||
|
||||
RUN ldconfig
|
||||
|
||||
EXPOSE 5000
|
||||
EXPOSE 8554
|
||||
EXPOSE 8555/tcp 8555/udp
|
||||
|
||||
@@ -16,8 +16,8 @@ function migrate_db_path() {
|
||||
if [[ -f "${config_file_yaml}" ]]; then
|
||||
config_file="${config_file_yaml}"
|
||||
elif [[ ! -f "${config_file}" ]]; then
|
||||
echo "[ERROR] Frigate config file not found"
|
||||
return 1
|
||||
# Frigate will create the config file on startup
|
||||
return 0
|
||||
fi
|
||||
unset config_file_yaml
|
||||
|
||||
|
||||
@@ -80,6 +80,14 @@ model:
|
||||
input_pixel_format: "bgr"
|
||||
```
|
||||
|
||||
#### `labelmap`
|
||||
|
||||
:::warning
|
||||
|
||||
If the labelmap is customized then the labels used for alerts will need to be adjusted as well. See [alert labels](../configuration/review.md#restricting-alerts-to-specific-labels) for more info.
|
||||
|
||||
:::
|
||||
|
||||
The labelmap can be customized to your needs. A common reason to do this is to combine multiple object types that are easily confused when you don't need to be as granular such as car/truck. By default, truck is renamed to car because they are often confused. You cannot add new object types, but you can change the names of existing objects in the model.
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -111,6 +111,6 @@ camera_groups:
|
||||
cameras:
|
||||
- driveway_cam
|
||||
- garage_cam
|
||||
icon: car
|
||||
icon: LuCar
|
||||
order: 0
|
||||
```
|
||||
|
||||
@@ -78,7 +78,7 @@ It is, but the definition of "unnecessary" varies. I want to ignore areas of mot
|
||||
|
||||
> For me, giving my masks ANY padding results in a lot of people detection I'm not interested in. I live in the city and catch a lot of the sidewalk on my camera. People walk by my front door all the time and the margin between the sidewalk and actually walking onto my stoop is very thin, so I basically have everything but the exact contours of my stoop masked out. This results in very tidy detections but this info keeps throwing me off. Am I just overthinking it?
|
||||
|
||||
This is what `required_zones` are for. You should define a zone (remember this is evaluated based on the bottom center of the bounding box) and make it required to save snapshots and clips (now events in 0.9.0). You can also use this in your conditions for a notification.
|
||||
This is what `required_zones` are for. You should define a zone (remember this is evaluated based on the bottom center of the bounding box) and make it required to save snapshots and clips (previously events in 0.9.0 to 0.13.0 and review items in 0.14.0 and later). You can also use this in your conditions for a notification.
|
||||
|
||||
> Maybe my specific situation just warrants this. I've just been having a hard time understanding the relevance of this information - it seems to be that it's exactly what would be expected when "masking out" an area of ANY image.
|
||||
|
||||
|
||||
@@ -81,6 +81,15 @@ detectors:
|
||||
device: ""
|
||||
```
|
||||
|
||||
### Single PCIE/M.2 Coral
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
coral:
|
||||
type: edgetpu
|
||||
device: pci
|
||||
```
|
||||
|
||||
### Multiple PCIE/M.2 Corals
|
||||
|
||||
```yaml
|
||||
@@ -136,23 +145,7 @@ model:
|
||||
|
||||
#### YOLOX
|
||||
|
||||
This detector also supports YOLOX. Frigate does not come with any YOLOX models preloaded, so you will need to supply your own models. This detector has been verified to work with the [yolox_tiny](https://github.com/openvinotoolkit/open_model_zoo/tree/master/models/public/yolox-tiny) model from Intel's Open Model Zoo. You can follow [these instructions](https://github.com/openvinotoolkit/open_model_zoo/tree/master/models/public/yolox-tiny#download-a-model-and-convert-it-into-openvino-ir-format) to retrieve the OpenVINO-compatible `yolox_tiny` model. Make sure that the model input dimensions match the `width` and `height` parameters, and `model_type` is set accordingly. See [Full Configuration Reference](/configuration/reference.md) for a list of possible `model_type` options. Below is an example of how `yolox_tiny` can be used in Frigate:
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
ov:
|
||||
type: openvino
|
||||
device: GPU
|
||||
|
||||
model:
|
||||
width: 416
|
||||
height: 416
|
||||
input_tensor: nchw
|
||||
input_pixel_format: bgr
|
||||
model_type: yolox
|
||||
path: /path/to/yolox_tiny.xml
|
||||
labelmap_path: /path/to/coco_80cl.txt
|
||||
```
|
||||
This detector also supports YOLOX. Frigate does not come with any YOLOX models preloaded, so you will need to supply your own models.
|
||||
|
||||
#### YOLO-NAS
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ birdseye:
|
||||
inactivity_threshold: 30
|
||||
# Optional: Configure the birdseye layout
|
||||
layout:
|
||||
# Optional: Scaling factor for the layout calculator (default: shown below)
|
||||
# Optional: Scaling factor for the layout calculator, range 1.0-5.0 (default: shown below)
|
||||
scaling_factor: 2.0
|
||||
# Optional: Maximum number of cameras to show at one time, showing the most recent (default: show all cameras)
|
||||
max_cameras: 1
|
||||
|
||||
@@ -7,6 +7,16 @@ The Review page of the Frigate UI is for quickly reviewing historical footage of
|
||||
|
||||
Review items are filterable by date, object type, and camera.
|
||||
|
||||
### Review items vs. events
|
||||
|
||||
In Frigate 0.13 and earlier versions, the UI presented "events". An event was synonymous with a tracked or detected object. In Frigate 0.14 and later, a review item is a time period where any number of tracked objects were active.
|
||||
|
||||
For example, consider a situation where two people walked past your house. One was walking a dog. At the same time, a car drove by on the street behind them.
|
||||
|
||||
In this scenario, Frigate 0.13 and earlier would show 4 events in the UI - one for each person, another for the dog, and yet another for the car. You would have had 4 separate videos to watch even though they would have all overlapped.
|
||||
|
||||
In 0.14 and later, all of that is bundled into a single review item which starts and ends to capture all of that activity. Reviews for a single camera cannot overlap. Once you have watched that time period on that camera, it is marked as reviewed.
|
||||
|
||||
## Alerts and Detections
|
||||
|
||||
Not every segment of video captured by Frigate may be of the same level of interest to you. Video of people who enter your property may be a different priority than those walking by on the sidewalk. For this reason, Frigate 0.14 categorizes review items as _alerts_ and _detections_. By default, all person and car objects are considered alerts. You can refine categorization of your review items by configuring required zones for them.
|
||||
|
||||
@@ -48,6 +48,10 @@ When pixels in the current camera frame are different than previous frames. When
|
||||
|
||||
A portion of the camera frame that is sent to object detection, regions can be sent due to motion, active objects, or occasionally for stationary objects. These are represented by green boxes in the debug live view.
|
||||
|
||||
## Review Item
|
||||
|
||||
A review item is a time period where any number of events/tracked objects were active. [See the review docs for more info](/configuration/review)
|
||||
|
||||
## Snapshot Score
|
||||
|
||||
The score shown in a snapshot is the score of that object at that specific moment in time.
|
||||
|
||||
@@ -274,13 +274,11 @@ cameras:
|
||||
- 0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432
|
||||
```
|
||||
|
||||
### Step 6: Enable recording and/or snapshots
|
||||
### Step 6: Enable recordings
|
||||
|
||||
In order to see Events in the Frigate UI, either snapshots or record will need to be enabled.
|
||||
In order to review activity in the Frigate UI, recordings need to be enabled.
|
||||
|
||||
#### Record
|
||||
|
||||
To enable recording video, add the `record` role to a stream and enable it in the config. If record is disabled in the config, turning it on via the UI will not have any effect.
|
||||
To enable recording video, add the `record` role to a stream and enable it in the config. If record is disabled in the config, it won't be possible to enable it in the UI.
|
||||
|
||||
```yaml
|
||||
mqtt: ...
|
||||
@@ -307,26 +305,6 @@ If you don't have separate streams for detect and record, you would just add the
|
||||
|
||||
By default, Frigate will retain video of all events for 10 days. The full set of options for recording can be found [here](../configuration/reference.md).
|
||||
|
||||
#### Snapshots
|
||||
|
||||
To enable snapshots of your events, just enable it in the config. Snapshots are taken from the detect stream because it is the only stream decoded.
|
||||
|
||||
```yaml
|
||||
mqtt: ...
|
||||
|
||||
detectors: ...
|
||||
|
||||
cameras:
|
||||
name_of_your_camera: ...
|
||||
detect: ...
|
||||
record: ...
|
||||
snapshots: # <----- Enable snapshots
|
||||
enabled: True
|
||||
motion: ...
|
||||
```
|
||||
|
||||
By default, Frigate will retain snapshots of all events for 10 days. The full set of options for snapshots can be found [here](../configuration/reference.md).
|
||||
|
||||
### Step 7: Complete config
|
||||
|
||||
At this point you have a complete config with basic functionality. You can see the [full config reference](../configuration/reference.md) for a complete list of configuration options.
|
||||
@@ -336,6 +314,8 @@ At this point you have a complete config with basic functionality. You can see t
|
||||
Now that you have a working install, you can use the following documentation for additional features:
|
||||
|
||||
1. [Configuring go2rtc](configuring_go2rtc.md) - Additional live view options and RTSP relay
|
||||
2. [Home Assistant Integration](../integrations/home-assistant.md) - Integrate with Home Assistant
|
||||
3. [Masks](../configuration/masks.md)
|
||||
4. [Zones](../configuration/zones.md)
|
||||
2. [Zones](../configuration/zones.md)
|
||||
3. [Review](../configuration/review.md)
|
||||
4. [Masks](../configuration/masks.md)
|
||||
5. [Home Assistant Integration](../integrations/home-assistant.md) - Integrate with Home Assistant
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ title: Home Assistant notifications
|
||||
|
||||
The best way to get started with notifications for Frigate is to use the [Blueprint](https://community.home-assistant.io/t/frigate-mobile-app-notifications-2-0/559732). You can use the yaml generated from the Blueprint as a starting point and customize from there.
|
||||
|
||||
It is generally recommended to trigger notifications based on the `frigate/events` mqtt topic. This provides the event_id needed to fetch [thumbnails/snapshots/clips](../integrations/home-assistant.md#notification-api) and other useful information to customize when and where you want to receive alerts. The data is published in the form of a change feed, which means you can reference the "previous state" of the object in the `before` section and the "current state" of the object in the `after` section. You can see an example [here](../integrations/mqtt.md#frigateevents).
|
||||
It is generally recommended to trigger notifications based on the `frigate/reviews` mqtt topic. This provides the event_id(s) needed to fetch [thumbnails/snapshots/clips](../integrations/home-assistant.md#notification-api) and other useful information to customize when and where you want to receive alerts. The data is published in the form of a change feed, which means you can reference the "previous state" of the object in the `before` section and the "current state" of the object in the `after` section. You can see an example [here](../integrations/mqtt.md#frigateevents).
|
||||
|
||||
Here is a simple example of a notification automation of events which will update the existing notification for each change. This means the image you see in the notification will update as Frigate finds a "better" image.
|
||||
|
||||
@@ -17,7 +17,7 @@ automation:
|
||||
topic: frigate/events
|
||||
action:
|
||||
- service: notify.mobile_app_pixel_3
|
||||
data_template:
|
||||
data:
|
||||
message: 'A {{trigger.payload_json["after"]["label"]}} was detected.'
|
||||
data:
|
||||
image: 'https://your.public.hass.address.com/api/frigate/notifications/{{trigger.payload_json["after"]["id"]}}/thumbnail.jpg?format=android'
|
||||
@@ -33,48 +33,18 @@ automation:
|
||||
description: ""
|
||||
trigger:
|
||||
- platform: mqtt
|
||||
topic: frigate/events
|
||||
payload: new
|
||||
value_template: "{{ value_json.type }}"
|
||||
topic: frigate/reviews
|
||||
payload: alert
|
||||
value_template: "{{ value_json['after']['severity'] }}"
|
||||
action:
|
||||
- service: notify.mobile_app_iphone
|
||||
data:
|
||||
message: 'A {{trigger.payload_json["after"]["label"]}} was detected.'
|
||||
message: 'A {{trigger.payload_json["after"]["data"]["objects"] | sort | join(", ") | title}} was detected.'
|
||||
data:
|
||||
image: >-
|
||||
https://your.public.hass.address.com/api/frigate/notifications/{{trigger.payload_json["after"]["id"]}}/thumbnail.jpg
|
||||
https://your.public.hass.address.com/api/frigate/notifications/{{trigger.payload_json["after"]["data"]["detections"][0]}}/thumbnail.jpg
|
||||
tag: '{{trigger.payload_json["after"]["id"]}}'
|
||||
when: '{{trigger.payload_json["after"]["start_time"]|int}}'
|
||||
entity_id: camera.{{trigger.payload_json["after"]["camera"] | replace("-","_") | lower}}
|
||||
mode: single
|
||||
```
|
||||
|
||||
## Conditions
|
||||
|
||||
Conditions with the `before` and `after` values allow a high degree of customization for automations.
|
||||
|
||||
When a person enters a zone named yard
|
||||
|
||||
```yaml
|
||||
condition:
|
||||
- "{{ trigger.payload_json['after']['label'] == 'person' }}"
|
||||
- "{{ 'yard' in trigger.payload_json['after']['entered_zones'] }}"
|
||||
```
|
||||
|
||||
When a person leaves a zone named yard
|
||||
|
||||
```yaml
|
||||
condition:
|
||||
- "{{ trigger.payload_json['after']['label'] == 'person' }}"
|
||||
- "{{ 'yard' in trigger.payload_json['before']['current_zones'] }}"
|
||||
- "{{ not 'yard' in trigger.payload_json['after']['current_zones'] }}"
|
||||
```
|
||||
|
||||
Notify for dogs in the front with a high top score
|
||||
|
||||
```yaml
|
||||
condition:
|
||||
- "{{ trigger.payload_json['after']['label'] == 'dog' }}"
|
||||
- "{{ trigger.payload_json['after']['camera'] == 'front' }}"
|
||||
- "{{ trigger.payload_json['after']['top_score'] > 0.98 }}"
|
||||
```
|
||||
|
||||
@@ -381,9 +381,13 @@ List of frames in the preview cache for the time range. Previews are only kept i
|
||||
|
||||
Specific preview frame from preview cache.
|
||||
|
||||
### `GET /<camera>/start/<start-timestamp>/end/<end-timestamp>/preview.gif`
|
||||
### `GET /<camera>/start/<start-timestamp>/end/<end-timestamp>/preview`
|
||||
|
||||
Gif made from preview video / frames during this time range
|
||||
Looping image made from preview video / frames during this time range.
|
||||
|
||||
| param | Type | Description |
|
||||
| --------- | ---- | -------------------------------- |
|
||||
| `format` | str | Format of preview [`gif`, `mp4`] |
|
||||
|
||||
## Recordings
|
||||
|
||||
@@ -455,6 +459,10 @@ Reviews from the database. Accepts the following query string parameters:
|
||||
| `limit` | int | Limit the number of events returned |
|
||||
| `severity` | str | Limit items to severity (alert, detection, significant_motion) |
|
||||
|
||||
### `GET /api/review/<id>`
|
||||
|
||||
Get review with `id` from the database.
|
||||
|
||||
### `GET /api/review/summary`
|
||||
|
||||
Summary of reviews for the last 30 days. Accepts the following query string parameters:
|
||||
|
||||
@@ -138,13 +138,14 @@ Message published for each changed review item. The first message is published w
|
||||
"person",
|
||||
"car"
|
||||
],
|
||||
"sub_labels": [],
|
||||
"sub_labels": ["Bob"],
|
||||
"zones": [
|
||||
"front_yard"
|
||||
],
|
||||
"audio": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `frigate/stats`
|
||||
|
||||
@@ -3,7 +3,7 @@ id: index
|
||||
title: Models
|
||||
---
|
||||
|
||||
<a href="https://plus.frigate.video" target="_blank" rel="nofollow">Frigate+</a> offers models trained on images submitted by Frigate+ users from their security cameras and is specifically designed for the way Frigate NVR analyzes video footage. These models offer higher accuracy with less resources. The images you upload are used to fine tune a baseline model trained from images uploaded by all Frigate+ users. This fine tuning process results in a model that is optimized for accuracy in your specific conditions.
|
||||
<a href="https://frigate.video/plus" target="_blank" rel="nofollow">Frigate+</a> offers models trained on images submitted by Frigate+ users from their security cameras and is specifically designed for the way Frigate NVR analyzes video footage. These models offer higher accuracy with less resources. The images you upload are used to fine tune a baseline model trained from images uploaded by all Frigate+ users. This fine tuning process results in a model that is optimized for accuracy in your specific conditions.
|
||||
|
||||
:::info
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ You can open `chrome://media-internals/` in another tab and then try to playback
|
||||
Frigate generally [recommends cameras with configurable sub streams](/frigate/hardware.md). However, if your camera does not have a sub stream that a suitable resolution, the main stream can be resized.
|
||||
|
||||
To do this efficiently the following setup is required:
|
||||
|
||||
1. A GPU or iGPU must be available to do the scaling.
|
||||
2. [ffmpeg presets for hwaccel](/configuration/hardware_acceleration.md) must be used
|
||||
3. Set the desired detection resolution for `detect -> width` and `detect -> height`.
|
||||
@@ -56,10 +57,44 @@ SQLite does not work well on a network share, if the `/media` folder is mapped t
|
||||
|
||||
If MQTT isn't working in docker try using the IP of the device hosting the MQTT server instead of `localhost`, `127.0.0.1`, or `mosquitto.ix-mosquitto.svc.cluster.local`.
|
||||
|
||||
This is because, by default, Frigate does not run in host mode so localhost points to the Frigate container and not the host device's network.
|
||||
This is because Frigate does not run in host mode so localhost points to the Frigate container and not the host device's network.
|
||||
|
||||
### How do I know if my camera is offline
|
||||
|
||||
A camera being offline can be detected via MQTT or /api/stats, the camera_fps for any offline camera will be 0.
|
||||
|
||||
Also, Home Assistant will mark any offline camera as being unavailable when the camera is offline
|
||||
Also, Home Assistant will mark any offline camera as being unavailable when the camera is offline.
|
||||
|
||||
### How can I view the Frigate log files without using the Web UI?
|
||||
|
||||
Frigate manages logs internally as well as outputs directly to Docker via standard output. To view these logs using the CLI, follow these steps:
|
||||
|
||||
- Open a terminal or command prompt on the host running your Frigate container.
|
||||
- Type the following command and press Enter:
|
||||
```
|
||||
docker logs -f frigate
|
||||
```
|
||||
This command tells Docker to show you the logs from the Frigate container.
|
||||
Note: If you've given your Frigate container a different name, replace "frigate" in the command with your container's actual name. The "-f" option means the logs will continue to update in real-time as new entries are added. To stop viewing the logs, press `Ctrl+C`. If you'd like to learn more about using Docker logs, including additional options and features, you can explore Docker's [official documentation](https://docs.docker.com/engine/reference/commandline/logs/).
|
||||
|
||||
Alternatively, when you create the Frigate Docker container, you can bind a directory on the host to the mountpoint `/dev/shm/logs` to not only be able to persist the logs to disk, but also to be able to query them directly from the host using your favorite log parsing/query utility.
|
||||
|
||||
```
|
||||
docker run -d \
|
||||
--name frigate \
|
||||
--restart=unless-stopped \
|
||||
--mount type=tmpfs,target=/tmp/cache,tmpfs-size=1000000000 \
|
||||
--device /dev/bus/usb:/dev/bus/usb \
|
||||
--device /dev/dri/renderD128 \
|
||||
--shm-size=64m \
|
||||
-v /path/to/your/storage:/media/frigate \
|
||||
-v /path/to/your/config:/config \
|
||||
-v /etc/localtime:/etc/localtime:ro \
|
||||
-v /path/to/local/log/dir:/dev/shm/logs \
|
||||
-e FRIGATE_RTSP_PASSWORD='password' \
|
||||
-p 5000:5000 \
|
||||
-p 8554:8554 \
|
||||
-p 8555:8555/tcp \
|
||||
-p 8555:8555/udp \
|
||||
ghcr.io/blakeblackshear/frigate:stable
|
||||
```
|
||||
|
||||
@@ -13,7 +13,6 @@ from flask import (
|
||||
request,
|
||||
)
|
||||
from peewee import DoesNotExist
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from frigate.const import EXPORT_DIR
|
||||
from frigate.models import Export, Recordings
|
||||
@@ -48,9 +47,9 @@ def export_recording(camera_name: str, start_time, end_time):
|
||||
|
||||
json: dict[str, any] = request.get_json(silent=True) or {}
|
||||
playback_factor = json.get("playback", "realtime")
|
||||
name: Optional[str] = json.get("name")
|
||||
friendly_name: Optional[str] = json.get("name")
|
||||
|
||||
if len(name or "") > 256:
|
||||
if len(friendly_name or "") > 256:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "File name is too long."}),
|
||||
401,
|
||||
@@ -78,7 +77,7 @@ def export_recording(camera_name: str, start_time, end_time):
|
||||
exporter = RecordingExporter(
|
||||
current_app.frigate_config,
|
||||
camera_name,
|
||||
secure_filename(name) if name else None,
|
||||
friendly_name,
|
||||
int(start_time),
|
||||
int(end_time),
|
||||
(
|
||||
|
||||
@@ -554,7 +554,9 @@ def vod_ts(camera_name, start_ts, end_ts):
|
||||
logger.warning(f"Recording clip is missing or empty: {recording.path}")
|
||||
|
||||
if not clips:
|
||||
logger.error("No recordings found for the requested time range")
|
||||
logger.error(
|
||||
f"No recordings found for {camera_name} during the requested time range"
|
||||
)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
|
||||
@@ -475,7 +475,7 @@ def motion_activity():
|
||||
logger.warning("No motion data found for the requested time range")
|
||||
return jsonify([])
|
||||
|
||||
df = df.astype(dtype={"motion": "float16"})
|
||||
df = df.astype(dtype={"motion": "float32"})
|
||||
|
||||
# set date as datetime index
|
||||
df["start_time"] = pd.to_datetime(df["start_time"], unit="s")
|
||||
@@ -497,11 +497,13 @@ def motion_activity():
|
||||
|
||||
for i in range(0, length, chunk):
|
||||
part = df.iloc[i : i + chunk]
|
||||
df.iloc[i : i + chunk, 0] = (
|
||||
(part["motion"] - part["motion"].min())
|
||||
/ (part["motion"].max() - part["motion"].min())
|
||||
* 100
|
||||
).fillna(0.0)
|
||||
min_val, max_val = part["motion"].min(), part["motion"].max()
|
||||
if min_val != max_val:
|
||||
df.iloc[i : i + chunk, 0] = (
|
||||
part["motion"].sub(min_val).div(max_val - min_val).mul(100).fillna(0)
|
||||
)
|
||||
else:
|
||||
df.iloc[i : i + chunk, 0] = 0.0
|
||||
|
||||
# change types for output
|
||||
df.index = df.index.astype(int) // (10**9)
|
||||
|
||||
@@ -68,7 +68,7 @@ class DetectionPublisher:
|
||||
def send_data(self, payload: any) -> None:
|
||||
"""Publish detection."""
|
||||
self.socket.send_string(self.topic.value, flags=zmq.SNDMORE)
|
||||
self.socket.send_pyobj(payload)
|
||||
self.socket.send_json(payload)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close()
|
||||
@@ -91,7 +91,7 @@ class DetectionSubscriber:
|
||||
|
||||
if has_update:
|
||||
topic = DetectionTypeEnum[self.socket.recv_string(flags=zmq.NOBLOCK)]
|
||||
return (topic, self.socket.recv_pyobj())
|
||||
return (topic, self.socket.recv_json())
|
||||
except zmq.ZMQError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class EventUpdatePublisher:
|
||||
self, payload: tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]
|
||||
) -> None:
|
||||
"""There is no communication back to the processes."""
|
||||
self.socket.send_pyobj(payload)
|
||||
self.socket.send_json(payload)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close()
|
||||
@@ -43,7 +43,7 @@ class EventUpdateSubscriber:
|
||||
has_update, _, _ = zmq.select([self.socket], [], [], timeout)
|
||||
|
||||
if has_update:
|
||||
return self.socket.recv_pyobj()
|
||||
return self.socket.recv_json()
|
||||
except zmq.ZMQError:
|
||||
pass
|
||||
|
||||
@@ -66,7 +66,7 @@ class EventEndPublisher:
|
||||
self, payload: tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]
|
||||
) -> None:
|
||||
"""There is no communication back to the processes."""
|
||||
self.socket.send_pyobj(payload)
|
||||
self.socket.send_json(payload)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close()
|
||||
@@ -89,7 +89,7 @@ class EventEndSubscriber:
|
||||
has_update, _, _ = zmq.select([self.socket], [], [], timeout)
|
||||
|
||||
if has_update:
|
||||
return self.socket.recv_pyobj()
|
||||
return self.socket.recv_json()
|
||||
except zmq.ZMQError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -37,14 +37,14 @@ class InterProcessCommunicator(Communicator):
|
||||
break
|
||||
|
||||
try:
|
||||
(topic, value) = self.socket.recv_pyobj(flags=zmq.NOBLOCK)
|
||||
(topic, value) = self.socket.recv_json(flags=zmq.NOBLOCK)
|
||||
|
||||
response = self._dispatcher(topic, value)
|
||||
|
||||
if response is not None:
|
||||
self.socket.send_pyobj(response)
|
||||
self.socket.send_json(response)
|
||||
else:
|
||||
self.socket.send_pyobj([])
|
||||
self.socket.send_json([])
|
||||
except zmq.ZMQError:
|
||||
break
|
||||
|
||||
@@ -65,8 +65,8 @@ class InterProcessRequestor:
|
||||
|
||||
def send_data(self, topic: str, data: any) -> any:
|
||||
"""Sends data and then waits for reply."""
|
||||
self.socket.send_pyobj((topic, data))
|
||||
return self.socket.recv_pyobj()
|
||||
self.socket.send_json((topic, data))
|
||||
return self.socket.recv_json()
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close()
|
||||
|
||||
@@ -77,8 +77,8 @@ class FFMpegConverter(threading.Thread):
|
||||
# write a PREVIEW at fps and 1 key frame per clip
|
||||
self.ffmpeg_cmd = parse_preset_hardware_acceleration_encode(
|
||||
config.ffmpeg.hwaccel_args,
|
||||
input="-f concat -y -protocol_whitelist pipe,file -safe 0 -i /dev/stdin",
|
||||
output=f"-g {PREVIEW_KEYFRAME_INTERVAL} -bf 0 -b:v {PREVIEW_QUALITY_BIT_RATES[self.config.record.preview.quality]} {FPS_VFR_PARAM} -movflags +faststart -pix_fmt yuv420p {self.path}",
|
||||
input="-f concat -y -protocol_whitelist pipe,file -safe 0 -threads 1 -i /dev/stdin",
|
||||
output=f"-threads 1 -g {PREVIEW_KEYFRAME_INTERVAL} -bf 0 -b:v {PREVIEW_QUALITY_BIT_RATES[self.config.record.preview.quality]} {FPS_VFR_PARAM} -movflags +faststart -pix_fmt yuv420p {self.path}",
|
||||
type=EncodeTypeEnum.preview,
|
||||
)
|
||||
|
||||
@@ -129,12 +129,12 @@ class FFMpegConverter(threading.Thread):
|
||||
self.requestor.send_data(
|
||||
INSERT_PREVIEW,
|
||||
{
|
||||
Previews.id: f"{self.config.name}_{end}",
|
||||
Previews.camera: self.config.name,
|
||||
Previews.path: self.path,
|
||||
Previews.start_time: start,
|
||||
Previews.end_time: end,
|
||||
Previews.duration: end - start,
|
||||
Previews.id.name: f"{self.config.name}_{end}",
|
||||
Previews.camera.name: self.config.name,
|
||||
Previews.path.name: self.path,
|
||||
Previews.start_time.name: start,
|
||||
Previews.end_time.name: end,
|
||||
Previews.duration.name: end - start,
|
||||
},
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -83,6 +83,7 @@ class OnvifController:
|
||||
|
||||
try:
|
||||
profiles = media.GetProfiles()
|
||||
logger.debug(f"Onvif profiles for {camera_name}: {profiles}")
|
||||
except (ONVIFError, Fault, TransportError) as e:
|
||||
logger.error(
|
||||
f"Unable to get Onvif media profiles for camera: {camera_name}: {e}"
|
||||
@@ -93,7 +94,6 @@ class OnvifController:
|
||||
for key, onvif_profile in enumerate(profiles):
|
||||
if (
|
||||
onvif_profile.VideoEncoderConfiguration
|
||||
and onvif_profile.VideoEncoderConfiguration.Encoding == "H264"
|
||||
and onvif_profile.PTZConfiguration
|
||||
and (
|
||||
onvif_profile.PTZConfiguration.DefaultContinuousPanTiltVelocitySpace
|
||||
@@ -102,6 +102,7 @@ class OnvifController:
|
||||
is not None
|
||||
)
|
||||
):
|
||||
# use the first profile that has a valid ptz configuration
|
||||
profile = onvif_profile
|
||||
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
|
||||
break
|
||||
|
||||
@@ -419,19 +419,19 @@ class RecordingMaintainer(threading.Thread):
|
||||
)
|
||||
|
||||
return {
|
||||
Recordings.id: f"{start_time.timestamp()}-{rand_id}",
|
||||
Recordings.camera: camera,
|
||||
Recordings.path: file_path,
|
||||
Recordings.start_time: start_time.timestamp(),
|
||||
Recordings.end_time: end_time.timestamp(),
|
||||
Recordings.duration: duration,
|
||||
Recordings.motion: segment_info.motion_count,
|
||||
Recordings.id.name: f"{start_time.timestamp()}-{rand_id}",
|
||||
Recordings.camera.name: camera,
|
||||
Recordings.path.name: file_path,
|
||||
Recordings.start_time.name: start_time.timestamp(),
|
||||
Recordings.end_time.name: end_time.timestamp(),
|
||||
Recordings.duration.name: duration,
|
||||
Recordings.motion.name: segment_info.motion_count,
|
||||
# TODO: update this to store list of active objects at some point
|
||||
Recordings.objects: segment_info.active_object_count
|
||||
Recordings.objects.name: segment_info.active_object_count
|
||||
+ (1 if manual_event else 0),
|
||||
Recordings.regions: segment_info.region_count,
|
||||
Recordings.dBFS: segment_info.average_dBFS,
|
||||
Recordings.segment_size: segment_size,
|
||||
Recordings.regions.name: segment_info.region_count,
|
||||
Recordings.dBFS.name: segment_info.average_dBFS,
|
||||
Recordings.segment_size.name: segment_size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Unable to store recording segment {cache_path}")
|
||||
|
||||
@@ -127,13 +127,13 @@ class PendingReviewSegment:
|
||||
|
||||
def get_data(self, ended: bool) -> dict:
|
||||
return {
|
||||
ReviewSegment.id: self.id,
|
||||
ReviewSegment.camera: self.camera,
|
||||
ReviewSegment.start_time: self.start_time,
|
||||
ReviewSegment.end_time: self.last_update if ended else None,
|
||||
ReviewSegment.severity: self.severity.value,
|
||||
ReviewSegment.thumb_path: self.frame_path,
|
||||
ReviewSegment.data: {
|
||||
ReviewSegment.id.name: self.id,
|
||||
ReviewSegment.camera.name: self.camera,
|
||||
ReviewSegment.start_time.name: self.start_time,
|
||||
ReviewSegment.end_time.name: self.last_update if ended else None,
|
||||
ReviewSegment.severity.name: self.severity.value,
|
||||
ReviewSegment.thumb_path.name: self.frame_path,
|
||||
ReviewSegment.data.name: {
|
||||
"detections": list(set(self.detections.keys())),
|
||||
"objects": list(set(self.detections.values())),
|
||||
"sub_labels": list(self.sub_labels),
|
||||
@@ -176,7 +176,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
"""New segment."""
|
||||
new_data = segment.get_data(ended=False)
|
||||
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, new_data)
|
||||
start_data = {k.name: v for k, v in new_data.items()}
|
||||
start_data = {k: v for k, v in new_data.items()}
|
||||
self.requestor.send_data(
|
||||
"reviews",
|
||||
json.dumps(
|
||||
@@ -207,8 +207,8 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
json.dumps(
|
||||
{
|
||||
"type": "update",
|
||||
"before": {k.name: v for k, v in prev_data.items()},
|
||||
"after": {k.name: v for k, v in new_data.items()},
|
||||
"before": {k: v for k, v in prev_data.items()},
|
||||
"after": {k: v for k, v in new_data.items()},
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -226,8 +226,8 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
json.dumps(
|
||||
{
|
||||
"type": "end",
|
||||
"before": {k.name: v for k, v in prev_data.items()},
|
||||
"after": {k.name: v for k, v in final_data.items()},
|
||||
"before": {k: v for k, v in prev_data.items()},
|
||||
"after": {k: v for k, v in final_data.items()},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -87,15 +87,16 @@ def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
||||
if not new_config["record"]:
|
||||
del new_config["record"]
|
||||
|
||||
if new_config.get("ui"):
|
||||
if new_config["ui"].get("use_experimental"):
|
||||
del new_config["ui"]["experimental"]
|
||||
# Remove UI fields
|
||||
if new_config.get("ui"):
|
||||
if new_config["ui"].get("use_experimental"):
|
||||
del new_config["ui"]["experimental"]
|
||||
|
||||
if new_config["ui"].get("live_mode"):
|
||||
del new_config["ui"]["live_mode"]
|
||||
if new_config["ui"].get("live_mode"):
|
||||
del new_config["ui"]["live_mode"]
|
||||
|
||||
if not new_config["ui"]:
|
||||
del new_config["ui"]
|
||||
if not new_config["ui"]:
|
||||
del new_config["ui"]
|
||||
|
||||
# remove rtmp
|
||||
if new_config.get("ffmpeg", {}).get("output_args", {}).get("rtmp"):
|
||||
|
||||
8
web/package-lock.json
generated
8
web/package-lock.json
generated
@@ -59,7 +59,7 @@
|
||||
"react-tracked": "^2.0.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-use-websocket": "^4.8.1",
|
||||
"react-zoom-pan-pinch": "^3.6.1",
|
||||
"react-zoom-pan-pinch": "3.4.4",
|
||||
"recoil": "^0.7.7",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"sonner": "^1.5.0",
|
||||
@@ -6841,9 +6841,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-zoom-pan-pinch": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.6.1.tgz",
|
||||
"integrity": "sha512-SdPqdk7QDSV7u/WulkFOi+cnza8rEZ0XX4ZpeH7vx3UZEg7DoyuAy3MCmm+BWv/idPQL2Oe73VoC0EhfCN+sZQ==",
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.4.4.tgz",
|
||||
"integrity": "sha512-lGTu7D9lQpYEQ6sH+NSlLA7gicgKRW8j+D/4HO1AbSV2POvKRFzdWQ8eI0r3xmOsl4dYQcY+teV6MhULeg1xBw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"react-tracked": "^2.0.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-use-websocket": "^4.8.1",
|
||||
"react-zoom-pan-pinch": "^3.6.1",
|
||||
"react-zoom-pan-pinch": "3.4.4",
|
||||
"recoil": "^0.7.7",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"sonner": "^1.5.0",
|
||||
|
||||
@@ -11,6 +11,10 @@ import { VideoPreview } from "../player/PreviewThumbnailPlayer";
|
||||
import { isCurrentHour } from "@/utils/dateUtil";
|
||||
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { useApiHost } from "@/api";
|
||||
import { isSafari } from "react-device-detect";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
type AnimatedEventCardProps = {
|
||||
event: ReviewSegment;
|
||||
@@ -21,6 +25,7 @@ export function AnimatedEventCard({
|
||||
selectedGroup,
|
||||
}: AnimatedEventCardProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const apiHost = useApiHost();
|
||||
|
||||
const currentHour = useMemo(() => isCurrentHour(event.start_time), [event]);
|
||||
|
||||
@@ -53,11 +58,16 @@ export function AnimatedEventCard({
|
||||
};
|
||||
}, [visibilityListener]);
|
||||
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
// interaction
|
||||
|
||||
const navigate = useNavigate();
|
||||
const onOpenReview = useCallback(() => {
|
||||
const url = selectedGroup ? `review?group=${selectedGroup}` : "review";
|
||||
const url =
|
||||
selectedGroup && selectedGroup != "default"
|
||||
? `review?group=${selectedGroup}`
|
||||
: "review";
|
||||
navigate(url, {
|
||||
state: {
|
||||
severity: event.severity,
|
||||
@@ -73,6 +83,8 @@ export function AnimatedEventCard({
|
||||
|
||||
// image behavior
|
||||
|
||||
const [alertVideos] = usePersistence("alertVideos", true);
|
||||
|
||||
const aspectRatio = useMemo(() => {
|
||||
if (!config || !Object.keys(config.cameras).includes(event.camera)) {
|
||||
return 16 / 9;
|
||||
@@ -95,39 +107,63 @@ export function AnimatedEventCard({
|
||||
className="size-full cursor-pointer overflow-hidden rounded md:rounded-lg"
|
||||
onClick={onOpenReview}
|
||||
>
|
||||
{previews ? (
|
||||
<VideoPreview
|
||||
relevantPreview={previews[previews.length - 1]}
|
||||
startTime={event.start_time}
|
||||
endTime={event.end_time}
|
||||
loop
|
||||
showProgress={false}
|
||||
setReviewed={() => {}}
|
||||
setIgnoreClick={() => {}}
|
||||
isPlayingBack={() => {}}
|
||||
windowVisible={windowVisible}
|
||||
{!alertVideos ? (
|
||||
<img
|
||||
className="size-full select-none"
|
||||
src={`${apiHost}${event.thumb_path.replace("/media/frigate/", "")}`}
|
||||
loading={isSafari ? "eager" : "lazy"}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
preload="auto"
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
disableRemotePlayback
|
||||
loop
|
||||
>
|
||||
<source
|
||||
src={`${baseUrl}api/review/${event.id}/preview?format=mp4`}
|
||||
type="video/mp4"
|
||||
/>
|
||||
</video>
|
||||
<>
|
||||
{previews ? (
|
||||
<VideoPreview
|
||||
relevantPreview={previews[previews.length - 1]}
|
||||
startTime={event.start_time}
|
||||
endTime={event.end_time}
|
||||
loop
|
||||
showProgress={false}
|
||||
setReviewed={() => {}}
|
||||
setIgnoreClick={() => {}}
|
||||
isPlayingBack={() => {}}
|
||||
onTimeUpdate={() => {
|
||||
if (!isLoaded) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}}
|
||||
windowVisible={windowVisible}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
preload="auto"
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
disableRemotePlayback
|
||||
loop
|
||||
onTimeUpdate={() => {
|
||||
if (!isLoaded) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<source
|
||||
src={`${baseUrl}api/review/${event.id}/preview?format=mp4`}
|
||||
type="video/mp4"
|
||||
/>
|
||||
</video>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 rounded bg-gradient-to-t from-slate-900/50 to-transparent">
|
||||
<div className="absolute bottom-0 left-1 w-full text-xs text-white">
|
||||
<TimeAgo time={event.start_time * 1000} dense />
|
||||
{isLoaded && (
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 rounded bg-gradient-to-t from-slate-900/50 to-transparent">
|
||||
<div className="absolute bottom-0 left-1 w-full text-xs text-white">
|
||||
<TimeAgo time={event.start_time * 1000} dense />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoaded && <Skeleton className="absolute inset-0" />}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { LuTrash } from "react-icons/lu";
|
||||
import { Button } from "../ui/button";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { FaDownload, FaPlay } from "react-icons/fa";
|
||||
import Chip from "../indicators/Chip";
|
||||
@@ -47,6 +47,15 @@ export default function ExportCard({
|
||||
update: string;
|
||||
}>();
|
||||
|
||||
const submitRename = useCallback(() => {
|
||||
if (editName == undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
onRename(exportedRecording.id, editName.update);
|
||||
setEditName(undefined);
|
||||
}, [editName, exportedRecording, onRename, setEditName]);
|
||||
|
||||
useKeyboardListener(
|
||||
editName != undefined ? ["Enter"] : [],
|
||||
(key, modifiers) => {
|
||||
@@ -57,8 +66,7 @@ export default function ExportCard({
|
||||
editName &&
|
||||
editName.update.length > 0
|
||||
) {
|
||||
onRename(exportedRecording.id, editName.update);
|
||||
setEditName(undefined);
|
||||
submitRename();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -84,7 +92,7 @@ export default function ExportCard({
|
||||
className="mt-3"
|
||||
type="search"
|
||||
placeholder={editName?.original}
|
||||
value={editName?.update}
|
||||
value={editName?.update || editName?.original}
|
||||
onChange={(e) =>
|
||||
setEditName({
|
||||
original: editName.original ?? "",
|
||||
@@ -97,10 +105,7 @@ export default function ExportCard({
|
||||
size="sm"
|
||||
variant="select"
|
||||
disabled={(editName?.update?.length ?? 0) == 0}
|
||||
onClick={() => {
|
||||
onRename(exportedRecording.id, editName.update);
|
||||
setEditName(undefined);
|
||||
}}
|
||||
onClick={() => submitRename()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
@@ -55,6 +55,8 @@ type ReviewFilterGroupProps = {
|
||||
filter?: ReviewFilter;
|
||||
motionOnly: boolean;
|
||||
filterList?: FilterList;
|
||||
showReviewed: boolean;
|
||||
setShowReviewed: (show: boolean) => void;
|
||||
onUpdateFilter: (filter: ReviewFilter) => void;
|
||||
setMotionOnly: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
@@ -66,6 +68,8 @@ export default function ReviewFilterGroup({
|
||||
filter,
|
||||
motionOnly,
|
||||
filterList,
|
||||
showReviewed,
|
||||
setShowReviewed,
|
||||
onUpdateFilter,
|
||||
setMotionOnly,
|
||||
}: ReviewFilterGroupProps) {
|
||||
@@ -190,10 +194,8 @@ export default function ReviewFilterGroup({
|
||||
)}
|
||||
{filters.includes("reviewed") && (
|
||||
<ShowReviewFilter
|
||||
showReviewed={filter?.showReviewed || 0}
|
||||
setShowReviewed={(reviewed) =>
|
||||
onUpdateFilter({ ...filter, showReviewed: reviewed })
|
||||
}
|
||||
showReviewed={showReviewed}
|
||||
setShowReviewed={setShowReviewed}
|
||||
/>
|
||||
)}
|
||||
{isDesktop && filters.includes("date") && (
|
||||
@@ -418,8 +420,8 @@ export function CamerasFilterButton({
|
||||
}
|
||||
|
||||
type ShowReviewedFilterProps = {
|
||||
showReviewed?: 0 | 1;
|
||||
setShowReviewed: (reviewed?: 0 | 1) => void;
|
||||
showReviewed: boolean;
|
||||
setShowReviewed: (reviewed: boolean) => void;
|
||||
};
|
||||
function ShowReviewFilter({
|
||||
showReviewed,
|
||||
@@ -434,9 +436,9 @@ function ShowReviewFilter({
|
||||
<div className="hidden h-9 cursor-pointer items-center justify-start rounded-md bg-secondary p-2 text-sm hover:bg-secondary/80 md:flex">
|
||||
<Switch
|
||||
id="reviewed"
|
||||
checked={showReviewedSwitch == 1}
|
||||
checked={showReviewedSwitch}
|
||||
onCheckedChange={() =>
|
||||
setShowReviewedSwitch(showReviewedSwitch == 0 ? 1 : 0)
|
||||
setShowReviewedSwitch(showReviewedSwitch == false ? true : false)
|
||||
}
|
||||
/>
|
||||
<Label className="ml-2 cursor-pointer text-primary" htmlFor="reviewed">
|
||||
@@ -446,12 +448,14 @@ function ShowReviewFilter({
|
||||
|
||||
<Button
|
||||
className="block duration-0 md:hidden"
|
||||
variant={showReviewedSwitch == 1 ? "select" : "default"}
|
||||
variant={showReviewedSwitch ? "select" : "default"}
|
||||
size="sm"
|
||||
onClick={() => setShowReviewedSwitch(showReviewedSwitch == 0 ? 1 : 0)}
|
||||
onClick={() =>
|
||||
setShowReviewedSwitch(showReviewedSwitch == false ? true : false)
|
||||
}
|
||||
>
|
||||
<FaCheckCircle
|
||||
className={`${showReviewedSwitch == 1 ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
className={`${showReviewedSwitch ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
@@ -521,7 +525,7 @@ function CalendarFilterButton({
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent>{content}</PopoverContent>
|
||||
<PopoverContent className="w-auto">{content}</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,13 @@ export default function ExportDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
if (range.before < range.after) {
|
||||
toast.error("End time must be after start time", {
|
||||
position: "top-center",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
axios
|
||||
.post(
|
||||
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
|
||||
|
||||
@@ -68,6 +68,13 @@ export default function MobileReviewSettingsDrawer({
|
||||
return;
|
||||
}
|
||||
|
||||
if (range.before < range.after) {
|
||||
toast.error("End time must be after start time", {
|
||||
position: "top-center",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
axios
|
||||
.post(
|
||||
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
|
||||
|
||||
@@ -5,6 +5,9 @@ import { FaCircle } from "react-icons/fa";
|
||||
import { getUTCOffset } from "@/utils/dateUtil";
|
||||
import { type DayContentProps } from "react-day-picker";
|
||||
import { LAST_24_HOURS_KEY } from "@/types/filter";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
|
||||
type WeekStartsOnType = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
type ReviewActivityCalendarProps = {
|
||||
reviewSummary?: ReviewSummary;
|
||||
@@ -16,6 +19,8 @@ export default function ReviewActivityCalendar({
|
||||
selectedDay,
|
||||
onSelect,
|
||||
}: ReviewActivityCalendarProps) {
|
||||
const [weekStartsOn] = usePersistence("weekStartsOn", 0);
|
||||
|
||||
const disabledDates = useMemo(() => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0);
|
||||
@@ -72,6 +77,7 @@ export default function ReviewActivityCalendar({
|
||||
DayContent: ReviewActivityDay,
|
||||
}}
|
||||
defaultMonth={selectedDay ?? new Date()}
|
||||
weekStartsOn={(weekStartsOn ?? 0) as WeekStartsOnType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -109,6 +115,8 @@ export function TimezoneAwareCalendar({
|
||||
selectedDay,
|
||||
onSelect,
|
||||
}: TimezoneAwareCalendarProps) {
|
||||
const [weekStartsOn] = usePersistence("weekStartsOn", 0);
|
||||
|
||||
const timezoneOffset = useMemo(
|
||||
() =>
|
||||
timezone ? Math.round(getUTCOffset(new Date(), timezone)) : undefined,
|
||||
@@ -162,6 +170,7 @@ export function TimezoneAwareCalendar({
|
||||
selected={selectedDay}
|
||||
onSelect={onSelect}
|
||||
defaultMonth={selectedDay ?? new Date()}
|
||||
weekStartsOn={(weekStartsOn ?? 0) as WeekStartsOnType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,16 +11,18 @@ type LivePlayerProps = {
|
||||
className?: string;
|
||||
birdseyeConfig: BirdseyeConfig;
|
||||
liveMode: LivePlayerMode;
|
||||
onClick?: () => void;
|
||||
pip?: boolean;
|
||||
containerRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export default function BirdseyeLivePlayer({
|
||||
className,
|
||||
birdseyeConfig,
|
||||
liveMode,
|
||||
onClick,
|
||||
pip,
|
||||
containerRef,
|
||||
onClick,
|
||||
}: LivePlayerProps) {
|
||||
let player;
|
||||
if (liveMode == "webrtc") {
|
||||
@@ -28,6 +30,7 @@ export default function BirdseyeLivePlayer({
|
||||
<WebRtcPlayer
|
||||
className={`size-full rounded-lg md:rounded-2xl`}
|
||||
camera="birdseye"
|
||||
pip={pip}
|
||||
/>
|
||||
);
|
||||
} else if (liveMode == "mse") {
|
||||
@@ -36,6 +39,7 @@ export default function BirdseyeLivePlayer({
|
||||
<MSEPlayer
|
||||
className={`size-full rounded-lg md:rounded-2xl`}
|
||||
camera="birdseye"
|
||||
pip={pip}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { toast } from "sonner";
|
||||
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASPECT_VERTICAL_LAYOUT } from "@/types/record";
|
||||
import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
|
||||
|
||||
// Android native hls does not seek correctly
|
||||
const USE_NATIVE_HLS = !isAndroid;
|
||||
@@ -29,6 +29,7 @@ const unsupportedErrorCodes = [
|
||||
|
||||
type HlsVideoPlayerProps = {
|
||||
videoRef: MutableRefObject<HTMLVideoElement | null>;
|
||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
visible: boolean;
|
||||
currentSource: string;
|
||||
hotKeys: boolean;
|
||||
@@ -40,10 +41,11 @@ type HlsVideoPlayerProps = {
|
||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
|
||||
toggleFullscreen?: () => void;
|
||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
onError?: (error: RecordingPlayerError) => void;
|
||||
};
|
||||
export default function HlsVideoPlayer({
|
||||
videoRef,
|
||||
containerRef,
|
||||
visible,
|
||||
currentSource,
|
||||
hotKeys,
|
||||
@@ -55,7 +57,7 @@ export default function HlsVideoPlayer({
|
||||
setFullResolution,
|
||||
onUploadFrame,
|
||||
toggleFullscreen,
|
||||
containerRef,
|
||||
onError,
|
||||
}: HlsVideoPlayerProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@@ -64,6 +66,7 @@ export default function HlsVideoPlayer({
|
||||
const hlsRef = useRef<Hls>();
|
||||
const [useHlsCompat, setUseHlsCompat] = useState(false);
|
||||
const [loadedMetadata, setLoadedMetadata] = useState(false);
|
||||
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
setLoadedMetadata(true);
|
||||
@@ -265,11 +268,42 @@ export default function HlsVideoPlayer({
|
||||
onPlaying={onPlaying}
|
||||
onPause={() => {
|
||||
setIsPlaying(false);
|
||||
clearTimeout(bufferTimeout);
|
||||
|
||||
if (isMobile && mobileCtrlTimeout) {
|
||||
clearTimeout(mobileCtrlTimeout);
|
||||
}
|
||||
}}
|
||||
onWaiting={() => {
|
||||
if (onError != undefined) {
|
||||
if (videoRef.current?.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBufferTimeout(
|
||||
setTimeout(() => {
|
||||
if (
|
||||
document.visibilityState === "visible" &&
|
||||
videoRef.current
|
||||
) {
|
||||
onError("stalled");
|
||||
}
|
||||
}, 3000),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onProgress={() => {
|
||||
if (onError != undefined) {
|
||||
if (videoRef.current?.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bufferTimeout) {
|
||||
clearTimeout(bufferTimeout);
|
||||
setBufferTimeout(undefined);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={() =>
|
||||
onTimeUpdate && videoRef.current
|
||||
? onTimeUpdate(videoRef.current.currentTime)
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function JSMpegPlayer({
|
||||
const onPlayingRef = useRef(onPlaying);
|
||||
const [showCanvas, setShowCanvas] = useState(false);
|
||||
const [hasData, setHasData] = useState(false);
|
||||
const hasDataRef = useRef(hasData);
|
||||
const [dimensionsReady, setDimensionsReady] = useState(false);
|
||||
|
||||
const selectedContainerRef = useMemo(
|
||||
@@ -110,6 +111,8 @@ export default function JSMpegPlayer({
|
||||
const canvas = canvasRef.current;
|
||||
let videoElement: JSMpeg.VideoElement | null = null;
|
||||
|
||||
setHasData(false);
|
||||
|
||||
if (videoWrapper && playbackEnabled) {
|
||||
// Delayed init to avoid issues with react strict mode
|
||||
const initPlayer = setTimeout(() => {
|
||||
@@ -120,9 +123,11 @@ export default function JSMpegPlayer({
|
||||
{
|
||||
protocols: [],
|
||||
audio: false,
|
||||
disableGl: true,
|
||||
disableWebAssembly: true,
|
||||
videoBufferSize: 1024 * 1024 * 4,
|
||||
onVideoDecode: () => {
|
||||
if (!hasData) {
|
||||
if (!hasDataRef.current) {
|
||||
setHasData(true);
|
||||
onPlayingRef.current?.();
|
||||
}
|
||||
@@ -151,6 +156,10 @@ export default function JSMpegPlayer({
|
||||
setShowCanvas(hasData && dimensionsReady);
|
||||
}, [hasData, dimensionsReady]);
|
||||
|
||||
useEffect(() => {
|
||||
hasDataRef.current = hasData;
|
||||
}, [hasData]);
|
||||
|
||||
return (
|
||||
<div className={cn(className, !containerRef.current && "size-full")}>
|
||||
<div
|
||||
|
||||
@@ -73,13 +73,30 @@ export default function LivePlayer({
|
||||
const liveMode = useCameraLiveMode(cameraConfig, preferredLiveMode);
|
||||
|
||||
const [liveReady, setLiveReady] = useState(false);
|
||||
|
||||
const liveReadyRef = useRef(liveReady);
|
||||
const cameraActiveRef = useRef(cameraActive);
|
||||
|
||||
useEffect(() => {
|
||||
liveReadyRef.current = liveReady;
|
||||
cameraActiveRef.current = cameraActive;
|
||||
}, [liveReady, cameraActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoLive || !liveReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cameraActive) {
|
||||
setLiveReady(false);
|
||||
const timer = setTimeout(() => {
|
||||
if (liveReadyRef.current && !cameraActiveRef.current) {
|
||||
setLiveReady(false);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
// live mode won't change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -92,6 +109,10 @@ export default function LivePlayer({
|
||||
return -1; // no reason to update the image when the window is not visible
|
||||
}
|
||||
|
||||
if (liveReady && !cameraActive) {
|
||||
return 300;
|
||||
}
|
||||
|
||||
if (liveReady) {
|
||||
return 60000;
|
||||
}
|
||||
@@ -113,6 +134,7 @@ export default function LivePlayer({
|
||||
activeTracking,
|
||||
offline,
|
||||
windowVisible,
|
||||
cameraActive,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -135,7 +157,7 @@ export default function LivePlayer({
|
||||
<WebRtcPlayer
|
||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||
camera={cameraConfig.live.stream_name}
|
||||
playbackEnabled={cameraActive}
|
||||
playbackEnabled={cameraActive || liveReady}
|
||||
audioEnabled={playAudio}
|
||||
microphoneEnabled={micEnabled}
|
||||
iOSCompatFullScreen={iOSCompatFullScreen}
|
||||
@@ -150,7 +172,7 @@ export default function LivePlayer({
|
||||
<MSEPlayer
|
||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||
camera={cameraConfig.live.stream_name}
|
||||
playbackEnabled={cameraActive}
|
||||
playbackEnabled={cameraActive || liveReady}
|
||||
audioEnabled={playAudio}
|
||||
onPlaying={playerIsPlaying}
|
||||
pip={pip}
|
||||
@@ -166,14 +188,16 @@ export default function LivePlayer({
|
||||
);
|
||||
}
|
||||
} else if (liveMode == "jsmpeg") {
|
||||
if (cameraActive || !showStillWithoutActivity) {
|
||||
if (cameraActive || !showStillWithoutActivity || liveReady) {
|
||||
player = (
|
||||
<JSMpegPlayer
|
||||
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||
camera={cameraConfig.name}
|
||||
width={cameraConfig.detect.width}
|
||||
height={cameraConfig.detect.height}
|
||||
playbackEnabled={cameraActive || !showStillWithoutActivity}
|
||||
playbackEnabled={
|
||||
cameraActive || !showStillWithoutActivity || liveReady
|
||||
}
|
||||
containerRef={containerRef ?? internalContainerRef}
|
||||
onPlaying={playerIsPlaying}
|
||||
/>
|
||||
|
||||
@@ -328,7 +328,7 @@ function PreviewVideoPlayer({
|
||||
)}
|
||||
</video>
|
||||
{cameraPreviews && !currentPreview && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background_alt text-primary md:rounded-2xl">
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background_alt text-primary dark:bg-black md:rounded-2xl">
|
||||
No Preview Found for {camera.replaceAll("_", " ")}
|
||||
</div>
|
||||
)}
|
||||
@@ -547,7 +547,7 @@ function PreviewFramesPlayer({
|
||||
onLoad={onImageLoaded}
|
||||
/>
|
||||
{previewFrames?.length === 0 && (
|
||||
<div className="-y-translate-1/2 align-center absolute inset-x-0 top-1/2 rounded-lg bg-background_alt text-center text-primary md:rounded-2xl">
|
||||
<div className="-y-translate-1/2 align-center absolute inset-x-0 top-1/2 rounded-lg bg-background_alt text-center text-primary dark:bg-black md:rounded-2xl">
|
||||
No Preview Found for {camera.replaceAll("_", " ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -234,11 +234,11 @@ export default function PreviewThumbnailPlayer({
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-t-l pointer-events-none absolute inset-x-0 top-0 h-[30%] w-full bg-gradient-to-b from-black/60 to-transparent",
|
||||
!isIOS && "z-10",
|
||||
!isSafari && "z-10",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("absolute left-0 top-2", !isIOS && "z-40")}>
|
||||
<div className={cn("absolute left-0 top-2", !isSafari && "z-40")}>
|
||||
<Tooltip>
|
||||
<div
|
||||
className="flex"
|
||||
@@ -287,7 +287,7 @@ export default function PreviewThumbnailPlayer({
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 h-[20%] w-full bg-gradient-to-t from-black/60 to-transparent",
|
||||
!isIOS && "z-10",
|
||||
!isSafari && "z-10",
|
||||
)}
|
||||
>
|
||||
<div className="mx-3 flex h-full items-end justify-between pb-1 text-sm text-white">
|
||||
|
||||
@@ -91,6 +91,7 @@ export default function DynamicVideoPlayer({
|
||||
// initial state
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(false);
|
||||
const [loadingTimeout, setLoadingTimeout] = useState<NodeJS.Timeout>();
|
||||
const [source, setSource] = useState(
|
||||
`${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`,
|
||||
@@ -130,9 +131,13 @@ export default function DynamicVideoPlayer({
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
if (isBuffering) {
|
||||
setIsBuffering(false);
|
||||
}
|
||||
|
||||
onTimestampUpdate(controller.getProgress(time));
|
||||
},
|
||||
[controller, onTimestampUpdate, isScrubbing, isLoading],
|
||||
[controller, onTimestampUpdate, isBuffering, isLoading, isScrubbing],
|
||||
);
|
||||
|
||||
const onUploadFrameToPlus = useCallback(
|
||||
@@ -188,6 +193,7 @@ export default function DynamicVideoPlayer({
|
||||
<>
|
||||
<HlsVideoPlayer
|
||||
videoRef={playerRef}
|
||||
containerRef={containerRef}
|
||||
visible={!(isScrubbing || isLoading)}
|
||||
currentSource={source}
|
||||
hotKeys={hotKeys}
|
||||
@@ -209,7 +215,11 @@ export default function DynamicVideoPlayer({
|
||||
setFullResolution={setFullResolution}
|
||||
onUploadFrame={onUploadFrameToPlus}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
containerRef={containerRef}
|
||||
onError={(error) => {
|
||||
if (error == "stalled" && !isScrubbing) {
|
||||
setIsBuffering(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<PreviewPlayer
|
||||
className={cn(
|
||||
@@ -221,14 +231,14 @@ export default function DynamicVideoPlayer({
|
||||
cameraPreviews={cameraPreviews}
|
||||
startTime={startTimestamp}
|
||||
isScrubbing={isScrubbing}
|
||||
onControllerReady={(previewController) => {
|
||||
setPreviewController(previewController);
|
||||
}}
|
||||
onControllerReady={(previewController) =>
|
||||
setPreviewController(previewController)
|
||||
}
|
||||
/>
|
||||
{!isScrubbing && isLoading && !noRecording && (
|
||||
{!isScrubbing && (isLoading || isBuffering) && !noRecording && (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
{!isScrubbing && noRecording && (
|
||||
{!isScrubbing && !isLoading && noRecording && (
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
No recordings found for this time
|
||||
</div>
|
||||
|
||||
@@ -245,7 +245,7 @@ export default function ZoneEditPane({
|
||||
}
|
||||
|
||||
let loiteringTimeQuery = "";
|
||||
if (loitering_time) {
|
||||
if (loitering_time >= 0) {
|
||||
loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,12 +38,22 @@ export function usePersistedOverlayState<S extends string>(
|
||||
(value: S | undefined, replace?: boolean) => void,
|
||||
() => void,
|
||||
] {
|
||||
const [persistedValue, setPersistedValue, , deletePersistedValue] =
|
||||
usePersistence<S>(key, defaultValue);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const currentLocationState = useMemo(() => location.state, [location]);
|
||||
|
||||
// currently selected value
|
||||
|
||||
const overlayStateValue = useMemo<S | undefined>(
|
||||
() => location.state && location.state[key],
|
||||
[location, key],
|
||||
);
|
||||
|
||||
// saved value from previous session
|
||||
|
||||
const [persistedValue, setPersistedValue, , deletePersistedValue] =
|
||||
usePersistence<S>(key, overlayStateValue);
|
||||
|
||||
const setOverlayStateValue = useCallback(
|
||||
(value: S | undefined, replace: boolean = false) => {
|
||||
setPersistedValue(value);
|
||||
@@ -56,11 +66,6 @@ export function usePersistedOverlayState<S extends string>(
|
||||
[key, currentLocationState, navigate],
|
||||
);
|
||||
|
||||
const overlayStateValue = useMemo<S | undefined>(
|
||||
() => location.state && location.state[key],
|
||||
[location, key],
|
||||
);
|
||||
|
||||
return [
|
||||
overlayStateValue ?? persistedValue ?? defaultValue,
|
||||
setOverlayStateValue,
|
||||
|
||||
@@ -34,7 +34,6 @@ export function usePersistence<S>(
|
||||
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
setInternalValue(defaultValue);
|
||||
|
||||
async function load() {
|
||||
const value = await getData(key);
|
||||
|
||||
@@ -3,6 +3,7 @@ import useApiFilter from "@/hooks/use-api-filter";
|
||||
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
||||
import { useTimezone } from "@/hooks/use-date-utils";
|
||||
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { RecordingStartingPoint } from "@/types/record";
|
||||
import {
|
||||
@@ -32,6 +33,8 @@ export default function Events() {
|
||||
"alert",
|
||||
);
|
||||
|
||||
const [showReviewed, setShowReviewed] = usePersistence("showReviewed", false);
|
||||
|
||||
const [recording, setRecording] =
|
||||
useOverlayState<RecordingStartingPoint>("recording");
|
||||
|
||||
@@ -69,10 +72,12 @@ export default function Events() {
|
||||
useApiFilter<ReviewFilter>();
|
||||
|
||||
useSearchEffect("group", (reviewGroup) => {
|
||||
if (config && reviewGroup) {
|
||||
if (config && reviewGroup && reviewGroup != "default") {
|
||||
const group = config.camera_groups[reviewGroup];
|
||||
const isBirdseyeOnly =
|
||||
group.cameras.length == 1 && group.cameras[0] == "birdseye";
|
||||
|
||||
if (group) {
|
||||
if (group && !isBirdseyeOnly) {
|
||||
setReviewFilter({
|
||||
...reviewFilter,
|
||||
cameras: group.cameras,
|
||||
@@ -204,14 +209,14 @@ export default function Events() {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (reviewFilter?.showReviewed != 1) {
|
||||
if (!showReviewed) {
|
||||
return current.filter((seg) => !seg.has_been_reviewed);
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
// only refresh when severity or filter changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [severity, reviewFilter, reviewItems?.all.length]);
|
||||
}, [severity, reviewFilter, showReviewed, reviewItems?.all.length]);
|
||||
|
||||
// review summary
|
||||
|
||||
@@ -434,6 +439,8 @@ export default function Events() {
|
||||
filter={reviewFilter}
|
||||
severity={severity ?? "alert"}
|
||||
startTime={startTime}
|
||||
showReviewed={showReviewed ?? false}
|
||||
setShowReviewed={setShowReviewed}
|
||||
setSeverity={setSeverity}
|
||||
markItemAsReviewed={markItemAsReviewed}
|
||||
markAllItemsAsReviewed={markAllItemsAsReviewed}
|
||||
|
||||
@@ -12,10 +12,14 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DeleteClipType, Export } from "@/types/export";
|
||||
import axios from "axios";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { LuFolderX } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
function Exports() {
|
||||
@@ -63,12 +67,26 @@ function Exports() {
|
||||
|
||||
const onHandleRename = useCallback(
|
||||
(id: string, update: string) => {
|
||||
axios.patch(`export/${id}/${update}`).then((response) => {
|
||||
if (response.status == 200) {
|
||||
setDeleteClip(undefined);
|
||||
mutate();
|
||||
}
|
||||
});
|
||||
axios
|
||||
.patch(`export/${id}/${encodeURIComponent(update)}`)
|
||||
.then((response) => {
|
||||
if (response.status == 200) {
|
||||
setDeleteClip(undefined);
|
||||
mutate();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response?.data?.message) {
|
||||
toast.error(
|
||||
`Failed to rename export: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to rename export: ${error.message}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
@@ -76,9 +94,12 @@ function Exports() {
|
||||
// Viewing
|
||||
|
||||
const [selected, setSelected] = useState<Export>();
|
||||
const [selectedAspect, setSelectedAspect] = useState(0.0);
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
|
||||
<Toaster closeButton={true} />
|
||||
|
||||
<AlertDialog
|
||||
open={deleteClip != undefined}
|
||||
onOpenChange={() => setDeleteClip(undefined)}
|
||||
@@ -111,15 +132,27 @@ function Exports() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-7xl">
|
||||
<DialogTitle>{selected?.name}</DialogTitle>
|
||||
<DialogContent
|
||||
className={cn("max-w-[80%]", isMobile && "landscape:max-w-[60%]")}
|
||||
>
|
||||
<DialogTitle className="capitalize">
|
||||
{selected?.name?.replaceAll("_", " ")}
|
||||
</DialogTitle>
|
||||
<video
|
||||
className="size-full rounded-lg md:rounded-2xl"
|
||||
className={cn(
|
||||
"size-full rounded-lg md:rounded-2xl",
|
||||
selectedAspect < 1.5 && "aspect-video h-full",
|
||||
)}
|
||||
playsInline
|
||||
preload="auto"
|
||||
autoPlay
|
||||
controls
|
||||
muted
|
||||
onLoadedData={(e) =>
|
||||
setSelectedAspect(
|
||||
e.currentTarget.videoWidth / e.currentTarget.videoHeight,
|
||||
)
|
||||
}
|
||||
>
|
||||
<source
|
||||
src={`${baseUrl}${selected?.video_path?.replace("/media/frigate/", "")}`}
|
||||
|
||||
@@ -113,7 +113,7 @@ function Live() {
|
||||
) : (
|
||||
<LiveDashboardView
|
||||
cameras={cameras}
|
||||
cameraGroup={cameraGroup}
|
||||
cameraGroup={cameraGroup ?? "default"}
|
||||
includeBirdseye={includesBirdseye}
|
||||
onSelectCamera={setSelectedCameraName}
|
||||
fullscreen={fullscreen}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
FaSortAmountDown,
|
||||
FaSortAmountUp,
|
||||
} from "react-icons/fa";
|
||||
import { LuFolderX } from "react-icons/lu";
|
||||
import { PiSlidersHorizontalFill } from "react-icons/pi";
|
||||
import useSWR from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
@@ -240,96 +241,104 @@ export default function SubmitPlus() {
|
||||
<PlusSortSelector selectedSort={sort} setSelectedSort={setSort} />
|
||||
</div>
|
||||
<div className="no-scrollbar flex size-full flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
|
||||
<div className="grid w-full gap-2 p-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<Dialog
|
||||
open={upload != undefined}
|
||||
onOpenChange={(open) => (!open ? setUpload(undefined) : null)}
|
||||
>
|
||||
<DialogContent className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Submit To Frigate+</DialogTitle>
|
||||
<DialogDescription>
|
||||
Objects in locations you want to avoid are not false
|
||||
positives. Submitting them as false positives will confuse the
|
||||
model.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<img
|
||||
className={`w-full ${grow} bg-black`}
|
||||
src={`${baseUrl}api/events/${upload?.id}/snapshot.jpg`}
|
||||
alt={`${upload?.label}`}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setUpload(undefined)}>Cancel</Button>
|
||||
<Button
|
||||
className="bg-success"
|
||||
onClick={() => onSubmitToPlus(false)}
|
||||
>
|
||||
This is a {upload?.label}
|
||||
</Button>
|
||||
<Button
|
||||
className="text-white"
|
||||
variant="destructive"
|
||||
onClick={() => onSubmitToPlus(true)}
|
||||
>
|
||||
This is not a {upload?.label}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{events?.map((event) => {
|
||||
if (event.data.type != "object" || event.plus_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="relative flex aspect-video w-full cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl"
|
||||
onClick={() => setUpload(event)}
|
||||
>
|
||||
<div className="absolute left-0 top-2 z-40">
|
||||
<Tooltip>
|
||||
<div className="flex">
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mx-3 pb-1 text-sm text-white">
|
||||
<Chip
|
||||
className={`z-0 flex items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500`}
|
||||
>
|
||||
{[event.label].map((object) => {
|
||||
return getIconForLabel(
|
||||
object,
|
||||
"size-3 text-white",
|
||||
);
|
||||
})}
|
||||
<div className="text-xs">
|
||||
{Math.round(event.data.score * 100)}%
|
||||
</div>
|
||||
</Chip>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
<TooltipContent className="capitalize">
|
||||
{[event.label]
|
||||
.map((text) => capitalizeFirstLetter(text))
|
||||
.sort()
|
||||
.join(", ")
|
||||
.replaceAll("-verified", "")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{isValidating ? (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
) : events?.length === 0 ? (
|
||||
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
||||
<LuFolderX className="size-16" />
|
||||
No snapshots found
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid w-full gap-2 p-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<Dialog
|
||||
open={upload != undefined}
|
||||
onOpenChange={(open) => (!open ? setUpload(undefined) : null)}
|
||||
>
|
||||
<DialogContent className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Submit To Frigate+</DialogTitle>
|
||||
<DialogDescription>
|
||||
Objects in locations you want to avoid are not false
|
||||
positives. Submitting them as false positives will confuse
|
||||
the model.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<img
|
||||
className="aspect-video h-full rounded-lg object-contain md:rounded-2xl"
|
||||
src={`${baseUrl}api/events/${event.id}/snapshot.jpg`}
|
||||
loading="lazy"
|
||||
className={`w-full ${grow} bg-black`}
|
||||
src={`${baseUrl}api/events/${upload?.id}/snapshot.jpg`}
|
||||
alt={`${upload?.label}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!isValidating && !isDone && <div ref={lastEventRef} />}
|
||||
{isValidating && <ActivityIndicator />}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setUpload(undefined)}>Cancel</Button>
|
||||
<Button
|
||||
className="bg-success"
|
||||
onClick={() => onSubmitToPlus(false)}
|
||||
>
|
||||
This is a {upload?.label}
|
||||
</Button>
|
||||
<Button
|
||||
className="text-white"
|
||||
variant="destructive"
|
||||
onClick={() => onSubmitToPlus(true)}
|
||||
>
|
||||
This is not a {upload?.label}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{events?.map((event) => {
|
||||
if (event.data.type != "object" || event.plus_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="relative flex aspect-video w-full cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl"
|
||||
onClick={() => setUpload(event)}
|
||||
>
|
||||
<div className="absolute left-0 top-2 z-40">
|
||||
<Tooltip>
|
||||
<div className="flex">
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mx-3 pb-1 text-sm text-white">
|
||||
<Chip
|
||||
className={`z-0 flex items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500`}
|
||||
>
|
||||
{[event.label].map((object) => {
|
||||
return getIconForLabel(
|
||||
object,
|
||||
"size-3 text-white",
|
||||
);
|
||||
})}
|
||||
<div className="text-xs">
|
||||
{Math.round(event.data.score * 100)}%
|
||||
</div>
|
||||
</Chip>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
<TooltipContent className="capitalize">
|
||||
{[event.label]
|
||||
.map((text) => capitalizeFirstLetter(text))
|
||||
.sort()
|
||||
.join(", ")
|
||||
.replaceAll("-verified", "")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<img
|
||||
className="aspect-video h-full rounded-lg object-contain md:rounded-2xl"
|
||||
src={`${baseUrl}api/events/${event.id}/snapshot.jpg`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!isValidating && !isDone && <div ref={lastEventRef} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -656,9 +665,12 @@ function PlusSortSelector({
|
||||
<div className="flex items-center justify-evenly p-2">
|
||||
<Button
|
||||
variant="select"
|
||||
disabled={!currentSort}
|
||||
onClick={() => {
|
||||
setSelectedSort(`${currentSort}_${currentDir}`);
|
||||
setOpen(false);
|
||||
if (currentSort) {
|
||||
setSelectedSort(`${currentSort}_${currentDir}`);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
|
||||
@@ -39,5 +39,7 @@ export type RecordingStartingPoint = {
|
||||
severity: ReviewSeverity;
|
||||
};
|
||||
|
||||
export type RecordingPlayerError = "stalled" | "startup";
|
||||
|
||||
export const ASPECT_VERTICAL_LAYOUT = 1.5;
|
||||
export const ASPECT_WIDE_LAYOUT = 2;
|
||||
|
||||
@@ -35,7 +35,6 @@ export type ReviewFilter = {
|
||||
zones?: string[];
|
||||
before?: number;
|
||||
after?: number;
|
||||
showReviewed?: 0 | 1;
|
||||
showAll?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -10,9 +10,13 @@ import {
|
||||
FaDog,
|
||||
FaFedex,
|
||||
FaFire,
|
||||
FaFootballBall,
|
||||
FaMotorcycle,
|
||||
FaMouse,
|
||||
FaUps,
|
||||
FaUsps,
|
||||
} from "react-icons/fa";
|
||||
import { GiDeer, GiHummingbird } from "react-icons/gi";
|
||||
import { GiDeer, GiHummingbird, GiPolarBear, GiSailboat } from "react-icons/gi";
|
||||
import { LuBox, LuLassoSelect } from "react-icons/lu";
|
||||
import * as LuIcons from "react-icons/lu";
|
||||
import { MdRecordVoiceOver } from "react-icons/md";
|
||||
@@ -27,10 +31,15 @@ export function getIconForLabel(label: string, className?: string) {
|
||||
}
|
||||
|
||||
switch (label) {
|
||||
// objects
|
||||
case "bear":
|
||||
return <GiPolarBear key={label} className={className} />;
|
||||
case "bicycle":
|
||||
return <FaBicycle key={label} className={className} />;
|
||||
case "bird":
|
||||
return <GiHummingbird key={label} className={className} />;
|
||||
case "boat":
|
||||
return <GiSailboat key={label} className={className} />;
|
||||
case "bus":
|
||||
return <FaBus key={label} className={className} />;
|
||||
case "car":
|
||||
@@ -46,10 +55,16 @@ export function getIconForLabel(label: string, className?: string) {
|
||||
return <FaDog key={label} className={className} />;
|
||||
case "fire_alarm":
|
||||
return <FaFire key={label} className={className} />;
|
||||
case "motorcycle":
|
||||
return <FaMotorcycle key={label} className={className} />;
|
||||
case "mouse":
|
||||
return <FaMouse key={label} className={className} />;
|
||||
case "package":
|
||||
return <LuBox key={label} className={className} />;
|
||||
case "person":
|
||||
return <BsPersonWalking key={label} className={className} />;
|
||||
case "sports_ball":
|
||||
return <FaFootballBall key={label} className={className} />;
|
||||
// audio
|
||||
case "crying":
|
||||
case "laughter":
|
||||
@@ -64,6 +79,8 @@ export function getIconForLabel(label: string, className?: string) {
|
||||
return <FaFedex key={label} className={className} />;
|
||||
case "ups":
|
||||
return <FaUps key={label} className={className} />;
|
||||
case "usps":
|
||||
return <FaUsps key={label} className={className} />;
|
||||
default:
|
||||
return <LuLassoSelect key={label} className={className} />;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ type EventViewProps = {
|
||||
filter?: ReviewFilter;
|
||||
severity: ReviewSeverity;
|
||||
startTime?: number;
|
||||
showReviewed: boolean;
|
||||
setShowReviewed: (show: boolean) => void;
|
||||
setSeverity: (severity: ReviewSeverity) => void;
|
||||
markItemAsReviewed: (review: ReviewSegment) => void;
|
||||
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
|
||||
@@ -78,6 +80,8 @@ export default function EventView({
|
||||
filter,
|
||||
severity,
|
||||
startTime,
|
||||
showReviewed,
|
||||
setShowReviewed,
|
||||
setSeverity,
|
||||
markItemAsReviewed,
|
||||
markAllItemsAsReviewed,
|
||||
@@ -108,7 +112,7 @@ export default function EventView({
|
||||
return { alert: 0, detection: 0, significant_motion: 0 };
|
||||
}
|
||||
|
||||
if (filter?.showReviewed == 1) {
|
||||
if (showReviewed) {
|
||||
return {
|
||||
alert: summary.total_alert ?? 0,
|
||||
detection: summary.total_detection ?? 0,
|
||||
@@ -121,7 +125,7 @@ export default function EventView({
|
||||
significant_motion: summary.total_motion - summary.reviewed_motion,
|
||||
};
|
||||
}
|
||||
}, [filter, reviewSummary]);
|
||||
}, [filter, showReviewed, reviewSummary]);
|
||||
|
||||
// review interaction
|
||||
|
||||
@@ -358,6 +362,8 @@ export default function EventView({
|
||||
filter={filter}
|
||||
motionOnly={motionOnly}
|
||||
filterList={reviewFilterList}
|
||||
showReviewed={showReviewed}
|
||||
setShowReviewed={setShowReviewed}
|
||||
onUpdateFilter={updateFilter}
|
||||
setMotionOnly={setMotionOnly}
|
||||
/>
|
||||
@@ -957,7 +963,7 @@ function MotionReview({
|
||||
);
|
||||
}
|
||||
|
||||
if (!relevantPreviews) {
|
||||
if (relevantPreviews == undefined) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
@@ -999,7 +1005,7 @@ function MotionReview({
|
||||
camera={camera.name}
|
||||
timeRange={currentTimeRange}
|
||||
startTime={previewStart}
|
||||
cameraPreviews={relevantPreviews || []}
|
||||
cameraPreviews={relevantPreviews}
|
||||
isScrubbing={scrubbing}
|
||||
onControllerReady={(controller) => {
|
||||
videoPlayersRef.current[camera.name] = controller;
|
||||
|
||||
@@ -418,6 +418,8 @@ export function RecordingView({
|
||||
filter={filter}
|
||||
motionOnly={false}
|
||||
filterList={reviewFilterList}
|
||||
showReviewed
|
||||
setShowReviewed={() => {}}
|
||||
onUpdateFilter={updateFilter}
|
||||
setMotionOnly={() => {}}
|
||||
/>
|
||||
@@ -699,32 +701,38 @@ function Timeline({
|
||||
<Skeleton className="size-full" />
|
||||
)
|
||||
) : (
|
||||
<div className="h-full overflow-auto bg-secondary">
|
||||
<div className="scrollbar-container h-full overflow-auto bg-secondary">
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-auto grid-cols-1 gap-4 overflow-auto p-4",
|
||||
"scrollbar-container grid h-auto grid-cols-1 gap-4 overflow-auto p-4",
|
||||
isMobile && "sm:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
{mainCameraReviewItems.map((review) => {
|
||||
if (review.severity == "significant_motion") {
|
||||
return;
|
||||
}
|
||||
{mainCameraReviewItems.length === 0 ? (
|
||||
<div className="mt-5 text-center text-primary">
|
||||
No events found for this time period.
|
||||
</div>
|
||||
) : (
|
||||
mainCameraReviewItems.map((review) => {
|
||||
if (review.severity === "significant_motion") {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReviewCard
|
||||
key={review.id}
|
||||
event={review}
|
||||
currentTime={currentTime}
|
||||
onClick={() => {
|
||||
manuallySetCurrentTime(
|
||||
review.start_time - REVIEW_PADDING,
|
||||
true,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<ReviewCard
|
||||
key={review.id}
|
||||
event={review}
|
||||
currentTime={currentTime}
|
||||
onClick={() => {
|
||||
manuallySetCurrentTime(
|
||||
review.start_time - REVIEW_PADDING,
|
||||
true,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function DraggableGridLayout({
|
||||
);
|
||||
|
||||
setPreferredLiveModes(newPreferredLiveModes);
|
||||
}, [cameras, config]);
|
||||
}, [cameras, config, windowVisible]);
|
||||
|
||||
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
|
||||
|
||||
|
||||
@@ -5,15 +5,18 @@ import { Button } from "@/components/ui/button";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
isDesktop,
|
||||
isFirefox,
|
||||
isIOS,
|
||||
isMobile,
|
||||
isSafari,
|
||||
useMobileOrientation,
|
||||
} from "react-device-detect";
|
||||
import { FaCompress, FaExpand } from "react-icons/fa";
|
||||
import { IoMdArrowBack } from "react-icons/io";
|
||||
import { LuPictureInPicture } from "react-icons/lu";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
||||
import useSWR from "swr";
|
||||
@@ -35,8 +38,17 @@ export default function LiveBirdseyeView({
|
||||
const [{ width: windowWidth, height: windowHeight }] =
|
||||
useResizeObserver(window);
|
||||
|
||||
// pip state
|
||||
|
||||
useEffect(() => {
|
||||
setPip(document.pictureInPictureElement != null);
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [document.pictureInPictureElement]);
|
||||
|
||||
// playback state
|
||||
|
||||
const [pip, setPip] = useState(false);
|
||||
const cameraAspectRatio = useMemo(() => {
|
||||
if (!config) {
|
||||
return 16 / 9;
|
||||
@@ -151,6 +163,23 @@ export default function LiveBirdseyeView({
|
||||
title={fullscreen ? "Close" : "Fullscreen"}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
{!isIOS && !isFirefox && config.birdseye.restream && (
|
||||
<CameraFeatureToggle
|
||||
className="p-2 md:p-0"
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={LuPictureInPicture}
|
||||
isActive={pip}
|
||||
title={pip ? "Close" : "Picture in Picture"}
|
||||
onClick={() => {
|
||||
if (!pip) {
|
||||
setPip(true);
|
||||
} else {
|
||||
document.exitPictureInPicture();
|
||||
setPip(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
@@ -177,6 +206,7 @@ export default function LiveBirdseyeView({
|
||||
birdseyeConfig={config.birdseye}
|
||||
liveMode={preferredLiveMode}
|
||||
containerRef={containerRef}
|
||||
pip={pip}
|
||||
/>
|
||||
</div>
|
||||
</TransformComponent>
|
||||
|
||||
@@ -372,7 +372,7 @@ export default function LiveCameraView({
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
)}
|
||||
{!isIOS && !isFirefox && (
|
||||
{!isIOS && !isFirefox && preferredLiveMode != "jsmpeg" && (
|
||||
<CameraFeatureToggle
|
||||
className="p-2 md:p-0"
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
@@ -452,8 +452,8 @@ export default function LiveCameraView({
|
||||
iOSCompatFullScreen={isIOS}
|
||||
preferredLiveMode={preferredLiveMode}
|
||||
pip={pip}
|
||||
setFullResolution={setFullResolution}
|
||||
containerRef={containerRef}
|
||||
setFullResolution={setFullResolution}
|
||||
onError={handleError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
|
||||
type LiveDashboardViewProps = {
|
||||
cameras: CameraConfig[];
|
||||
cameraGroup?: string;
|
||||
cameraGroup: string;
|
||||
includeBirdseye: boolean;
|
||||
onSelectCamera: (camera: string) => void;
|
||||
fullscreen: boolean;
|
||||
@@ -64,9 +64,32 @@ export default function LiveDashboardView({
|
||||
// recent events
|
||||
|
||||
const eventUpdate = useFrigateReviews();
|
||||
|
||||
const alertCameras = useMemo(() => {
|
||||
if (!config || cameraGroup == "default") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (includeBirdseye && cameras.length == 0) {
|
||||
return Object.values(config.cameras)
|
||||
.filter((cam) => cam.birdseye.enabled)
|
||||
.map((cam) => cam.name)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
return cameras
|
||||
.map((cam) => cam.name)
|
||||
.filter((cam) => config.camera_groups[cameraGroup]?.cameras.includes(cam))
|
||||
.join(",");
|
||||
}, [cameras, cameraGroup, config, includeBirdseye]);
|
||||
|
||||
const { data: allEvents, mutate: updateEvents } = useSWR<ReviewSegment[]>([
|
||||
"review",
|
||||
{ limit: 10, severity: "alert" },
|
||||
{
|
||||
limit: 10,
|
||||
severity: "alert",
|
||||
cameras: alertCameras,
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -110,33 +133,6 @@ export default function LiveDashboardView({
|
||||
[key: string]: LivePlayerMode;
|
||||
}>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!cameras) return;
|
||||
|
||||
const mseSupported =
|
||||
"MediaSource" in window || "ManagedMediaSource" in window;
|
||||
|
||||
const newPreferredLiveModes = cameras.reduce(
|
||||
(acc, camera) => {
|
||||
const isRestreamed =
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(
|
||||
camera.live.stream_name,
|
||||
);
|
||||
|
||||
if (!mseSupported) {
|
||||
acc[camera.name] = isRestreamed ? "webrtc" : "jsmpeg";
|
||||
} else {
|
||||
acc[camera.name] = isRestreamed ? "mse" : "jsmpeg";
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as { [key: string]: LivePlayerMode },
|
||||
);
|
||||
|
||||
setPreferredLiveModes(newPreferredLiveModes);
|
||||
}, [cameras, config]);
|
||||
|
||||
const [{ height: containerHeight }] = useResizeObserver(containerRef);
|
||||
|
||||
const hasScrollbar = useMemo(() => {
|
||||
@@ -190,6 +186,33 @@ export default function LiveDashboardView({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cameras) return;
|
||||
|
||||
const mseSupported =
|
||||
"MediaSource" in window || "ManagedMediaSource" in window;
|
||||
|
||||
const newPreferredLiveModes = cameras.reduce(
|
||||
(acc, camera) => {
|
||||
const isRestreamed =
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(
|
||||
camera.live.stream_name,
|
||||
);
|
||||
|
||||
if (!mseSupported) {
|
||||
acc[camera.name] = isRestreamed ? "webrtc" : "jsmpeg";
|
||||
} else {
|
||||
acc[camera.name] = isRestreamed ? "mse" : "jsmpeg";
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as { [key: string]: LivePlayerMode },
|
||||
);
|
||||
|
||||
setPreferredLiveModes(newPreferredLiveModes);
|
||||
}, [cameras, config, windowVisible]);
|
||||
|
||||
const cameraRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (!visibleCameraObserver.current) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "../../components/ui/select";
|
||||
|
||||
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
||||
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
||||
|
||||
export default function GeneralSettingsView() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@@ -53,6 +54,8 @@ export default function GeneralSettingsView() {
|
||||
|
||||
const [autoLive, setAutoLive] = usePersistence("autoLiveView", true);
|
||||
const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1);
|
||||
const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0);
|
||||
const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -89,6 +92,25 @@ export default function GeneralSettingsView() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-row items-center justify-start gap-2">
|
||||
<Switch
|
||||
id="images-only"
|
||||
checked={alertVideos}
|
||||
onCheckedChange={setAlertVideos}
|
||||
/>
|
||||
<Label className="cursor-pointer" htmlFor="images-only">
|
||||
Play Alert Videos
|
||||
</Label>
|
||||
</div>
|
||||
<div className="my-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
By default, recent alerts on the Live dashboard play as small
|
||||
looping videos. Disable this option to only show a static
|
||||
image of recent alerts on this device/browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-3 flex w-full flex-col space-y-6">
|
||||
@@ -142,6 +164,41 @@ export default function GeneralSettingsView() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
Calendar
|
||||
</Heading>
|
||||
|
||||
<div className="mt-2 space-y-6">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">First Weekday</div>
|
||||
<div className="my-2 text-sm text-muted-foreground">
|
||||
<p>The day that the weeks of the review calendar begin on.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={weekStartsOn?.toString()}
|
||||
onValueChange={(value) => setWeekStartsOn(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
{WEEK_STARTS_ON[weekStartsOn ?? 0]}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{WEEK_STARTS_ON.map((day, index) => (
|
||||
<SelectItem
|
||||
key={index}
|
||||
className="cursor-pointer"
|
||||
value={index.toString()}
|
||||
>
|
||||
{day}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user