forked from Github/frigate
Compare commits
36 Commits
v0.14.0-be
...
v0.14.0-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,9 @@ 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";
|
||||
|
||||
type AnimatedEventCardProps = {
|
||||
event: ReviewSegment;
|
||||
@@ -21,6 +24,7 @@ export function AnimatedEventCard({
|
||||
selectedGroup,
|
||||
}: AnimatedEventCardProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const apiHost = useApiHost();
|
||||
|
||||
const currentHour = useMemo(() => isCurrentHour(event.start_time), [event]);
|
||||
|
||||
@@ -57,7 +61,10 @@ export function AnimatedEventCard({
|
||||
|
||||
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 +80,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,32 +104,42 @@ 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"}
|
||||
/>
|
||||
) : (
|
||||
<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={() => {}}
|
||||
windowVisible={windowVisible}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
preload="auto"
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
disableRemotePlayback
|
||||
loop
|
||||
>
|
||||
<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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,12 @@ 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 { DeleteClipType, Export } from "@/types/export";
|
||||
import axios from "axios";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { LuFolderX } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
function Exports() {
|
||||
@@ -63,12 +65,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],
|
||||
);
|
||||
@@ -79,6 +95,8 @@ function Exports() {
|
||||
|
||||
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)}
|
||||
|
||||
@@ -113,7 +113,7 @@ function Live() {
|
||||
) : (
|
||||
<LiveDashboardView
|
||||
cameras={cameras}
|
||||
cameraGroup={cameraGroup}
|
||||
cameraGroup={cameraGroup ?? "default"}
|
||||
includeBirdseye={includesBirdseye}
|
||||
onSelectCamera={setSelectedCameraName}
|
||||
fullscreen={fullscreen}
|
||||
|
||||
@@ -656,9 +656,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,10 +701,10 @@ 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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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