Merge remote-tracking branch 'origin/release-0.10.0' into gstreamer

This commit is contained in:
YS 2022-02-09 15:02:31 +03:00
commit 1f280111e8
36 changed files with 30940 additions and 514 deletions

View File

@ -22,3 +22,5 @@ RUN pip3 install pylint black
# Install Node 14 # Install Node 14
RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - \ RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - \
&& apt-get install -y nodejs && apt-get install -y nodejs
RUN npm install -g npm@latest

View File

@ -61,8 +61,8 @@ cameras:
roles: roles:
- detect - detect
detect: detect:
width: 640 width: 896
height: 480 height: 672
fps: 7 fps: 7
``` ```

View File

@ -159,8 +159,11 @@ detect:
enabled: True enabled: True
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate) # Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate)
max_disappeared: 25 max_disappeared: 25
# Optional: Frequency for running detection on stationary objects (default: 10x the frame rate) # Optional: Frequency for running detection on stationary objects (default: shown below)
stationary_interval: 50 # When set to 0, object detection will never be run on stationary objects. If set to 10, it will be run on every 10th frame.
stationary_interval: 0
# Optional: Number of frames without a position change for an object to be considered stationary (default: shown below)
stationary_threshold: 10
# Optional: Object configuration # Optional: Object configuration
# NOTE: Can be overridden at the camera level # NOTE: Can be overridden at the camera level
@ -224,6 +227,9 @@ motion:
record: record:
# Optional: Enable recording (default: shown below) # Optional: Enable recording (default: shown below)
enabled: False enabled: False
# Optional: Number of minutes to wait between cleanup runs (default: shown below)
# This can be used to reduce the frequency of deleting recording segments from disk if you want to minimize i/o
expire_interval: 60
# Optional: Retention settings for recording # Optional: Retention settings for recording
retain: retain:
# Optional: Number of days to retain recordings regardless of events (default: shown below) # Optional: Number of days to retain recordings regardless of events (default: shown below)
@ -264,7 +270,7 @@ record:
# here, the segments will already be gone by the time this mode is applied. # here, the segments will already be gone by the time this mode is applied.
# For example, if the camera retain mode is "motion", the segments without motion are # For example, if the camera retain mode is "motion", the segments without motion are
# never stored, so setting the mode to "all" here won't bring them back. # never stored, so setting the mode to "all" here won't bring them back.
mode: active_objects mode: motion
# Optional: Per object retention days # Optional: Per object retention days
objects: objects:
person: 15 person: 15
@ -377,7 +383,7 @@ cameras:
# camera. # camera.
front_steps: front_steps:
# Required: List of x,y coordinates to define the polygon of the zone. # Required: List of x,y coordinates to define the polygon of the zone.
# NOTE: Coordinates can be generated at https://www.image-map.net/ # NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box.
coordinates: 545,1077,747,939,788,805 coordinates: 545,1077,747,939,788,805
# Optional: List of objects that can trigger this zone (default: all tracked objects) # Optional: List of objects that can trigger this zone (default: all tracked objects)
objects: objects:

View File

@ -97,15 +97,3 @@ processes:
| 0 N/A N/A 12827 C ffmpeg 417MiB | | 0 N/A N/A 12827 C ffmpeg 417MiB |
+-----------------------------------------------------------------------------+ +-----------------------------------------------------------------------------+
``` ```
To further improve performance, you can set ffmpeg to skip frames in the output,
using the fps filter:
```yaml
output_args:
- -filter:v
- fps=fps=5
```
This setting, for example, allows Frigate to consume my 10-15fps camera streams on
my relatively low powered Haswell machine with relatively low cpu usage.

View File

@ -3,7 +3,9 @@ id: zones
title: Zones title: Zones
--- ---
Zones allow you to define a specific area of the frame and apply additional filters for object types so you can determine whether or not an object is within a particular area. Zones cannot have the same name as a camera. If desired, a single zone can include multiple cameras if you have multiple cameras covering the same area by configuring zones with the same name for each camera. Zones allow you to define a specific area of the frame and apply additional filters for object types so you can determine whether or not an object is within a particular area. Presence in a zone is evaluated based on the bottom center of the bounding box for the object. It does not matter how much of the bounding box overlaps with the zone.
Zones cannot have the same name as a camera. If desired, a single zone can include multiple cameras if you have multiple cameras covering the same area by configuring zones with the same name for each camera.
During testing, enable the Zones option for the debug feed so you can adjust as needed. The zone line will increase in thickness when any object enters the zone. During testing, enable the Zones option for the debug feed so you can adjust as needed. The zone line will increase in thickness when any object enters the zone.

View File

@ -62,6 +62,8 @@ cameras:
roles: roles:
- detect - detect
- rtmp - rtmp
rtmp:
enabled: False # <-- RTMP should be disabled if your stream is not H264
detect: detect:
width: 1280 # <---- update for your camera's resolution width: 1280 # <---- update for your camera's resolution
height: 720 # <---- update for your camera's resolution height: 720 # <---- update for your camera's resolution
@ -71,7 +73,9 @@ cameras:
At this point you should be able to start Frigate and see the the video feed in the UI. At this point you should be able to start Frigate and see the the video feed in the UI.
If you get a green image from the camera, this means ffmpeg was not able to get the video feed from your camera. Check the logs for error messages from ffmpeg. The default ffmpeg arguments are designed to work with RTSP cameras that support TCP connections. FFmpeg arguments for other types of cameras can be found [here](/configuration/camera_specific). If you get a green image from the camera, this means ffmpeg was not able to get the video feed from your camera. Check the logs for error messages from ffmpeg. The default ffmpeg arguments are designed to work with H264 RTSP cameras that support TCP connections. If you do not have H264 cameras, make sure you have disabled RTMP. It is possible to enable it, but you must tell ffmpeg to re-encode the video with customized output args.
FFmpeg arguments for other types of cameras can be found [here](/configuration/camera_specific).
### Step 5: Configure hardware acceleration (optional) ### Step 5: Configure hardware acceleration (optional)

View File

@ -25,6 +25,30 @@ automation:
when: '{{trigger.payload_json["after"]["start_time"]|int}}' when: '{{trigger.payload_json["after"]["start_time"]|int}}'
``` ```
Note that iOS devices support live previews of cameras by adding a camera entity id to the message data.
```yaml
automation:
- alias: Security_Frigate_Notifications
description: ""
trigger:
- platform: mqtt
topic: frigate/events
payload: new
value_template: "{{ value_json.type }}"
action:
- service: notify.mobile_app_iphone
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
tag: '{{trigger.payload_json["after"]["id"]}}'
when: '{{trigger.payload_json["after"]["start_time"]|int}}'
entity_id: camera.{{trigger.payload_json["after"]["camera"]}}
mode: single
```
## Conditions ## Conditions
Conditions with the `before` and `after` values allow a high degree of customization for automations. Conditions with the `before` and `after` values allow a high degree of customization for automations.

View File

@ -177,6 +177,15 @@ HassOS users can install via the addon repository.
6. Start the addon container 6. Start the addon container
7. (not for proxy addon) If you are using hardware acceleration for ffmpeg, you may need to disable "Protection mode" 7. (not for proxy addon) If you are using hardware acceleration for ffmpeg, you may need to disable "Protection mode"
There are several versions of the addon available:
| Addon Version | Description |
| ------------------------------ | ---------------------------------------------------------- |
| Frigate NVR | Current release with protection mode on |
| Frigate NVR (Full Access) | Current release with the option to disable protection mode |
| Frigate NVR Beta | Beta release with protection mode on |
| Frigate NVR Beta (Full Access) | Beta release with the option to disable protection mode |
## Home Assistant Supervised ## Home Assistant Supervised
:::tip :::tip

View File

@ -45,11 +45,14 @@ that card.
## Configuration ## Configuration
When configuring the integration, you will be asked for the following parameters: When configuring the integration, you will be asked for the `URL` of your frigate instance which is the URL you use to access Frigate in the browser. This may look like `http://<host>:5000/`. If you are using HassOS with the addon, the URL should be one of the following depending on which addon version you are using. Note that if you are using the Proxy Addon, you do NOT point the integration at the proxy URL. Just enter the URL used to access frigate directly from your network.
| Variable | Description | | Addon Version | URL |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------ | -------------------------------------- |
| URL | The `URL` of your frigate instance, the URL you use to access Frigate in the browser. This may look like `http://<host>:5000/`. If you are using HassOS with the addon, the URL should be `http://ccab4aaf-frigate:5000` (or `http://ccab4aaf-frigate-beta:5000` if your are using the beta version of the addon). Live streams required port 1935, see [RTMP streams](#streams) | | Frigate NVR | `http://ccab4aaf-frigate:5000` |
| Frigate NVR (Full Access) | `http://ccab4aaf-frigate-fa:5000` |
| Frigate NVR Beta | `http://ccab4aaf-frigate-beta:5000` |
| Frigate NVR Beta (Full Access) | `http://ccab4aaf-frigate-fa-beta:5000` |
<a name="options"></a> <a name="options"></a>

View File

@ -55,7 +55,9 @@ Message published for each changed event. The first message is published when th
"entered_zones": ["yard", "driveway"], "entered_zones": ["yard", "driveway"],
"thumbnail": null, "thumbnail": null,
"has_snapshot": false, "has_snapshot": false,
"has_clip": false "has_clip": false,
"motionless_count": 0, // number of frames the object has been motionless
"position_changes": 2 // number of times the object has changed position
}, },
"after": { "after": {
"id": "1607123955.475377-mxklsc", "id": "1607123955.475377-mxklsc",
@ -75,7 +77,9 @@ Message published for each changed event. The first message is published when th
"entered_zones": ["yard", "driveway"], "entered_zones": ["yard", "driveway"],
"thumbnail": null, "thumbnail": null,
"has_snapshot": false, "has_snapshot": false,
"has_clip": false "has_clip": false,
"motionless_count": 0, // number of frames the object has been motionless
"position_changes": 2 // number of times the object has changed position
} }
} }
``` ```

14859
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,13 +12,13 @@
"clear": "docusaurus clear" "clear": "docusaurus clear"
}, },
"dependencies": { "dependencies": {
"@docusaurus/core": "^2.0.0-beta.6", "@docusaurus/core": "^2.0.0-beta.15",
"@docusaurus/preset-classic": "^2.0.0-beta.6", "@docusaurus/preset-classic": "^2.0.0-beta.15",
"@mdx-js/react": "^1.6.21", "@mdx-js/react": "^1.6.22",
"clsx": "^1.1.1", "clsx": "^1.1.1",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"react": "^16.8.4", "react": "^16.14.0",
"react-dom": "^16.8.4" "react-dom": "^16.14.0"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
@ -31,5 +31,8 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"devDependencies": {
"@types/react": "^16.14.0"
} }
} }

View File

@ -80,9 +80,7 @@ class RetainModeEnum(str, Enum):
class RetainConfig(FrigateBaseModel): class RetainConfig(FrigateBaseModel):
default: float = Field(default=10, title="Default retention period.") default: float = Field(default=10, title="Default retention period.")
mode: RetainModeEnum = Field( mode: RetainModeEnum = Field(default=RetainModeEnum.motion, title="Retain mode.")
default=RetainModeEnum.active_objects, title="Retain mode."
)
objects: Dict[str, float] = Field( objects: Dict[str, float] = Field(
default_factory=dict, title="Object retention period." default_factory=dict, title="Object retention period."
) )
@ -111,6 +109,10 @@ class RecordRetainConfig(FrigateBaseModel):
class RecordConfig(FrigateBaseModel): class RecordConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable record on all cameras.") enabled: bool = Field(default=False, title="Enable record on all cameras.")
expire_interval: int = Field(
default=60,
title="Number of minutes to wait between cleanup runs.",
)
# deprecated - to be removed in a future version # deprecated - to be removed in a future version
retain_days: Optional[float] = Field(title="Recording retention period in days.") retain_days: Optional[float] = Field(title="Recording retention period in days.")
retain: RecordRetainConfig = Field( retain: RecordRetainConfig = Field(
@ -179,7 +181,13 @@ class DetectConfig(FrigateBaseModel):
title="Maximum number of frames the object can dissapear before detection ends." title="Maximum number of frames the object can dissapear before detection ends."
) )
stationary_interval: Optional[int] = Field( stationary_interval: Optional[int] = Field(
default=0,
title="Frame interval for checking stationary objects.", title="Frame interval for checking stationary objects.",
ge=0,
)
stationary_threshold: Optional[int] = Field(
default=10,
title="Number of frames without a position change for an object to be considered stationary",
ge=1, ge=1,
) )
@ -542,10 +550,8 @@ class CameraLiveConfig(FrigateBaseModel):
class CameraConfig(FrigateBaseModel): class CameraConfig(FrigateBaseModel):
name: Optional[str] = Field(title="Camera name.") name: Optional[str] = Field(title="Camera name.", regex="^[a-zA-Z0-9_-]+$")
ffmpeg: Optional[CameraFfmpegConfig] = Field( ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.")
title="FFmpeg configuration for the camera."
)
gstreamer: Optional[CameraGStreamerConfig] = Field( gstreamer: Optional[CameraGStreamerConfig] = Field(
title="GStreamer configuration for the camera." title="GStreamer configuration for the camera."
) )
@ -927,11 +933,6 @@ class FrigateConfig(FrigateBaseModel):
if camera_config.detect.max_disappeared is None: if camera_config.detect.max_disappeared is None:
camera_config.detect.max_disappeared = max_disappeared camera_config.detect.max_disappeared = max_disappeared
# Default stationary_interval configuration
stationary_interval = camera_config.detect.fps * 10
if camera_config.detect.stationary_interval is None:
camera_config.detect.stationary_interval = stationary_interval
# FFMPEG input substitution # FFMPEG input substitution
if "ffmpeg" in camera_config: if "ffmpeg" in camera_config:
for input in camera_config.ffmpeg.inputs: for input in camera_config.ffmpeg.inputs:
@ -1008,14 +1009,18 @@ class FrigateConfig(FrigateBaseModel):
camera_config.record.retain.days = camera_config.record.retain_days camera_config.record.retain.days = camera_config.record.retain_days
# warning if the higher level record mode is potentially more restrictive than the events # warning if the higher level record mode is potentially more restrictive than the events
rank_map = {
RetainModeEnum.all: 0,
RetainModeEnum.motion: 1,
RetainModeEnum.active_objects: 2,
}
if ( if (
camera_config.record.retain.days != 0 camera_config.record.retain.days != 0
and camera_config.record.retain.mode != RetainModeEnum.all and rank_map[camera_config.record.retain.mode]
and camera_config.record.events.retain.mode > rank_map[camera_config.record.events.retain.mode]
!= camera_config.record.retain.mode
): ):
logger.warning( logger.warning(
f"Recording retention is configured for {camera_config.record.retain.mode} and event retention is configured for {camera_config.record.events.retain.mode}. The more restrictive retention policy will be applied." f"{name}: Recording retention is configured for {camera_config.record.retain.mode} and event retention is configured for {camera_config.record.events.retain.mode}. The more restrictive retention policy will be applied."
) )
# generage the ffmpeg commands # generage the ffmpeg commands
camera_config.create_decoder_cmds() camera_config.create_decoder_cmds()

View File

@ -15,6 +15,16 @@ from frigate.models import Event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def should_update_db(prev_event, current_event):
return (
prev_event["top_score"] != current_event["top_score"]
or prev_event["entered_zones"] != current_event["entered_zones"]
or prev_event["thumbnail"] != current_event["thumbnail"]
or prev_event["has_clip"] != current_event["has_clip"]
or prev_event["has_snapshot"] != current_event["has_snapshot"]
)
class EventProcessor(threading.Thread): class EventProcessor(threading.Thread):
def __init__( def __init__(
self, config, camera_processes, event_queue, event_processed_queue, stop_event self, config, camera_processes, event_queue, event_processed_queue, stop_event
@ -48,7 +58,9 @@ class EventProcessor(threading.Thread):
if event_type == "start": if event_type == "start":
self.events_in_process[event_data["id"]] = event_data self.events_in_process[event_data["id"]] = event_data
elif event_type == "update": elif event_type == "update" and should_update_db(
self.events_in_process[event_data["id"]], event_data
):
self.events_in_process[event_data["id"]] = event_data self.events_in_process[event_data["id"]] = event_data
# TODO: this will generate a lot of db activity possibly # TODO: this will generate a lot of db activity possibly
if event_data["has_clip"] or event_data["has_snapshot"]: if event_data["has_clip"] or event_data["has_snapshot"]:

View File

@ -133,6 +133,8 @@ def delete_event(id):
if event.has_snapshot: if event.has_snapshot:
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg") media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
media.unlink(missing_ok=True) media.unlink(missing_ok=True)
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
media.unlink(missing_ok=True)
if event.has_clip: if event.has_clip:
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4") media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
media.unlink(missing_ok=True) media.unlink(missing_ok=True)
@ -362,7 +364,13 @@ def best(camera_name, label):
box_size = 300 box_size = 300
box = best_object.get("box", (0, 0, box_size, box_size)) box = best_object.get("box", (0, 0, box_size, box_size))
region = calculate_region( region = calculate_region(
best_frame.shape, box[0], box[1], box[2], box[3], box_size, multiplier=1.1 best_frame.shape,
box[0],
box[1],
box[2],
box[3],
box_size,
multiplier=1.1,
) )
best_frame = best_frame[region[1] : region[3], region[0] : region[2]] best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
@ -516,12 +524,17 @@ def recordings(camera_name):
FROM C2 FROM C2
WHERE cnt = 0 WHERE cnt = 0
) )
SELECT id, label, camera, top_score, start_time, end_time
FROM event
WHERE camera = ? AND end_time IS NULL
UNION ALL
SELECT MIN(id) as id, label, camera, MAX(top_score) as top_score, MIN(ts) AS start_time, max(ts) AS end_time SELECT MIN(id) as id, label, camera, MAX(top_score) as top_score, MIN(ts) AS start_time, max(ts) AS end_time
FROM C3 FROM C3
GROUP BY label, grpnum GROUP BY label, grpnum
ORDER BY start_time;""", ORDER BY start_time;""",
camera_name, camera_name,
camera_name, camera_name,
camera_name,
) )
event: Event event: Event
@ -709,7 +722,15 @@ def vod_event(id):
end_ts = ( end_ts = (
datetime.now().timestamp() if event.end_time is None else event.end_time datetime.now().timestamp() if event.end_time is None else event.end_time
) )
return vod_ts(event.camera, event.start_time, end_ts) vod_response = vod_ts(event.camera, event.start_time, end_ts)
# If the recordings are not found, set has_clip to false
if (
type(vod_response) == tuple
and len(vod_response) == 2
and vod_response[1] == 404
):
Event.update(has_clip=False).where(Event.id == id).execute()
return vod_response
duration = int((event.end_time - event.start_time) * 1000) duration = int((event.end_time - event.start_time) * 1000)
return jsonify( return jsonify(

View File

@ -101,14 +101,13 @@ class TrackedObject:
return median(scores) return median(scores)
def update(self, current_frame_time, obj_data): def update(self, current_frame_time, obj_data):
significant_update = False thumb_update = False
zone_change = False significant_change = False
self.obj_data.update(obj_data)
# if the object is not in the current frame, add a 0.0 to the score history # if the object is not in the current frame, add a 0.0 to the score history
if self.obj_data["frame_time"] != current_frame_time: if obj_data["frame_time"] != current_frame_time:
self.score_history.append(0.0) self.score_history.append(0.0)
else: else:
self.score_history.append(self.obj_data["score"]) self.score_history.append(obj_data["score"])
# only keep the last 10 scores # only keep the last 10 scores
if len(self.score_history) > 10: if len(self.score_history) > 10:
self.score_history = self.score_history[-10:] self.score_history = self.score_history[-10:]
@ -122,24 +121,24 @@ class TrackedObject:
if not self.false_positive: if not self.false_positive:
# determine if this frame is a better thumbnail # determine if this frame is a better thumbnail
if self.thumbnail_data is None or is_better_thumbnail( if self.thumbnail_data is None or is_better_thumbnail(
self.thumbnail_data, self.obj_data, self.camera_config.frame_shape self.thumbnail_data, obj_data, self.camera_config.frame_shape
): ):
self.thumbnail_data = { self.thumbnail_data = {
"frame_time": self.obj_data["frame_time"], "frame_time": obj_data["frame_time"],
"box": self.obj_data["box"], "box": obj_data["box"],
"area": self.obj_data["area"], "area": obj_data["area"],
"region": self.obj_data["region"], "region": obj_data["region"],
"score": self.obj_data["score"], "score": obj_data["score"],
} }
significant_update = True thumb_update = True
# check zones # check zones
current_zones = [] current_zones = []
bottom_center = (self.obj_data["centroid"][0], self.obj_data["box"][3]) bottom_center = (obj_data["centroid"][0], obj_data["box"][3])
# check each zone # check each zone
for name, zone in self.camera_config.zones.items(): for name, zone in self.camera_config.zones.items():
# if the zone is not for this object type, skip # if the zone is not for this object type, skip
if len(zone.objects) > 0 and not self.obj_data["label"] in zone.objects: if len(zone.objects) > 0 and not obj_data["label"] in zone.objects:
continue continue
contour = zone.contour contour = zone.contour
# check if the object is in the zone # check if the object is in the zone
@ -150,12 +149,29 @@ class TrackedObject:
if name not in self.entered_zones: if name not in self.entered_zones:
self.entered_zones.append(name) self.entered_zones.append(name)
if not self.false_positive:
# if the zones changed, signal an update # if the zones changed, signal an update
if not self.false_positive and set(self.current_zones) != set(current_zones): if set(self.current_zones) != set(current_zones):
zone_change = True significant_change = True
# if the position changed, signal an update
if self.obj_data["position_changes"] != obj_data["position_changes"]:
significant_change = True
# if the motionless_count crosses the stationary threshold
if (
self.obj_data["motionless_count"]
> self.camera_config.detect.stationary_threshold
):
significant_change = True
# update at least once per minute
if self.obj_data["frame_time"] - self.previous["frame_time"] > 60:
significant_change = True
self.obj_data.update(obj_data)
self.current_zones = current_zones self.current_zones = current_zones
return (significant_update, zone_change) return (thumb_update, significant_change)
def to_dict(self, include_thumbnail: bool = False): def to_dict(self, include_thumbnail: bool = False):
snapshot_time = ( snapshot_time = (
@ -178,6 +194,7 @@ class TrackedObject:
"area": self.obj_data["area"], "area": self.obj_data["area"],
"region": self.obj_data["region"], "region": self.obj_data["region"],
"motionless_count": self.obj_data["motionless_count"], "motionless_count": self.obj_data["motionless_count"],
"position_changes": self.obj_data["position_changes"],
"current_zones": self.current_zones.copy(), "current_zones": self.current_zones.copy(),
"entered_zones": self.entered_zones.copy(), "entered_zones": self.entered_zones.copy(),
"has_clip": self.has_clip, "has_clip": self.has_clip,
@ -266,7 +283,13 @@ class TrackedObject:
box = self.thumbnail_data["box"] box = self.thumbnail_data["box"]
box_size = 300 box_size = 300
region = calculate_region( region = calculate_region(
best_frame.shape, box[0], box[1], box[2], box[3], box_size, multiplier=1.1 best_frame.shape,
box[0],
box[1],
box[2],
box[3],
box_size,
multiplier=1.1,
) )
best_frame = best_frame[region[1] : region[3], region[0] : region[2]] best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
@ -459,11 +482,11 @@ class CameraState:
for id in updated_ids: for id in updated_ids:
updated_obj = tracked_objects[id] updated_obj = tracked_objects[id]
significant_update, zone_change = updated_obj.update( thumb_update, significant_update = updated_obj.update(
frame_time, current_detections[id] frame_time, current_detections[id]
) )
if significant_update: if thumb_update:
# ensure this frame is stored in the cache # ensure this frame is stored in the cache
if ( if (
updated_obj.thumbnail_data["frame_time"] == frame_time updated_obj.thumbnail_data["frame_time"] == frame_time
@ -473,13 +496,13 @@ class CameraState:
updated_obj.last_updated = frame_time updated_obj.last_updated = frame_time
# if it has been more than 5 seconds since the last publish # if it has been more than 5 seconds since the last thumb update
# and the last update is greater than the last publish or # and the last update is greater than the last publish or
# the object has changed zones # the object has changed significantly
if ( if (
frame_time - updated_obj.last_published > 5 frame_time - updated_obj.last_published > 5
and updated_obj.last_updated > updated_obj.last_published and updated_obj.last_updated > updated_obj.last_published
) or zone_change: ) or significant_update:
# call event handlers # call event handlers
for c in self.callbacks["update"]: for c in self.callbacks["update"]:
c(self.name, updated_obj, frame_time) c(self.name, updated_obj, frame_time)
@ -732,6 +755,10 @@ class TrackedObjectProcessor(threading.Thread):
if not snapshot_config.enabled: if not snapshot_config.enabled:
return False return False
# object never changed position
if obj.obj_data["position_changes"] == 0:
return False
# if there are required zones and there is no overlap # if there are required zones and there is no overlap
required_zones = snapshot_config.required_zones required_zones = snapshot_config.required_zones
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones): if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
@ -752,6 +779,10 @@ class TrackedObjectProcessor(threading.Thread):
if not record_config.enabled: if not record_config.enabled:
return False return False
# object never changed position
if obj.obj_data["position_changes"] == 0:
return False
# If there are required zones and there is no overlap # If there are required zones and there is no overlap
required_zones = record_config.events.required_zones required_zones = record_config.events.required_zones
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones): if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
@ -773,6 +804,10 @@ class TrackedObjectProcessor(threading.Thread):
return True return True
def should_mqtt_snapshot(self, camera, obj: TrackedObject): def should_mqtt_snapshot(self, camera, obj: TrackedObject):
# object never changed position
if obj.obj_data["position_changes"] == 0:
return False
# if there are required zones and there is no overlap # if there are required zones and there is no overlap
required_zones = self.config.cameras[camera].mqtt.required_zones required_zones = self.config.cameras[camera].mqtt.required_zones
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones): if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):

View File

@ -20,7 +20,9 @@ class ObjectTracker:
def __init__(self, config: DetectConfig): def __init__(self, config: DetectConfig):
self.tracked_objects = {} self.tracked_objects = {}
self.disappeared = {} self.disappeared = {}
self.positions = {}
self.max_disappeared = config.max_disappeared self.max_disappeared = config.max_disappeared
self.detect_config = config
def register(self, index, obj): def register(self, index, obj):
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
@ -28,24 +30,84 @@ class ObjectTracker:
obj["id"] = id obj["id"] = id
obj["start_time"] = obj["frame_time"] obj["start_time"] = obj["frame_time"]
obj["motionless_count"] = 0 obj["motionless_count"] = 0
obj["position_changes"] = 0
self.tracked_objects[id] = obj self.tracked_objects[id] = obj
self.disappeared[id] = 0 self.disappeared[id] = 0
self.positions[id] = {
"xmins": [],
"ymins": [],
"xmaxs": [],
"ymaxs": [],
"xmin": 0,
"ymin": 0,
"xmax": self.detect_config.width,
"ymax": self.detect_config.height,
}
def deregister(self, id): def deregister(self, id):
del self.tracked_objects[id] del self.tracked_objects[id]
del self.disappeared[id] del self.disappeared[id]
# tracks the current position of the object based on the last N bounding boxes
# returns False if the object has moved outside its previous position
def update_position(self, id, box):
position = self.positions[id]
position_box = (
position["xmin"],
position["ymin"],
position["xmax"],
position["ymax"],
)
xmin, ymin, xmax, ymax = box
iou = intersection_over_union(position_box, box)
# if the iou drops below the threshold
# assume the object has moved to a new position and reset the computed box
if iou < 0.6:
self.positions[id] = {
"xmins": [xmin],
"ymins": [ymin],
"xmaxs": [xmax],
"ymaxs": [ymax],
"xmin": xmin,
"ymin": ymin,
"xmax": xmax,
"ymax": ymax,
}
return False
# if there are less than stationary_threshold entries for the position, add the bounding box
# and recompute the position box
if len(position["xmins"]) < self.detect_config.stationary_threshold:
position["xmins"].append(xmin)
position["ymins"].append(ymin)
position["xmaxs"].append(xmax)
position["ymaxs"].append(ymax)
# by using percentiles here, we hopefully remove outliers
position["xmin"] = np.percentile(position["xmins"], 15)
position["ymin"] = np.percentile(position["ymins"], 15)
position["xmax"] = np.percentile(position["xmaxs"], 85)
position["ymax"] = np.percentile(position["ymaxs"], 85)
return True
def update(self, id, new_obj): def update(self, id, new_obj):
self.disappeared[id] = 0 self.disappeared[id] = 0
if ( # update the motionless count if the object has not moved to a new position
intersection_over_union(self.tracked_objects[id]["box"], new_obj["box"]) if self.update_position(id, new_obj["box"]):
> 0.9
):
self.tracked_objects[id]["motionless_count"] += 1 self.tracked_objects[id]["motionless_count"] += 1
else: else:
self.tracked_objects[id]["motionless_count"] = 0 self.tracked_objects[id]["motionless_count"] = 0
self.tracked_objects[id]["position_changes"] += 1
self.tracked_objects[id].update(new_obj) self.tracked_objects[id].update(new_obj)
def update_frame_times(self, frame_time):
for id in self.tracked_objects.keys():
self.tracked_objects[id]["frame_time"] = frame_time
self.tracked_objects[id]["motionless_count"] += 1
def match_and_update(self, frame_time, new_objects): def match_and_update(self, frame_time, new_objects):
# group by name # group by name
new_object_groups = defaultdict(lambda: []) new_object_groups = defaultdict(lambda: [])

View File

@ -242,7 +242,7 @@ class RecordingMaintainer(threading.Thread):
[ [
o o
for o in frame[1] for o in frame[1]
if not o["false_positive"] and o["motionless_count"] > 0 if not o["false_positive"] and o["motionless_count"] == 0
] ]
) )
@ -297,6 +297,7 @@ class RecordingMaintainer(threading.Thread):
end_time=end_time.timestamp(), end_time=end_time.timestamp(),
duration=duration, duration=duration,
motion=motion_count, motion=motion_count,
# TODO: update this to store list of active objects at some point
objects=active_count, objects=active_count,
) )
except Exception as e: except Exception as e:
@ -509,7 +510,8 @@ class RecordingCleanup(threading.Thread):
oldest_timestamp = datetime.datetime.now().timestamp() oldest_timestamp = datetime.datetime.now().timestamp()
except FileNotFoundError: except FileNotFoundError:
logger.warning(f"Unable to find file from recordings database: {p}") logger.warning(f"Unable to find file from recordings database: {p}")
oldest_timestamp = datetime.datetime.now().timestamp() Recordings.delete().where(Recordings.id == oldest_recording.id).execute()
return
logger.debug(f"Oldest recording in the db: {oldest_timestamp}") logger.debug(f"Oldest recording in the db: {oldest_timestamp}")
process = sp.run( process = sp.run(
@ -560,7 +562,7 @@ class RecordingCleanup(threading.Thread):
# self.sync_recordings() # self.sync_recordings()
# Expire tmp clips every minute, recordings and clean directories every hour. # Expire tmp clips every minute, recordings and clean directories every hour.
for counter in itertools.cycle(range(60)): for counter in itertools.cycle(range(self.config.record.expire_interval)):
if self.stop_event.wait(60): if self.stop_event.wait(60):
logger.info(f"Exiting recording cleanup...") logger.info(f"Exiting recording cleanup...")
break break

View File

@ -1245,6 +1245,30 @@ class TestConfig(unittest.TestCase):
runtime_config = frigate_config.runtime_config runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].snapshots.retain.default == 1.5 assert runtime_config.cameras["back"].snapshots.retain.default == 1.5
def test_fails_on_bad_camera_name(self):
config = {
"mqtt": {"host": "mqtt"},
"snapshots": {"retain": {"default": 1.5}},
"cameras": {
"back camer#": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
}
},
}
frigate_config = FrigateConfig(**config)
self.assertRaises(
ValidationError, lambda: frigate_config.runtime_config.cameras
)
@mock.patch( @mock.patch(
"frigate.config.gst_discover", "frigate.config.gst_discover",
return_value={"video": "video/x-h265"}, return_value={"video": "video/x-h265"},
@ -1282,6 +1306,7 @@ class TestConfig(unittest.TestCase):
} }
}, },
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.dict(exclude_unset=True)

View File

@ -574,6 +574,9 @@ class EventsPerSecond:
# compute the (approximate) events in the last n seconds # compute the (approximate) events in the last n seconds
now = datetime.datetime.now().timestamp() now = datetime.datetime.now().timestamp()
seconds = min(now - self._start, last_n_seconds) seconds = min(now - self._start, last_n_seconds)
# avoid divide by zero
if seconds == 0:
seconds = 1
return ( return (
len([t for t in self._timestamps if t > (now - last_n_seconds)]) / seconds len([t for t in self._timestamps if t > (now - last_n_seconds)]) / seconds
) )
@ -629,26 +632,6 @@ def load_labels(path, encoding="utf-8"):
return {index: line.strip() for index, line in enumerate(lines)} return {index: line.strip() for index, line in enumerate(lines)}
def load_labels(path, encoding="utf-8"):
"""Loads labels from file (with or without index numbers).
Args:
path: path to label file.
encoding: label file encoding.
Returns:
Dictionary mapping indices to labels.
"""
with open(path, "r", encoding=encoding) as f:
lines = f.readlines()
if not lines:
return {}
if lines[0].split(" ", maxsplit=1)[0].isdigit():
pairs = [line.split(" ", maxsplit=1) for line in lines]
return {int(index): label.strip() for index, label in pairs}
else:
return {index: line.strip() for index, line in enumerate(lines)}
class FrameManager(ABC): class FrameManager(ABC):
@abstractmethod @abstractmethod
def create(self, name, size) -> AnyStr: def create(self, name, size) -> AnyStr:

View File

@ -3,6 +3,7 @@ import itertools
import logging import logging
import multiprocessing as mp import multiprocessing as mp
import queue import queue
import random
import signal import signal
import subprocess as sp import subprocess as sp
import threading import threading
@ -151,10 +152,10 @@ def capture_frames(
try: try:
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size) frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
except Exception as e: except Exception as e:
logger.info(f"{camera_name}: ffmpeg sent a broken frame. {e}") logger.error(f"{camera_name}: Unable to read frames from ffmpeg process.")
if ffmpeg_process.poll() != None: if ffmpeg_process.poll() != None:
logger.info( logger.error(
f"{camera_name}: ffmpeg process is not running. exiting capture thread..." f"{camera_name}: ffmpeg process is not running. exiting capture thread..."
) )
frame_manager.delete(frame_name) frame_manager.delete(frame_name)
@ -219,12 +220,11 @@ class CameraWatchdog(threading.Thread):
if not self.capture_thread.is_alive(): if not self.capture_thread.is_alive():
self.logger.error( self.logger.error(
f"FFMPEG process crashed unexpectedly for {self.camera_name}." f"Ffmpeg process crashed unexpectedly for {self.camera_name}."
) )
self.logger.error( self.logger.error(
"The following ffmpeg logs include the last 100 lines prior to exit." "The following ffmpeg logs include the last 100 lines prior to exit."
) )
self.logger.error("You may have invalid args defined for this camera.")
self.logpipe.dump() self.logpipe.dump()
self.start_ffmpeg_detect() self.start_ffmpeg_detect()
elif now - self.capture_thread.current_frame.value > 20: elif now - self.capture_thread.current_frame.value > 20:
@ -468,6 +468,8 @@ def process_frames(
fps_tracker = EventsPerSecond() fps_tracker = EventsPerSecond()
fps_tracker.start() fps_tracker.start()
startup_scan_counter = 0
while not stop_event.is_set(): while not stop_event.is_set():
if exit_on_empty and frame_queue.empty(): if exit_on_empty and frame_queue.empty():
logger.info(f"Exiting track_objects...") logger.info(f"Exiting track_objects...")
@ -488,19 +490,15 @@ def process_frames(
logger.info(f"{camera_name}: frame {frame_time} is not in memory store.") logger.info(f"{camera_name}: frame {frame_time} is not in memory store.")
continue continue
if not detection_enabled.value:
fps.value = fps_tracker.eps()
object_tracker.match_and_update(frame_time, [])
detected_objects_queue.put(
(camera_name, frame_time, object_tracker.tracked_objects, [], [])
)
detection_fps.value = object_detector.fps.eps()
frame_manager.close(f"{camera_name}{frame_time}")
continue
# look for motion # look for motion
motion_boxes = motion_detector.detect(frame) motion_boxes = motion_detector.detect(frame)
regions = []
# if detection is disabled
if not detection_enabled.value:
object_tracker.match_and_update(frame_time, [])
else:
# get stationary object ids # get stationary object ids
# check every Nth frame for stationary objects # check every Nth frame for stationary objects
# disappeared objects are not stationary # disappeared objects are not stationary
@ -508,10 +506,13 @@ def process_frames(
stationary_object_ids = [ stationary_object_ids = [
obj["id"] obj["id"]
for obj in object_tracker.tracked_objects.values() for obj in object_tracker.tracked_objects.values()
# if there hasn't been motion for 10 frames # if there hasn't been motion for N frames
if obj["motionless_count"] >= 10 if obj["motionless_count"] >= detect_config.stationary_threshold
# and it isn't due for a periodic check # and it isn't due for a periodic check
and obj["motionless_count"] % detect_config.stationary_interval != 0 and (
detect_config.stationary_interval == 0
or obj["motionless_count"] % detect_config.stationary_interval != 0
)
# and it hasn't disappeared # and it hasn't disappeared
and object_tracker.disappeared[obj["id"]] == 0 and object_tracker.disappeared[obj["id"]] == 0
# and it doesn't overlap with any current motion boxes # and it doesn't overlap with any current motion boxes
@ -531,16 +532,45 @@ def process_frames(
region_min_size = max(model_shape[0], model_shape[1]) region_min_size = max(model_shape[0], model_shape[1])
# compute regions # compute regions
regions = [ regions = [
calculate_region(frame_shape, a[0], a[1], a[2], a[3], region_min_size, multiplier=1.2) calculate_region(
frame_shape,
a[0],
a[1],
a[2],
a[3],
region_min_size,
multiplier=random.uniform(1.2, 1.5),
)
for a in combined_boxes for a in combined_boxes
] ]
# consolidate regions with heavy overlap # consolidate regions with heavy overlap
regions = [ regions = [
calculate_region(frame_shape, a[0], a[1], a[2], a[3], region_min_size, multiplier=1.0) calculate_region(
frame_shape, a[0], a[1], a[2], a[3], region_min_size, multiplier=1.0
)
for a in reduce_boxes(regions, 0.4) for a in reduce_boxes(regions, 0.4)
] ]
# if starting up, get the next startup scan region
if startup_scan_counter < 9:
ymin = int(frame_shape[0] / 3 * startup_scan_counter / 3)
ymax = int(frame_shape[0] / 3 + ymin)
xmin = int(frame_shape[1] / 3 * startup_scan_counter / 3)
xmax = int(frame_shape[1] / 3 + xmin)
regions.append(
calculate_region(
frame_shape,
xmin,
ymin,
xmax,
ymax,
region_min_size,
multiplier=1.2,
)
)
startup_scan_counter += 1
# resize regions and detect # resize regions and detect
# seed with stationary objects # seed with stationary objects
detections = [ detections = [
@ -554,6 +584,7 @@ def process_frames(
for obj in object_tracker.tracked_objects.values() for obj in object_tracker.tracked_objects.values()
if obj["id"] in stationary_object_ids if obj["id"] in stationary_object_ids
] ]
for region in regions: for region in regions:
detections.extend( detections.extend(
detect( detect(
@ -569,7 +600,7 @@ def process_frames(
######### #########
# merge objects, check for clipped objects and look again up to 4 times # merge objects, check for clipped objects and look again up to 4 times
######### #########
refining = True refining = len(regions) > 0
refine_count = 0 refine_count = 0
while refining and refine_count < 4: while refining and refine_count < 4:
refining = False refining = False
@ -591,13 +622,17 @@ def process_frames(
idxs = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4) idxs = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4)
for index in idxs: for index in idxs:
index = index if isinstance(index, np.int32) else index[0] obj = group[index[0]]
obj = group[index]
if clipped(obj, frame_shape): if clipped(obj, frame_shape):
box = obj[2] box = obj[2]
# calculate a new region that will hopefully get the entire object # calculate a new region that will hopefully get the entire object
region = calculate_region( region = calculate_region(
frame_shape, box[0], box[1], box[2], box[3], region_min_size frame_shape,
box[0],
box[1],
box[2],
box[3],
region_min_size,
) )
regions.append(region) regions.append(region)
@ -625,6 +660,9 @@ def process_frames(
## drop detections that overlap too much ## drop detections that overlap too much
consolidated_detections = [] consolidated_detections = []
# if detection was run on this frame, consolidate
if len(regions) > 0:
# group by name # group by name
detected_object_groups = defaultdict(lambda: []) detected_object_groups = defaultdict(lambda: [])
for detection in detections: for detection in detections:
@ -660,9 +698,11 @@ def process_frames(
consolidated_detections.append( consolidated_detections.append(
sorted_by_area[current_detection_idx] sorted_by_area[current_detection_idx]
) )
# now that we have refined our detections, we need to track objects # now that we have refined our detections, we need to track objects
object_tracker.match_and_update(frame_time, consolidated_detections) object_tracker.match_and_update(frame_time, consolidated_detections)
# else, just update the frame times for the stationary objects
else:
object_tracker.update_frame_times(frame_time)
# add to the queue if not full # add to the queue if not full
if detected_objects_queue.full(): if detected_objects_queue.full():

14794
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,329 @@
import { h } from 'preact';
import { useEffect, useState, useCallback, useMemo, useRef } from 'preact/hooks';
import ArrowRight from '../icons/ArrowRight';
import ArrowRightDouble from '../icons/ArrowRightDouble';
const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf();
const Calender = ({ onChange, calenderRef, close }) => {
const keyRef = useRef([]);
const date = new Date();
const year = date.getFullYear();
const month = date.getMonth();
const daysMap = useMemo(() => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], []);
const monthMap = useMemo(
() => [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
],
[]
);
const [state, setState] = useState({
getMonthDetails: [],
year,
month,
selectedDay: null,
timeRange: { before: null, after: null },
monthDetails: null,
});
const getNumberOfDays = useCallback((year, month) => {
return 40 - new Date(year, month, 40).getDate();
}, []);
const getDayDetails = useCallback(
(args) => {
const date = args.index - args.firstDay;
const day = args.index % 7;
let prevMonth = args.month - 1;
let prevYear = args.year;
if (prevMonth < 0) {
prevMonth = 11;
prevYear--;
}
const prevMonthNumberOfDays = getNumberOfDays(prevYear, prevMonth);
const _date = (date < 0 ? prevMonthNumberOfDays + date : date % args.numberOfDays) + 1;
const month = date < 0 ? -1 : date >= args.numberOfDays ? 1 : 0;
const timestamp = new Date(args.year, args.month, _date).getTime();
return {
date: _date,
day,
month,
timestamp,
dayString: daysMap[day],
};
},
[getNumberOfDays, daysMap]
);
const getMonthDetails = useCallback(
(year, month) => {
const firstDay = new Date(year, month).getDay();
const numberOfDays = getNumberOfDays(year, month);
const monthArray = [];
const rows = 6;
let currentDay = null;
let index = 0;
const cols = 7;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
currentDay = getDayDetails({
index,
numberOfDays,
firstDay,
year,
month,
});
monthArray.push(currentDay);
index++;
}
}
return monthArray;
},
[getNumberOfDays, getDayDetails]
);
useEffect(() => {
setState((prev) => ({ ...prev, selectedDay: todayTimestamp, monthDetails: getMonthDetails(year, month) }));
}, [year, month, getMonthDetails]);
useEffect(() => {
// add refs for keyboard navigation
if (state.monthDetails) {
keyRef.current = keyRef.current.slice(0, state.monthDetails.length);
}
// set today date in focus for keyboard navigation
const todayDate = new Date(todayTimestamp).getDate();
keyRef.current.find((t) => t.tabIndex === todayDate)?.focus();
}, [state.monthDetails]);
const isCurrentDay = (day) => day.timestamp === todayTimestamp;
const isSelectedRange = useCallback(
(day) => {
if (!state.timeRange.after || !state.timeRange.before) return;
return day.timestamp < state.timeRange.before && day.timestamp >= state.timeRange.after;
},
[state.timeRange]
);
const isFirstDayInRange = useCallback(
(day) => {
if (isCurrentDay(day)) return;
return state.timeRange.after === day.timestamp;
},
[state.timeRange.after]
);
const isLastDayInRange = useCallback(
(day) => {
return state.timeRange.before === new Date(day.timestamp).setHours(24, 0, 0, 0);
},
[state.timeRange.before]
);
const getMonthStr = useCallback(
(month) => {
return monthMap[Math.max(Math.min(11, month), 0)] || 'Month';
},
[monthMap]
);
const onDateClick = (day) => {
const { before, after } = state.timeRange;
let timeRange = { before: null, after: null };
// user has selected a date < after, reset values
if (after === null || day.timestamp < after) {
timeRange = { before: new Date(day.timestamp).setHours(24, 0, 0, 0), after: day.timestamp };
}
// user has selected a date > after
if (after !== null && before !== new Date(day.timestamp).setHours(24, 0, 0, 0) && day.timestamp > after) {
timeRange = {
after,
before:
day.timestamp >= todayTimestamp
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
: new Date(day.timestamp).setHours(24, 0, 0, 0),
};
}
// reset values
if (before === new Date(day.timestamp).setHours(24, 0, 0, 0)) {
timeRange = { before: null, after: null };
}
setState((prev) => ({
...prev,
timeRange,
selectedDay: day.timestamp,
}));
if (onChange) {
onChange(timeRange.after ? { before: timeRange.before / 1000, after: timeRange.after / 1000 } : ['all']);
}
};
const setYear = useCallback(
(offset) => {
const year = state.year + offset;
const month = state.month;
setState((prev) => {
return {
...prev,
year,
monthDetails: getMonthDetails(year, month),
};
});
},
[state.year, state.month, getMonthDetails]
);
const setMonth = (offset) => {
let year = state.year;
let month = state.month + offset;
if (month === -1) {
month = 11;
year--;
} else if (month === 12) {
month = 0;
year++;
}
setState((prev) => {
return {
...prev,
year,
month,
monthDetails: getMonthDetails(year, month),
};
});
};
const handleKeydown = (e, day, index) => {
if ((keyRef.current && e.key === 'Enter') || e.keyCode === 32) {
e.preventDefault();
day.month === 0 && onDateClick(day);
}
if (e.key === 'ArrowLeft') {
index > 0 && keyRef.current[index - 1].focus();
}
if (e.key === 'ArrowRight') {
index < 41 && keyRef.current[index + 1].focus();
}
if (e.key === 'ArrowUp') {
e.preventDefault();
index > 6 && keyRef.current[index - 7].focus();
}
if (e.key === 'ArrowDown') {
e.preventDefault();
index < 36 && keyRef.current[index + 7].focus();
}
if (e.key === 'Escape') {
close();
}
};
const renderCalendar = () => {
const days =
state.monthDetails &&
state.monthDetails.map((day, idx) => {
return (
<div
onClick={() => onDateClick(day)}
onkeydown={(e) => handleKeydown(e, day, idx)}
ref={(ref) => (keyRef.current[idx] = ref)}
tabIndex={day.month === 0 ? day.date : null}
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
}
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''}
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
key={idx}
>
<div className="font-light">
<span className="text-gray-400">{day.date}</span>
</div>
</div>
);
});
return (
<div>
<div className="w-full flex justify-start flex-shrink">
{['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'].map((d, i) => (
<div key={i} className="w-12 text-xs font-light text-center">
{d}
</div>
))}
</div>
<div className="w-full h-56">{days}</div>
</div>
);
};
return (
<div className="select-none w-96 flex flex-shrink" ref={calenderRef}>
<div className="py-4 px-6">
<div className="flex items-center">
<div className="w-1/6 relative flex justify-around">
<div
tabIndex={100}
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
onClick={() => setYear(-1)}
>
<ArrowRightDouble className="h-2/6 transform rotate-180 " />
</div>
</div>
<div className="w-1/6 relative flex justify-around ">
<div
tabIndex={101}
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
onClick={() => setMonth(-1)}
>
<ArrowRight className="h-2/6 transform rotate-180 red" />
</div>
</div>
<div className="w-1/3">
<div className="text-3xl text-center text-gray-200 font-extralight">{state.year}</div>
<div className="text-center text-gray-400 font-extralight">{getMonthStr(state.month)}</div>
</div>
<div className="w-1/6 relative flex justify-around ">
<div
tabIndex={102}
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
onClick={() => setMonth(1)}
>
<ArrowRight className="h-2/6" />
</div>
</div>
<div className="w-1/6 relative flex justify-around " tabIndex={104} onClick={() => setYear(1)}>
<div className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800">
<ArrowRightDouble className="h-2/6" />
</div>
</div>
</div>
<div className="mt-3">{renderCalendar()}</div>
</div>
</div>
);
};
export default Calender;

View File

@ -0,0 +1,162 @@
import { h } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
export const DateFilterOptions = [
{
label: 'All',
value: ['all'],
},
{
label: 'Today',
value: {
//Before
before: new Date().setHours(24, 0, 0, 0) / 1000,
//After
after: new Date().setHours(0, 0, 0, 0) / 1000,
},
},
{
label: 'Yesterday',
value: {
//Before
before: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(24, 0, 0, 0) / 1000,
//After
after: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(0, 0, 0, 0) / 1000,
},
},
{
label: 'Last 7 Days',
value: {
//Before
before: new Date().setHours(24, 0, 0, 0) / 1000,
//After
after: new Date(new Date().setDate(new Date().getDate() - 7)).setHours(0, 0, 0, 0) / 1000,
},
},
{
label: 'This Month',
value: {
//Before
before: new Date().setHours(24, 0, 0, 0) / 1000,
//After
after: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
},
},
{
label: 'Last Month',
value: {
//Before
before: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
//After
after: new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1).getTime() / 1000,
},
},
{
label: 'Custom Range',
value: 'custom_range',
},
];
export default function DatePicker({
helpText,
keyboardType = 'text',
inputRef,
label,
leadingIcon: LeadingIcon,
onBlur,
onChangeText,
onFocus,
readonly,
trailingIcon: TrailingIcon,
value: propValue = '',
...props
}) {
const [isFocused, setFocused] = useState(false);
const [value, setValue] = useState(propValue);
useEffect(() => {
if (propValue !== value) {
setValue(propValue);
}
}, [propValue, setValue, value]);
const handleFocus = useCallback(
(event) => {
setFocused(true);
onFocus && onFocus(event);
},
[onFocus]
);
const handleBlur = useCallback(
(event) => {
setFocused(false);
onBlur && onBlur(event);
},
[onBlur]
);
const handleChange = useCallback(
(event) => {
const { value } = event.target;
setValue(value);
onChangeText && onChangeText(value);
},
[onChangeText, setValue]
);
const onClick = (e) => {
props.onclick(e);
};
const labelMoved = isFocused || value !== '';
return (
<div className="w-full">
{props.children}
<div
className={`bg-gray-100 dark:bg-gray-700 rounded rounded-b-none border-gray-400 border-b p-1 pl-4 pr-3 ${
isFocused ? 'border-blue-500 dark:border-blue-500' : ''
}`}
ref={inputRef}
>
<label
className="flex space-x-2 items-center"
data-testid={`label-${label.toLowerCase().replace(/[^\w]+/g, '_')}`}
>
{LeadingIcon ? (
<div className="w-10 h-full">
<LeadingIcon />
</div>
) : null}
<div className="relative w-full">
<input
className="h-6 mt-6 w-full bg-transparent focus:outline-none focus:ring-0"
type={keyboardType}
readOnly
onBlur={handleBlur}
onFocus={handleFocus}
onInput={handleChange}
tabIndex="0"
onClick={onClick}
value={propValue}
{...props}
/>
<div
className={`absolute top-3 transition transform text-gray-600 dark:text-gray-400 ${
labelMoved ? 'text-xs -translate-y-2' : ''
} ${isFocused ? 'text-blue-500 dark:text-blue-500' : ''}`}
>
<p>{label}</p>
</div>
</div>
{TrailingIcon ? (
<div className="w-10 h-10">
<TrailingIcon />
</div>
) : null}
</label>
</div>
{helpText ? <div className="text-xs pl-3 pt-1">{helpText}</div> : null}
</div>
);
}

View File

@ -21,7 +21,10 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate, se
events={recording.events} events={recording.events}
selected={recording.date === selectedDate} selected={recording.date === selectedDate}
> >
{recording.recordings.slice().reverse().map((item, i) => ( {recording.recordings
.slice()
.reverse()
.map((item, i) => (
<div className="mb-2 w-full"> <div className="mb-2 w-full">
<div <div
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${ className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
@ -35,7 +38,10 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate, se
</div> </div>
<div className="flex-1 text-right">{item.events.length} Events</div> <div className="flex-1 text-right">{item.events.length} Events</div>
</div> </div>
{item.events.slice().reverse().map((event) => ( {item.events
.slice()
.reverse()
.map((event) => (
<EventCard camera={camera} event={event} delay={item.delay} /> <EventCard camera={camera} event={event} delay={item.delay} />
))} ))}
</div> </div>
@ -83,8 +89,10 @@ export function ExpandableList({ title, events = 0, children, selected = false }
export function EventCard({ camera, event, delay }) { export function EventCard({ camera, event, delay }) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const start = fromUnixTime(event.start_time); const start = fromUnixTime(event.start_time);
const end = fromUnixTime(event.end_time); let duration = 0;
const duration = addSeconds(new Date(0), differenceInSeconds(end, start)); if (event.end_time) {
duration = addSeconds(new Date(0), differenceInSeconds(fromUnixTime(event.end_time), start));
}
const position = differenceInSeconds(start, startOfHour(start)); const position = differenceInSeconds(start, startOfHour(start));
const offset = Object.entries(delay) const offset = Object.entries(delay)
.map(([p, d]) => (position > p ? d : 0)) .map(([p, d]) => (position > p ? d : 0))
@ -102,7 +110,9 @@ export function EventCard({ camera, event, delay }) {
<div className="flex-1"> <div className="flex-1">
<div className="text-2xl text-white leading-tight capitalize">{event.label}</div> <div className="text-2xl text-white leading-tight capitalize">{event.label}</div>
<div className="text-xs md:text-normal text-gray-300">Start: {format(start, 'HH:mm:ss')}</div> <div className="text-xs md:text-normal text-gray-300">Start: {format(start, 'HH:mm:ss')}</div>
<div className="text-xs md:text-normal text-gray-300">Duration: {format(duration, 'mm:ss')}</div> <div className="text-xs md:text-normal text-gray-300">
Duration: {duration ? format(duration, 'mm:ss') : 'In Progress'}
</div>
</div> </div>
<div className="text-lg text-white text-right leading-tight">{(event.top_score * 100).toFixed(1)}%</div> <div className="text-lg text-white text-right leading-tight">{(event.top_score * 100).toFixed(1)}%</div>
</div> </div>

View File

@ -27,7 +27,7 @@ export default function RelativeModal({
const handleKeydown = useCallback( const handleKeydown = useCallback(
(event) => { (event) => {
const focusable = ref.current.querySelectorAll('[tabindex]'); const focusable = ref.current && ref.current.querySelectorAll('[tabindex]');
if (event.key === 'Tab' && focusable.length) { if (event.key === 'Tab' && focusable.length) {
if (event.shiftKey && document.activeElement === focusable[0]) { if (event.shiftKey && document.activeElement === focusable[0]) {
focusable[focusable.length - 1].focus(); focusable[focusable.length - 1].focus();
@ -69,14 +69,15 @@ export default function RelativeModal({
let newTop = top; let newTop = top;
let newLeft = left; let newLeft = left;
// too far right
if (newLeft + width + WINDOW_PADDING >= windowWidth - WINDOW_PADDING) {
newLeft = windowWidth - width - WINDOW_PADDING;
}
// too far left // too far left
else if (left < WINDOW_PADDING) { if (left < WINDOW_PADDING) {
newLeft = WINDOW_PADDING; newLeft = WINDOW_PADDING;
} }
// too far right
else if (newLeft + width + WINDOW_PADDING >= windowWidth - WINDOW_PADDING) {
newLeft = windowWidth - width - WINDOW_PADDING;
}
// too close to bottom // too close to bottom
if (top + menuHeight > windowHeight - WINDOW_PADDING + window.scrollY) { if (top + menuHeight > windowHeight - WINDOW_PADDING + window.scrollY) {
newTop = WINDOW_PADDING; newTop = WINDOW_PADDING;

View File

@ -3,74 +3,27 @@ import ArrowDropdown from '../icons/ArrowDropdown';
import ArrowDropup from '../icons/ArrowDropup'; import ArrowDropup from '../icons/ArrowDropup';
import Menu, { MenuItem } from './Menu'; import Menu, { MenuItem } from './Menu';
import TextField from './TextField'; import TextField from './TextField';
import DatePicker from './DatePicker';
import Calender from './Calender';
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
export default function Select({ label, onChange, options: inputOptions = [], selected: propSelected }) { export default function Select({
type,
label,
onChange,
paramName,
options: inputOptions = [],
selected: propSelected,
}) {
const options = useMemo( const options = useMemo(
() => () =>
typeof inputOptions[0] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions, typeof inputOptions[0] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions,
[inputOptions] [inputOptions]
); );
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const [selected, setSelected] = useState( const [selected, setSelected] = useState();
Math.max( const [datePickerValue, setDatePickerValue] = useState();
options.findIndex(({ value }) => value === propSelected),
0
)
);
const [focused, setFocused] = useState(null);
const ref = useRef(null);
const handleSelect = useCallback(
(value, label) => {
setSelected(options.findIndex((opt) => opt.value === value));
onChange && onChange(value, label);
setShowMenu(false);
},
[onChange, options]
);
const handleClick = useCallback(() => {
setShowMenu(true);
}, [setShowMenu]);
const handleKeydown = useCallback(
(event) => {
switch (event.key) {
case 'Enter': {
if (!showMenu) {
setShowMenu(true);
setFocused(selected);
} else {
setSelected(focused);
onChange && onChange(options[focused].value, options[focused].label);
setShowMenu(false);
}
break;
}
case 'ArrowDown': {
const newIndex = focused + 1;
newIndex < options.length && setFocused(newIndex);
break;
}
case 'ArrowUp': {
const newIndex = focused - 1;
newIndex > -1 && setFocused(newIndex);
break;
}
// no default
}
},
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
);
const handleDismiss = useCallback(() => {
setShowMenu(false);
}, [setShowMenu]);
// Reset the state if the prop value changes // Reset the state if the prop value changes
useEffect(() => { useEffect(() => {
@ -85,6 +38,193 @@ export default function Select({ label, onChange, options: inputOptions = [], se
// DO NOT include `selected` // DO NOT include `selected`
}, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps }, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (type === 'datepicker') {
if ('after' && 'before' in propSelected) {
if (!propSelected.before || !propSelected.after) return setDatePickerValue('all');
for (let i = 0; i < inputOptions.length; i++) {
if (
inputOptions[i].value &&
Object.entries(inputOptions[i].value).sort().toString() === Object.entries(propSelected).sort().toString()
) {
setDatePickerValue(inputOptions[i]?.label);
break;
} else {
setDatePickerValue(
`${new Date(propSelected.after * 1000).toLocaleDateString()} -> ${new Date(
propSelected.before * 1000 - 1
).toLocaleDateString()}`
);
}
}
}
}
if (type === 'dropdown') {
setSelected(
Math.max(
options.findIndex(({ value }) => Object.values(propSelected).includes(value)),
0
)
);
}
}, [type, options, inputOptions, propSelected, setSelected]);
const [focused, setFocused] = useState(null);
const [showCalender, setShowCalender] = useState(false);
const calenderRef = useRef(null);
const ref = useRef(null);
const handleSelect = useCallback(
(value) => {
setSelected(options.findIndex(({ value }) => Object.values(propSelected).includes(value)));
setShowMenu(false);
//show calender date range picker
if (value === 'custom_range') return setShowCalender(true);
onChange && onChange(value);
},
[onChange, options, propSelected, setSelected]
);
const handleDateRange = useCallback(
(range) => {
onChange && onChange(range);
setShowMenu(false);
},
[onChange]
);
const handleClick = useCallback(() => {
setShowMenu(true);
}, [setShowMenu]);
const handleKeydownDatePicker = useCallback(
(event) => {
switch (event.key) {
case 'Enter': {
if (!showMenu) {
setShowMenu(true);
setFocused(selected);
} else {
setSelected(focused);
if (options[focused].value === 'custom_range') {
setShowMenu(false);
return setShowCalender(true);
}
onChange && onChange(options[focused].value);
setShowMenu(false);
}
break;
}
case 'ArrowDown': {
event.preventDefault();
const newIndex = focused + 1;
newIndex < options.length && setFocused(newIndex);
break;
}
case 'ArrowUp': {
event.preventDefault();
const newIndex = focused - 1;
newIndex > -1 && setFocused(newIndex);
break;
}
// no default
}
},
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
);
const handleKeydown = useCallback(
(event) => {
switch (event.key) {
case 'Enter': {
if (!showMenu) {
setShowMenu(true);
setFocused(selected);
} else {
setSelected(focused);
onChange && onChange({ [paramName]: options[focused].value });
setShowMenu(false);
}
break;
}
case 'ArrowDown': {
event.preventDefault();
const newIndex = focused + 1;
newIndex < options.length && setFocused(newIndex);
break;
}
case 'ArrowUp': {
event.preventDefault();
const newIndex = focused - 1;
newIndex > -1 && setFocused(newIndex);
break;
}
// no default
}
},
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected, paramName]
);
const handleDismiss = useCallback(() => {
setShowMenu(false);
}, [setShowMenu]);
const findDOMNodes = (component) => {
return (component && (component.base || (component.nodeType === 1 && component))) || null;
};
useEffect(() => {
const addBackDrop = (e) => {
if (showCalender && !findDOMNodes(calenderRef.current).contains(e.target)) {
setShowCalender(false);
}
};
window.addEventListener('click', addBackDrop);
return function cleanup() {
window.removeEventListener('click', addBackDrop);
};
}, [showCalender]);
switch (type) {
case 'datepicker':
return (
<Fragment>
<DatePicker
inputRef={ref}
label={label}
onchange={onChange}
onclick={handleClick}
onkeydown={handleKeydownDatePicker}
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
value={datePickerValue}
/>
{showCalender && (
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref}>
<Calender onChange={handleDateRange} calenderRef={calenderRef} close={() => setShowCalender(false)} />
</Menu>
)}
{showMenu ? (
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
{options.map(({ value, label }, i) => (
<MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
))}
</Menu>
) : null}
</Fragment>
);
// case 'dropdown':
default:
return ( return (
<Fragment> <Fragment>
<TextField <TextField
@ -100,10 +240,17 @@ export default function Select({ label, onChange, options: inputOptions = [], se
{showMenu ? ( {showMenu ? (
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative> <Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
{options.map(({ value, label }, i) => ( {options.map(({ value, label }, i) => (
<MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} /> <MenuItem
key={value}
label={label}
focus={focused === i}
onSelect={handleSelect}
value={{ [paramName]: value }}
/>
))} ))}
</Menu> </Menu>
) : null} ) : null}
</Fragment> </Fragment>
); );
} }
}

View File

@ -5,21 +5,40 @@ import { fireEvent, render, screen } from '@testing-library/preact';
describe('Select', () => { describe('Select', () => {
test('on focus, shows a menu', async () => { test('on focus, shows a menu', async () => {
const handleChange = jest.fn(); const handleChange = jest.fn();
render(<Select label="Tacos" onChange={handleChange} options={['tacos', 'burritos']} />); render(
<Select
label="Tacos"
type="dropdown"
onChange={handleChange}
options={['all', 'tacos', 'burritos']}
paramName={['dinner']}
selected=""
/>
);
expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('textbox')); fireEvent.click(screen.getByRole('textbox'));
expect(screen.queryByRole('listbox')).toBeInTheDocument(); expect(screen.queryByRole('listbox')).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'all' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'tacos' })).toBeInTheDocument(); expect(screen.queryByRole('option', { name: 'tacos' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'burritos' })).toBeInTheDocument(); expect(screen.queryByRole('option', { name: 'burritos' })).toBeInTheDocument();
fireEvent.click(screen.queryByRole('option', { name: 'burritos' })); fireEvent.click(screen.queryByRole('option', { name: 'tacos' }));
expect(handleChange).toHaveBeenCalledWith('burritos', 'burritos'); expect(handleChange).toHaveBeenCalledWith({ dinner: 'tacos' });
}); });
test('allows keyboard navigation', async () => { test('allows keyboard navigation', async () => {
const handleChange = jest.fn(); const handleChange = jest.fn();
render(<Select label="Tacos" onChange={handleChange} options={['tacos', 'burritos']} />); render(
<Select
label="Tacos"
type="dropdown"
onChange={handleChange}
options={['tacos', 'burritos']}
paramName={['dinner']}
selected=""
/>
);
expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
const input = screen.getByRole('textbox'); const input = screen.getByRole('textbox');
@ -29,6 +48,6 @@ describe('Select', () => {
fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(handleChange).toHaveBeenCalledWith('burritos', 'burritos'); expect(handleChange).toHaveBeenCalledWith({ dinner: 'burritos' });
}); });
}); });

View File

@ -18,7 +18,8 @@ export const useSearchString = (limit, searchParams) => {
const removeDefaultSearchKeys = useCallback((searchParams) => { const removeDefaultSearchKeys = useCallback((searchParams) => {
searchParams.delete('limit'); searchParams.delete('limit');
searchParams.delete('include_thumbnails'); searchParams.delete('include_thumbnails');
searchParams.delete('before'); // removed deletion of "before" as its used by DatePicker
// searchParams.delete('before');
}, []); }, []);
return { searchString, setSearchString, removeDefaultSearchKeys }; return { searchString, setSearchString, removeDefaultSearchKeys };

View File

@ -0,0 +1,18 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowLeft({ className = '' }) {
return (
<svg
className={`fill-current ${className}`}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm-1.218 19l-1.782-1.75 5.25-5.25-5.25-5.25 1.782-1.75 6.968 7-6.968 7z" />
</svg>
);
}
export default memo(ArrowLeft);

View File

@ -0,0 +1,12 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowRight({ className = '' }) {
return (
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M5 3l3.057-3 11.943 12-11.943 12-3.057-3 9-9z" />
</svg>
);
}
export default memo(ArrowRight);

View File

@ -0,0 +1,12 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowRightDouble({ className = '' }) {
return (
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M0 3.795l2.995-2.98 11.132 11.185-11.132 11.186-2.995-2.981 8.167-8.205-8.167-8.205zm18.04 8.205l-8.167 8.205 2.995 2.98 11.132-11.185-11.132-11.186-2.995 2.98 8.167 8.206z" />
</svg>
);
}
export default memo(ArrowRightDouble);

View File

@ -1,31 +1,26 @@
import { h } from 'preact'; import { h } from 'preact';
import Select from '../../../components/Select'; import Select from '../../../components/Select';
import { useCallback, useMemo } from 'preact/hooks'; import { useCallback } from 'preact/hooks';
const Filter = ({ onChange, searchParams, paramName, options }) => { function Filter({ onChange, searchParams, paramName, options, ...rest }) {
const handleSelect = useCallback( const handleSelect = useCallback(
(key) => { (key) => {
const newParams = new URLSearchParams(searchParams.toString()); const newParams = new URLSearchParams(searchParams.toString());
if (key !== 'all') { Object.keys(key).map((entries) => {
newParams.set(paramName, key); if (key[entries] !== 'all') {
newParams.set(entries, key[entries]);
} else { } else {
newParams.delete(paramName); paramName.map((p) => newParams.delete(p));
} }
});
onChange(newParams); onChange(newParams);
}, },
[searchParams, paramName, onChange] [searchParams, paramName, onChange]
); );
const selectOptions = useMemo(() => ['all', ...options], [options]); const obj = {};
paramName.map((name) => Object.assign(obj, { [name]: searchParams.get(name) }), [searchParams]);
return ( return <Select onChange={handleSelect} options={options} selected={obj} paramName={paramName} {...rest} />;
<Select }
label={`${paramName.charAt(0).toUpperCase()}${paramName.substr(1)}`}
onChange={handleSelect}
options={selectOptions}
selected={searchParams.get(paramName) || 'all'}
/>
);
};
export default Filter; export default Filter;

View File

@ -3,7 +3,13 @@ import { useCallback, useMemo } from 'preact/hooks';
import Link from '../../../components/Link'; import Link from '../../../components/Link';
import { route } from 'preact-router'; import { route } from 'preact-router';
const Filterable = ({ onFilter, pathname, searchParams, paramName, name, removeDefaultSearchKeys }) => { function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
const removeDefaultSearchKeys = useCallback((searchParams) => {
searchParams.delete('limit');
searchParams.delete('include_thumbnails');
// searchParams.delete('before');
}, []);
const href = useMemo(() => { const href = useMemo(() => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
params.set(paramName, name); params.set(paramName, name);
@ -27,6 +33,6 @@ const Filterable = ({ onFilter, pathname, searchParams, paramName, name, removeD
{name} {name}
</Link> </Link>
); );
}; }
export default Filterable; export default Filterable;

View File

@ -1,11 +1,13 @@
import { h } from 'preact'; import { h } from 'preact';
import Filter from './filter'; import Filter from './filter';
import { useConfig } from '../../../api'; import { useConfig } from '../../../api';
import { useMemo } from 'preact/hooks'; import { useMemo, useState } from 'preact/hooks';
import { DateFilterOptions } from '../../../components/DatePicker';
import Button from '../../../components/Button';
const Filters = ({ onChange, searchParams }) => { const Filters = ({ onChange, searchParams }) => {
const [viewFilters, setViewFilters] = useState(false);
const { data } = useConfig(); const { data } = useConfig();
const cameras = useMemo(() => Object.keys(data.cameras), [data]); const cameras = useMemo(() => Object.keys(data.cameras), [data]);
const zones = useMemo( const zones = useMemo(
@ -27,12 +29,52 @@ const Filters = ({ onChange, searchParams }) => {
}, data.objects?.track || []) }, data.objects?.track || [])
.filter((value, i, self) => self.indexOf(value) === i); .filter((value, i, self) => self.indexOf(value) === i);
}, [data]); }, [data]);
return ( return (
<div className="flex space-x-4"> <div>
<Filter onChange={onChange} options={cameras} paramName="camera" searchParams={searchParams} /> <Button
<Filter onChange={onChange} options={zones} paramName="zone" searchParams={searchParams} /> onClick={() => setViewFilters(!viewFilters)}
<Filter onChange={onChange} options={labels} paramName="label" searchParams={searchParams} /> className="block xs:hidden w-full mb-4 text-center"
type="text"
>
{`${viewFilters ? 'Hide Filter' : 'Filter'}`}
</Button>
<div className={`xs:flex space-y-1 xs:space-y-0 xs:space-x-4 ${viewFilters ? 'flex-col' : 'hidden'}`}>
<Filter
type="dropdown"
onChange={onChange}
options={['all', ...cameras]}
paramName={['camera']}
label="Camera"
searchParams={searchParams}
/>
<Filter
type="dropdown"
onChange={onChange}
options={['all', ...zones]}
paramName={['zone']}
label="Zone"
searchParams={searchParams}
/>
<Filter
type="dropdown"
onChange={onChange}
options={['all', ...labels]}
paramName={['label']}
label="Label"
searchParams={searchParams}
/>
<Filter
type="datepicker"
onChange={onChange}
options={DateFilterOptions}
paramName={['before', 'after']}
label="DatePicker"
searchParams={searchParams}
/>
</div>
</div> </div>
); );
}; };

View File

@ -64,6 +64,9 @@ export default function Recording({ camera, date, hour, seconds }) {
this.player.playlist.currentItem(selectedHour); this.player.playlist.currentItem(selectedHour);
if (seconds !== undefined) { if (seconds !== undefined) {
this.player.currentTime(seconds); this.player.currentTime(seconds);
// Force playback rate to be correct
const playbackRate = this.player.playbackRate();
this.player.defaultPlaybackRate(playbackRate);
} }
} }
} }