feat: Add support for Frigate reviews / detections [initial PR] (#2315)
- Add support for Frigate reviews / detections. - Add support for GenAI metadata. - Significant internal refactor to more flexible "UnifiedQuery" to allow mixing cameras with simple metadata and review metadata (e.g. a timeline view of a Frigate camera with reviews, and a Reolink camera with simple metadata). - Add support for folder media as camera media. There are a few more PRs to commit prior to this going live, but commiting this for now due to the scale of the change. BREAKING CHANGE: `media_type` and `events_type` are retired under `live`, `viewer` and `timeline` configuration sections, instead media type is associated (once) with the camera under `media`.
This commit is contained in:
@@ -17,7 +17,7 @@ Formerly known as: `Frigate Card`
|
||||
A comprehensive camera card for Home Assistant.
|
||||
|
||||
- Live viewing of multiple cameras.
|
||||
- Clips and snapshot browsing via mini-gallery.
|
||||
- Clips, snapshot and review browsing via mini-gallery.
|
||||
- Automatic updating to continually show latest clip / snapshot.
|
||||
- Support for filtering events by zone and label.
|
||||
- Arbitrary entity access via menu (e.g. motion sensor access).
|
||||
|
||||
@@ -175,6 +175,15 @@ action: custom:advanced-camera-card-action
|
||||
advanced_camera_card_action: image
|
||||
```
|
||||
|
||||
## `info`
|
||||
|
||||
Show media metadata (e.g. event time, camera, descriptions).
|
||||
|
||||
```yaml
|
||||
action: custom:advanced-camera-card-action
|
||||
advanced_camera_card_action: info
|
||||
```
|
||||
|
||||
## `live`
|
||||
|
||||
Change to the `live` view.
|
||||
@@ -385,14 +394,14 @@ advanced_camera_card_action: ptz_digital
|
||||
# [...]
|
||||
```
|
||||
|
||||
| Parameter | Default | Description |
|
||||
| ----------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `action` | | Must be `custom:advanced-camera-card-action`. |
|
||||
| `advanced_camera_card_action` | | Must be `ptz_digital`. |
|
||||
| `target_id` | The currently selected camera or media | The target (camera or media) to execute a digital PTZ action on. Can be a camera ID, or another media ID (e.g. for Frigate, can specify a media/event ID). |
|
||||
| `ptz_action` | Optional action that is one of `left`, `right`, `up`, `down`, `zoom_in` or `zoom_out`. |
|
||||
| `ptz_phase` | Optional parameter that is one of `start` or `stop` to start or stop the movement separately. |
|
||||
| `absolute` | Optional parameter to specify exact absolute pan and zoom settings. See below. |
|
||||
| Parameter | Default | Description |
|
||||
| ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `action` | | Must be `custom:advanced-camera-card-action`. |
|
||||
| `advanced_camera_card_action` | | Must be `ptz_digital`. |
|
||||
| `target_id` | The currently selected camera or media | The target (camera or media) to execute a digital PTZ action on. Can be a camera ID, or another media ID (e.g. for Frigate, can specify a media/event ID). |
|
||||
| `ptz_action` | | Optional action that is one of `left`, `right`, `up`, `down`, `zoom_in` or `zoom_out`. |
|
||||
| `ptz_phase` | | Optional parameter that is one of `start` or `stop` to start or stop the movement separately. |
|
||||
| `absolute` | | Optional parameter to specify exact absolute pan and zoom settings. See below. |
|
||||
|
||||
> [!NOTE]
|
||||
> If no `ptz_action` is specified and no `absolute` value is specified, the camera returns to its "home" position. See [Camera layout configuration](../../cameras/README.md?id=layout-configuration) to configure the default "home" position for digital PTZ.
|
||||
@@ -472,6 +481,22 @@ action: custom:advanced-camera-card-action
|
||||
advanced_camera_card_action: screenshot
|
||||
```
|
||||
|
||||
## `set_review`
|
||||
|
||||
Mark a review as reviewed or unreviewed.
|
||||
|
||||
```yaml
|
||||
action: custom:advanced-camera-card-action
|
||||
advanced_camera_card_action: set_review
|
||||
# [...]
|
||||
```
|
||||
|
||||
| Parameter | Default | Description |
|
||||
| ----------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `action` | | Must be `custom:advanced-camera-card-action`. |
|
||||
| `advanced_camera_card_action` | | Must be `set_review`. |
|
||||
| `reviewed` | | If `true` marks as reviewed, if `false` marks as unreviewed. If not specified (the default), toggles the reviewed status. |
|
||||
|
||||
## `sleep`
|
||||
|
||||
Take no action for a given duration. Useful to pause between multiple other actions.
|
||||
@@ -627,6 +652,12 @@ elements:
|
||||
tap_action:
|
||||
action: custom:advanced-camera-card-action
|
||||
advanced_camera_card_action: live
|
||||
- type: custom:advanced-camera-card-menu-icon
|
||||
icon: mdi:alpha-i-circle-outline
|
||||
title: Info
|
||||
tap_action:
|
||||
action: custom:advanced-camera-card-action
|
||||
advanced_camera_card_action: info
|
||||
- type: custom:advanced-camera-card-menu-icon
|
||||
icon: mdi:alpha-n-circle
|
||||
title: Turn off substream
|
||||
@@ -794,18 +825,25 @@ elements:
|
||||
advanced_camera_card_action: snapshots
|
||||
- type: custom:advanced-camera-card-menu-icon
|
||||
icon: mdi:alpha-h-circle-outline
|
||||
title: Set reviewed
|
||||
tap_action:
|
||||
action: custom:advanced-camera-card-action
|
||||
advanced_camera_card_action: set_review
|
||||
reviewed: true
|
||||
- type: custom:advanced-camera-card-menu-icon
|
||||
icon: mdi:alpha-i-circle-outline
|
||||
title: Show timeline
|
||||
tap_action:
|
||||
action: custom:advanced-camera-card-action
|
||||
advanced_camera_card_action: timeline
|
||||
- type: custom:advanced-camera-card-menu-icon
|
||||
icon: mdi:alpha-i-circle-outline
|
||||
icon: mdi:alpha-j-circle-outline
|
||||
title: Unmute
|
||||
tap_action:
|
||||
action: custom:advanced-camera-card-action
|
||||
advanced_camera_card_action: unmute
|
||||
- type: custom:advanced-camera-card-menu-icon
|
||||
icon: mdi:alpha-j-circle-outline
|
||||
icon: mdi:alpha-k-circle-outline
|
||||
title: Add status bar contents
|
||||
tap_action:
|
||||
- action: custom:advanced-camera-card-action
|
||||
@@ -834,7 +872,7 @@ elements:
|
||||
priority: 50
|
||||
sufficient: false
|
||||
- type: custom:advanced-camera-card-menu-icon
|
||||
icon: mdi:alpha-k-circle-outline
|
||||
icon: mdi:alpha-l-circle-outline
|
||||
title: Remove status bar contents
|
||||
tap_action:
|
||||
- action: custom:advanced-camera-card-action
|
||||
@@ -863,21 +901,21 @@ elements:
|
||||
priority: 50
|
||||
sufficient: false
|
||||
- type: custom:advanced-camera-card-menu-icon
|
||||
icon: mdi:alpha-l-circle-outline
|
||||
icon: mdi:alpha-m-circle-outline
|
||||
title: Reset status bar contents
|
||||
tap_action:
|
||||
- action: custom:advanced-camera-card-action
|
||||
advanced_camera_card_action: status_bar
|
||||
status_bar_action: reset
|
||||
- type: custom:advanced-camera-card-menu-icon
|
||||
icon: mdi:alpha-m-circle-outline
|
||||
icon: mdi:alpha-n-circle-outline
|
||||
title: View Folder Media
|
||||
tap_action:
|
||||
- action: custom:advanced-camera-card-action
|
||||
advanced_camera_card_action: folder
|
||||
folder: my-folder
|
||||
- type: custom:advanced-camera-card-menu-icon
|
||||
icon: mdi:alpha-n-circle-outline
|
||||
icon: mdi:alpha-o-circle-outline
|
||||
title: View Folders Gallery
|
||||
tap_action:
|
||||
- action: custom:advanced-camera-card-action
|
||||
|
||||
@@ -29,6 +29,7 @@ cameras_global:
|
||||
| `icon` | Autodetected from `camera_entity` if that is specified. | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. |
|
||||
| `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. This `id` may be used in [conditions](../conditions.md), dependencies or custom [actions](../actions/README.md) to refer to a given camera unambiguously. |
|
||||
| `live_provider` | `auto` | The choice of live stream provider. See [Live Provider](live-provider.md). |
|
||||
| `media` | | Controls the default media configuration (e.g. thumbnails) for this camera. See below. |
|
||||
| `proxy` | | Controls whether/how content is proxied via [hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) (must be installed separately). See below. |
|
||||
| `title` | Autodetected from `camera_entity` if that is specified. | A friendly name for this camera to use in the card. |
|
||||
| `triggers` | | Define what should cause this camera to update/trigger. See below. |
|
||||
@@ -165,6 +166,36 @@ cameras:
|
||||
| -------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `width_factor` | `1` | Width multiplier for this camera in grid mode (minimum: `0.1`). When selected, width becomes `width_factor * grid_selected_width_factor`, capped at 100%. |
|
||||
|
||||
## `media`
|
||||
|
||||
The `media` block configures the default media options for this camera which
|
||||
defines which media is shown as thumbnails and on the timeline.
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
- camera_entity: camera.office
|
||||
media:
|
||||
# [...]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `type` | `auto` | The default media type to show for this camera. One of `auto`, `events`, `recordings`, `reviews` or `folder`. See below for description of each. |
|
||||
| `events_type` | `all` | If `type` is `events`, what subtype of events to show. One of `clips`, `snapshots` or `all` (default). |
|
||||
| `folders` | | An optional list of folder IDs to use when `type` is `folder`. If not specified, and `type` is `folder`, will default to showing the default (first) configured folder. See [Folder Configuration](../folders.md). |
|
||||
|
||||
### Media Types
|
||||
|
||||
Not all camera engines support all media types. See [Camera Engines](engine.md) for details.
|
||||
|
||||
| Type | Description |
|
||||
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `events` | Typically represents an interesting event recorded from the camera, in either a video clip or image snapshot (see `events_type` parameter). |
|
||||
| `recordings` | Typically represents continuous video recordings from the camera. |
|
||||
| `reviews` | Typically represents an alert / detection of some kind that the user can review. |
|
||||
| `folder` | Arbitrary media from a [folder](../folders.md), e.g. a Home Assistant media folder. |
|
||||
| `auto` | Automatically prioritize available media based on camera capabilities. Order of precedence: `reviews` > `clips` > `snapshots` > `recordings`. |
|
||||
|
||||
### Layout Configuration
|
||||
|
||||
The `layout` block configures the fit and position of the media _within_ the camera dimensions (in order to control the dimensions for the whole card see [the card dimensions configuration](../dimensions.md) ).
|
||||
@@ -182,7 +213,7 @@ cameras:
|
||||
| `fit` | `contain` | If `contain`, the media is contained within the camera container/card and letterboxed if necessary. If `cover`, the media is expanded proportionally (i.e. maintaining the media aspect ratio) until the camera/card dimensions are fully covered. If `fill`, the media is stretched to fill the camera/card dimensions (i.e. ignoring the media aspect ratio). See [CSS object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) for technical details and a visualization. Note that if `aspect_ratio` is also set, this is controlling the behavior "within" that aspect-ratio, otherwise it's within the container for the camera (which is effectively the whole card for single card configurations). |
|
||||
| `pan` | | A dictionary that may contain an `x` and `y` percentage (`0` - `100`) to control the position of the media when "digitally zoomed in" (see `zoom` parameter). This can be effectively used to "pan"/cut the media shown. A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See visualizations below. |
|
||||
| `position` | | A dictionary that may contain an `x` and `y` percentage (`0` - `100`) to control the position of the media when the fit is `cover` (for other values of `fit` this option has no effect). This can be effectively used to "pan"/cut the media shown. At any given time, only one of `x` and `y` will have an effect, depending on whether media width is larger than the camera/card dimensions (in which case `x` controls the position) or the media height is larger than the camera/card dimensions (in which case `y` controls the position). A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See [CSS object-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) for technicals. See visualizations below. |
|
||||
| `view_box` | | A dictionary that may contain a `top`, `bottom`, `left` and `right` percentage (`0` - `100`) to precisely crop what part of the media to show by specifying a % inset value from each side. Browsers apply this cropping after `position` and `fit` have been applied. Unlike `zoom`, the user cannot dynamically zoom back out -- however the builtin media controls will work as normal. See visualizations below. Limited [browser support](https://caniuse.com/mdn-css_properties_object-view-box):    |
|
||||
| `view_box` | | A dictionary that may contain a `top`, `bottom`, `left` and `right` percentage (`0` - `100`) to precisely crop what part of the media to show by specifying a % inset value from each side. Browsers apply this cropping after `position` and `fit` have been applied. Unlike `zoom`, the user cannot dynamically zoom back out -- however the builtin media controls will work as normal. See visualizations below. Limited [browser support](https://caniuse.com/mdn-css_properties_object-view-box):    |
|
||||
| `zoom` | `1.0` | A value between `1.0` and `10.0` inclusive that defines how much additional "digital zoom" to apply to this camera by default. Unlike with `view_box` the user can easily "zoom back out". Often used in conjuction with `pan`. When zoomed in the [builtin browser media controls](../live.md?id=controls) will automatically be disabled (as otherwise they would be enlarged also). |
|
||||
|
||||
> [!NOTE]
|
||||
@@ -358,6 +389,26 @@ cameras:
|
||||
| `events` | `[events, clips, snapshots]` | Whether to trigger the camera when `events` occur (whether or not media is available) or whenever updated `clips` or `snapshots` are detected. Detection support varies by camera [engine](engine.md). |
|
||||
| `motion` | `false` | Whether to not to trigger the camera by automatically detecting and using the motion `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the motion `binary_sensor` entity has been enabled in Home Assistant. |
|
||||
| `occupancy` | `false` | Whether to not to trigger the camera by automatically detecting and using the occupancy `binary_sensor` for this camera and its configured zones and labels. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant. If this camera has configured zones, only occupancy sensors for those zones are used -- if the overall _camera_ occupancy sensor is also required, it can be manually added to `entities`. If this camera has configured labels, only occupancy sensors for those labels are used. |
|
||||
| `reviews` | | Configuration for triggering on Frigate review items. See below. Frigate-specific. |
|
||||
|
||||
### `reviews`
|
||||
|
||||
The `reviews` block configures triggering based on review items (e.g., alerts and detections from Frigate).
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
- camera_entity: camera.office
|
||||
triggers:
|
||||
reviews:
|
||||
severities:
|
||||
- high
|
||||
description: true
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `severities` | `[high]` | An array of severity levels to trigger on. Possible values: `high` (equivalent to Frigate alerts), `medium` (equivalent to Frigate detections), `low`. At least one severity must be configured for review triggers to be active. |
|
||||
| `description` | `true` | Whether to trigger on review description updates (e.g. Frigate GenAI descriptions). Severity must also match. |
|
||||
|
||||
## Fully expanded reference
|
||||
|
||||
@@ -378,6 +429,14 @@ cameras:
|
||||
occupancy: true
|
||||
entities:
|
||||
- binary_sensor.front_door_sensor
|
||||
events:
|
||||
- events
|
||||
- clips
|
||||
- snapshots
|
||||
reviews:
|
||||
severities:
|
||||
- high
|
||||
description: true
|
||||
cast:
|
||||
method: standard
|
||||
dimensions:
|
||||
|
||||
@@ -88,11 +88,11 @@ cameras:
|
||||
# [...]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `entity` | | The RTSP camera entity to pass to the WebRTC Card for this camera. |
|
||||
| `url` | Depends on the camera engine (e.g. Frigate cameras will automatically use the camera name since this is the [recommended setup](https://deploy-preview-4055--frigate-docs.netlify.app/guides/configuring_go2rtc/)). | The RTSP url to pass to the WebRTC Card, e.g. `rtsp://USERNAME:PASSWORD@CAMERA:554/RTSP_PATH` |
|
||||
| `*` | | Any options specified in the `webrtc_card:` YAML dictionary are silently passed through to the AlexxIT's WebRTC Card. See [WebRTC Configuration](https://github.com/AlexxIT/WebRTC#configuration) for full details this external card provides, e.g. `ui: true` will enable the WebRTC Card UI. |
|
||||
| Option | Default | Description |
|
||||
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `entity` | | The RTSP camera entity to pass to the WebRTC Card for this camera. |
|
||||
| `url` | Depends on the camera engine (e.g. Frigate cameras will automatically use the camera name since this is the [recommended setup](https://docs.frigate.video/guides/configuring_go2rtc/)). | The RTSP url to pass to the WebRTC Card, e.g. `rtsp://USERNAME:PASSWORD@CAMERA:554/RTSP_PATH` |
|
||||
| `*` | | Any options specified in the `webrtc_card:` YAML dictionary are silently passed through to the AlexxIT's WebRTC Card. See [WebRTC Configuration](https://github.com/AlexxIT/WebRTC#configuration) for full details this external card provides, e.g. `ui: true` will enable the WebRTC Card UI. |
|
||||
|
||||
## Fully expanded reference
|
||||
|
||||
|
||||
@@ -12,13 +12,13 @@ folders:
|
||||
# [...]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | | An optional folder `id` which can be used by the [`folder` action](./actions/custom/README.md?id=folder) to show a particular folder contents. |
|
||||
| `ha` | | Options for `ha` folder types. See below. |
|
||||
| `icon` | | An optional folder icon. |
|
||||
| `title` | | An optional folder title. |
|
||||
| `type` | `ha` | The type of folder, `ha` for Home Assistant media folders (currently the only supported type of folder). |
|
||||
| Option | Default | Description |
|
||||
| ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | | An optional folder `id` which can be used by [camera `media` configuration](./cameras/README.md#media) or [`folder` actions](./actions/custom/README.md?id=folder) to show a particular folder's contents. |
|
||||
| `ha` | | Options for `ha` folder types. See below. |
|
||||
| `icon` | | An optional folder icon. |
|
||||
| `title` | | An optional folder title. |
|
||||
| `type` | `ha` | The type of folder, `ha` for Home Assistant media folders (currently the only supported type of folder). |
|
||||
|
||||
## `ha`
|
||||
|
||||
|
||||
+12
-16
@@ -93,16 +93,15 @@ live:
|
||||
# [...]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ----------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `events_media_type` | `all` | Whether to show `clips`, `snapshots` or `all` in the thumbnail carousel in the `live` view. This setting is only relevant when the `media_type` parameter is set to `events`. |
|
||||
| `media_type` | `events` | Whether to load `events` or `recordings` media. |
|
||||
| `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`). |
|
||||
| `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
|
||||
| `show_download_control` | `true` | Whether to show the download control on each thumbnail. |
|
||||
| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. |
|
||||
| `show_timeline_control` | `true` | Whether to show the timeline ('target') control on each thumbnail. |
|
||||
| `size` | `100` | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `300`. |
|
||||
| Option | Default | Description |
|
||||
| ----------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`). |
|
||||
| `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
|
||||
| `show_download_control` | `false` | Whether to show the download control on each thumbnail. |
|
||||
| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. |
|
||||
| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. |
|
||||
| `show_timeline_control` | `false` | Whether to show the timeline ('target') control on each thumbnail. |
|
||||
| `size` | `100` | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `300`. |
|
||||
|
||||
### `timeline`
|
||||
|
||||
@@ -118,7 +117,6 @@ live:
|
||||
| Option | Default | Description |
|
||||
| ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `clustering_threshold` | `3` | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. |
|
||||
| `events_media_type` | `all` | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot. |
|
||||
| `format` | | Configuration for the timeline time & date format. See below. |
|
||||
| `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`). |
|
||||
| `pan_mode` | `pan` | See [timeline pan mode](timeline-pan-mode.md). |
|
||||
@@ -219,20 +217,18 @@ live:
|
||||
# Optionally override the default style.
|
||||
right: 5%
|
||||
thumbnails:
|
||||
media_type: events
|
||||
events_media_type: all
|
||||
size: 100
|
||||
show_details: false
|
||||
show_download_control: true
|
||||
show_download_control: false
|
||||
show_favorite_control: true
|
||||
show_timeline_control: true
|
||||
show_review_control: true
|
||||
show_timeline_control: false
|
||||
mode: none
|
||||
timeline:
|
||||
style: ribbon
|
||||
mode: none
|
||||
pan_mode: pan
|
||||
clustering_threshold: 3
|
||||
events_media_type: all
|
||||
show_recordings: true
|
||||
window_seconds: 3600
|
||||
format:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# `media_gallery`
|
||||
|
||||
The `media_gallery` is used for providing an overview of all `clips`,
|
||||
`snapshots`, `recordings` and `folder` contents in a thumbnail gallery.
|
||||
`snapshots`, `recordings`, `reviews` and `folder` contents in a thumbnail
|
||||
gallery.
|
||||
|
||||
```yaml
|
||||
media_gallery:
|
||||
@@ -44,9 +45,10 @@ media_gallery:
|
||||
| Option | Default | Description |
|
||||
| ----------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `show_details` | `false` | Whether to show media details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
|
||||
| `show_download_control` | `true` | Whether to show the download control on each thumbnail. |
|
||||
| `show_download_control` | `false` | Whether to show the download control on each thumbnail. |
|
||||
| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. |
|
||||
| `show_timeline_control` | `true` | Whether to show the timeline ('target') control on each thumbnail. |
|
||||
| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. |
|
||||
| `show_timeline_control` | `false` | Whether to show the timeline ('target') control on each thumbnail. |
|
||||
| `size` | `100` | The size of the thumbnails in the gallery. Must be >= `75` and <= `300`. |
|
||||
|
||||
## Fully expanded reference
|
||||
@@ -61,9 +63,10 @@ media_gallery:
|
||||
thumbnails:
|
||||
size: 100
|
||||
show_details: false
|
||||
show_download_control: true
|
||||
show_download_control: false
|
||||
show_favorite_control: true
|
||||
show_timeline_control: true
|
||||
show_review_control: true
|
||||
show_timeline_control: false
|
||||
actions:
|
||||
entity: light.office_main_lights
|
||||
tap_action:
|
||||
|
||||
@@ -93,9 +93,11 @@ media_viewer:
|
||||
| ----------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`). |
|
||||
| `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
|
||||
| `show_download_control` | `true` | Whether to show the download control on each thumbnail. |
|
||||
| `show_download_control` | `false` | Whether to show the download control on each thumbnail. |
|
||||
| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. |
|
||||
| `show_timeline_control` | `true` | Whether to show the timeline ('target') control on each thumbnail. |
|
||||
| `show_info_control` | `true` | Whether to show the info ('i') control on each thumbnail. |
|
||||
| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. |
|
||||
| `show_timeline_control` | `false` | Whether to show the timeline ('target') control on each thumbnail. |
|
||||
| `size` | `100` | The size of the thumbnails in the thumbnail carousel pixels. Must be >= `75` and <= `300`. |
|
||||
|
||||
### `timeline`
|
||||
@@ -112,7 +114,6 @@ media_viewer:
|
||||
| Option | Default | Description |
|
||||
| ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `clustering_threshold` | `3` | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. |
|
||||
| `events_media_type` | `all` | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot. |
|
||||
| `format` | | Configuration for the timeline time & date format. See below. |
|
||||
| `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`). |
|
||||
| `pan_mode` | `pan` | See [timeline pan mode](timeline-pan-mode.md). |
|
||||
@@ -199,15 +200,16 @@ media_viewer:
|
||||
size: 100
|
||||
mode: none
|
||||
show_details: false
|
||||
show_download_control: true
|
||||
show_download_control: false
|
||||
show_favorite_control: true
|
||||
show_timeline_control: true
|
||||
show_info_control: true
|
||||
show_review_control: true
|
||||
show_timeline_control: false
|
||||
timeline:
|
||||
style: ribbon
|
||||
mode: none
|
||||
pan_mode: pan
|
||||
clustering_threshold: 3
|
||||
events_media_type: all
|
||||
show_recordings: true
|
||||
window_seconds: 3600
|
||||
format:
|
||||
|
||||
@@ -37,6 +37,7 @@ menu:
|
||||
| `folders` | The `folders` menu button: brings the user to a [gallery](./media-gallery.md) of folders on tap and to the media viewer with media from the folder on hold. Will only appear if [`folders`](./folders.md) are configured. |
|
||||
| `fullscreen` | The `fullscreen` menu button: expand the card to consume the fullscreen. Please note that fullscreen behavior on iPhone is limited, see [troubleshooting](../troubleshooting.md?id=fullscreen-doesn39t-work-on-iphone). |
|
||||
| `image` | The `image` view menu button: brings the user to the static `image` view. |
|
||||
| `info` | The `info` menu button: show media metadata (e.g. event time, camera, description). |
|
||||
| `iris` | The main Advanced Camera Card `iris` menu button: brings the user to the default configured view (`view.default`), or collapses/expands the menu if the `menu.style` is `hidden` . |
|
||||
| `live` | The `live` view menu button: brings the user to the `live` view. |
|
||||
| `media_player` | The `media_player` menu button: sends the visible media to a remote media player. Supports Frigate clips, snapshots and live camera (only for cameras that specify a `camera_entity` and only using the default HA stream (equivalent to the `ha` live provider)). `jsmpeg` or `webrtc-card` are not supported, although live can still be played as long as `camera_entity` is specified. In the player list, a `tap` will send the media to the player, a `hold` will stop the media on the player. |
|
||||
@@ -50,13 +51,13 @@ menu:
|
||||
|
||||
### Options for each button
|
||||
|
||||
| Option | Default | Description |
|
||||
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `alignment` | `matching` | Whether this menu item should have an alignment that is `matching` the menu alignment or `opposing` the menu alignment. Can be used to create two separate groups of buttons on the menu. The `priority` option orders buttons within a given `alignment`. |
|
||||
| `enabled` | `true` for `iris`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode` and `ptz_home`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `ptz_controls` | Whether or not to show the button. |
|
||||
| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. See also [custom icons](../usage/custom-icons.md). |
|
||||
| `permanent` | `false` | If `false` the menu item is hidden when the menu has the `hidden` style and the menu is closed, otherwise it is shown (and sorted to the front). |
|
||||
| `priority` | `50` | The menu item priority. Higher priority items are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`). Priority applies separately to `matching` and `opposing` groups (see `alignment` above). Minimum `0`, maximum `100`. |
|
||||
| Option | Default | Description |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `alignment` | `matching` | Whether this menu item should have an alignment that is `matching` the menu alignment or `opposing` the menu alignment. Can be used to create two separate groups of buttons on the menu. The `priority` option orders buttons within a given `alignment`. |
|
||||
| `enabled` | `true` for `iris`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode`, `ptz_home` and `info`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `ptz_controls` | Whether or not to show the button. |
|
||||
| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. See also [custom icons](../usage/custom-icons.md). |
|
||||
| `permanent` | `false` | If `false` the menu item is hidden when the menu has the `hidden` style and the menu is closed, otherwise it is shown (and sorted to the front). |
|
||||
| `priority` | `50` | The menu item priority. Higher priority items are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`). Priority applies separately to `matching` and `opposing` groups (see `alignment` above). Minimum `0`, maximum `100`. |
|
||||
|
||||
### Additional options: `microphone`
|
||||
|
||||
@@ -132,6 +133,11 @@ menu:
|
||||
enabled: false
|
||||
alignment: matching
|
||||
icon: mdi:image
|
||||
info:
|
||||
priority: 50
|
||||
enabled: true
|
||||
alignment: matching
|
||||
icon: mdi:information-outline
|
||||
iris:
|
||||
priority: 50
|
||||
enabled: true
|
||||
|
||||
@@ -28,12 +28,13 @@ status_bar:
|
||||
|
||||
### Available Items
|
||||
|
||||
| Button name | Description |
|
||||
| ------------ | ------------------------------------------------------ |
|
||||
| `engine` | The icon of the camera engine for the relevant camera. |
|
||||
| `resolution` | The detected media resolution (if any). |
|
||||
| `technology` | The detected media technology (if any). |
|
||||
| `title` | The media title. |
|
||||
| Button name | Description |
|
||||
| ------------ | ------------------------------------------------------------------------------------------- |
|
||||
| `engine` | The icon of the camera engine for the relevant camera. |
|
||||
| `resolution` | The detected media resolution (if any). |
|
||||
| `severity` | The media severity indicator (if any) for review severity (e.g. Frigate alerts/detections). |
|
||||
| `technology` | The detected media technology (if any). |
|
||||
| `title` | The media title. |
|
||||
|
||||
### Options for each item
|
||||
|
||||
@@ -75,6 +76,9 @@ status_bar:
|
||||
resolution:
|
||||
enabled: true
|
||||
priority: 50
|
||||
severity:
|
||||
enabled: true
|
||||
priority: 50
|
||||
technology:
|
||||
enabled: true
|
||||
priority: 50
|
||||
|
||||
@@ -10,7 +10,7 @@ timeline:
|
||||
|
||||
You can interact with the timeline in a number of ways:
|
||||
|
||||
- Clicking on an event will take you to the media viewer for that event.
|
||||
- Clicking on an event/review will take you to the media viewer for that event/review.
|
||||
- Clicking on the "background", or a camera title, will take you to the recordings for that camera (seeking to the clicked time).
|
||||
- Clicking on the time axis will take you to recordings for all cameras (seeking to the clicked time).
|
||||
|
||||
@@ -18,7 +18,6 @@ You can interact with the timeline in a number of ways:
|
||||
| ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `clustering_threshold` | `3` | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. |
|
||||
| `controls` | | Configuration for the timeline controls. See below. |
|
||||
| `events_media_type` | `all` | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot. |
|
||||
| `format` | | Configuration for the timeline time & date format. See below. |
|
||||
| `show_recordings` | `true` | Whether to show recordings on the timeline (specifically: which hours have any recorded content). |
|
||||
| `style` | `stack` | Whether the timeline should show events as a single flat `ribbon` or a `stack` of events that are clustered using the `clustering_threshold`. |
|
||||
@@ -69,6 +68,7 @@ timeline:
|
||||
| `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
|
||||
| `show_download_control` | `true` | Whether to show the download control on each thumbnail. |
|
||||
| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. |
|
||||
| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. |
|
||||
| `show_timeline_control` | `true` | Whether to show the timeline ('target') control on each thumbnail. |
|
||||
| `size` | `100` | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `300`. |
|
||||
|
||||
@@ -80,7 +80,6 @@ timeline:
|
||||
timeline:
|
||||
style: stack
|
||||
clustering_threshold: 3
|
||||
events_media_type: all
|
||||
show_recordings: true
|
||||
window_seconds: 3600
|
||||
format:
|
||||
@@ -92,5 +91,6 @@ timeline:
|
||||
show_details: true
|
||||
show_download_control: true
|
||||
show_favorite_control: true
|
||||
show_review_control: true
|
||||
show_timeline_control: true
|
||||
```
|
||||
|
||||
@@ -147,6 +147,8 @@ This card supports several different views.
|
||||
| `live` | Shows the live camera view with the configured [live provider](./cameras/live-provider.md). |
|
||||
| `recording` | Shows a viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` menu icon. |
|
||||
| `recordings` | Shows a gallery of recent (last day) recordings for this camera and its dependents. |
|
||||
| `review` | Shows a viewer for the most recent unreviewed review item (e.g. alerts/detections in Frigate). |
|
||||
| `reviews` | Shows a viewer for the most recent unreviewed review item (e.g. alerts/detections in Frigate). |
|
||||
| `snapshot` | Shows a viewer for the most recent snapshot for this camera. Can also be accessed by holding down the `snapshots` menu icon. |
|
||||
| `snapshots` | Shows a gallery of snapshots for this camera. |
|
||||
| `timeline` | Shows an event timeline. |
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"ignorePatterns": [
|
||||
{
|
||||
"pattern": "vscode://"
|
||||
},
|
||||
{
|
||||
"pattern": "https://www.npmjs.com/"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getMediaDownloadPath } from '../../ha/download';
|
||||
import { EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { QuerySource } from '../../query-source.js';
|
||||
import { Endpoint } from '../../types';
|
||||
import { ViewMedia } from '../../view/item';
|
||||
import { ViewItemCapabilities } from '../../view/types';
|
||||
@@ -55,6 +56,7 @@ export class BrowseMediaCameraManagerEngine
|
||||
): EventQuery[] | null {
|
||||
return [
|
||||
{
|
||||
source: QuerySource.Camera,
|
||||
type: QueryType.Event,
|
||||
cameraIDs: cameraIDs,
|
||||
...query,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CameraManagerReadOnlyConfigStore } from './store';
|
||||
import {
|
||||
CameraManagerCameraMetadata,
|
||||
CameraQuery,
|
||||
DefaultQueryParameters,
|
||||
Engine,
|
||||
EngineOptions,
|
||||
EventQuery,
|
||||
@@ -17,11 +18,15 @@ import {
|
||||
PartialEventQuery,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
PartialReviewQuery,
|
||||
QueryReturnType,
|
||||
QueryType,
|
||||
RecordingQuery,
|
||||
RecordingQueryResultsMap,
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
ReviewQuery,
|
||||
ReviewQueryResultsMap,
|
||||
} from './types';
|
||||
|
||||
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
|
||||
@@ -31,6 +36,15 @@ export interface CameraManagerEngine {
|
||||
|
||||
createCamera(hass: HomeAssistant, cameraConfig: CameraConfig): Promise<Camera>;
|
||||
|
||||
/**
|
||||
* Get default query parameters for a camera based on its configuration.
|
||||
* Engines read their own config and return generic filter params.
|
||||
*/
|
||||
getDefaultQueryParameters(
|
||||
camera: Camera,
|
||||
queryType: QueryType,
|
||||
): DefaultQueryParameters;
|
||||
|
||||
generateDefaultEventQuery(
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
cameraIDs: Set<string>,
|
||||
@@ -70,6 +84,19 @@ export interface CameraManagerEngine {
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<RecordingSegmentsQueryResultsMap | null>;
|
||||
|
||||
generateDefaultReviewQuery(
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
cameraIDs: Set<string>,
|
||||
query?: PartialReviewQuery,
|
||||
): ReviewQuery[] | null;
|
||||
|
||||
getReviews(
|
||||
hass: HomeAssistant,
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
query: ReviewQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<ReviewQueryResultsMap | null>;
|
||||
|
||||
generateMediaFromEvents(
|
||||
hass: HomeAssistant,
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
@@ -84,6 +111,13 @@ export interface CameraManagerEngine {
|
||||
results: QueryReturnType<RecordingQuery>,
|
||||
): ViewMedia[] | null;
|
||||
|
||||
generateMediaFromReviews(
|
||||
hass: HomeAssistant,
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
query: ReviewQuery,
|
||||
results: QueryReturnType<ReviewQuery>,
|
||||
): ViewMedia[] | null;
|
||||
|
||||
getMediaDownloadPath(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
@@ -97,6 +131,13 @@ export interface CameraManagerEngine {
|
||||
favorite: boolean,
|
||||
): Promise<void>;
|
||||
|
||||
reviewMedia(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
reviewed: boolean,
|
||||
): Promise<void>;
|
||||
|
||||
getQueryResultMaxAge(query: CameraQuery): number | null;
|
||||
|
||||
getMediaSeekTime(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Endpoint,
|
||||
PTZCapabilities,
|
||||
PTZMovementType,
|
||||
SEVERITIES,
|
||||
} from '../../types';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import { Camera, CameraInitializationOptions } from '../camera';
|
||||
@@ -22,18 +23,21 @@ import {
|
||||
getGo2RTCStreamEndpoint,
|
||||
} from '../utils/go2rtc/endpoint';
|
||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||
import {
|
||||
FrigateEventWatcherRequest,
|
||||
FrigateEventWatcherSubscriptionInterface,
|
||||
} from './event-watcher';
|
||||
import { getPTZInfo } from './requests';
|
||||
import { FrigateEventChange, PTZInfo } from './types';
|
||||
import {
|
||||
FRIGATE_SEVERITY_MAP,
|
||||
FrigateEventChange,
|
||||
FrigateReviewChange,
|
||||
PTZInfo,
|
||||
} from './types';
|
||||
import { FrigateWatcherRequest, FrigateWatcherSubscriptionInterface } from './watcher';
|
||||
|
||||
const CAMERA_BIRDSEYE = 'birdseye' as const;
|
||||
|
||||
interface FrigateCameraInitializationOptions extends CameraInitializationOptions {
|
||||
entityRegistryManager: EntityRegistryManager;
|
||||
frigateEventWatcher: FrigateEventWatcherSubscriptionInterface;
|
||||
frigateEventWatcher: FrigateWatcherSubscriptionInterface<FrigateEventChange>;
|
||||
frigateReviewWatcher: FrigateWatcherSubscriptionInterface<FrigateReviewChange>;
|
||||
}
|
||||
|
||||
export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
|
||||
@@ -47,6 +51,7 @@ export class FrigateCamera extends Camera {
|
||||
|
||||
if (this._capabilities?.has('trigger')) {
|
||||
await this._subscribeToEvents(options.hass, options.frigateEventWatcher);
|
||||
await this._subscribeToReviews(options.hass, options.frigateReviewWatcher);
|
||||
}
|
||||
|
||||
return this;
|
||||
@@ -183,6 +188,7 @@ export class FrigateCamera extends Camera {
|
||||
clips: !birdseye,
|
||||
snapshots: !birdseye,
|
||||
recordings: !birdseye,
|
||||
reviews: !birdseye,
|
||||
...(combinedPTZ && { ptz: combinedPTZ }),
|
||||
};
|
||||
}
|
||||
@@ -415,7 +421,7 @@ export class FrigateCamera extends Camera {
|
||||
|
||||
protected async _subscribeToEvents(
|
||||
hass: HomeAssistant,
|
||||
frigateEventWatcher: FrigateEventWatcherSubscriptionInterface,
|
||||
frigateEventWatcher: FrigateWatcherSubscriptionInterface<FrigateEventChange>,
|
||||
): Promise<void> {
|
||||
const config = this.getConfig();
|
||||
if (!config.triggers.events.length || !config.frigate.camera_name) {
|
||||
@@ -424,7 +430,7 @@ export class FrigateCamera extends Camera {
|
||||
|
||||
/* istanbul ignore next -- exercising the matcher is not possible when the
|
||||
test uses an event watcher -- @preserve */
|
||||
const request: FrigateEventWatcherRequest = {
|
||||
const request: FrigateWatcherRequest<FrigateEventChange> = {
|
||||
instanceID: config.frigate.client_id,
|
||||
callback: (event: FrigateEventChange) => this._frigateEventHandler(event),
|
||||
matcher: (event: FrigateEventChange): boolean =>
|
||||
@@ -480,4 +486,92 @@ export class FrigateCamera extends Camera {
|
||||
snapshot: snapshotChange && eventsToTriggerOn.includes('snapshots'),
|
||||
});
|
||||
};
|
||||
|
||||
protected async _subscribeToReviews(
|
||||
hass: HomeAssistant,
|
||||
frigateReviewWatcher: FrigateWatcherSubscriptionInterface<FrigateReviewChange>,
|
||||
): Promise<void> {
|
||||
const config = this.getConfig();
|
||||
const reviewConfig = config.triggers.reviews;
|
||||
|
||||
// Must have at least one severity configured and a camera name to subscribe
|
||||
if (!reviewConfig.severities.length || !config.frigate.camera_name) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* istanbul ignore next -- exercising the matcher is not possible when the
|
||||
test uses a review watcher -- @preserve */
|
||||
const request: FrigateWatcherRequest<FrigateReviewChange> = {
|
||||
instanceID: config.frigate.client_id,
|
||||
callback: (review: FrigateReviewChange) => this._frigateReviewHandler(review),
|
||||
matcher: (review: FrigateReviewChange): boolean =>
|
||||
review.after.camera === config.frigate.camera_name,
|
||||
};
|
||||
|
||||
await frigateReviewWatcher.subscribe(hass, request);
|
||||
this._onDestroy(() => frigateReviewWatcher.unsubscribe(request));
|
||||
}
|
||||
|
||||
protected _frigateReviewHandler = (review: FrigateReviewChange): void => {
|
||||
const config = this.getConfig();
|
||||
const cameraID = this._config.id;
|
||||
|
||||
if (!cameraID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
config.frigate.zones?.length &&
|
||||
!config.frigate.zones.some((zone) => review.after.data.zones?.includes(zone))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
config.frigate.labels?.length &&
|
||||
!config.frigate.labels.some((label) => review.after.data.objects?.includes(label))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reviewConfig = config.triggers.reviews;
|
||||
|
||||
// Map Frigate severity to card severity.
|
||||
const cardSeverity = SEVERITIES.find(
|
||||
(key) => FRIGATE_SEVERITY_MAP[key] === review.after.severity,
|
||||
);
|
||||
|
||||
// Check if this is a description update (GenAI added/changed title or scene)
|
||||
const isDescriptionUpdate =
|
||||
review.type === 'genai' ||
|
||||
(review.type === 'update' &&
|
||||
(review.after.data.metadata?.title !== review.before.data.metadata?.title ||
|
||||
review.after.data.metadata?.scene !== review.before.data.metadata?.scene ||
|
||||
review.after.data.metadata?.shortSummary !==
|
||||
review.before.data.metadata?.shortSummary));
|
||||
|
||||
const shouldTriggerOnSeverity =
|
||||
cardSeverity && reviewConfig.severities.includes(cardSeverity);
|
||||
|
||||
// Severity must match first - it's the gate condition.
|
||||
if (!shouldTriggerOnSeverity) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For 'update' events, only trigger if description changed (when
|
||||
// description updates are on). For 'new' and 'end' events, always trigger
|
||||
// if severity matched
|
||||
const shouldTriggerOnDescription = reviewConfig.description && isDescriptionUpdate;
|
||||
|
||||
if (review.type === 'update' && !shouldTriggerOnDescription) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._eventCallback?.({
|
||||
cameraID,
|
||||
fidelity: 'high',
|
||||
type: review.type,
|
||||
review: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CameraConfig } from '../../config/schema/cameras';
|
||||
import { getEntityTitle } from '../../ha/get-entity-title';
|
||||
import { EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { QuerySource, hasUnsupportedFilters } from '../../query-source.js';
|
||||
import { Endpoint } from '../../types';
|
||||
import {
|
||||
allPromises,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerRequestCache,
|
||||
CameraQuery,
|
||||
DefaultQueryParameters,
|
||||
Engine,
|
||||
EngineOptions,
|
||||
EventQuery,
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
PartialEventQuery,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
PartialReviewQuery,
|
||||
QueryResults,
|
||||
QueryResultsType,
|
||||
QueryReturnType,
|
||||
@@ -50,30 +53,38 @@ import {
|
||||
RecordingSegment,
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
ReviewQuery,
|
||||
ReviewQueryResultsMap,
|
||||
} from '../types';
|
||||
import { FrigateCamera, isBirdseye } from './camera';
|
||||
import { FrigateEventWatcher } from './event-watcher';
|
||||
import { FrigateViewMediaFactory } from './media';
|
||||
import { FrigateViewMediaClassifier } from './media-classifier';
|
||||
import {
|
||||
NativeFrigateEventQuery,
|
||||
NativeFrigateRecordingSegmentsQuery,
|
||||
NativeFrigateReviewQuery,
|
||||
getEventSummary,
|
||||
getEvents,
|
||||
getRecordingSegments,
|
||||
getRecordingsSummary,
|
||||
getReviews,
|
||||
retainEvent,
|
||||
setReviewsReviewed,
|
||||
} from './requests';
|
||||
import {
|
||||
FRIGATE_SEVERITY_MAP,
|
||||
FrigateEventQueryResults,
|
||||
FrigateRecording,
|
||||
FrigateRecordingQueryResults,
|
||||
FrigateRecordingSegmentsQueryResults,
|
||||
FrigateReviewQueryResults,
|
||||
} from './types';
|
||||
import { FrigateEventWatcher, FrigateReviewWatcher } from './watcher';
|
||||
|
||||
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||
const MEDIA_METADATA_REQUEST_CACHE_AGE_SECONDS = 60;
|
||||
const REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||
|
||||
class FrigateQueryResultsClassifier {
|
||||
public static isFrigateEventQueryResults(
|
||||
@@ -98,6 +109,12 @@ class FrigateQueryResultsClassifier {
|
||||
results.type === QueryResultsType.RecordingSegments
|
||||
);
|
||||
}
|
||||
|
||||
public static isFrigateReviewQueryResults(
|
||||
results: QueryResults,
|
||||
): results is FrigateReviewQueryResults {
|
||||
return results.engine === Engine.Frigate && results.type === QueryResultsType.Review;
|
||||
}
|
||||
}
|
||||
|
||||
export class FrigateCameraManagerEngine
|
||||
@@ -106,6 +123,7 @@ export class FrigateCameraManagerEngine
|
||||
{
|
||||
protected _entityRegistryManager: EntityRegistryManager;
|
||||
protected _frigateEventWatcher: FrigateEventWatcher;
|
||||
protected _frigateReviewWatcher: FrigateReviewWatcher;
|
||||
protected _recordingSegmentsCache: RecordingSegmentsCache;
|
||||
protected _requestCache: CameraManagerRequestCache;
|
||||
|
||||
@@ -126,6 +144,7 @@ export class FrigateCameraManagerEngine
|
||||
super(stateWatcher, eventCallback);
|
||||
this._entityRegistryManager = entityRegistryManager;
|
||||
this._frigateEventWatcher = new FrigateEventWatcher();
|
||||
this._frigateReviewWatcher = new FrigateReviewWatcher();
|
||||
this._recordingSegmentsCache = recordingSegmentsCache;
|
||||
this._requestCache = requestCache;
|
||||
}
|
||||
@@ -146,9 +165,25 @@ export class FrigateCameraManagerEngine
|
||||
entityRegistryManager: this._entityRegistryManager,
|
||||
stateWatcher: this._stateWatcher,
|
||||
frigateEventWatcher: this._frigateEventWatcher,
|
||||
frigateReviewWatcher: this._frigateReviewWatcher,
|
||||
});
|
||||
}
|
||||
|
||||
public override getDefaultQueryParameters(
|
||||
camera: Camera,
|
||||
queryType: QueryType,
|
||||
): DefaultQueryParameters {
|
||||
if (queryType !== QueryType.Event && queryType !== QueryType.Review) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const cameraConfig = camera.getConfig();
|
||||
return {
|
||||
...(cameraConfig.frigate.labels && { what: new Set(cameraConfig.frigate.labels) }),
|
||||
...(cameraConfig.frigate.zones && { where: new Set(cameraConfig.frigate.zones) }),
|
||||
};
|
||||
}
|
||||
|
||||
public async getMediaDownloadPath(
|
||||
_hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
@@ -182,51 +217,10 @@ export class FrigateCameraManagerEngine
|
||||
cameraIDs: Set<string>,
|
||||
query?: PartialEventQuery,
|
||||
): EventQuery[] | null {
|
||||
const relevantCameraConfigs = [...store.getCameraConfigs(cameraIDs)];
|
||||
|
||||
// If all cameras specify exactly the same zones or labels (incl. none), we
|
||||
// can use a single batch query which will be better performance wise,
|
||||
// otherwise we must fan out to multiple queries in order to precisely match
|
||||
// the user's intent.
|
||||
const uniqueZoneArrays = uniqWith(
|
||||
relevantCameraConfigs.map((config) => config?.frigate.zones),
|
||||
isEqual,
|
||||
);
|
||||
const uniqueLabelArrays = uniqWith(
|
||||
relevantCameraConfigs.map((config) => config?.frigate.labels),
|
||||
isEqual,
|
||||
);
|
||||
|
||||
if (uniqueZoneArrays.length === 1 && uniqueLabelArrays.length === 1) {
|
||||
return [
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: cameraIDs,
|
||||
...(uniqueLabelArrays[0] && { what: new Set(uniqueLabelArrays[0]) }),
|
||||
...(uniqueZoneArrays[0] && { where: new Set(uniqueZoneArrays[0]) }),
|
||||
...query,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const output: EventQuery[] = [];
|
||||
for (const cameraID of cameraIDs) {
|
||||
const cameraConfig = store.getCameraConfig(cameraID);
|
||||
if (cameraConfig) {
|
||||
output.push({
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set([cameraID]),
|
||||
...(cameraConfig.frigate.labels && {
|
||||
what: new Set(cameraConfig.frigate.labels),
|
||||
}),
|
||||
...(cameraConfig.frigate.zones && {
|
||||
where: new Set(cameraConfig.frigate.zones),
|
||||
}),
|
||||
...query,
|
||||
});
|
||||
}
|
||||
}
|
||||
return output.length ? output : null;
|
||||
return this._generateBatchableQuery(store, cameraIDs, {
|
||||
type: QueryType.Event,
|
||||
...query,
|
||||
});
|
||||
}
|
||||
|
||||
public generateDefaultRecordingQuery(
|
||||
@@ -236,6 +230,7 @@ export class FrigateCameraManagerEngine
|
||||
): RecordingQuery[] {
|
||||
return [
|
||||
{
|
||||
source: QuerySource.Camera,
|
||||
type: QueryType.Recording,
|
||||
cameraIDs: cameraIDs,
|
||||
...query,
|
||||
@@ -262,6 +257,86 @@ export class FrigateCameraManagerEngine
|
||||
];
|
||||
}
|
||||
|
||||
public generateDefaultReviewQuery(
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
cameraIDs: Set<string>,
|
||||
query?: PartialReviewQuery,
|
||||
): ReviewQuery[] | null {
|
||||
return this._generateBatchableQuery(store, cameraIDs, {
|
||||
type: QueryType.Review,
|
||||
...query,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to generate batchable queries for events and reviews.
|
||||
* If all cameras have identical zones/labels config, creates a single batch query.
|
||||
* Otherwise fans out to per-camera queries.
|
||||
*/
|
||||
protected _generateBatchableQuery(
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
cameraIDs: Set<string>,
|
||||
query: PartialEventQuery & { type: QueryType.Event },
|
||||
): EventQuery[] | null;
|
||||
protected _generateBatchableQuery(
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
cameraIDs: Set<string>,
|
||||
query: PartialReviewQuery & { type: QueryType.Review },
|
||||
): ReviewQuery[] | null;
|
||||
protected _generateBatchableQuery(
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
cameraIDs: Set<string>,
|
||||
query: (PartialEventQuery | PartialReviewQuery) & {
|
||||
type: QueryType.Event | QueryType.Review;
|
||||
},
|
||||
): (EventQuery | ReviewQuery)[] | null {
|
||||
const relevantCameraConfigs = [...store.getCameraConfigs(cameraIDs)];
|
||||
|
||||
// If all cameras specify exactly the same zones or labels (incl. none), we
|
||||
// can use a single batch query which will be better performance wise,
|
||||
// otherwise we must fan out to multiple queries in order to precisely match
|
||||
// the user's intent.
|
||||
const uniqueZoneArrays = uniqWith(
|
||||
relevantCameraConfigs.map((config) => config?.frigate.zones),
|
||||
isEqual,
|
||||
);
|
||||
const uniqueLabelArrays = uniqWith(
|
||||
relevantCameraConfigs.map((config) => config?.frigate.labels),
|
||||
isEqual,
|
||||
);
|
||||
|
||||
if (uniqueZoneArrays.length === 1 && uniqueLabelArrays.length === 1) {
|
||||
return [
|
||||
{
|
||||
source: QuerySource.Camera,
|
||||
...query,
|
||||
cameraIDs: cameraIDs,
|
||||
...(uniqueLabelArrays[0] && { what: new Set(uniqueLabelArrays[0]) }),
|
||||
...(uniqueZoneArrays[0] && { where: new Set(uniqueZoneArrays[0]) }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const output: (EventQuery | ReviewQuery)[] = [];
|
||||
for (const cameraID of cameraIDs) {
|
||||
const cameraConfig = store.getCameraConfig(cameraID);
|
||||
if (cameraConfig) {
|
||||
output.push({
|
||||
source: QuerySource.Camera,
|
||||
...query,
|
||||
cameraIDs: new Set([cameraID]),
|
||||
...(cameraConfig.frigate.labels && {
|
||||
what: new Set(cameraConfig.frigate.labels),
|
||||
}),
|
||||
...(cameraConfig.frigate.zones && {
|
||||
where: new Set(cameraConfig.frigate.zones),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return output.length ? output : null;
|
||||
}
|
||||
|
||||
public async favoriteMedia(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
@@ -276,6 +351,24 @@ export class FrigateCameraManagerEngine
|
||||
media.setFavorite(favorite);
|
||||
}
|
||||
|
||||
public async reviewMedia(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
reviewed: boolean,
|
||||
): Promise<void> {
|
||||
if (!FrigateViewMediaClassifier.isFrigateReview(media)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await setReviewsReviewed(
|
||||
hass,
|
||||
cameraConfig.frigate.client_id,
|
||||
[media.getID()],
|
||||
reviewed,
|
||||
);
|
||||
}
|
||||
|
||||
protected _buildInstanceToCameraIDMapFromQuery(
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
cameraIDs: Set<string>,
|
||||
@@ -314,6 +407,17 @@ export class FrigateCameraManagerEngine
|
||||
query: EventQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<EventQueryResultsMap | null> {
|
||||
if (
|
||||
hasUnsupportedFilters(query, {
|
||||
favorite: true,
|
||||
tags: true,
|
||||
what: true,
|
||||
where: true,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: EventQueryResultsMap = new Map();
|
||||
|
||||
const processInstanceQuery = async (
|
||||
@@ -374,12 +478,80 @@ export class FrigateCameraManagerEngine
|
||||
return output.size ? output : null;
|
||||
}
|
||||
|
||||
public async getReviews(
|
||||
hass: HomeAssistant,
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
query: ReviewQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<ReviewQueryResultsMap | null> {
|
||||
if (hasUnsupportedFilters(query, { what: true, where: true, reviewed: true })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: ReviewQueryResultsMap = new Map();
|
||||
|
||||
const processInstanceQuery = async (
|
||||
instanceID: string,
|
||||
cameraIDs?: Set<string>,
|
||||
): Promise<void> => {
|
||||
if (!cameraIDs || !cameraIDs.size) {
|
||||
return;
|
||||
}
|
||||
const instanceQuery = { ...query, cameraIDs: cameraIDs };
|
||||
const cachedResult =
|
||||
engineOptions?.useCache ?? true ? this._requestCache.get(instanceQuery) : null;
|
||||
if (cachedResult) {
|
||||
output.set(query, cachedResult as FrigateReviewQueryResults);
|
||||
return;
|
||||
}
|
||||
|
||||
const nativeQuery: NativeFrigateReviewQuery = {
|
||||
instance_id: instanceID,
|
||||
cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(store, cameraIDs)),
|
||||
...(query.what && { labels: Array.from(query.what) }),
|
||||
...(query.where && { zones: Array.from(query.where) }),
|
||||
...(query.end && { before: Math.floor(query.end.getTime() / 1000) }),
|
||||
...(query.start && { after: Math.floor(query.start.getTime() / 1000) }),
|
||||
...(query.limit && { limit: query.limit }),
|
||||
...(query.severity && { severity: FRIGATE_SEVERITY_MAP[query.severity] }),
|
||||
...(query.reviewed !== undefined && { reviewed: query.reviewed }),
|
||||
};
|
||||
|
||||
const result: FrigateReviewQueryResults = {
|
||||
type: QueryResultsType.Review,
|
||||
engine: Engine.Frigate,
|
||||
instanceID: instanceID,
|
||||
reviews: await getReviews(hass, nativeQuery),
|
||||
expiry: add(new Date(), { seconds: REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS }),
|
||||
cached: false,
|
||||
};
|
||||
|
||||
if (engineOptions?.useCache ?? true) {
|
||||
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
|
||||
}
|
||||
output.set(instanceQuery, result);
|
||||
};
|
||||
|
||||
const instances = this._buildInstanceToCameraIDMapFromQuery(store, query.cameraIDs);
|
||||
|
||||
await Promise.all(
|
||||
Array.from(instances.keys()).map((instanceID) =>
|
||||
processInstanceQuery(instanceID, instances.get(instanceID)),
|
||||
),
|
||||
);
|
||||
return output.size ? output : null;
|
||||
}
|
||||
|
||||
public async getRecordings(
|
||||
hass: HomeAssistant,
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
query: RecordingQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<RecordingQueryResultsMap | null> {
|
||||
if (hasUnsupportedFilters(query)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: RecordingQueryResultsMap = new Map();
|
||||
|
||||
const processQuery = async (
|
||||
@@ -641,11 +813,50 @@ export class FrigateCameraManagerEngine
|
||||
return output;
|
||||
}
|
||||
|
||||
public generateMediaFromReviews(
|
||||
_hass: HomeAssistant,
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
query: ReviewQuery,
|
||||
results: QueryReturnType<ReviewQuery>,
|
||||
): ViewMedia[] | null {
|
||||
if (!FrigateQueryResultsClassifier.isFrigateReviewQueryResults(results)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: ViewMedia[] = [];
|
||||
for (const review of results.reviews) {
|
||||
const cameraID = this._getCameraIDMatch(
|
||||
store,
|
||||
query,
|
||||
results.instanceID,
|
||||
review.camera,
|
||||
);
|
||||
if (!cameraID) {
|
||||
continue;
|
||||
}
|
||||
const cameraConfig = this._getQueryableCameraConfig(store, cameraID);
|
||||
if (!cameraConfig) {
|
||||
continue;
|
||||
}
|
||||
const media = FrigateViewMediaFactory.createReviewViewMedia(
|
||||
cameraID,
|
||||
review,
|
||||
cameraConfig,
|
||||
);
|
||||
if (media) {
|
||||
output.push(media);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public getQueryResultMaxAge(query: CameraQuery): number | null {
|
||||
if (query.type === QueryType.Event) {
|
||||
return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
|
||||
} else if (query.type === QueryType.Recording) {
|
||||
return RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS;
|
||||
} else if (query.type === QueryType.Review) {
|
||||
return REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -657,11 +868,22 @@ export class FrigateCameraManagerEngine
|
||||
target: Date,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<number | null> {
|
||||
const start = media.getStartTime();
|
||||
const end = media.getEndTime();
|
||||
const mediaStart = media.getStartTime();
|
||||
const mediaEnd = media.getEndTime();
|
||||
const cameraID = media.getCameraID();
|
||||
const mediaType = media.getMediaType();
|
||||
|
||||
if (!start || !end || target < start || target > end || !cameraID) {
|
||||
if (!mediaStart || !cameraID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For recordings/reviews, use hour boundaries since Frigate recordings are
|
||||
// hour-long. For clips/snapshots, use the actual media time range.
|
||||
const isRecordingBased = mediaType === 'recording' || mediaType === 'review';
|
||||
const start = isRecordingBased ? startOfHour(mediaStart) : mediaStart;
|
||||
const end = isRecordingBased ? endOfHour(mediaStart) : mediaEnd;
|
||||
|
||||
if (!end || target < start || target > end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -762,6 +984,7 @@ export class FrigateCameraManagerEngine
|
||||
hass,
|
||||
store,
|
||||
{
|
||||
source: QuerySource.Camera,
|
||||
type: QueryType.Recording,
|
||||
cameraIDs: cameraIDs,
|
||||
},
|
||||
@@ -822,6 +1045,7 @@ export class FrigateCameraManagerEngine
|
||||
): Promise<void> {
|
||||
const cameraIDs = this._recordingSegmentsCache.getCameraIDs();
|
||||
const recordingQuery: RecordingQuery = {
|
||||
source: QuerySource.Camera,
|
||||
cameraIDs: new Set(cameraIDs),
|
||||
type: QueryType.Recording,
|
||||
};
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { FrigateEventChange, frigateEventChangeSchema } from './types';
|
||||
|
||||
export interface FrigateEventWatcherRequest {
|
||||
instanceID: string;
|
||||
matcher?(event: FrigateEventChange): boolean;
|
||||
callback(event: FrigateEventChange): void;
|
||||
}
|
||||
|
||||
export interface FrigateEventWatcherSubscriptionInterface {
|
||||
subscribe(hass: HomeAssistant, request: FrigateEventWatcherRequest): Promise<void>;
|
||||
unsubscribe(callback: FrigateEventWatcherRequest): void;
|
||||
}
|
||||
|
||||
type SubscriptionUnsubscribe = () => Promise<void>;
|
||||
|
||||
export class FrigateEventWatcher implements FrigateEventWatcherSubscriptionInterface {
|
||||
protected _requests: FrigateEventWatcherRequest[] = [];
|
||||
protected _unsubscribeCallback: Record<string, SubscriptionUnsubscribe> = {};
|
||||
|
||||
public async subscribe(
|
||||
hass: HomeAssistant,
|
||||
request: FrigateEventWatcherRequest,
|
||||
): Promise<void> {
|
||||
const shouldSubscribe = !this._hasSubscribers(request.instanceID);
|
||||
this._requests.push(request);
|
||||
if (shouldSubscribe) {
|
||||
this._unsubscribeCallback[request.instanceID] =
|
||||
await hass.connection.subscribeMessage<string>(
|
||||
(data) => this._receiveHandler(request.instanceID, data),
|
||||
{ type: 'frigate/events/subscribe', instance_id: request.instanceID },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async unsubscribe(request: FrigateEventWatcherRequest): Promise<void> {
|
||||
this._requests = this._requests.filter(
|
||||
(existingRequest) => existingRequest !== request,
|
||||
);
|
||||
|
||||
if (!this._hasSubscribers(request.instanceID)) {
|
||||
await this._unsubscribeCallback[request.instanceID]();
|
||||
delete this._unsubscribeCallback[request.instanceID];
|
||||
}
|
||||
}
|
||||
|
||||
protected _hasSubscribers(instanceID: string): boolean {
|
||||
return !!this._requests.filter((request) => request.instanceID === instanceID)
|
||||
.length;
|
||||
}
|
||||
|
||||
protected _receiveHandler(instanceID: string, data: string): void {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(data);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.warn('Received non-JSON payload as Frigate event', data);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedEvent = frigateEventChangeSchema.safeParse(json);
|
||||
if (!parsedEvent.success) {
|
||||
console.warn('Received malformed Frigate event from Home Assistant', data);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const request of this._requests) {
|
||||
if (
|
||||
request.instanceID === instanceID &&
|
||||
(!request.matcher || request.matcher(parsedEvent.data))
|
||||
) {
|
||||
request.callback(parsedEvent.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
import { ViewMedia } from '../../view/item';
|
||||
import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media';
|
||||
import {
|
||||
FrigateEventViewMedia,
|
||||
FrigateRecordingViewMedia,
|
||||
FrigateReviewViewMedia,
|
||||
} from './media';
|
||||
|
||||
export class FrigateViewMediaClassifier {
|
||||
public static isFrigateMedia(
|
||||
media: ViewMedia,
|
||||
): media is FrigateEventViewMedia | FrigateRecordingViewMedia {
|
||||
return this.isFrigateEvent(media) || this.isFrigateRecording(media);
|
||||
): media is
|
||||
| FrigateEventViewMedia
|
||||
| FrigateRecordingViewMedia
|
||||
| FrigateReviewViewMedia {
|
||||
return (
|
||||
this.isFrigateEvent(media) ||
|
||||
this.isFrigateRecording(media) ||
|
||||
this.isFrigateReview(media)
|
||||
);
|
||||
}
|
||||
public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia {
|
||||
return media instanceof FrigateEventViewMedia;
|
||||
@@ -15,4 +26,7 @@ export class FrigateViewMediaClassifier {
|
||||
): media is FrigateRecordingViewMedia {
|
||||
return media instanceof FrigateRecordingViewMedia;
|
||||
}
|
||||
public static isFrigateReview(media: ViewMedia): media is FrigateReviewViewMedia {
|
||||
return media instanceof FrigateReviewViewMedia;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { fromUnixTime } from 'date-fns';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { CameraConfig } from '../../config/schema/cameras';
|
||||
import { Severity } from '../../types';
|
||||
import {
|
||||
EventViewMedia,
|
||||
RecordingViewMedia,
|
||||
ReviewViewMedia,
|
||||
VideoContentType,
|
||||
ViewMedia,
|
||||
ViewMediaType,
|
||||
} from '../../view/item';
|
||||
import { FrigateEvent, FrigateRecording } from './types';
|
||||
import { FrigateEvent, FrigateRecording, FrigateReview } from './types';
|
||||
import {
|
||||
getEventMediaContentID,
|
||||
getEventThumbnailURL,
|
||||
@@ -16,6 +18,10 @@ import {
|
||||
getRecordingID,
|
||||
getRecordingMediaContentID,
|
||||
getRecordingTitle,
|
||||
getReviewMediaContentID,
|
||||
getReviewSeverity,
|
||||
getReviewThumbnailURL,
|
||||
getReviewTitle,
|
||||
} from './util';
|
||||
|
||||
export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
|
||||
@@ -66,6 +72,9 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
|
||||
public getTitle(): string | null {
|
||||
return getEventTitle(this._event);
|
||||
}
|
||||
public getDescription(): string | null {
|
||||
return this._event.data?.description ?? null;
|
||||
}
|
||||
public getThumbnail(): string | null {
|
||||
return this._thumbnail;
|
||||
}
|
||||
@@ -146,6 +155,74 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
|
||||
}
|
||||
}
|
||||
|
||||
export class FrigateReviewViewMedia extends ViewMedia implements ReviewViewMedia {
|
||||
protected _review: FrigateReview;
|
||||
protected _contentID: string;
|
||||
protected _thumbnail: string | null;
|
||||
protected _title: string;
|
||||
|
||||
constructor(
|
||||
cameraID: string,
|
||||
review: FrigateReview,
|
||||
contentID: string,
|
||||
thumbnail: string | null,
|
||||
) {
|
||||
super(ViewMediaType.Review, { cameraID });
|
||||
this._review = review;
|
||||
this._contentID = contentID;
|
||||
this._thumbnail = thumbnail;
|
||||
this._title = this._review.data.metadata?.title ?? getReviewTitle(review);
|
||||
}
|
||||
|
||||
public getID(): string {
|
||||
return this._review.id;
|
||||
}
|
||||
public getStartTime(): Date {
|
||||
return fromUnixTime(this._review.start_time);
|
||||
}
|
||||
public getEndTime(): Date | null {
|
||||
return this._review.end_time ? fromUnixTime(this._review.end_time) : null;
|
||||
}
|
||||
public inProgress(): boolean | null {
|
||||
return !this.getEndTime();
|
||||
}
|
||||
public getVideoContentType(): VideoContentType | null {
|
||||
return VideoContentType.HLS;
|
||||
}
|
||||
public getContentID(): string | null {
|
||||
return this._contentID;
|
||||
}
|
||||
public getTitle(): string | null {
|
||||
return this._review.data.metadata?.title ?? this._title;
|
||||
}
|
||||
public getDescription(): string | null {
|
||||
return (
|
||||
this._review.data.metadata?.scene ??
|
||||
this._review.data.metadata?.shortSummary ??
|
||||
null
|
||||
);
|
||||
}
|
||||
public getThumbnail(): string | null {
|
||||
return this._thumbnail;
|
||||
}
|
||||
public getSeverity(): Severity | null {
|
||||
return getReviewSeverity(this._review.severity);
|
||||
}
|
||||
public isReviewed(): boolean {
|
||||
return !!this._review.has_been_reviewed;
|
||||
}
|
||||
public setReviewed(reviewed: boolean): void {
|
||||
this._review.has_been_reviewed = reviewed;
|
||||
}
|
||||
public getWhat(): string[] | null {
|
||||
return this._review.data.objects ?? null;
|
||||
}
|
||||
public getWhere(): string[] | null {
|
||||
const zones = this._review.data.zones;
|
||||
return zones?.length ? zones : null;
|
||||
}
|
||||
}
|
||||
|
||||
export class FrigateViewMediaFactory {
|
||||
static createEventViewMedia(
|
||||
mediaType: ViewMediaType,
|
||||
@@ -201,4 +278,25 @@ export class FrigateViewMediaFactory {
|
||||
getRecordingTitle(cameraTitle, recording),
|
||||
);
|
||||
}
|
||||
|
||||
static createReviewViewMedia(
|
||||
cameraID: string,
|
||||
review: FrigateReview,
|
||||
cameraConfig: CameraConfig,
|
||||
): FrigateReviewViewMedia | null {
|
||||
if (!cameraConfig.frigate.client_id || !cameraConfig.frigate.camera_name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new FrigateReviewViewMedia(
|
||||
cameraID,
|
||||
review,
|
||||
getReviewMediaContentID(
|
||||
cameraConfig.frigate.client_id,
|
||||
cameraConfig.frigate.camera_name,
|
||||
review,
|
||||
),
|
||||
getReviewThumbnailURL(cameraConfig.frigate.client_id, review),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
eventSummarySchema,
|
||||
FrigateEvent,
|
||||
frigateEventsSchema,
|
||||
FrigateReview,
|
||||
frigateReviewsSchema,
|
||||
PTZInfo,
|
||||
ptzInfoSchema,
|
||||
recordingSegmentsSchema,
|
||||
@@ -15,6 +17,8 @@ import {
|
||||
recordingSummarySchema,
|
||||
RetainResult,
|
||||
retainResultSchema,
|
||||
ReviewResult,
|
||||
reviewResultSchema,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
@@ -176,3 +180,65 @@ export const getPTZInfo = async (
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
export interface NativeFrigateReviewQuery {
|
||||
instance_id: string;
|
||||
cameras?: string[];
|
||||
labels?: string[];
|
||||
zones?: string[];
|
||||
severity?: string;
|
||||
after?: number;
|
||||
before?: number;
|
||||
limit?: number;
|
||||
reviewed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get review items over websocket. May throw.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param params The review search parameters.
|
||||
* @returns An array of 'FrigateReview's.
|
||||
*/
|
||||
export const getReviews = async (
|
||||
hass: HomeAssistant,
|
||||
params: NativeFrigateReviewQuery,
|
||||
): Promise<FrigateReview[]> => {
|
||||
return await homeAssistantWSRequest(
|
||||
hass,
|
||||
frigateReviewsSchema,
|
||||
{
|
||||
type: 'frigate/reviews/get',
|
||||
...params,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
export const setReviewsReviewed = async (
|
||||
hass: HomeAssistant,
|
||||
instance_id: string,
|
||||
ids: string[],
|
||||
reviewed?: boolean,
|
||||
): Promise<void> => {
|
||||
const request = {
|
||||
type: 'frigate/reviews/viewed',
|
||||
instance_id,
|
||||
ids,
|
||||
|
||||
// Frigate uses 'viewed' to mean the review has been reviewed.
|
||||
...(reviewed !== undefined && { viewed: reviewed }),
|
||||
};
|
||||
|
||||
const response = await homeAssistantWSRequest<ReviewResult>(
|
||||
hass,
|
||||
reviewResultSchema,
|
||||
request,
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new AdvancedCameraCardError(localize('error.failed_response'), {
|
||||
request: request,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
EventQueryResults,
|
||||
RecordingQueryResults,
|
||||
RecordingSegmentsQueryResults,
|
||||
ReviewQueryResults,
|
||||
} from '../types';
|
||||
|
||||
const dayStringToDate = (arg: unknown): Date | unknown => {
|
||||
@@ -24,6 +25,12 @@ export const eventSchema = z.object({
|
||||
top_score: z.number().nullable(),
|
||||
zones: z.string().array(),
|
||||
retain_indefinitely: z.boolean().optional(),
|
||||
data: z
|
||||
.object({
|
||||
// GenAI-generated text description of the object/event
|
||||
description: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export const frigateEventsSchema = eventSchema.array();
|
||||
|
||||
@@ -57,6 +64,12 @@ export const retainResultSchema = z.object({
|
||||
});
|
||||
export type RetainResult = z.infer<typeof retainResultSchema>;
|
||||
|
||||
export const reviewResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
});
|
||||
export type ReviewResult = z.infer<typeof reviewResultSchema>;
|
||||
|
||||
export interface FrigateRecording {
|
||||
cameraID: string;
|
||||
startTime: Date;
|
||||
@@ -124,3 +137,60 @@ export interface FrigateRecordingSegmentsQueryResults
|
||||
engine: Engine.Frigate;
|
||||
instanceID: string;
|
||||
}
|
||||
|
||||
// =============
|
||||
// Review Types
|
||||
// =============
|
||||
|
||||
// Maps card severity to Frigate severity
|
||||
export const FRIGATE_SEVERITY_MAP = {
|
||||
high: 'alert',
|
||||
medium: 'detection',
|
||||
low: 'significant_motion',
|
||||
} as const;
|
||||
|
||||
export type FrigateReviewSeverity =
|
||||
(typeof FRIGATE_SEVERITY_MAP)[keyof typeof FRIGATE_SEVERITY_MAP];
|
||||
|
||||
// Review data schema (only fields we need for display)
|
||||
const frigateReviewDataSchema = z.object({
|
||||
objects: z.string().array().optional(),
|
||||
zones: z.string().array().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
title: z.string().optional(),
|
||||
scene: z.string().optional(),
|
||||
shortSummary: z.string().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Review item schema
|
||||
const frigateReviewSchema = z.object({
|
||||
id: z.string(),
|
||||
camera: z.string(),
|
||||
severity: z.enum(['alert', 'detection', 'significant_motion']),
|
||||
start_time: z.number(),
|
||||
end_time: z.number().nullable(),
|
||||
thumb_path: z.string().nullable(),
|
||||
has_been_reviewed: z.boolean().optional(),
|
||||
data: frigateReviewDataSchema,
|
||||
});
|
||||
export const frigateReviewsSchema = frigateReviewSchema.array();
|
||||
|
||||
export type FrigateReview = z.infer<typeof frigateReviewSchema>;
|
||||
|
||||
// Review change schema for live WebSocket updates
|
||||
export const frigateReviewChangeSchema = z.object({
|
||||
before: frigateReviewSchema,
|
||||
after: frigateReviewSchema,
|
||||
type: z.enum(['new', 'update', 'end', 'genai']),
|
||||
});
|
||||
export type FrigateReviewChange = z.infer<typeof frigateReviewChangeSchema>;
|
||||
|
||||
export interface FrigateReviewQueryResults extends ReviewQueryResults {
|
||||
engine: Engine.Frigate;
|
||||
instanceID: string;
|
||||
reviews: FrigateReview[];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { toZonedTime } from 'date-fns-tz';
|
||||
import { CameraConfig } from '../../config/schema/cameras';
|
||||
import { ClipsOrSnapshots } from '../../types';
|
||||
import { Severity } from '../../types';
|
||||
import { formatDateAndTime, prettifyTitle } from '../../utils/basic';
|
||||
import { FrigateEvent, FrigateRecording } from './types';
|
||||
import {
|
||||
FrigateEvent,
|
||||
FrigateRecording,
|
||||
FrigateReview,
|
||||
FrigateReviewSeverity,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Given an event generate a title.
|
||||
@@ -51,36 +56,36 @@ export const getEventMediaContentID = (
|
||||
clientId: string,
|
||||
cameraName: string,
|
||||
event: FrigateEvent,
|
||||
mediaType: ClipsOrSnapshots,
|
||||
mediaType: 'clips' | 'snapshots',
|
||||
): string => {
|
||||
return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a recording identifier.
|
||||
* @param clientId The Frigate client id.
|
||||
* @param cameraName The Frigate camera name.
|
||||
* @param recording The Frigate recording.
|
||||
* @returns A recording identifier.
|
||||
* Build a recording media content ID from a start time.
|
||||
*/
|
||||
const buildRecordingMediaContentID = (
|
||||
clientId: string,
|
||||
cameraName: string,
|
||||
startTime: Date,
|
||||
): string =>
|
||||
[
|
||||
'media-source://frigate',
|
||||
clientId,
|
||||
'recordings',
|
||||
cameraName,
|
||||
`${startTime.getFullYear()}-${String(startTime.getMonth() + 1).padStart(2, '0')}-${String(startTime.getDate()).padStart(2, '0')}`,
|
||||
String(startTime.getHours()).padStart(2, '0'),
|
||||
].join('/');
|
||||
|
||||
/**
|
||||
* Generate a recording media content ID.
|
||||
*/
|
||||
export const getRecordingMediaContentID = (
|
||||
clientId: string,
|
||||
cameraName: string,
|
||||
recording: FrigateRecording,
|
||||
): string => {
|
||||
return [
|
||||
'media-source://frigate',
|
||||
clientId,
|
||||
'recordings',
|
||||
cameraName,
|
||||
`${recording.startTime.getFullYear()}-${String(
|
||||
recording.startTime.getMonth() + 1,
|
||||
).padStart(2, '0')}-${String(
|
||||
String(recording.startTime.getDate()).padStart(2, '0'),
|
||||
)}`,
|
||||
String(recording.startTime.getHours()).padStart(2, '0'),
|
||||
].join('/');
|
||||
};
|
||||
): string => buildRecordingMediaContentID(clientId, cameraName, recording.startTime);
|
||||
|
||||
/**
|
||||
* Get a recording ID for internal de-duping.
|
||||
@@ -96,3 +101,65 @@ export const getRecordingID = (
|
||||
cameraConfig.frigate.camera_name ?? ''
|
||||
}/${recording.startTime.getTime()}/${recording.endTime.getTime()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a review generate a title.
|
||||
* @param review The Frigate review item.
|
||||
*/
|
||||
export const getReviewTitle = (review: FrigateReview): string => {
|
||||
const objects = review.data.objects?.length
|
||||
? review.data.objects.map((o) => prettifyTitle(o)).join(', ')
|
||||
: '';
|
||||
|
||||
if (objects) {
|
||||
return objects;
|
||||
}
|
||||
|
||||
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const durationSeconds = Math.round(
|
||||
review.end_time
|
||||
? review.end_time - review.start_time
|
||||
: Date.now() / 1000 - review.start_time,
|
||||
);
|
||||
|
||||
return `${formatDateAndTime(
|
||||
toZonedTime(review.start_time * 1000, localTimezone),
|
||||
)} [${durationSeconds}s${objects}]`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a review media content ID.
|
||||
*/
|
||||
export const getReviewMediaContentID = (
|
||||
clientId: string,
|
||||
cameraName: string,
|
||||
review: FrigateReview,
|
||||
): string =>
|
||||
buildRecordingMediaContentID(clientId, cameraName, new Date(review.start_time * 1000));
|
||||
|
||||
/**
|
||||
* Get a thumbnail URL for a review.
|
||||
*/
|
||||
export const getReviewThumbnailURL = (
|
||||
clientId: string,
|
||||
review: FrigateReview,
|
||||
): string | null => {
|
||||
if (!review.thumb_path) {
|
||||
return null;
|
||||
}
|
||||
const path = review.thumb_path.replace('/media/frigate/', '');
|
||||
return `/api/frigate/${clientId}/${path}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get generic review severity.
|
||||
*/
|
||||
export const getReviewSeverity = (severity: FrigateReviewSeverity): Severity => {
|
||||
if (severity === 'alert') {
|
||||
return 'high';
|
||||
}
|
||||
if (severity === 'detection') {
|
||||
return 'medium';
|
||||
}
|
||||
return 'low';
|
||||
};
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { z } from 'zod';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import {
|
||||
FrigateEventChange,
|
||||
FrigateReviewChange,
|
||||
frigateEventChangeSchema,
|
||||
frigateReviewChangeSchema,
|
||||
} from './types';
|
||||
|
||||
// Generic request interface for Frigate watchers
|
||||
export interface FrigateWatcherRequest<T> {
|
||||
instanceID: string;
|
||||
matcher?(item: T): boolean;
|
||||
callback(item: T): void;
|
||||
}
|
||||
|
||||
// Generic subscription interface
|
||||
export interface FrigateWatcherSubscriptionInterface<T> {
|
||||
subscribe(hass: HomeAssistant, request: FrigateWatcherRequest<T>): Promise<void>;
|
||||
unsubscribe(request: FrigateWatcherRequest<T>): void;
|
||||
}
|
||||
|
||||
type SubscriptionUnsubscribe = () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Base class for Frigate WebSocket watchers.
|
||||
* Handles subscription management and message routing to callbacks.
|
||||
*/
|
||||
abstract class FrigateWatcher<T> implements FrigateWatcherSubscriptionInterface<T> {
|
||||
protected abstract _type: string;
|
||||
protected abstract _schema: z.ZodType<T>;
|
||||
|
||||
protected _requests: FrigateWatcherRequest<T>[] = [];
|
||||
protected _unsubscribeCallback: Record<string, SubscriptionUnsubscribe> = {};
|
||||
|
||||
public async subscribe(
|
||||
hass: HomeAssistant,
|
||||
request: FrigateWatcherRequest<T>,
|
||||
): Promise<void> {
|
||||
const shouldSubscribe = !this._hasSubscribers(request.instanceID);
|
||||
this._requests.push(request);
|
||||
if (shouldSubscribe) {
|
||||
this._unsubscribeCallback[request.instanceID] =
|
||||
await hass.connection.subscribeMessage<string>(
|
||||
(data) => this._receiveHandler(request.instanceID, data),
|
||||
{ type: this._type, instance_id: request.instanceID },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async unsubscribe(request: FrigateWatcherRequest<T>): Promise<void> {
|
||||
this._requests = this._requests.filter(
|
||||
(existingRequest) => existingRequest !== request,
|
||||
);
|
||||
|
||||
if (!this._hasSubscribers(request.instanceID)) {
|
||||
await this._unsubscribeCallback[request.instanceID]();
|
||||
delete this._unsubscribeCallback[request.instanceID];
|
||||
}
|
||||
}
|
||||
|
||||
protected _hasSubscribers(instanceID: string): boolean {
|
||||
return !!this._requests.filter((request) => request.instanceID === instanceID)
|
||||
.length;
|
||||
}
|
||||
|
||||
protected _receiveHandler(instanceID: string, data: string): void {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(data);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.warn(`Received non-JSON payload from subscription: ${this._type}`, data);
|
||||
return;
|
||||
}
|
||||
|
||||
const parseResult = this._schema.safeParse(json);
|
||||
if (!parseResult.success) {
|
||||
console.warn(`Received malformed message from subscription: ${this._type}`, data);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const request of this._requests) {
|
||||
if (
|
||||
request.instanceID === instanceID &&
|
||||
(!request.matcher || request.matcher(parseResult.data))
|
||||
) {
|
||||
request.callback(parseResult.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watcher for Frigate event updates via WebSocket.
|
||||
*/
|
||||
export class FrigateEventWatcher extends FrigateWatcher<FrigateEventChange> {
|
||||
protected _type = 'frigate/events/subscribe';
|
||||
protected _schema = frigateEventChangeSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watcher for Frigate review updates via WebSocket.
|
||||
*/
|
||||
export class FrigateReviewWatcher extends FrigateWatcher<FrigateReviewChange> {
|
||||
protected _type = 'frigate/reviews/subscribe';
|
||||
protected _schema = frigateReviewChangeSchema;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CameraEventCallback,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraQuery,
|
||||
DefaultQueryParameters,
|
||||
Engine,
|
||||
EngineOptions,
|
||||
EventQuery,
|
||||
@@ -23,11 +24,15 @@ import {
|
||||
PartialEventQuery,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
PartialReviewQuery,
|
||||
QueryReturnType,
|
||||
QueryType,
|
||||
RecordingQuery,
|
||||
RecordingQueryResultsMap,
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
ReviewQuery,
|
||||
ReviewQueryResultsMap,
|
||||
} from '../types';
|
||||
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
|
||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||
@@ -67,6 +72,13 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
});
|
||||
}
|
||||
|
||||
public getDefaultQueryParameters(
|
||||
_camera: Camera,
|
||||
_queryType: QueryType,
|
||||
): DefaultQueryParameters {
|
||||
return {};
|
||||
}
|
||||
|
||||
public generateDefaultEventQuery(
|
||||
_store: CameraManagerReadOnlyConfigStore,
|
||||
_cameraIDs: Set<string>,
|
||||
@@ -118,6 +130,23 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
return null;
|
||||
}
|
||||
|
||||
public generateDefaultReviewQuery(
|
||||
_store: CameraManagerReadOnlyConfigStore,
|
||||
_cameraIDs: Set<string>,
|
||||
_query?: PartialReviewQuery,
|
||||
): ReviewQuery[] | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
public async getReviews(
|
||||
_hass: HomeAssistant,
|
||||
_store: CameraManagerReadOnlyConfigStore,
|
||||
_query: ReviewQuery,
|
||||
_engineOptions?: EngineOptions,
|
||||
): Promise<ReviewQueryResultsMap | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
public generateMediaFromEvents(
|
||||
_hass: HomeAssistant,
|
||||
_store: CameraManagerReadOnlyConfigStore,
|
||||
@@ -136,6 +165,15 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
return null;
|
||||
}
|
||||
|
||||
public generateMediaFromReviews(
|
||||
_hass: HomeAssistant,
|
||||
_store: CameraManagerReadOnlyConfigStore,
|
||||
_query: ReviewQuery,
|
||||
_results: QueryReturnType<ReviewQuery>,
|
||||
): ViewMedia[] | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
public async getMediaDownloadPath(
|
||||
_hass: HomeAssistant,
|
||||
_cameraConfig: CameraConfig,
|
||||
@@ -153,6 +191,15 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
public async reviewMedia(
|
||||
_hass: HomeAssistant,
|
||||
_cameraConfig: CameraConfig,
|
||||
_media: ViewMedia,
|
||||
_reviewed: boolean,
|
||||
): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
public getQueryResultMaxAge(_query: CameraQuery): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
CameraEndpointsContext,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraQuery,
|
||||
DefaultQueryParameters,
|
||||
Engine,
|
||||
EngineOptions,
|
||||
EventQuery,
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
PartialQueryConcreteType,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
PartialReviewQuery,
|
||||
QueryResults,
|
||||
QueryResultsType,
|
||||
QueryReturnType,
|
||||
@@ -60,6 +62,8 @@ import {
|
||||
RecordingSegmentsQueryResults,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
ResultsMap,
|
||||
ReviewQuery,
|
||||
ReviewQueryResults,
|
||||
} from './types.js';
|
||||
|
||||
export class CameraQueryClassifier {
|
||||
@@ -83,6 +87,11 @@ export class CameraQueryClassifier {
|
||||
): query is MediaMetadataQuery {
|
||||
return query.type === QueryType.MediaMetadata;
|
||||
}
|
||||
public static isReviewQuery(
|
||||
query: CameraQuery | PartialCameraQuery,
|
||||
): query is ReviewQuery {
|
||||
return query.type === QueryType.Review;
|
||||
}
|
||||
}
|
||||
|
||||
export class QueryResultClassifier {
|
||||
@@ -106,9 +115,14 @@ export class QueryResultClassifier {
|
||||
): queryResults is MediaMetadataQueryResults {
|
||||
return queryResults?.type === QueryResultsType.MediaMetadata;
|
||||
}
|
||||
public static isReviewQueryResult(
|
||||
queryResults?: QueryResults | null,
|
||||
): queryResults is ReviewQueryResults {
|
||||
return queryResults?.type === QueryResultsType.Review;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExtendedMediaQueryResult<T extends MediaQuery> {
|
||||
interface ExtendedMediaQueryResult<T extends MediaQuery> {
|
||||
queries: T[];
|
||||
results: ViewItem[];
|
||||
}
|
||||
@@ -308,6 +322,17 @@ export class CameraManager {
|
||||
});
|
||||
}
|
||||
|
||||
public getDefaultQueryParameters(
|
||||
cameraID: string,
|
||||
queryType: QueryType,
|
||||
): DefaultQueryParameters {
|
||||
const camera = this._store.getCamera(cameraID);
|
||||
if (!camera) {
|
||||
return {};
|
||||
}
|
||||
return camera.getEngine().getDefaultQueryParameters(camera, queryType);
|
||||
}
|
||||
|
||||
public generateDefaultRecordingQueries(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery?: PartialRecordingQuery,
|
||||
@@ -328,6 +353,16 @@ export class CameraManager {
|
||||
});
|
||||
}
|
||||
|
||||
public generateDefaultReviewQueries(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery?: PartialReviewQuery,
|
||||
): ReviewQuery[] | null {
|
||||
return this._generateDefaultQueries(cameraIDs, {
|
||||
type: QueryType.Review,
|
||||
...partialQuery,
|
||||
});
|
||||
}
|
||||
|
||||
protected _generateDefaultQueries<PQT extends PartialCameraQuery>(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery: PQT,
|
||||
@@ -356,6 +391,12 @@ export class CameraManager {
|
||||
cameraIDs,
|
||||
partialQuery,
|
||||
);
|
||||
} else if (CameraQueryClassifier.isReviewQuery(partialQuery)) {
|
||||
queries = engine.generateDefaultReviewQuery(
|
||||
this._store,
|
||||
cameraIDs,
|
||||
partialQuery,
|
||||
);
|
||||
}
|
||||
|
||||
for (const query of queries ?? []) {
|
||||
@@ -568,6 +609,33 @@ export class CameraManager {
|
||||
);
|
||||
}
|
||||
|
||||
public async reviewMedia(media: ViewMedia, reviewed: boolean): Promise<void> {
|
||||
const cameraConfig = this._store.getCameraConfigForMedia(media);
|
||||
const engine = this._store.getEngineForMedia(media);
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
|
||||
if (!cameraConfig || !engine || !hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryStartTime = new Date();
|
||||
|
||||
await this._requestLimit.add(() =>
|
||||
engine.reviewMedia(hass, cameraConfig, media, reviewed),
|
||||
);
|
||||
|
||||
log(
|
||||
this._api.getConfigManager().getCardWideConfig(),
|
||||
'Advanced Camera Card CameraManager review media request (',
|
||||
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
|
||||
'Media:',
|
||||
media.getID(),
|
||||
', Reviewed:',
|
||||
reviewed,
|
||||
')',
|
||||
);
|
||||
}
|
||||
|
||||
public areMediaQueriesResultsFresh<T extends MediaQuery>(
|
||||
resultsTimestamp: Date,
|
||||
queries: T[] | null,
|
||||
@@ -667,6 +735,13 @@ export class CameraManager {
|
||||
query,
|
||||
engineOptions,
|
||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||
} else if (CameraQueryClassifier.isReviewQuery(query)) {
|
||||
engineResult = (await engine.getReviews(
|
||||
hass,
|
||||
this._store,
|
||||
query,
|
||||
engineOptions,
|
||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||
}
|
||||
|
||||
engineResult?.forEach((value, key) => results.set(key, value));
|
||||
@@ -739,6 +814,11 @@ export class CameraManager {
|
||||
QueryResultClassifier.isRecordingQueryResult(result)
|
||||
) {
|
||||
media = engine.generateMediaFromRecordings(hass, this._store, query, result);
|
||||
} else if (
|
||||
CameraQueryClassifier.isReviewQuery(query) &&
|
||||
QueryResultClassifier.isReviewQueryResult(result)
|
||||
) {
|
||||
media = engine.generateMediaFromReviews(hass, this._store, query, result);
|
||||
}
|
||||
if (media) {
|
||||
mediaArray.push(...media);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { BrowseMediaStep, BrowseMediaTarget } from '../../ha/browse-media/walker';
|
||||
import { isMediaWithinDates } from '../../ha/browse-media/within-dates';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { hasUnsupportedFilters } from '../../query-source.js';
|
||||
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||
import { ViewMedia } from '../../view/item';
|
||||
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
||||
@@ -224,8 +225,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
||||
query: EventQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<EventQueryResultsMap | null> {
|
||||
// MotionEye does not support these query types and they will never match.
|
||||
if (query.favorite || query.tags?.size || query.what?.size || query.where?.size) {
|
||||
if (hasUnsupportedFilters(query)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { DeviceRegistryManager } from '../../ha/registry/device';
|
||||
import { EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { hasUnsupportedFilters } from '../../query-source.js';
|
||||
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||
import { ViewMedia } from '../../view/item';
|
||||
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
||||
@@ -255,14 +256,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||
query: EventQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<EventQueryResultsMap | null> {
|
||||
// Reolink does not support these query types and they will never match.
|
||||
if (
|
||||
query.favorite ||
|
||||
query.tags?.size ||
|
||||
query.what?.size ||
|
||||
query.where?.size ||
|
||||
query.hasSnapshot
|
||||
) {
|
||||
if (hasUnsupportedFilters(query) || query.hasSnapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+53
-16
@@ -1,7 +1,8 @@
|
||||
import { ExpiringEqualityCache } from '../cache/expiring-cache';
|
||||
import { SSLCiphers } from '../config/schema/cameras';
|
||||
import { AdvancedCameraCardView } from '../config/schema/common/const';
|
||||
import { CapabilityKey, Endpoint, Icon } from '../types';
|
||||
import { BaseQuery, QueryFilters, QuerySource } from '../query-source';
|
||||
import { CapabilityKey, Endpoint, Icon, Severity } from '../types';
|
||||
import { ViewMedia } from '../view/item';
|
||||
|
||||
// ====
|
||||
@@ -13,6 +14,7 @@ export enum QueryType {
|
||||
Recording = 'recording-query',
|
||||
RecordingSegments = 'recording-segments-query',
|
||||
MediaMetadata = 'media-metadata',
|
||||
Review = 'review-query',
|
||||
}
|
||||
|
||||
export enum QueryResultsType {
|
||||
@@ -20,6 +22,7 @@ export enum QueryResultsType {
|
||||
Recording = 'recording-results',
|
||||
RecordingSegments = 'recording-segments-results',
|
||||
MediaMetadata = 'media-metadata-results',
|
||||
Review = 'review-results',
|
||||
}
|
||||
|
||||
export enum Engine {
|
||||
@@ -46,10 +49,12 @@ interface LimitedDataQuery {
|
||||
}
|
||||
|
||||
export interface MediaQuery
|
||||
extends CameraQuery,
|
||||
extends BaseQuery,
|
||||
CameraQuery,
|
||||
QueryFilters,
|
||||
Partial<TimeBasedDataQuery>,
|
||||
Partial<LimitedDataQuery> {
|
||||
favorite?: boolean;
|
||||
source: QuerySource.Camera;
|
||||
}
|
||||
|
||||
export interface QueryResults {
|
||||
@@ -74,14 +79,18 @@ export type QueryReturnType<QT> = QT extends EventQuery
|
||||
? RecordingSegmentsQueryResults
|
||||
: QT extends MediaMetadataQuery
|
||||
? MediaMetadataQueryResults
|
||||
: never;
|
||||
: QT extends ReviewQuery
|
||||
? ReviewQueryResults
|
||||
: never;
|
||||
export type PartialQueryConcreteType<PQT> = PQT extends PartialEventQuery
|
||||
? EventQuery
|
||||
: PQT extends PartialRecordingQuery
|
||||
? RecordingQuery
|
||||
: PQT extends PartialRecordingSegmentsQuery
|
||||
? RecordingSegmentsQuery
|
||||
: never;
|
||||
: PQT extends PartialReviewQuery
|
||||
? ReviewQuery
|
||||
: never;
|
||||
|
||||
export type ResultsMap<QT> = Map<QT, QueryReturnType<QT>>;
|
||||
export type EventQueryResultsMap = ResultsMap<EventQuery>;
|
||||
@@ -140,16 +149,21 @@ export interface EngineOptions {
|
||||
export interface CameraEvent {
|
||||
cameraID: string;
|
||||
|
||||
type: 'new' | 'update' | 'end';
|
||||
type:
|
||||
| 'new' // A new event has started.
|
||||
| 'update' // An update for an event is available (except GenAI).
|
||||
| 'end' // An event has ended.
|
||||
| 'genai'; // An AI based update is available.
|
||||
|
||||
// When fidelity is `high`, the engine is assumed to provide exact details of
|
||||
// what new media is available. Otherwise all media types are assumed to be
|
||||
// possibly newly available.
|
||||
fidelity?: 'high' | 'low';
|
||||
|
||||
// Whether a new clip/snapshot/recording may be available.
|
||||
// Whether new media may be available.
|
||||
clip?: boolean;
|
||||
snapshot?: boolean;
|
||||
review?: boolean;
|
||||
}
|
||||
export type CameraEventCallback = (ev: CameraEvent) => void;
|
||||
|
||||
@@ -158,6 +172,21 @@ export class CameraManagerRequestCache extends ExpiringEqualityCache<
|
||||
QueryResults
|
||||
> {}
|
||||
|
||||
// ====================
|
||||
// Default Query Params
|
||||
// ====================
|
||||
|
||||
/**
|
||||
* Default query parameters that engines can provide based on camera configuration.
|
||||
*/
|
||||
export interface DefaultQueryParameters {
|
||||
// Object labels (what was detected)
|
||||
what?: Set<string>;
|
||||
|
||||
// Zones (where detection occurred)
|
||||
where?: Set<string>;
|
||||
}
|
||||
|
||||
// ===========
|
||||
// Event Query
|
||||
// ===========
|
||||
@@ -170,15 +199,6 @@ export interface EventQuery extends MediaQuery {
|
||||
|
||||
// Frigate equivalent: has_clip
|
||||
hasClip?: boolean;
|
||||
|
||||
// Frigate equivalent: label
|
||||
what?: Set<string>;
|
||||
|
||||
// Frigate equivalent: sub_label
|
||||
tags?: Set<string>;
|
||||
|
||||
// Frigate equivalent: zone
|
||||
where?: Set<string>;
|
||||
}
|
||||
export type PartialEventQuery = Partial<EventQuery>;
|
||||
|
||||
@@ -225,3 +245,20 @@ export interface MediaMetadataQueryResults extends QueryResults {
|
||||
type: QueryResultsType.MediaMetadata;
|
||||
metadata: MediaMetadata;
|
||||
}
|
||||
|
||||
// ============
|
||||
// Review Query
|
||||
// ============
|
||||
|
||||
export interface ReviewQuery extends MediaQuery {
|
||||
type: QueryType.Review;
|
||||
|
||||
severity?: Severity;
|
||||
}
|
||||
export type PartialReviewQuery = Partial<ReviewQuery>;
|
||||
|
||||
export interface ReviewQueryResults extends QueryResults {
|
||||
type: QueryResultsType.Review;
|
||||
}
|
||||
|
||||
export type ReviewQueryResultsMap = ResultsMap<ReviewQuery>;
|
||||
|
||||
@@ -53,7 +53,7 @@ export class ActionsManager implements ActionsExecutor {
|
||||
let specificActions: Actions | undefined = undefined;
|
||||
if (view?.is('live')) {
|
||||
specificActions = config?.live.actions;
|
||||
} else if (view?.isMediaGalleryView()) {
|
||||
} else if (view?.isGalleryView()) {
|
||||
specificActions = config?.media_gallery?.actions;
|
||||
} else if (view?.isViewerView()) {
|
||||
specificActions = config?.media_viewer.actions;
|
||||
|
||||
@@ -21,26 +21,26 @@ export class BaseAction<T extends ActionConfig> implements Action {
|
||||
|
||||
protected _shouldSeekConfirmation(api: CardActionsAPI): boolean {
|
||||
const hass = api.getHASSManager().getHASS();
|
||||
const action: ActionConfig = this._action;
|
||||
|
||||
return (
|
||||
(typeof this._action.confirmation === 'boolean' && this._action.confirmation) ||
|
||||
(typeof this._action.confirmation === 'object' &&
|
||||
(!this._action.confirmation.exemptions ||
|
||||
!this._action.confirmation.exemptions.some(
|
||||
(entry) => entry.user === hass?.user.id,
|
||||
)))
|
||||
(typeof action.confirmation === 'boolean' && action.confirmation) ||
|
||||
(typeof action.confirmation === 'object' &&
|
||||
(!action.confirmation.exemptions ||
|
||||
!action.confirmation.exemptions.some((entry) => entry.user === hass?.user.id)))
|
||||
);
|
||||
}
|
||||
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
if (this._shouldSeekConfirmation(api)) {
|
||||
const actionName = isAdvancedCameraCardCustomAction(this._action)
|
||||
? this._action.advanced_camera_card_action
|
||||
: this._action.action;
|
||||
const action: ActionConfig = this._action;
|
||||
const baseAction = action.action;
|
||||
const actionName = isAdvancedCameraCardCustomAction(action)
|
||||
? action.advanced_camera_card_action
|
||||
: baseAction;
|
||||
const text =
|
||||
(typeof this._action.confirmation === 'object'
|
||||
? this._action.confirmation.text
|
||||
: null) ?? `${localize('actions.confirmation')}: ${actionName}`;
|
||||
(typeof action.confirmation === 'object' ? action.confirmation.text : null) ??
|
||||
`${localize('actions.confirmation')}: ${actionName}`;
|
||||
if (!confirm(text)) {
|
||||
throw new ActionAbortError(localize('actions.abort'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MediaDetailsController } from '../../../components-lib/media/details-controller';
|
||||
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
|
||||
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class InfoAction extends AdvancedCameraCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
const item = api.getViewManager().getView()?.queryResults?.getSelectedResult();
|
||||
if (!ViewItemClassifier.isMedia(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
controller.calculate(api.getCameraManager(), item);
|
||||
|
||||
api.getOverlayMessageManager().setMessage(
|
||||
controller.getMessage({
|
||||
hass: api.getHASSManager().getHASS() ?? undefined,
|
||||
viewItemManager: api.getViewItemManager(),
|
||||
viewManagerEpoch: api.getViewManager().getEpoch(),
|
||||
capabilities: api.getViewItemManager().getCapabilities(item),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SetReviewActionConfig } from '../../../config/schema/actions/custom/set-review';
|
||||
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class SetReviewAction extends AdvancedCameraCardAction<SetReviewActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
const view = api.getViewManager().getView();
|
||||
const queryResults = view?.queryResults;
|
||||
const item = queryResults?.getSelectedResult();
|
||||
|
||||
if (!ViewItemClassifier.isReview(item) || !queryResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetReviewedState = this._action.reviewed ?? !item.isReviewed();
|
||||
|
||||
await api.getViewItemManager().reviewMedia(item, targetReviewedState);
|
||||
|
||||
// Clone the item to ensure Lit detects the change.
|
||||
// Test-case: Setting a media item reviewed via the menu, should update the
|
||||
// reviewed state in a thumbnail.
|
||||
const clonedItem = item.clone();
|
||||
clonedItem.setReviewed(targetReviewedState);
|
||||
|
||||
api.getViewManager().setViewByParameters({
|
||||
params: {
|
||||
queryResults: queryResults.clone().replaceItem(item, clonedItem),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,10 @@ import { CustomAction } from './actions/custom';
|
||||
import { DefaultAction } from './actions/default';
|
||||
import { DisplayModeSelectAction } from './actions/display-mode-select';
|
||||
import { DownloadAction } from './actions/download';
|
||||
import { ExpandAction } from './actions/expand';
|
||||
import { EffectAction } from './actions/effect';
|
||||
import { ExpandAction } from './actions/expand';
|
||||
import { FullscreenAction } from './actions/fullscreen';
|
||||
import { InfoAction } from './actions/info';
|
||||
import { InternalCallbackAction } from './actions/internal-callback';
|
||||
import { LogAction } from './actions/log';
|
||||
import { MediaPlayerAction } from './actions/media-player';
|
||||
@@ -33,6 +34,7 @@ import { PTZDigitalAction } from './actions/ptz-digital';
|
||||
import { PTZMultiAction } from './actions/ptz-multi';
|
||||
import { ReloadAction } from './actions/reload';
|
||||
import { ScreenshotAction } from './actions/screenshot';
|
||||
import { SetReviewAction } from './actions/set-review';
|
||||
import { SleepAction } from './actions/sleep';
|
||||
import { StatusBarAction } from './actions/status-bar';
|
||||
import { SubstreamOffAction } from './actions/substream-off';
|
||||
@@ -93,6 +95,8 @@ export class ActionFactory {
|
||||
case 'live':
|
||||
case 'recording':
|
||||
case 'recordings':
|
||||
case 'review':
|
||||
case 'reviews':
|
||||
case 'snapshot':
|
||||
case 'snapshots':
|
||||
case 'timeline':
|
||||
@@ -110,6 +114,8 @@ export class ActionFactory {
|
||||
return new ExpandAction(context, action, options?.config);
|
||||
case 'fullscreen':
|
||||
return new FullscreenAction(context, action, options?.config);
|
||||
case 'info':
|
||||
return new InfoAction(context, action, options?.config);
|
||||
case 'menu_toggle':
|
||||
return new MenuToggleAction(context, action, options?.config);
|
||||
case 'camera_select':
|
||||
@@ -156,6 +162,8 @@ export class ActionFactory {
|
||||
return new StatusBarAction(context, action, options?.config);
|
||||
case 'reload':
|
||||
return new ReloadAction(context, action, options?.config);
|
||||
case 'set_review':
|
||||
return new SetReviewAction(context, action, options?.config);
|
||||
case INTERNAL_CALLBACK_ACTION:
|
||||
return new InternalCallbackAction(context, action, options?.config);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ActionEventTarget } from '../action-handler-directive';
|
||||
import { isCardInPanel } from '../ha/panel';
|
||||
import { setOrRemoveAttribute } from '../utils/basic';
|
||||
import { isBeingCasted } from '../utils/casting';
|
||||
import { CardMediaReviewEventTarget } from '../utils/review';
|
||||
import { ViewItem } from '../view/item';
|
||||
import { ActionExecutionRequestEventTarget } from './actions/utils/execution-request';
|
||||
import { InitializationAspect } from './initialization-manager';
|
||||
import { CardElementAPI } from './types';
|
||||
@@ -13,7 +15,8 @@ export type MenuToggleCallback = () => void;
|
||||
export type CardHTMLElement = LitElement &
|
||||
ReactiveControllerHost &
|
||||
ActionEventTarget &
|
||||
ActionExecutionRequestEventTarget;
|
||||
ActionExecutionRequestEventTarget &
|
||||
CardMediaReviewEventTarget;
|
||||
|
||||
export class CardElementManager {
|
||||
protected _api: CardElementAPI;
|
||||
@@ -120,6 +123,10 @@ export class CardElementManager {
|
||||
'advanced-camera-card:action:execution-request',
|
||||
this._api.getActionsManager().handleActionExecutionRequestEvent,
|
||||
);
|
||||
this._element.addEventListener(
|
||||
'advanced-camera-card:media:reviewed',
|
||||
this._handleMediaReviewed,
|
||||
);
|
||||
|
||||
// Listen for HA `navigate` actions.
|
||||
// See: https://github.com/home-assistant/frontend/blob/273992c8e9c3062c6e49481b6d7d688a07067232/src/common/navigate.ts#L43
|
||||
@@ -198,6 +205,10 @@ export class CardElementManager {
|
||||
'advanced-camera-card:action:execution-request',
|
||||
this._api.getActionsManager().handleActionExecutionRequestEvent,
|
||||
);
|
||||
this._element.removeEventListener(
|
||||
'advanced-camera-card:media:reviewed',
|
||||
this._handleMediaReviewed,
|
||||
);
|
||||
|
||||
window.removeEventListener(
|
||||
'location-changed',
|
||||
@@ -208,4 +219,15 @@ export class CardElementManager {
|
||||
this._api.getQueryStringManager().requestExecution,
|
||||
);
|
||||
}
|
||||
|
||||
protected _handleMediaReviewed = (ev: CustomEvent<ViewItem>): void => {
|
||||
// If the selected media item has a change of review status, update the card
|
||||
// (e.g. for the menu).
|
||||
if (
|
||||
this._api.getViewManager().getView()?.queryResults?.getSelectedResult() ===
|
||||
ev.detail
|
||||
) {
|
||||
this.update();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,9 +28,11 @@ import { InitializationManager } from './initialization-manager';
|
||||
import { InteractionManager } from './interaction-manager';
|
||||
import { KeyboardStateManager } from './keyboard-state-manager';
|
||||
import { MediaLoadedInfoManager } from './media-info-manager';
|
||||
|
||||
import { MediaPlayerManager } from './media-player-manager';
|
||||
import { MessageManager } from './message-manager';
|
||||
import { MicrophoneManager } from './microphone-manager';
|
||||
import { OverlayMessageManager } from './overlay-message-manager';
|
||||
import { QueryStringManager } from './query-string-manager';
|
||||
import { StatusBarItemManager } from './status-bar-item-manager';
|
||||
import { StyleManager } from './style-manager';
|
||||
@@ -56,6 +58,7 @@ import {
|
||||
CardMediaPlayerAPI,
|
||||
CardMessageAPI,
|
||||
CardMicrophoneAPI,
|
||||
CardOverlayMessageAPI,
|
||||
CardQueryStringAPI,
|
||||
CardStyleAPI,
|
||||
CardTriggersAPI,
|
||||
@@ -87,6 +90,7 @@ export class CardController
|
||||
CardMediaPlayerAPI,
|
||||
CardMessageAPI,
|
||||
CardMicrophoneAPI,
|
||||
CardOverlayMessageAPI,
|
||||
CardQueryStringAPI,
|
||||
CardStyleAPI,
|
||||
CardTriggersAPI,
|
||||
@@ -118,9 +122,11 @@ export class CardController
|
||||
protected _interactionManager = new InteractionManager(this);
|
||||
protected _keyboardStateManager = new KeyboardStateManager(this);
|
||||
protected _mediaLoadedInfoManager = new MediaLoadedInfoManager(this);
|
||||
|
||||
protected _mediaPlayerManager = new MediaPlayerManager(this);
|
||||
protected _messageManager = new MessageManager(this);
|
||||
protected _microphoneManager = new MicrophoneManager(this);
|
||||
protected _overlayMessageManager = new OverlayMessageManager(this);
|
||||
protected _queryStringManager = new QueryStringManager(this);
|
||||
protected _statusBarItemManager = new StatusBarItemManager(this);
|
||||
protected _styleManager = new StyleManager(this);
|
||||
@@ -248,6 +254,10 @@ export class CardController
|
||||
this._microphoneManager = new MicrophoneManager(this);
|
||||
}
|
||||
|
||||
public getOverlayMessageManager(): OverlayMessageManager {
|
||||
return this._overlayMessageManager;
|
||||
}
|
||||
|
||||
public getQueryStringManager(): QueryStringManager {
|
||||
return this._queryStringManager;
|
||||
}
|
||||
|
||||
@@ -15,10 +15,8 @@ export class FoldersExecutor {
|
||||
this._ha = engines?.ha ?? new HAFoldersEngine();
|
||||
}
|
||||
|
||||
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
|
||||
return (
|
||||
this._getFolderEngine(folder.type)?.generateDefaultFolderQuery(folder) ?? null
|
||||
);
|
||||
public getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null {
|
||||
return this._getFolderEngine(folder.type)?.getDefaultQueryParameters(folder) ?? null;
|
||||
}
|
||||
|
||||
public generateChildFolderQuery(
|
||||
@@ -79,6 +77,15 @@ export class FoldersExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean {
|
||||
return (
|
||||
this._getFolderEngine(query.folder.type)?.areResultsFresh(
|
||||
resultsTimestamp,
|
||||
query,
|
||||
) ?? true
|
||||
);
|
||||
}
|
||||
|
||||
private _getFolderEngine(type?: FolderType): FoldersEngine | null {
|
||||
switch (type) {
|
||||
case folderTypeSchema.enum.ha:
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { sub } from 'date-fns';
|
||||
import { NonEmptyTuple } from 'type-fest';
|
||||
import { ConditionState } from '../../../conditions/types';
|
||||
import {
|
||||
FolderConfig,
|
||||
folderTypeSchema,
|
||||
HA_MEDIA_SOURCE_ROOT,
|
||||
HAFolderPathComponent,
|
||||
HAFolderConfig,
|
||||
HAFolderPathComponent,
|
||||
} from '../../../config/schema/folders';
|
||||
import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media';
|
||||
import { BrowseMediaViewFolder } from '../../../ha/browse-media/item';
|
||||
import {
|
||||
BrowseMedia,
|
||||
BROWSE_MEDIA_CACHE_SECONDS,
|
||||
BrowseMediaCache,
|
||||
BrowseMediaMetadata,
|
||||
RichBrowseMedia,
|
||||
@@ -22,16 +23,18 @@ import {
|
||||
} from '../../../ha/browse-media/walker';
|
||||
import { getMediaDownloadPath } from '../../../ha/download';
|
||||
import { HomeAssistant } from '../../../ha/types';
|
||||
import { QuerySource } from '../../../query-source.js';
|
||||
import { Endpoint } from '../../../types';
|
||||
|
||||
import { ViewFolder, ViewItem } from '../../../view/item';
|
||||
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||
import { ViewItemCapabilities } from '../../../view/types';
|
||||
import {
|
||||
DownloadHelpers,
|
||||
EngineOptions,
|
||||
FolderPathComponent,
|
||||
FolderQuery,
|
||||
FoldersEngine,
|
||||
FolderPathComponent,
|
||||
} from '../types';
|
||||
import { MediaMatcher } from './media-matcher';
|
||||
import { MetadataGenerator } from './metadata-generator.js';
|
||||
@@ -83,17 +86,18 @@ export class HAFoldersEngine implements FoldersEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
|
||||
public getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null {
|
||||
if (folder.type !== folderTypeSchema.enum.ha) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
source: QuerySource.Folder,
|
||||
folder,
|
||||
path: this.getDefaultFolderPathComponents(folder.ha),
|
||||
path: this._getDefaultPathComponents(folder.ha),
|
||||
};
|
||||
}
|
||||
|
||||
private getDefaultFolderPathComponents(
|
||||
private _getDefaultPathComponents(
|
||||
haFolderConfig?: HAFolderConfig,
|
||||
): NonEmptyTuple<FolderPathComponent> {
|
||||
const shouldAddDefaultRoot =
|
||||
@@ -158,14 +162,21 @@ export class HAFoldersEngine implements FoldersEngine {
|
||||
targets: BrowseMediaTarget<BrowseMediaMetadata>[],
|
||||
): BrowseMediaStep<BrowseMediaMetadata>[] => {
|
||||
const nextComponent = pathComponents.shift();
|
||||
const limit = query.limit ?? null;
|
||||
|
||||
return [
|
||||
{
|
||||
targets,
|
||||
metadataGenerator: (media: BrowseMedia, parent?: BrowseMedia) =>
|
||||
metadataGenerator: (media, parent) =>
|
||||
this._metadataGenerator.generate(media, parent, nextComponent?.ha?.parsers),
|
||||
|
||||
// At the final step (no nextComponent), apply limit via earlyExit.
|
||||
...(limit && {
|
||||
earlyExit: (media) => media.length >= limit,
|
||||
}),
|
||||
|
||||
...(nextComponent && {
|
||||
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
||||
matcher: (media) =>
|
||||
this._mediaMatcher.match(hass, media, {
|
||||
matchers: nextComponent.ha?.matchers,
|
||||
// Set foldersOnly to true if there are more stages in the path,
|
||||
@@ -187,9 +198,11 @@ export class HAFoldersEngine implements FoldersEngine {
|
||||
},
|
||||
);
|
||||
|
||||
return getViewItemsFromBrowseMediaArray(browseMedia, {
|
||||
const results = getViewItemsFromBrowseMediaArray(browseMedia, {
|
||||
folder: query.folder,
|
||||
path: query.path,
|
||||
});
|
||||
return query.limit ? results.slice(0, query.limit) : results;
|
||||
}
|
||||
|
||||
public generateChildFolderQuery(
|
||||
@@ -202,7 +215,7 @@ export class HAFoldersEngine implements FoldersEngine {
|
||||
}
|
||||
|
||||
// Get the full configured path to find parsers/matchers for this depth.
|
||||
const fullPath = this.getDefaultFolderPathComponents(query.folder.ha);
|
||||
const fullPath = this._getDefaultPathComponents(query.folder.ha);
|
||||
const nextConfiguredComponent = fullPath[query.path.length];
|
||||
|
||||
// Use the configured component's parsers/matchers if available, otherwise
|
||||
@@ -214,4 +227,11 @@ export class HAFoldersEngine implements FoldersEngine {
|
||||
path: [...query.path, { folder, ha }],
|
||||
};
|
||||
}
|
||||
|
||||
public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean {
|
||||
return (
|
||||
!!query &&
|
||||
resultsTimestamp >= sub(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { cloneDeep } from 'lodash-es';
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { FolderConfig, FolderConfigWithoutID } from '../../config/schema/folders';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { hasUnsupportedFilters } from '../../query-source.js';
|
||||
import { Endpoint } from '../../types';
|
||||
import { getFolderID } from '../../utils/folder';
|
||||
import { ViewFolder, ViewItem } from '../../view/item';
|
||||
import { ViewItemCapabilities } from '../../view/types';
|
||||
import { CardFoldersAPI } from '../types';
|
||||
@@ -26,7 +28,7 @@ export class FoldersManager {
|
||||
public addFolders(folders: FolderConfigWithoutID[]): void {
|
||||
for (const folder of folders) {
|
||||
const folderNumber = this._folders.size;
|
||||
const id = folder.id ?? `folder/${folderNumber.toString()}`;
|
||||
const id = getFolderID(folder, folderNumber);
|
||||
if (this._folders.has(id)) {
|
||||
throw new FolderInitializationError(
|
||||
localize('error.duplicate_folder_id'),
|
||||
@@ -57,9 +59,9 @@ export class FoldersManager {
|
||||
: this._folders.values().next().value ?? null;
|
||||
}
|
||||
|
||||
public generateDefaultFolderQuery(folder?: FolderConfig): FolderQuery | null {
|
||||
public getDefaultQueryParameters(folder?: FolderConfig): FolderQuery | null {
|
||||
const _folder = folder ?? this.getFolder();
|
||||
return _folder ? this._executor.generateDefaultFolderQuery(_folder) : null;
|
||||
return _folder ? this._executor.getDefaultQueryParameters(_folder) : null;
|
||||
}
|
||||
|
||||
public generateChildFolderQuery(
|
||||
@@ -74,12 +76,20 @@ export class FoldersManager {
|
||||
conditionState?: ConditionState,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<ViewItem[] | null> {
|
||||
if (hasUnsupportedFilters(query)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
return hass
|
||||
? this._executor.expandFolder(hass, query, conditionState, engineOptions)
|
||||
: null;
|
||||
}
|
||||
|
||||
public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean {
|
||||
return this._executor.areResultsFresh(resultsTimestamp, query);
|
||||
}
|
||||
|
||||
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
|
||||
return this._executor.getItemCapabilities(item);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConditionState } from '../../conditions/types';
|
||||
import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
|
||||
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { BaseQuery, QueryFilters, QuerySource } from '../../query-source';
|
||||
import { Endpoint } from '../../types';
|
||||
import { AdvancedCameraCardError } from '../../types.js';
|
||||
import { ViewFolder, ViewItem } from '../../view/item';
|
||||
@@ -30,12 +31,14 @@ export interface FolderPathComponent extends FolderPathComponentMetadata {
|
||||
folder?: ViewFolder;
|
||||
}
|
||||
|
||||
export interface FolderQuery {
|
||||
export interface FolderQuery extends BaseQuery, QueryFilters {
|
||||
source: QuerySource.Folder;
|
||||
folder: FolderConfig;
|
||||
|
||||
// A trail of paths to navigate back to the "root", with the last path being
|
||||
// the path that this query directly refers to.
|
||||
path: NonEmptyTuple<FolderPathComponent>;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ===============
|
||||
@@ -47,7 +50,8 @@ export interface DownloadHelpers {
|
||||
}
|
||||
|
||||
export interface FoldersEngine {
|
||||
generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null;
|
||||
getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null;
|
||||
|
||||
generateChildFolderQuery(query: FolderQuery, folder: ViewFolder): FolderQuery | null;
|
||||
|
||||
expandFolder(
|
||||
@@ -63,5 +67,8 @@ export interface FoldersEngine {
|
||||
item: ViewItem,
|
||||
options?: DownloadHelpers,
|
||||
): Promise<Endpoint | null>;
|
||||
|
||||
favorite(hass: HomeAssistant | null, item: ViewItem, favorite: boolean): Promise<void>;
|
||||
|
||||
areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { OverlayMessage } from '../types';
|
||||
import { CardOverlayMessageAPI } from './types';
|
||||
|
||||
export class OverlayMessageManager {
|
||||
protected _message: OverlayMessage | null = null;
|
||||
protected _api: CardOverlayMessageAPI;
|
||||
|
||||
constructor(api: CardOverlayMessageAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public getMessage(): OverlayMessage | null {
|
||||
return this._message;
|
||||
}
|
||||
|
||||
public hasMessage(): boolean {
|
||||
return this._message !== null;
|
||||
}
|
||||
|
||||
public setMessage(message: OverlayMessage): void {
|
||||
this._message = message;
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
if (this._message) {
|
||||
this._message = null;
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,10 +47,12 @@ export class StatusBarItemManager {
|
||||
? options?.cameraManager?.getCameraMetadata(options?.view?.camera)
|
||||
: null;
|
||||
const engineIcon = cameraMetadata?.engineIcon ?? null;
|
||||
const selectedResult = options?.view?.queryResults?.getSelectedResult();
|
||||
const severity = selectedResult?.getSeverity() ?? null;
|
||||
const title = options?.view?.is('live')
|
||||
? cameraMetadata?.title ?? null
|
||||
: options?.view?.isViewerView()
|
||||
? options?.view.queryResults?.getSelectedResult()?.getTitle() ?? null
|
||||
? selectedResult?.getTitle() ?? null
|
||||
: null;
|
||||
const resolution = options?.mediaLoadedInfo
|
||||
? this._calculateResolution(options?.mediaLoadedInfo)
|
||||
@@ -60,6 +62,17 @@ export class StatusBarItemManager {
|
||||
: null;
|
||||
|
||||
return [
|
||||
...(severity
|
||||
? [
|
||||
{
|
||||
type: 'custom:advanced-camera-card-status-bar-icon' as const,
|
||||
icon: 'mdi:circle-medium',
|
||||
severity,
|
||||
...options?.statusConfig?.items.severity,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
...(title
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -104,14 +104,16 @@ export class TriggersManager {
|
||||
.actions.trigger;
|
||||
const defaultView = this._api.getConfigManager().getConfig()?.view.default;
|
||||
|
||||
// If this is a high-fidelity event where we are certain about new media,
|
||||
// don't take action unless it's to change to live (Frigate engine may pump
|
||||
// out events where there's no new media to show). Other trigger actions
|
||||
// (e.g. media, update) do not make sense without having some new media.
|
||||
// Early exit guard: If this is a high-fidelity event where we are certain
|
||||
// about new media, don't take action unless it's to change to live (Frigate
|
||||
// engine may pump out events where there's no new media to show). Other
|
||||
// trigger actions (e.g. media, update) do not make sense without having
|
||||
// some new media.
|
||||
if (
|
||||
ev.fidelity === 'high' &&
|
||||
!ev.snapshot &&
|
||||
!ev.clip &&
|
||||
!ev.review &&
|
||||
!(
|
||||
triggerAction === 'live' ||
|
||||
(triggerAction === 'default' && defaultView === 'live')
|
||||
@@ -139,12 +141,26 @@ export class TriggersManager {
|
||||
},
|
||||
});
|
||||
} else if (ev.fidelity === 'high' && triggerAction === 'media') {
|
||||
await this._api.getViewManager().setViewByParametersWithNewQuery({
|
||||
params: {
|
||||
view: ev.clip ? 'clip' : 'snapshot',
|
||||
camera: ev.cameraID,
|
||||
},
|
||||
});
|
||||
// Choose the most appropriate media view based on what's available.
|
||||
// Priority: review > clip > snapshot
|
||||
const view = ev.review
|
||||
? 'review'
|
||||
: ev.clip
|
||||
? 'clip'
|
||||
: ev.snapshot
|
||||
? 'snapshot'
|
||||
: /* istanbul ignore next: unreachable due to early exit guard above -- @preserve */
|
||||
null;
|
||||
|
||||
/* istanbul ignore next: unreachable due to early exit guard above -- @preserve */
|
||||
if (view) {
|
||||
await this._api.getViewManager().setViewByParametersWithNewQuery({
|
||||
params: {
|
||||
view,
|
||||
camera: ev.cameraID,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { MediaLoadedInfoManager } from './media-info-manager';
|
||||
import type { MediaPlayerManager } from './media-player-manager';
|
||||
import type { MessageManager } from './message-manager';
|
||||
import type { MicrophoneManager } from './microphone-manager';
|
||||
import type { OverlayMessageManager } from './overlay-message-manager';
|
||||
import type { QueryStringManager } from './query-string-manager';
|
||||
import type { StatusBarItemManager } from './status-bar-item-manager';
|
||||
import type { StyleManager } from './style-manager';
|
||||
@@ -52,6 +53,7 @@ export interface CardActionsAPI {
|
||||
getMediaPlayerManager(): MediaPlayerManager;
|
||||
getMessageManager(): MessageManager;
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getOverlayMessageManager(): OverlayMessageManager;
|
||||
getStatusBarItemManager(): StatusBarItemManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
getViewItemManager(): ViewItemManager;
|
||||
@@ -149,6 +151,7 @@ export interface CardElementAPI {
|
||||
getMediaPlayerManager(): MediaPlayerManager;
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getQueryStringManager(): QueryStringManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
export interface CardExpandAPI {
|
||||
@@ -245,6 +248,10 @@ export interface CardMessageAPI {
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
}
|
||||
|
||||
export interface CardOverlayMessageAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
}
|
||||
|
||||
export interface CardMicrophoneAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
|
||||
@@ -54,6 +54,12 @@ export class ViewItemManager {
|
||||
}
|
||||
}
|
||||
|
||||
public async reviewMedia(item: ViewItem, reviewed: boolean): Promise<void> {
|
||||
if (ViewItemClassifier.isReview(item)) {
|
||||
return await this._api.getCameraManager().reviewMedia(item, reviewed);
|
||||
}
|
||||
}
|
||||
|
||||
private _getMediaSource(item: ViewItem): ViewMediaSource | null {
|
||||
if (ViewItemClassifier.isMedia(item) && item.getCameraID()) {
|
||||
return ViewMediaSource.Camera;
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Query } from '../../../view/query';
|
||||
import { QueryResults } from '../../../view/query-results';
|
||||
import { UnifiedQuery } from '../../../view/unified-query';
|
||||
import { View } from '../../../view/view';
|
||||
import { ViewModifier } from '../types';
|
||||
|
||||
export class SetQueryViewModifier implements ViewModifier {
|
||||
protected _query?: Query | null;
|
||||
protected _query?: UnifiedQuery | null;
|
||||
protected _queryResults?: QueryResults | null;
|
||||
|
||||
constructor(options?: { query?: Query | null; queryResults?: QueryResults | null }) {
|
||||
constructor(options?: {
|
||||
query?: UnifiedQuery | null;
|
||||
queryResults?: QueryResults | null;
|
||||
}) {
|
||||
this._query = options?.query;
|
||||
this._queryResults = options?.queryResults;
|
||||
}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { CapabilitySearchKeys, MediaQuery } from '../../camera-manager/types';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
|
||||
import { ClipsOrSnapshotsOrAll } from '../../types';
|
||||
import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import {
|
||||
EventMediaQuery,
|
||||
FolderViewQuery,
|
||||
MediaQueries,
|
||||
Query,
|
||||
RecordingMediaQuery,
|
||||
} from '../../view/query';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { QueryResults } from '../../view/query-results';
|
||||
import { CardViewAPI } from '../types';
|
||||
import { QueryExecutorOptions, QueryExecutorResult } from './types';
|
||||
|
||||
export class QueryExecutor {
|
||||
protected _api: CardViewAPI;
|
||||
|
||||
constructor(api: CardViewAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public async executeDefaultEventQuery(options?: {
|
||||
cameraID?: string;
|
||||
eventsMediaType?: ClipsOrSnapshotsOrAll;
|
||||
executorOptions?: QueryExecutorOptions;
|
||||
}): Promise<QueryExecutorResult | null> {
|
||||
const capabilitySearch: CapabilitySearchKeys =
|
||||
!options?.eventsMediaType || options?.eventsMediaType === 'all'
|
||||
? {
|
||||
anyCapabilities: ['clips', 'snapshots'],
|
||||
}
|
||||
: options.eventsMediaType;
|
||||
|
||||
const cameraManager = this._api.getCameraManager();
|
||||
const cameraIDs = options?.cameraID
|
||||
? cameraManager
|
||||
.getStore()
|
||||
.getAllDependentCameras(options.cameraID, capabilitySearch)
|
||||
: cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch);
|
||||
if (!cameraIDs.size) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawQueries = cameraManager.generateDefaultEventQueries(cameraIDs, {
|
||||
limit: this._getChunkLimit(),
|
||||
...(options?.eventsMediaType === 'clips' && { hasClip: true }),
|
||||
...(options?.eventsMediaType === 'snapshots' && { hasSnapshot: true }),
|
||||
});
|
||||
if (!rawQueries) {
|
||||
return null;
|
||||
}
|
||||
const queries = new EventMediaQuery(rawQueries);
|
||||
return await this.executeMediaQuery(queries, options?.executorOptions);
|
||||
}
|
||||
|
||||
public async executeDefaultRecordingQuery(options?: {
|
||||
cameraID?: string;
|
||||
executorOptions?: QueryExecutorOptions;
|
||||
}): Promise<QueryExecutorResult | null> {
|
||||
const cameraManager = this._api.getCameraManager();
|
||||
const cameraIDs = options?.cameraID
|
||||
? cameraManager.getStore().getAllDependentCameras(options.cameraID, 'recordings')
|
||||
: cameraManager.getStore().getCameraIDsWithCapability('recordings');
|
||||
if (!cameraIDs.size) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, {
|
||||
limit: this._getChunkLimit(),
|
||||
});
|
||||
if (!rawQueries) {
|
||||
return null;
|
||||
}
|
||||
const queries = new RecordingMediaQuery(rawQueries);
|
||||
return await this.executeMediaQuery(queries, options?.executorOptions);
|
||||
}
|
||||
|
||||
public async executeQuery(
|
||||
query: Query,
|
||||
executorOptions?: QueryExecutorOptions,
|
||||
): Promise<QueryExecutorResult | null> {
|
||||
/* istanbul ignore else: this path cannot be reached -- @preserve */
|
||||
if (QueryClassifier.isMediaQuery(query)) {
|
||||
return await this.executeMediaQuery(query, executorOptions);
|
||||
} else if (QueryClassifier.isFolderQuery(query)) {
|
||||
return await this._executeFolderQuery(query, executorOptions);
|
||||
}
|
||||
|
||||
/* istanbul ignore next: this path cannot be reached -- @preserve */
|
||||
return null;
|
||||
}
|
||||
|
||||
public async executeMediaQuery(
|
||||
query: MediaQueries,
|
||||
executorOptions?: QueryExecutorOptions,
|
||||
): Promise<QueryExecutorResult | null> {
|
||||
const queries = query.getQuery();
|
||||
if (!queries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaArray = await this._api
|
||||
.getCameraManager()
|
||||
.executeMediaQueries<MediaQuery>(queries, {
|
||||
useCache: executorOptions?.useCache,
|
||||
});
|
||||
const queryResults = mediaArray
|
||||
? this._generateQueriesResults(mediaArray, executorOptions)
|
||||
: null;
|
||||
return queryResults ? { query, queryResults } : null;
|
||||
}
|
||||
|
||||
private _generateQueriesResults(
|
||||
itemArray: ViewItem[],
|
||||
executorOptions?: QueryExecutorOptions,
|
||||
): QueryResults | null {
|
||||
const queryResults = new QueryResults({ results: itemArray });
|
||||
if (executorOptions?.rejectResults?.(queryResults)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (executorOptions?.selectResult?.id) {
|
||||
queryResults.selectBestResult((media) =>
|
||||
media.findIndex((m) => m.getID() === executorOptions.selectResult?.id),
|
||||
);
|
||||
} else if (executorOptions?.selectResult?.func) {
|
||||
queryResults.selectResultIfFound(executorOptions.selectResult.func);
|
||||
} else if (executorOptions?.selectResult?.time) {
|
||||
queryResults.selectBestResult((itemArray) =>
|
||||
findBestMediaTimeIndex(
|
||||
itemArray,
|
||||
executorOptions.selectResult?.time?.time as Date,
|
||||
executorOptions.selectResult?.time?.favorCameraID,
|
||||
),
|
||||
);
|
||||
}
|
||||
return queryResults;
|
||||
}
|
||||
|
||||
public async executeFolderQuery(
|
||||
executorOptions?: QueryExecutorOptions,
|
||||
): Promise<QueryExecutorResult | null> {
|
||||
const folder = this._api.getFoldersManager().getFolder(executorOptions?.folder);
|
||||
if (!folder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = this._api.getFoldersManager().generateDefaultFolderQuery(folder);
|
||||
if (!query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._executeFolderQuery(new FolderViewQuery(query), executorOptions);
|
||||
}
|
||||
|
||||
private async _executeFolderQuery(
|
||||
query: FolderViewQuery,
|
||||
executorOptions?: QueryExecutorOptions,
|
||||
): Promise<QueryExecutorResult | null> {
|
||||
const rawQuery = query.getQuery();
|
||||
if (!rawQuery) {
|
||||
return null;
|
||||
}
|
||||
const itemArray = await this._api
|
||||
.getFoldersManager()
|
||||
.expandFolder(rawQuery, this._api.getConditionStateManager().getState(), {
|
||||
useCache: executorOptions?.useCache,
|
||||
});
|
||||
|
||||
const queryResults = itemArray
|
||||
? this._generateQueriesResults(itemArray, executorOptions)
|
||||
: null;
|
||||
return queryResults ? { query, queryResults } : null;
|
||||
}
|
||||
|
||||
protected _getChunkLimit(): number {
|
||||
const cardWideConfig = this._api.getConfigManager().getCardWideConfig();
|
||||
return (
|
||||
cardWideConfig?.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { ViewContext } from 'view';
|
||||
import { AdvancedCameraCardError } from '../../types.js';
|
||||
import { ViewItem } from '../../view/item.js';
|
||||
import { QueryResults } from '../../view/query-results.js';
|
||||
import { Query } from '../../view/query.js';
|
||||
|
||||
import { View, ViewParameters } from '../../view/view.js';
|
||||
|
||||
export interface ViewModifier {
|
||||
@@ -26,11 +26,6 @@ export interface QueryExecutorOptions {
|
||||
useCache?: boolean;
|
||||
}
|
||||
|
||||
export interface QueryExecutorResult {
|
||||
query: Query;
|
||||
queryResults: QueryResults;
|
||||
}
|
||||
|
||||
export interface ViewFactoryOptions {
|
||||
// An existing view to evolve from.
|
||||
baseView?: View | null;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ViewContext } from 'view';
|
||||
import { log } from '../../utils/debug';
|
||||
import { getStreamCameraID } from '../../utils/substream';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { View } from '../../view/view';
|
||||
import { InitializationAspect } from '../initialization-manager';
|
||||
import { CardViewAPI } from '../types';
|
||||
@@ -234,19 +233,19 @@ export class ViewManager implements ViewManagerInterface {
|
||||
// If the user is currently using the viewer, and then switches to the
|
||||
// gallery we make an attempt to keep the query/queryResults the same so
|
||||
// the gallery can be used to click back and forth to the viewer, and the
|
||||
// selected media can be centered in the gallery. See the matching code in
|
||||
// `updated()` in `gallery.ts`. We specifically must ensure that the new
|
||||
// target media of the gallery (e.g. clips, snapshots or recordings) is
|
||||
// equal to the queries that are currently used in the viewer.
|
||||
// selected media can be centered in the gallery.
|
||||
//
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
|
||||
|
||||
const switchingFromViewerToGallery =
|
||||
this._view?.isViewerView() && newView?.isMediaGalleryView();
|
||||
const newMediaType = newView?.getDefaultMediaType();
|
||||
const alreadyHasMatchingQuery =
|
||||
QueryClassifier.getMediaType(this._view?.query) === newMediaType;
|
||||
return !!switchingFromViewerToGallery && alreadyHasMatchingQuery;
|
||||
if (!this._view?.isViewerView() || !newView?.isGalleryView()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the viewer came from this gallery type. If they did, we preserve
|
||||
// the query/results to ensure consistency in navigation between media &
|
||||
// gallery.
|
||||
const originView = this._view?.context?.gallery?.originView;
|
||||
return originView === newView.view;
|
||||
}
|
||||
|
||||
public setViewWithMergedContext(context: ViewContext | null): void {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { sub } from 'date-fns';
|
||||
import { ClipsOrSnapshotsOrAll } from '../../types';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
|
||||
import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
|
||||
import { QueryResults } from '../../view/query-results';
|
||||
import { UnifiedQuery } from '../../view/unified-query';
|
||||
import { MediaTypeSpec, UnifiedQueryBuilder } from '../../view/unified-query-builder';
|
||||
import { UnifiedQueryRunner } from '../../view/unified-query-runner';
|
||||
import { View } from '../../view/view';
|
||||
import { CardViewAPI } from '../types';
|
||||
import { MergeContextViewModifier } from './modifiers/merge-context';
|
||||
import { RemoveContextPropertyViewModifier } from './modifiers/remove-context-property';
|
||||
import { SetQueryViewModifier } from './modifiers/set-query';
|
||||
import { QueryExecutor } from './query-executor';
|
||||
import { QueryExecutorOptions, ViewModifier } from './types';
|
||||
|
||||
/**
|
||||
@@ -14,27 +18,42 @@ import { QueryExecutorOptions, ViewModifier } from './types';
|
||||
* and if a query is made as part of this view the result can be applied later.
|
||||
*/
|
||||
export class ViewQueryExecutor {
|
||||
protected _api: CardViewAPI;
|
||||
protected _executor: QueryExecutor;
|
||||
private _api: CardViewAPI;
|
||||
|
||||
constructor(api: CardViewAPI, executor?: QueryExecutor) {
|
||||
constructor(api: CardViewAPI) {
|
||||
this._api = api;
|
||||
this._executor = executor ?? new QueryExecutor(api);
|
||||
}
|
||||
|
||||
public async getExistingQueryModifiers(
|
||||
view: View,
|
||||
queryExecutorOptions?: QueryExecutorOptions,
|
||||
): Promise<ViewModifier[] | null> {
|
||||
return view.query
|
||||
if (!view.query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runner = new UnifiedQueryRunner(
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
this._api.getConditionStateManager(),
|
||||
);
|
||||
|
||||
const items = await runner.execute(view.query, {
|
||||
useCache: queryExecutorOptions?.useCache,
|
||||
});
|
||||
|
||||
const queryResults = this._applyResultSelection(
|
||||
new QueryResults({ results: items }),
|
||||
queryExecutorOptions,
|
||||
);
|
||||
|
||||
return queryResults
|
||||
? [
|
||||
new SetQueryViewModifier({
|
||||
queryResults: (
|
||||
await this._executor.executeQuery(view.query, queryExecutorOptions)
|
||||
)?.queryResults,
|
||||
queryResults,
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
: null;
|
||||
}
|
||||
|
||||
public async getNewQueryModifiers(
|
||||
@@ -47,7 +66,7 @@ export class ViewQueryExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
protected async _executeNewQuery(
|
||||
private async _executeNewQuery(
|
||||
view: View,
|
||||
queryExecutorOptions?: QueryExecutorOptions,
|
||||
): Promise<ViewModifier[] | null> {
|
||||
@@ -56,71 +75,111 @@ export class ViewQueryExecutor {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaType = view?.getDefaultMediaType();
|
||||
const viewModifiers: ViewModifier[] = [];
|
||||
const builder = new UnifiedQueryBuilder(
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
);
|
||||
const runner = new UnifiedQueryRunner(
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
this._api.getConditionStateManager(),
|
||||
);
|
||||
|
||||
const executeMediaQuery = async (
|
||||
mediaType: ClipsOrSnapshotsOrAll | 'recordings' | null,
|
||||
): Promise<ViewModifier[]> => {
|
||||
/* istanbul ignore if: this path cannot be reached -- @preserve */
|
||||
if (!mediaType) {
|
||||
const executeQuery = async (query: UnifiedQuery | null): Promise<ViewModifier[]> => {
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results =
|
||||
mediaType === 'recordings'
|
||||
? await this._executor.executeDefaultRecordingQuery({
|
||||
...(!view.isGrid() && { cameraID: view.camera }),
|
||||
executorOptions: queryExecutorOptions,
|
||||
})
|
||||
: mediaType === 'clips' || mediaType === 'snapshots' || mediaType === 'all'
|
||||
? await this._executor.executeDefaultEventQuery({
|
||||
...(!view.isGrid() && { cameraID: view.camera }),
|
||||
eventsMediaType: mediaType,
|
||||
executorOptions: queryExecutorOptions,
|
||||
})
|
||||
: /* istanbul ignore next -- @preserve */
|
||||
null;
|
||||
const items = await runner.execute(query, {
|
||||
useCache: queryExecutorOptions?.useCache,
|
||||
});
|
||||
|
||||
return results ? [new SetQueryViewModifier(results)] : [];
|
||||
return [
|
||||
new SetQueryViewModifier({
|
||||
query,
|
||||
queryResults: new QueryResults({ results: items }),
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const executeFolderQuery = async (): Promise<ViewModifier[]> => {
|
||||
const results = await this._executor.executeFolderQuery(queryExecutorOptions);
|
||||
return results ? [new SetQueryViewModifier(results)] : [];
|
||||
};
|
||||
const cameraForQuery = view.isGrid() ? undefined : view.camera;
|
||||
|
||||
switch (view.view) {
|
||||
case 'live':
|
||||
if (config.live.controls.thumbnails.mode !== 'none') {
|
||||
viewModifiers.push(
|
||||
...(await executeMediaQuery(
|
||||
config.live.controls.thumbnails.media_type === 'recordings'
|
||||
? 'recordings'
|
||||
: config.live.controls.thumbnails.events_media_type,
|
||||
)),
|
||||
);
|
||||
const defaultQuery = builder.buildDefaultCameraQuery(cameraForQuery);
|
||||
if (defaultQuery) {
|
||||
viewModifiers.push(...(await executeQuery(defaultQuery)));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'media':
|
||||
// If the user is looking at media in the `media` view and then
|
||||
// changes camera (via the menu) it should default to showing clips
|
||||
// for the new camera.
|
||||
viewModifiers.push(...(await executeMediaQuery('clips')));
|
||||
case 'timeline':
|
||||
// Timeline view always queries all cameras with media capabilities.
|
||||
viewModifiers.push(...(await executeQuery(builder.buildDefaultCameraQuery())));
|
||||
break;
|
||||
|
||||
case 'media':
|
||||
// If the user is looking at media in the `media` view and then
|
||||
// changes camera (via the menu) it should default to showing clips
|
||||
// for the new camera.
|
||||
case 'clip':
|
||||
case 'clips':
|
||||
viewModifiers.push(
|
||||
...(await executeQuery(
|
||||
builder.buildCameraMediaQuery(MediaTypeSpec.clips(), {
|
||||
cameraID: cameraForQuery,
|
||||
limit: this._getLimit(),
|
||||
}),
|
||||
)),
|
||||
);
|
||||
|
||||
break;
|
||||
|
||||
case 'snapshot':
|
||||
case 'snapshots':
|
||||
viewModifiers.push(
|
||||
...(await executeQuery(
|
||||
builder.buildCameraMediaQuery(MediaTypeSpec.snapshots(), {
|
||||
cameraID: cameraForQuery,
|
||||
limit: this._getLimit(),
|
||||
}),
|
||||
)),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'recording':
|
||||
case 'recordings':
|
||||
viewModifiers.push(...(await executeMediaQuery(mediaType)));
|
||||
viewModifiers.push(
|
||||
...(await executeQuery(
|
||||
builder.buildCameraMediaQuery(MediaTypeSpec.recordings(), {
|
||||
cameraID: cameraForQuery,
|
||||
limit: this._getLimit(),
|
||||
}),
|
||||
)),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'review':
|
||||
case 'reviews':
|
||||
viewModifiers.push(
|
||||
...(await executeQuery(
|
||||
builder.buildCameraMediaQuery(MediaTypeSpec.reviews(), {
|
||||
cameraID: cameraForQuery,
|
||||
limit: this._getLimit(),
|
||||
}),
|
||||
)),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'folder':
|
||||
case 'folders':
|
||||
viewModifiers.push(...(await executeFolderQuery()));
|
||||
viewModifiers.push(
|
||||
...(await executeQuery(
|
||||
builder.buildDefaultFolderQuery(queryExecutorOptions?.folder, {
|
||||
limit: this._getLimit(),
|
||||
}),
|
||||
)),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -131,7 +190,7 @@ export class ViewQueryExecutor {
|
||||
return viewModifiers;
|
||||
}
|
||||
|
||||
protected _getTimelineWindowViewModifier(view: View): ViewModifier[] {
|
||||
private _getTimelineWindowViewModifier(view: View): ViewModifier[] {
|
||||
if (view.is('live')) {
|
||||
// For live views, always force the timeline to now, regardless of
|
||||
// presence or not of events.
|
||||
@@ -166,7 +225,7 @@ export class ViewQueryExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
protected _getSeekTimeModifier(time?: Date): ViewModifier[] {
|
||||
private _getSeekTimeModifier(time?: Date): ViewModifier[] {
|
||||
if (time) {
|
||||
return [
|
||||
new MergeContextViewModifier({
|
||||
@@ -179,4 +238,39 @@ export class ViewQueryExecutor {
|
||||
return [new RemoveContextPropertyViewModifier('mediaViewer', 'seek')];
|
||||
}
|
||||
}
|
||||
|
||||
private _applyResultSelection(
|
||||
queryResults: QueryResults,
|
||||
options?: QueryExecutorOptions,
|
||||
): QueryResults | null {
|
||||
if (options?.rejectResults?.(queryResults)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeSelection = options?.selectResult?.time;
|
||||
if (options?.selectResult?.id) {
|
||||
queryResults.selectBestResult((media) =>
|
||||
media.findIndex((m) => m.getID() === options.selectResult?.id),
|
||||
);
|
||||
} else if (options?.selectResult?.func) {
|
||||
queryResults.selectResultIfFound(options.selectResult.func);
|
||||
} else if (timeSelection) {
|
||||
queryResults.selectBestResult((itemArray) =>
|
||||
findBestMediaTimeIndex(
|
||||
itemArray,
|
||||
timeSelection.time,
|
||||
timeSelection.favorCameraID,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return queryResults;
|
||||
}
|
||||
|
||||
private _getLimit(): number {
|
||||
return (
|
||||
this._api.getConfigManager().getConfig()?.performance?.features
|
||||
?.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-6
@@ -17,6 +17,7 @@ import './components/menu.js';
|
||||
import { AdvancedCameraCardMenu } from './components/menu.js';
|
||||
import './components/message.js';
|
||||
import { renderMessage } from './components/message.js';
|
||||
import './components/overlay-message.js';
|
||||
import './components/overlay.js';
|
||||
import { AdvancedCameraCardOverlay } from './components/overlay.js';
|
||||
import './components/status-bar';
|
||||
@@ -32,7 +33,7 @@ import { REPO_URL } from './const.js';
|
||||
import { HomeAssistant, LovelaceCardEditor } from './ha/types.js';
|
||||
import { localize } from './localize/localize.js';
|
||||
import cardStyle from './scss/card.scss';
|
||||
import { MediaLoadedInfo, Message } from './types.js';
|
||||
import { MediaLoadedInfo, Message, OverlayMessage } from './types.js';
|
||||
import { hasAction } from './utils/action.js';
|
||||
import { getReleaseVersion } from './utils/diagnostics';
|
||||
|
||||
@@ -235,9 +236,9 @@ class AdvancedCameraCard extends LitElement {
|
||||
|
||||
return html`
|
||||
${position === 'overlay'
|
||||
? html`<advanced-camera-card-overlay
|
||||
>${getContents('overlay')}</advanced-camera-card-overlay
|
||||
>`
|
||||
? html`<advanced-camera-card-overlay>
|
||||
${getContents('overlay')}
|
||||
</advanced-camera-card-overlay>`
|
||||
: html`<div class="outerlay" data-position="${position}">
|
||||
${getContents('outerlay')}
|
||||
</div>`}
|
||||
@@ -360,8 +361,9 @@ class AdvancedCameraCard extends LitElement {
|
||||
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
|
||||
@advanced-camera-card:message=${(ev: CustomEvent<Message>) =>
|
||||
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
|
||||
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
|
||||
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
|
||||
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
|
||||
this._controller.getMediaLoadedInfoManager().set(ev.detail);
|
||||
}}
|
||||
@advanced-camera-card:media:unloaded=${() =>
|
||||
this._controller.getMediaLoadedInfoManager().clear()}
|
||||
@advanced-camera-card:media:volumechange=${
|
||||
@@ -374,6 +376,11 @@ class AdvancedCameraCard extends LitElement {
|
||||
() => this.requestUpdate() /* Refresh play/pause menu button */
|
||||
}
|
||||
@advanced-camera-card:focus=${() => this.focus()}
|
||||
@advanced-camera-card:overlay-message:show=${(
|
||||
ev: CustomEvent<OverlayMessage>,
|
||||
) => this._controller.getOverlayMessageManager().setMessage(ev.detail)}
|
||||
@advanced-camera-card:overlay-message:dismiss=${() =>
|
||||
this._controller.getOverlayMessageManager().reset()}
|
||||
>
|
||||
${showLoading
|
||||
? html`<advanced-camera-card-loading
|
||||
@@ -454,6 +461,11 @@ class AdvancedCameraCard extends LitElement {
|
||||
>
|
||||
</advanced-camera-card-elements>`
|
||||
: ``}
|
||||
${this._controller.getOverlayMessageManager().getMessage()
|
||||
? html`<advanced-camera-card-overlay-message
|
||||
.message=${this._controller.getOverlayMessageManager().getMessage()}
|
||||
></advanced-camera-card-overlay-message>`
|
||||
: ''}
|
||||
</ha-card>`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
|
||||
import { ViewFolder, ViewItem } from '../../view/item';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { View } from '../../view/view';
|
||||
|
||||
export const upFolderClickHandler = (
|
||||
_item: ViewItem,
|
||||
ev: Event,
|
||||
viewManagerEpoch?: ViewManagerEpoch,
|
||||
): void => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
|
||||
const query = viewManagerEpoch?.manager.getView()?.query;
|
||||
if (!query || !QueryClassifier.isFolderQuery(query)) {
|
||||
return;
|
||||
}
|
||||
const rawQuery = query?.getQuery();
|
||||
if (!rawQuery?.path || rawQuery?.path.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = rawQuery.path.slice(0, -1);
|
||||
|
||||
viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||
params: {
|
||||
query: query.clone().setQuery({
|
||||
folder: rawQuery.folder,
|
||||
path: [path[0], ...path.slice(1)],
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpFolderMediaItem = (view?: View | null): ViewFolder | null => {
|
||||
const query = view?.query;
|
||||
if (!query || !QueryClassifier.isFolderQuery(query)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawQuery = query.getQuery();
|
||||
if (!rawQuery?.folder || !rawQuery?.path || rawQuery.path.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ViewFolder(rawQuery.folder, {
|
||||
icon: 'mdi:arrow-up-left',
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types';
|
||||
import { AdvancedCameraCardView } from '../../config/schema/common/const';
|
||||
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
|
||||
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
import { QueryResults } from '../../view/query-results';
|
||||
import { UnifiedQuery } from '../../view/unified-query';
|
||||
import { UnifiedQueryRunner } from '../../view/unified-query-runner';
|
||||
import { View } from '../../view/view';
|
||||
import { GalleryColumnCountRoundMethod } from './gallery-core-controller';
|
||||
|
||||
interface GalleryViewContext {
|
||||
// The gallery view type the user navigated from (when in viewer). Used to
|
||||
// determine if query/results should be preserved when returning.
|
||||
originView?: AdvancedCameraCardView;
|
||||
}
|
||||
|
||||
declare module 'view' {
|
||||
interface ViewContext {
|
||||
gallery?: GalleryViewContext;
|
||||
}
|
||||
}
|
||||
|
||||
// The minimum width of a thumbnail with details enabled.
|
||||
const GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
||||
|
||||
// The minimum width of a folder thumbnail with details enabled.
|
||||
const FOLDER_THUMBNAIL_DETAILS_WIDTH_MIN = 200;
|
||||
|
||||
export class GalleryController {
|
||||
private _host: HTMLElement;
|
||||
private _items: ViewItem[] | null = null;
|
||||
private _foldersOnly = false;
|
||||
|
||||
public constructor(host: HTMLElement) {
|
||||
this._host = host;
|
||||
}
|
||||
|
||||
public getItems(): ViewItem[] | null {
|
||||
return this._items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set items from view query results.
|
||||
* Media is reversed so newest appears first in the gallery.
|
||||
*/
|
||||
public setItemsFromView(newView?: View | null, oldView?: View | null): void {
|
||||
const newResults = newView?.queryResults?.getResults() ?? null;
|
||||
if (newResults === null) {
|
||||
this._items = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._items || oldView?.queryResults?.getResults() !== newResults) {
|
||||
// Gallery places the most recent media at the top (the query results
|
||||
// place the most recent media at the end for use in the viewer).
|
||||
this._items = [...newResults].reverse();
|
||||
}
|
||||
|
||||
this._foldersOnly = this._items?.every((item) => ViewItemClassifier.isFolder(item));
|
||||
}
|
||||
|
||||
public setThumbnailSize(size?: number): void {
|
||||
this._host.style.setProperty(
|
||||
'--advanced-camera-card-thumbnail-size',
|
||||
`${size ?? THUMBNAIL_WIDTH_DEFAULT}px`,
|
||||
);
|
||||
}
|
||||
|
||||
public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number {
|
||||
if (!thumbnailConfig) {
|
||||
return THUMBNAIL_WIDTH_DEFAULT;
|
||||
}
|
||||
if (!thumbnailConfig.show_details) {
|
||||
return thumbnailConfig.size;
|
||||
}
|
||||
|
||||
// Use smaller width when all items are folders
|
||||
return this._foldersOnly
|
||||
? FOLDER_THUMBNAIL_DETAILS_WIDTH_MIN
|
||||
: GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN;
|
||||
}
|
||||
|
||||
public getColumnCountRoundMethod(
|
||||
thumbnailConfig?: MediaGalleryThumbnailsConfig,
|
||||
): GalleryColumnCountRoundMethod {
|
||||
return thumbnailConfig?.show_details ? 'floor' : 'ceil';
|
||||
}
|
||||
|
||||
public async extend(
|
||||
runner: UnifiedQueryRunner,
|
||||
viewManagerEpoch: ViewManagerEpoch,
|
||||
direction: 'earlier' | 'later',
|
||||
useCache = true,
|
||||
): Promise<void> {
|
||||
const view = viewManagerEpoch.manager.getView();
|
||||
if (!view?.query || !view?.queryResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingResults = view.queryResults.getResults();
|
||||
if (!existingResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
let extended: { query: UnifiedQuery; results: ViewItem[] } | null;
|
||||
try {
|
||||
extended = await runner.extend(view.query, existingResults, direction, {
|
||||
useCache,
|
||||
});
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (extended) {
|
||||
viewManagerEpoch.manager.setViewByParameters({
|
||||
baseView: view,
|
||||
params: {
|
||||
query: extended.query,
|
||||
queryResults: new QueryResults({
|
||||
results: extended.results,
|
||||
}).selectResultIfFound(
|
||||
(item) => item === view.queryResults?.getSelectedResult(),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { FoldersManager } from '../../card-controller/folders/manager';
|
||||
import { ViewManagerInterface } from '../../card-controller/view/types';
|
||||
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
|
||||
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { GalleryColumnCountRoundMethod } from './gallery-core-controller';
|
||||
|
||||
// The minimum width of a (folder) thumbnail with details enabled. This is
|
||||
// shorter than for regular camera media as this will consist of just a name.
|
||||
export const FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 200;
|
||||
|
||||
export class FolderGalleryController {
|
||||
private _host: HTMLElement;
|
||||
|
||||
public constructor(host: HTMLElement) {
|
||||
this._host = host;
|
||||
}
|
||||
|
||||
public setThumbnailSize(size?: number): void {
|
||||
this._host.style.setProperty(
|
||||
'--advanced-camera-card-thumbnail-size',
|
||||
`${size ?? THUMBNAIL_WIDTH_DEFAULT}px`,
|
||||
);
|
||||
}
|
||||
|
||||
public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number {
|
||||
return !thumbnailConfig
|
||||
? THUMBNAIL_WIDTH_DEFAULT
|
||||
: thumbnailConfig.show_details
|
||||
? FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN
|
||||
: thumbnailConfig.size;
|
||||
}
|
||||
|
||||
public getColumnCountRoundMethod(
|
||||
thumbnailConfig?: MediaGalleryThumbnailsConfig,
|
||||
): GalleryColumnCountRoundMethod {
|
||||
return thumbnailConfig?.show_details ? 'floor' : 'ceil';
|
||||
}
|
||||
|
||||
public itemClickHandler(
|
||||
viewManager: ViewManagerInterface,
|
||||
item: ViewItem,
|
||||
ev: Event,
|
||||
foldersManager?: FoldersManager,
|
||||
): void {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
|
||||
const view = viewManager.getView();
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
if (ViewItemClassifier.isMedia(item)) {
|
||||
viewManager.setViewByParameters({
|
||||
params: {
|
||||
view: 'media',
|
||||
queryResults: view.queryResults
|
||||
?.clone()
|
||||
.selectResultIfFound((result) => result === item),
|
||||
},
|
||||
});
|
||||
} else if (
|
||||
ViewItemClassifier.isFolder(item) &&
|
||||
QueryClassifier.isFolderQuery(view.query)
|
||||
) {
|
||||
const rawQuery = view.query.getQuery();
|
||||
if (!rawQuery || !foldersManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newQuery = foldersManager.generateChildFolderQuery(rawQuery, item);
|
||||
if (!newQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewManager.setViewByParametersWithExistingQuery({
|
||||
params: {
|
||||
query: view.query.clone().setQuery(newQuery),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { CameraManager, ExtendedMediaQueryResult } from '../../camera-manager/manager';
|
||||
import { EventQuery, MediaQuery, RecordingQuery } from '../../camera-manager/types';
|
||||
import {
|
||||
ViewManagerEpoch,
|
||||
ViewManagerInterface,
|
||||
} from '../../card-controller/view/types';
|
||||
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
|
||||
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import { EventMediaQuery, RecordingMediaQuery } from '../../view/query';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { QueryResults } from '../../view/query-results';
|
||||
import { View } from '../../view/view';
|
||||
import { GalleryColumnCountRoundMethod } from './gallery-core-controller';
|
||||
|
||||
// The minimum width of a thumbnail with details enabled.
|
||||
export const MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
||||
|
||||
export class MediaGalleryController {
|
||||
private _host: HTMLElement;
|
||||
private _media: ViewItem[] | null = null;
|
||||
|
||||
public constructor(host: HTMLElement) {
|
||||
this._host = host;
|
||||
}
|
||||
|
||||
public getMedia(): ViewItem[] | null {
|
||||
return this._media;
|
||||
}
|
||||
|
||||
public setMediaFromView(newView?: View | null, oldView?: View | null): void {
|
||||
const newResults = newView?.queryResults?.getResults() ?? null;
|
||||
if (newResults === null) {
|
||||
this._media = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._media || oldView?.queryResults?.getResults() !== newResults) {
|
||||
// Media gallery places the most recent media at the top (the query
|
||||
// results place the most recent media at the end for use in the viewer).
|
||||
// This is copied to a new array to avoid reversing the query results in
|
||||
// place.
|
||||
this._media = [...newResults].reverse();
|
||||
}
|
||||
}
|
||||
|
||||
public setThumbnailSize(size?: number): void {
|
||||
this._host.style.setProperty(
|
||||
'--advanced-camera-card-thumbnail-size',
|
||||
`${size ?? THUMBNAIL_WIDTH_DEFAULT}px`,
|
||||
);
|
||||
}
|
||||
|
||||
public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number {
|
||||
return !thumbnailConfig
|
||||
? THUMBNAIL_WIDTH_DEFAULT
|
||||
: thumbnailConfig.show_details
|
||||
? MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN
|
||||
: thumbnailConfig.size;
|
||||
}
|
||||
|
||||
public getColumnCountRoundMethod(
|
||||
thumbnailConfig?: MediaGalleryThumbnailsConfig,
|
||||
): GalleryColumnCountRoundMethod {
|
||||
return thumbnailConfig?.show_details ? 'floor' : 'ceil';
|
||||
}
|
||||
|
||||
public async extendMediaGallery(
|
||||
cameraManager: CameraManager,
|
||||
viewManagerEpoch: ViewManagerEpoch,
|
||||
direction: 'earlier' | 'later',
|
||||
useCache = true,
|
||||
): Promise<void> {
|
||||
const view = viewManagerEpoch.manager.getView();
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = view.query;
|
||||
const existingMedia = view.queryResults?.getResults();
|
||||
if (!existingMedia || !query || !QueryClassifier.isMediaQuery(query)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawQueries = query.getQuery() ?? null;
|
||||
if (!rawQueries) {
|
||||
return;
|
||||
}
|
||||
|
||||
let extension: ExtendedMediaQueryResult<MediaQuery> | null;
|
||||
try {
|
||||
extension = await cameraManager.extendMediaQueries<MediaQuery>(
|
||||
rawQueries,
|
||||
existingMedia,
|
||||
direction,
|
||||
{
|
||||
useCache: useCache,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (extension) {
|
||||
const newMediaQueries = QueryClassifier.isEventQuery(query)
|
||||
? new EventMediaQuery(extension.queries as EventQuery[])
|
||||
: QueryClassifier.isRecordingQuery(query)
|
||||
? new RecordingMediaQuery(extension.queries as RecordingQuery[])
|
||||
: /* istanbul ignore next: this path cannot be reached -- @preserve */
|
||||
null;
|
||||
|
||||
/* istanbul ignore else: this path cannot be reached, as we explicitly
|
||||
check for media queries above -- @preserve */
|
||||
if (newMediaQueries) {
|
||||
viewManagerEpoch.manager.setViewByParameters({
|
||||
baseView: view,
|
||||
params: {
|
||||
query: newMediaQueries,
|
||||
queryResults: new QueryResults({
|
||||
results: extension.results,
|
||||
}).selectResultIfFound(
|
||||
(media) => media === view.queryResults?.getSelectedResult(),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public itemClickHandler(
|
||||
viewManager: ViewManagerInterface,
|
||||
reversedIndex: number,
|
||||
ev: Event,
|
||||
): void {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
|
||||
const view = viewManager.getView();
|
||||
if (!view || !this._media?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewManager.setViewByParameters({
|
||||
params: {
|
||||
view: 'media',
|
||||
queryResults: view.queryResults?.clone().selectIndex(
|
||||
// Media in the gallery is reversed vs the queryResults (see
|
||||
// note above).
|
||||
this._media.length - reversedIndex - 1,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,27 +12,28 @@ import {
|
||||
} from 'date-fns';
|
||||
import { LitElement } from 'lit';
|
||||
import { isEqual, orderBy, uniqWith } from 'lodash-es';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { CameraManager, CameraQueryClassifier } from '../camera-manager/manager';
|
||||
import { DateRange, PartialDateRange } from '../camera-manager/range';
|
||||
import { CameraQuery, MediaMetadata, QueryType } from '../camera-manager/types';
|
||||
import {
|
||||
EventQuery,
|
||||
MediaMetadata,
|
||||
QueryType,
|
||||
ReviewQuery,
|
||||
} from '../camera-manager/types';
|
||||
import { FoldersManager } from '../card-controller/folders/manager';
|
||||
import { ViewManagerInterface } from '../card-controller/view/types';
|
||||
import { SelectOption, SelectValues } from '../components/select';
|
||||
import { CardWideConfig } from '../config/schema/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { ViewMediaType } from '../types';
|
||||
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
|
||||
import { EventMediaQuery, RecordingMediaQuery } from '../view/query';
|
||||
import { QueryClassifier } from '../view/query-classifier';
|
||||
|
||||
interface MediaFilterControls {
|
||||
events: boolean;
|
||||
recordings: boolean;
|
||||
favorites: boolean;
|
||||
}
|
||||
import { UnifiedQueryBuilder } from '../view/unified-query-builder';
|
||||
|
||||
export interface MediaFilterCoreDefaults {
|
||||
cameraIDs?: string[];
|
||||
favorite?: MediaFilterCoreFavoriteSelection;
|
||||
mediaType?: MediaFilterMediaType;
|
||||
reviewed?: MediaFilterCoreReviewedSelection;
|
||||
mediaTypes?: MediaFilterMediaType[];
|
||||
what?: string[];
|
||||
when?: string;
|
||||
where?: string[];
|
||||
@@ -44,6 +45,11 @@ export enum MediaFilterCoreFavoriteSelection {
|
||||
NotFavorite = 'not-favorite',
|
||||
}
|
||||
|
||||
export enum MediaFilterCoreReviewedSelection {
|
||||
Reviewed = 'reviewed',
|
||||
NotReviewed = 'not-reviewed',
|
||||
}
|
||||
|
||||
export enum MediaFilterCoreWhen {
|
||||
Today = 'today',
|
||||
Yesterday = 'yesterday',
|
||||
@@ -56,6 +62,7 @@ export enum MediaFilterMediaType {
|
||||
Clips = 'clips',
|
||||
Snapshots = 'snapshots',
|
||||
Recordings = 'recordings',
|
||||
Reviews = 'reviews',
|
||||
}
|
||||
|
||||
export class MediaFilterController {
|
||||
@@ -72,6 +79,7 @@ export class MediaFilterController {
|
||||
protected _whereOptions: SelectOption[] = [];
|
||||
protected _tagsOptions: SelectOption[] = [];
|
||||
protected _favoriteOptions: SelectOption[];
|
||||
protected _reviewedOptions: SelectOption[];
|
||||
|
||||
protected _defaults: MediaFilterCoreDefaults | null = null;
|
||||
protected _viewManager: ViewManagerInterface | null = null;
|
||||
@@ -102,6 +110,20 @@ export class MediaFilterController {
|
||||
value: MediaFilterMediaType.Recordings,
|
||||
label: localize('media_filter.media_types.recordings'),
|
||||
},
|
||||
{
|
||||
value: MediaFilterMediaType.Reviews,
|
||||
label: localize('media_filter.media_types.reviews'),
|
||||
},
|
||||
];
|
||||
this._reviewedOptions = [
|
||||
{
|
||||
value: MediaFilterCoreReviewedSelection.Reviewed,
|
||||
label: localize('media_filter.reviewed'),
|
||||
},
|
||||
{
|
||||
value: MediaFilterCoreReviewedSelection.NotReviewed,
|
||||
label: localize('media_filter.not_reviewed'),
|
||||
},
|
||||
];
|
||||
this._staticWhenOptions = [
|
||||
{
|
||||
@@ -149,6 +171,9 @@ export class MediaFilterController {
|
||||
public getFavoriteOptions(): SelectOption[] {
|
||||
return this._favoriteOptions;
|
||||
}
|
||||
public getReviewedOptions(): SelectOption[] {
|
||||
return this._reviewedOptions;
|
||||
}
|
||||
public getDefaults(): MediaFilterCoreDefaults | null {
|
||||
return this._defaults;
|
||||
}
|
||||
@@ -158,229 +183,204 @@ export class MediaFilterController {
|
||||
|
||||
public async valueChangeHandler(
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
cardWideConfig: CardWideConfig,
|
||||
values: {
|
||||
camera?: string | string[];
|
||||
mediaType?: MediaFilterMediaType;
|
||||
camera?: SelectValues;
|
||||
mediaTypes?: SelectValues;
|
||||
when: {
|
||||
selected?: string | string[];
|
||||
selected?: SelectValues;
|
||||
from?: Date | null;
|
||||
to?: Date | null;
|
||||
};
|
||||
favorite?: MediaFilterCoreFavoriteSelection;
|
||||
where?: string | string[];
|
||||
what?: string | string[];
|
||||
tags?: string | string[];
|
||||
favorite?: SelectValues;
|
||||
reviewed?: SelectValues;
|
||||
where?: SelectValues;
|
||||
what?: SelectValues;
|
||||
tags?: SelectValues;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_ev?: unknown,
|
||||
): Promise<void> {
|
||||
const getArrayValueAsSet = (val?: SelectValues): Set<string> | null => {
|
||||
const getArrayValueAsSet = <T extends string>(val?: SelectValues): Set<T> | null => {
|
||||
// The reported value may be '' if the field is clearable (i.e. the user
|
||||
// can click 'x').
|
||||
if (val && Array.isArray(val) && val.length && !val.includes('')) {
|
||||
return new Set([...val]);
|
||||
return new Set([...val]) as Set<T>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const cameraIDs =
|
||||
getArrayValueAsSet(values.camera) ?? this._getAllCameraIDs(cameraManager);
|
||||
if (!cameraIDs.size || !values.mediaType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const when = this._getWhen(values.when);
|
||||
const favorite = values.favorite
|
||||
? values.favorite === MediaFilterCoreFavoriteSelection.Favorite
|
||||
: null;
|
||||
|
||||
// A note on views:
|
||||
// - In the below, if the user selects a camera to view media for, the main
|
||||
// view camera is also set to that value (e.g. a user browsing the
|
||||
// gallery, chooses a different camera in the media filter, then
|
||||
// subsequently chooses the live button -- they would expect the live view
|
||||
// for that filtered camera not the prior camera).
|
||||
// - Similarly, if the user chooses clips or snapshots, set the actual view
|
||||
// to 'clips' or 'snapshots' in order to ensure the right icon is shown as
|
||||
// selected in the menu.
|
||||
const reviewed = values.reviewed
|
||||
? values.reviewed === MediaFilterCoreReviewedSelection.Reviewed
|
||||
: null;
|
||||
const where = getArrayValueAsSet(values.where);
|
||||
const what = getArrayValueAsSet(values.what);
|
||||
const tags = getArrayValueAsSet(values.tags);
|
||||
const limit = cardWideConfig.performance?.features.media_chunk_size;
|
||||
|
||||
if (
|
||||
values.mediaType === MediaFilterMediaType.Clips ||
|
||||
values.mediaType === MediaFilterMediaType.Snapshots
|
||||
) {
|
||||
const where = getArrayValueAsSet(values.where);
|
||||
const what = getArrayValueAsSet(values.what);
|
||||
const tags = getArrayValueAsSet(values.tags);
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
const query = builder.buildFilterQuery(
|
||||
getArrayValueAsSet(values.camera),
|
||||
getArrayValueAsSet<ViewMediaType>(values.mediaTypes),
|
||||
{
|
||||
...(when?.start && { start: when.start }),
|
||||
...(when?.end && { end: when.end }),
|
||||
...(limit && { limit }),
|
||||
...(favorite !== null && { favorite }),
|
||||
...(reviewed !== null && { reviewed }),
|
||||
...(tags && { tags }),
|
||||
...(what && { what }),
|
||||
...(where && { where }),
|
||||
},
|
||||
);
|
||||
|
||||
const queries = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: cameraIDs,
|
||||
...(tags && { tags: tags }),
|
||||
...(what && { what: what }),
|
||||
...(where && { where: where }),
|
||||
...(favorite !== null && { favorite: favorite }),
|
||||
...(when && {
|
||||
...(when.start && { start: when.start }),
|
||||
...(when.end && { end: when.end }),
|
||||
}),
|
||||
...(limit && { limit: limit }),
|
||||
...(values.mediaType === MediaFilterMediaType.Clips && { hasClip: true }),
|
||||
...(values.mediaType === MediaFilterMediaType.Snapshots && {
|
||||
hasSnapshot: true,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
this._viewManager?.setViewByParametersWithExistingQuery({
|
||||
params: {
|
||||
query: queries,
|
||||
|
||||
// See 'A note on views' above for these two arguments
|
||||
...(cameraIDs.size === 1 && { camera: [...cameraIDs][0] }),
|
||||
view: values.mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const queries = new RecordingMediaQuery([
|
||||
{
|
||||
type: QueryType.Recording,
|
||||
cameraIDs: cameraIDs,
|
||||
...(limit && { limit: limit }),
|
||||
...(when && {
|
||||
...(when.start && { start: when.start }),
|
||||
...(when.end && { end: when.end }),
|
||||
}),
|
||||
...(favorite !== null && { favorite: favorite }),
|
||||
},
|
||||
]);
|
||||
|
||||
this._viewManager?.setViewByParametersWithExistingQuery({
|
||||
params: {
|
||||
query: queries,
|
||||
|
||||
// See 'A note on views' above for these two arguments
|
||||
...(cameraIDs.size === 1 && { camera: [...cameraIDs][0] }),
|
||||
view: 'recordings',
|
||||
},
|
||||
});
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get camera IDs from the query (builder may have defaulted these)
|
||||
const queryCameraIDs = query.getAllCameraIDs();
|
||||
const cameraID = queryCameraIDs.size === 1 ? [...queryCameraIDs][0] : undefined;
|
||||
|
||||
this._viewManager?.setViewByParametersWithExistingQuery({
|
||||
params: {
|
||||
query,
|
||||
// If single camera, set it as the active camera for menu navigation
|
||||
...(cameraID && { camera: cameraID }),
|
||||
},
|
||||
});
|
||||
|
||||
// Need to ensure we update the element as the date-picker selections may
|
||||
// have changed, and we need to un/set the selected class.
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
protected _getAllCameraIDs(cameraManager: CameraManager): Set<string> {
|
||||
return cameraManager.getStore().getCameraIDsWithCapability({
|
||||
anyCapabilities: ['clips', 'snapshots', 'recordings'],
|
||||
});
|
||||
}
|
||||
|
||||
public computeInitialDefaultsFromView(cameraManager: CameraManager): void {
|
||||
public computeInitialDefaultsFromView(
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
): void {
|
||||
const view = this._viewManager?.getView();
|
||||
const query = view?.query;
|
||||
const allCameraIDs = this._getAllCameraIDs(cameraManager);
|
||||
if (!view || !QueryClassifier.isMediaQuery(query) || !allCameraIDs.size) {
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
const allCameraIDs = builder.getAllMediaCapableCameraIDs();
|
||||
if (!view || !query?.hasNodes() || !allCameraIDs.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queries = query.getQuery();
|
||||
if (!queries) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mediaType: MediaFilterMediaType | undefined;
|
||||
const mediaQueries = query.getMediaQueries();
|
||||
const mediaTypes: MediaFilterMediaType[] = [];
|
||||
let cameraIDs: string[] | undefined;
|
||||
let what: string[] | undefined;
|
||||
let where: string[] | undefined;
|
||||
let favorite: MediaFilterCoreFavoriteSelection | undefined;
|
||||
let reviewed: MediaFilterCoreReviewedSelection | undefined;
|
||||
let tags: string[] | undefined;
|
||||
|
||||
const cameraIDSets = uniqWith(
|
||||
queries.map((query: CameraQuery) => query.cameraIDs),
|
||||
isEqual,
|
||||
);
|
||||
// Special note: If all visible cameras are selected, this is the same as no
|
||||
// selector at all.
|
||||
if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, allCameraIDs)) {
|
||||
cameraIDs = [...queries[0].cameraIDs];
|
||||
const cameraIDsFromQuery = query.getAllCameraIDs();
|
||||
|
||||
// Note: With folder filtering, selecting all cameras is NOT the same as
|
||||
// selecting none. "All cameras" means just camera media, while "none"
|
||||
// would include folder media too.
|
||||
if (cameraIDsFromQuery.size > 0) {
|
||||
cameraIDs = [...cameraIDsFromQuery];
|
||||
}
|
||||
|
||||
const favoriteValues = uniqWith(
|
||||
queries.map((query) => query.favorite),
|
||||
isEqual,
|
||||
// Extract favorite from all media queries (only if explicitly set to true/false)
|
||||
const favoriteValues = new Set(
|
||||
mediaQueries.map((mediaQuery) =>
|
||||
CameraQueryClassifier.isEventQuery(mediaQuery) ? mediaQuery.favorite : undefined,
|
||||
),
|
||||
);
|
||||
if (favoriteValues.length === 1 && queries[0].favorite !== undefined) {
|
||||
favorite = queries[0].favorite
|
||||
? MediaFilterCoreFavoriteSelection.Favorite
|
||||
: MediaFilterCoreFavoriteSelection.NotFavorite;
|
||||
if (favoriteValues.size === 1) {
|
||||
const fav = [...favoriteValues][0];
|
||||
if (fav !== undefined) {
|
||||
favorite = fav
|
||||
? MediaFilterCoreFavoriteSelection.Favorite
|
||||
: MediaFilterCoreFavoriteSelection.NotFavorite;
|
||||
}
|
||||
}
|
||||
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (QueryClassifier.isEventQuery(view.query)) {
|
||||
const queries = view.query.getQuery();
|
||||
// Detect media types from queries
|
||||
const eventQueries = query.getMediaQueries<EventQuery>({ type: QueryType.Event });
|
||||
if (eventQueries.length > 0) {
|
||||
const hasClips = eventQueries.some((q) => q.hasClip);
|
||||
const hasSnapshots = eventQueries.some((q) => q.hasSnapshot);
|
||||
const hasNeither = !hasClips && !hasSnapshots;
|
||||
|
||||
/* istanbul ignore if: the if path cannot be reached -- @preserve */
|
||||
if (!queries) {
|
||||
return;
|
||||
if (hasClips || hasNeither) {
|
||||
mediaTypes.push(MediaFilterMediaType.Clips);
|
||||
}
|
||||
|
||||
const hasClips = uniqWith(
|
||||
queries.map((query) => query.hasClip),
|
||||
isEqual,
|
||||
);
|
||||
const hasSnapshots = uniqWith(
|
||||
queries.map((query) => query.hasSnapshot),
|
||||
isEqual,
|
||||
);
|
||||
if (hasClips.length === 1 && hasSnapshots.length === 1) {
|
||||
mediaType = !!hasClips[0]
|
||||
? MediaFilterMediaType.Clips
|
||||
: !!hasSnapshots[0]
|
||||
? MediaFilterMediaType.Snapshots
|
||||
: undefined;
|
||||
if (hasSnapshots || hasNeither) {
|
||||
mediaTypes.push(MediaFilterMediaType.Snapshots);
|
||||
}
|
||||
}
|
||||
if (query.hasMediaQueriesOfType(QueryType.Recording)) {
|
||||
mediaTypes.push(MediaFilterMediaType.Recordings);
|
||||
}
|
||||
if (query.hasMediaQueriesOfType(QueryType.Review)) {
|
||||
mediaTypes.push(MediaFilterMediaType.Reviews);
|
||||
}
|
||||
|
||||
if (eventQueries.length > 0) {
|
||||
const whatSets = uniqWith(
|
||||
queries.map((query) => query.what),
|
||||
eventQueries.map((q) => q.what),
|
||||
isEqual,
|
||||
);
|
||||
if (whatSets.length === 1 && queries[0].what?.size) {
|
||||
what = [...queries[0].what];
|
||||
if (whatSets.length === 1 && eventQueries[0].what?.size) {
|
||||
what = [...eventQueries[0].what];
|
||||
}
|
||||
const whereSets = uniqWith(
|
||||
queries.map((query) => query.where),
|
||||
eventQueries.map((q) => q.where),
|
||||
isEqual,
|
||||
);
|
||||
if (whereSets.length === 1 && queries[0].where?.size) {
|
||||
where = [...queries[0].where];
|
||||
if (whereSets.length === 1 && eventQueries[0].where?.size) {
|
||||
where = [...eventQueries[0].where];
|
||||
}
|
||||
const tagsSets = uniqWith(
|
||||
queries.map((query) => query.tags),
|
||||
eventQueries.map((q) => q.tags),
|
||||
isEqual,
|
||||
);
|
||||
if (tagsSets.length === 1 && queries[0].tags?.size) {
|
||||
tags = [...queries[0].tags];
|
||||
if (tagsSets.length === 1 && eventQueries[0].tags?.size) {
|
||||
tags = [...eventQueries[0].tags];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract reviewed from review queries (only if explicitly set to true/false)
|
||||
const reviewQueries = query.getMediaQueries<ReviewQuery>({ type: QueryType.Review });
|
||||
if (reviewQueries.length > 0) {
|
||||
const reviewedValues = new Set(reviewQueries.map((q) => q.reviewed));
|
||||
if (reviewedValues.size === 1) {
|
||||
const rev = [...reviewedValues][0];
|
||||
if (rev !== undefined) {
|
||||
reviewed = rev
|
||||
? MediaFilterCoreReviewedSelection.Reviewed
|
||||
: MediaFilterCoreReviewedSelection.NotReviewed;
|
||||
}
|
||||
}
|
||||
} else if (QueryClassifier.isRecordingQuery(view.query)) {
|
||||
mediaType = MediaFilterMediaType.Recordings;
|
||||
}
|
||||
|
||||
this._defaults = {
|
||||
...(mediaType && { mediaType: mediaType }),
|
||||
...(cameraIDs && { cameraIDs: cameraIDs }),
|
||||
...(what && { what: what }),
|
||||
...(where && { where: where }),
|
||||
...(favorite !== undefined && { favorite: favorite }),
|
||||
...(tags && { tags: tags }),
|
||||
...(mediaTypes.length && { mediaTypes }),
|
||||
...(cameraIDs && { cameraIDs }),
|
||||
...(what && { what }),
|
||||
...(where && { where }),
|
||||
...(favorite !== undefined && { favorite }),
|
||||
...(reviewed !== undefined && { reviewed }),
|
||||
...(tags && { tags }),
|
||||
};
|
||||
}
|
||||
|
||||
public computeCameraOptions(cameraManager: CameraManager): void {
|
||||
this._cameraOptions = [...this._getAllCameraIDs(cameraManager)].map((cameraID) => ({
|
||||
public computeCameraOptions(
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
): void {
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
this._cameraOptions = [...builder.getAllMediaCapableCameraIDs()].map((cameraID) => ({
|
||||
value: cameraID,
|
||||
label: cameraManager.getCameraMetadata(cameraID)?.title ?? cameraID,
|
||||
}));
|
||||
@@ -441,23 +441,6 @@ export class MediaFilterController {
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
public getControlsToShow(cameraManager: CameraManager): MediaFilterControls {
|
||||
const view = this._viewManager?.getView();
|
||||
const events = QueryClassifier.isEventQuery(view?.query);
|
||||
const recordings = QueryClassifier.isRecordingQuery(view?.query);
|
||||
const managerCapabilities = cameraManager.getAggregateCameraCapabilities();
|
||||
|
||||
return {
|
||||
events: events,
|
||||
recordings: recordings,
|
||||
favorites: events
|
||||
? managerCapabilities?.has('favorite-events')
|
||||
: recordings
|
||||
? managerCapabilities?.has('favorite-recordings')
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
protected _computeWhenOptions(): void {
|
||||
this._whenOptions = [...this._staticWhenOptions, ...this._metaDataWhenOptions];
|
||||
}
|
||||
|
||||
+130
-13
@@ -1,27 +1,40 @@
|
||||
import { format } from 'date-fns';
|
||||
import { CameraManager } from '../../camera-manager/manager';
|
||||
import { CameraManagerCameraMetadata } from '../../camera-manager/types';
|
||||
import { ViewItemManager } from '../../card-controller/view/item-manager';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { Icon } from '../../types';
|
||||
import { MetadataField, OverlayMessage, OverlayMessageControl } from '../../types';
|
||||
import { getDurationString, prettifyTitle } from '../../utils/basic';
|
||||
import {
|
||||
downloadMedia,
|
||||
navigateToTimeline,
|
||||
toggleFavorite,
|
||||
toggleReviewed,
|
||||
} from '../../utils/media-actions';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
import { ViewItemCapabilities } from '../../view/types';
|
||||
|
||||
interface Detail {
|
||||
icon?: Icon;
|
||||
hint?: string;
|
||||
title: string;
|
||||
export interface OverlayControlsContext {
|
||||
hass?: HomeAssistant;
|
||||
viewItemManager?: ViewItemManager;
|
||||
viewManagerEpoch?: ViewManagerEpoch;
|
||||
capabilities?: ViewItemCapabilities | null;
|
||||
}
|
||||
|
||||
export class ThumbnailDetailsController {
|
||||
private _details: Detail[] = [];
|
||||
private _heading: string | null = null;
|
||||
export class MediaDetailsController {
|
||||
private _details: MetadataField[] = [];
|
||||
private _heading: MetadataField | null = null;
|
||||
private _item: ViewItem | null = null;
|
||||
|
||||
public calculate(
|
||||
cameraManager?: CameraManager | null,
|
||||
item?: ViewItem,
|
||||
seek?: Date,
|
||||
): void {
|
||||
this._item = item ?? null;
|
||||
const cameraID = ViewItemClassifier.isMedia(item) ? item.getCameraID() : null;
|
||||
const cameraMetadata = cameraID
|
||||
? cameraManager?.getCameraMetadata(cameraID) ?? null
|
||||
@@ -43,12 +56,34 @@ export class ThumbnailDetailsController {
|
||||
const rawScore = item.getScore();
|
||||
const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null;
|
||||
|
||||
this._heading = whatWithTags ? `${whatWithTags}${score ? ` ${score}` : ''}` : null;
|
||||
this._heading = whatWithTags
|
||||
? { title: `${whatWithTags}${score ? ` ${score}` : ''}` }
|
||||
: null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (ViewItemClassifier.isReview(item)) {
|
||||
const title = item.getTitle();
|
||||
const severity = item.getSeverity();
|
||||
|
||||
this._heading = title
|
||||
? {
|
||||
title: title,
|
||||
emphasis: severity ?? undefined,
|
||||
hint:
|
||||
localize('common.severity') +
|
||||
': ' +
|
||||
localize('common.severities.' + severity),
|
||||
icon: { icon: 'mdi:circle-medium' },
|
||||
}
|
||||
: null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (cameraMetadata?.title) {
|
||||
this._heading = cameraMetadata.title;
|
||||
this._heading = {
|
||||
title: cameraMetadata.title,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,7 +172,9 @@ export class ThumbnailDetailsController {
|
||||
|
||||
// To avoid duplication, if the event has a starttime, the title is omitted
|
||||
// from the details.
|
||||
const includeTitle = !ViewItemClassifier.isEvent(item) || !startTime;
|
||||
const includeTitle =
|
||||
(!ViewItemClassifier.isEvent(item) && !ViewItemClassifier.isReview(item)) ||
|
||||
!startTime;
|
||||
this._details = [
|
||||
...(includeTitle && itemTitle
|
||||
? [
|
||||
@@ -154,11 +191,91 @@ export class ThumbnailDetailsController {
|
||||
];
|
||||
}
|
||||
|
||||
public getHeading(): string | null {
|
||||
public getHeading(): MetadataField | null {
|
||||
return this._heading;
|
||||
}
|
||||
|
||||
public getDetails(): Detail[] {
|
||||
public getDetails(): MetadataField[] {
|
||||
return this._details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an overlay message for the item.
|
||||
* @param context Optional context to include controls.
|
||||
* @returns An OverlayMessage.
|
||||
*/
|
||||
public getMessage(context?: OverlayControlsContext): OverlayMessage {
|
||||
return {
|
||||
heading: this._heading ?? undefined,
|
||||
controls: context ? this._getControls(context) : undefined,
|
||||
details: this._details,
|
||||
text: ViewItemClassifier.isMedia(this._item)
|
||||
? this._item.getDescription() ?? undefined
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
protected _getControls(context: OverlayControlsContext): OverlayMessageControl[] {
|
||||
const controls: OverlayMessageControl[] = [];
|
||||
const item = this._item;
|
||||
|
||||
if (!item) {
|
||||
return controls;
|
||||
}
|
||||
|
||||
if (ViewItemClassifier.isReview(item)) {
|
||||
const isReviewed = item.isReviewed();
|
||||
controls.push({
|
||||
title: isReviewed
|
||||
? localize('common.set_reviews.unreviewed')
|
||||
: localize('common.set_reviews.reviewed'),
|
||||
icon: { icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline' },
|
||||
callback: async () => {
|
||||
const success = await toggleReviewed(item, context);
|
||||
return success ? this.getMessage(context) : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (context.capabilities?.canFavorite && ViewItemClassifier.isMedia(item)) {
|
||||
const isFavorite = item.isFavorite();
|
||||
controls.push({
|
||||
title: localize('thumbnail.retain_indefinitely'),
|
||||
icon: { icon: isFavorite ? 'mdi:star' : 'mdi:star-outline' },
|
||||
emphasis: isFavorite ? 'medium' : undefined,
|
||||
callback: async () => {
|
||||
const success = await toggleFavorite(item, context);
|
||||
return success ? this.getMessage(context) : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (context.capabilities?.canDownload && item.getID()) {
|
||||
controls.push({
|
||||
title: localize('thumbnail.download'),
|
||||
icon: { icon: 'mdi:download' },
|
||||
callback: async () => {
|
||||
await downloadMedia(item, context);
|
||||
|
||||
// Close overlay message after download.
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (ViewItemClassifier.supportsTimeline(item) && context.viewManagerEpoch) {
|
||||
controls.push({
|
||||
title: localize('thumbnail.timeline'),
|
||||
icon: { icon: 'mdi:target' },
|
||||
callback: () => {
|
||||
navigateToTimeline(item, context);
|
||||
|
||||
// Close overlay after timeline navigation
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return controls;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,11 @@ import { FullscreenManager } from '../card-controller/fullscreen/fullscreen-mana
|
||||
import { MediaPlayerManager } from '../card-controller/media-player-manager';
|
||||
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
||||
import { ViewManager } from '../card-controller/view/view-manager';
|
||||
import { VIEWS_USER_SPECIFIED } from '../config/schema/common/const';
|
||||
import {
|
||||
AdvancedCameraCardView,
|
||||
VIEWS_USER_SPECIFIED,
|
||||
} from '../config/schema/common/const';
|
||||
import { MenuItemBase } from '../config/schema/elements/custom/menu/base';
|
||||
import { MenuItem } from '../config/schema/elements/custom/menu/types';
|
||||
import { AdvancedCameraCardConfig } from '../config/schema/types';
|
||||
import { getEntityTitle } from '../ha/get-entity-title';
|
||||
@@ -19,6 +23,7 @@ import {
|
||||
createMediaPlayerAction,
|
||||
createPTZControlsAction,
|
||||
createPTZMultiAction,
|
||||
createSetReviewAction,
|
||||
createViewAction,
|
||||
isAdvancedCameraCardCustomAction,
|
||||
} from '../utils/action';
|
||||
@@ -27,7 +32,6 @@ import { isBeingCasted } from '../utils/casting';
|
||||
import { getPTZTarget } from '../utils/ptz';
|
||||
import { getStreamCameraID, hasSubstream } from '../utils/substream';
|
||||
import { ViewItemClassifier } from '../view/item-classifier';
|
||||
import { QueryClassifier } from '../view/query-classifier';
|
||||
import { View } from '../view/view';
|
||||
import { getCameraIDsForViewName, isViewSupportedByCamera } from '../view/view-support';
|
||||
|
||||
@@ -77,9 +81,12 @@ export class MenuButtonController {
|
||||
this._getClipsButton(config, cameraManager, foldersManager, options?.view),
|
||||
this._getSnapshotsButton(config, cameraManager, foldersManager, options?.view),
|
||||
this._getRecordingsButton(config, cameraManager, foldersManager, options?.view),
|
||||
this._getReviewsButton(config, cameraManager, foldersManager, options?.view),
|
||||
this._getImageButton(config, cameraManager, foldersManager, options?.view),
|
||||
this._getTimelineButton(config, cameraManager, foldersManager, options?.view),
|
||||
this._getDownloadButton(config, cameraManager, options?.view),
|
||||
this._getInfoButton(config, cameraManager, options?.view),
|
||||
this._getSetReviewButton(config, options?.view),
|
||||
this._getCameraUIButton(config, options?.showCameraUIButton),
|
||||
this._getMicrophoneButton(
|
||||
config,
|
||||
@@ -245,17 +252,45 @@ export class MenuButtonController {
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a media button (clips/snapshots) should be shown. These are
|
||||
* hidden by default if reviews are supported.
|
||||
*/
|
||||
protected _shouldShowEventMediaButton(
|
||||
viewName: 'clips' | 'snapshots',
|
||||
buttonConfig: MenuItemBase,
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): boolean {
|
||||
const supportsEventView =
|
||||
view &&
|
||||
isViewSupportedByCamera(viewName, cameraManager, foldersManager, view.camera);
|
||||
const supportsReviewsView =
|
||||
view &&
|
||||
isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera);
|
||||
|
||||
// Show if: explicitly enabled OR (supports view AND reviews not supported)
|
||||
return !!supportsEventView && (buttonConfig.enabled || !supportsReviewsView);
|
||||
}
|
||||
|
||||
protected _getClipsButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
return view &&
|
||||
isViewSupportedByCamera('clips', cameraManager, foldersManager, view.camera)
|
||||
return this._shouldShowEventMediaButton(
|
||||
'clips',
|
||||
config.menu.buttons.clips,
|
||||
cameraManager,
|
||||
foldersManager,
|
||||
view,
|
||||
)
|
||||
? {
|
||||
icon: 'mdi:filmstrip',
|
||||
...config.menu.buttons.clips,
|
||||
enabled: true,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: localize('config.view.views.clips'),
|
||||
style: view?.is('clips') ? this._getEmphasizedStyle() : {},
|
||||
@@ -271,11 +306,17 @@ export class MenuButtonController {
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
return view &&
|
||||
isViewSupportedByCamera('snapshots', cameraManager, foldersManager, view.camera)
|
||||
return this._shouldShowEventMediaButton(
|
||||
'snapshots',
|
||||
config.menu.buttons.snapshots,
|
||||
cameraManager,
|
||||
foldersManager,
|
||||
view,
|
||||
)
|
||||
? {
|
||||
icon: 'mdi:camera',
|
||||
...config.menu.buttons.snapshots,
|
||||
enabled: true,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: localize('config.view.views.snapshots'),
|
||||
style: view?.is('snapshots') ? this._getEmphasizedStyle() : {},
|
||||
@@ -305,6 +346,26 @@ export class MenuButtonController {
|
||||
: null;
|
||||
}
|
||||
|
||||
protected _getReviewsButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
return view &&
|
||||
isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera)
|
||||
? {
|
||||
icon: 'mdi:play-box-multiple',
|
||||
...config.menu.buttons.reviews,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: localize('config.view.views.reviews'),
|
||||
style: view.is('reviews') ? this._getEmphasizedStyle() : {},
|
||||
tap_action: createViewAction('reviews'),
|
||||
hold_action: createViewAction('review'),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
protected _getImageButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
cameraManager: CameraManager,
|
||||
@@ -365,6 +426,49 @@ export class MenuButtonController {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected _getInfoButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
_cameraManager: CameraManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
const selectedItem = view?.queryResults?.getSelectedResult();
|
||||
if (!ViewItemClassifier.isMedia(selectedItem)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
icon: 'mdi:information-outline',
|
||||
...config.menu.buttons.info,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: localize('config.menu.buttons.info'),
|
||||
tap_action: createGeneralAction('info'),
|
||||
};
|
||||
}
|
||||
|
||||
protected _getSetReviewButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
const selectedItem = view?.queryResults?.getSelectedResult();
|
||||
if (!view?.isViewerView() || !ViewItemClassifier.isReview(selectedItem)) {
|
||||
return null;
|
||||
}
|
||||
const isReviewed = selectedItem.isReviewed();
|
||||
if (isReviewed === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline',
|
||||
...config.menu.buttons.set_review,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: isReviewed
|
||||
? localize('common.set_reviews.unreviewed')
|
||||
: localize('common.set_reviews.reviewed'),
|
||||
tap_action: createSetReviewAction(),
|
||||
style: isReviewed ? this._getEmphasizedStyle() : {},
|
||||
};
|
||||
}
|
||||
|
||||
protected _getCameraUIButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
showCameraUIButton?: boolean,
|
||||
@@ -667,9 +771,8 @@ export class MenuButtonController {
|
||||
}
|
||||
|
||||
if (folders.length === 1) {
|
||||
const isSelected =
|
||||
QueryClassifier.isFolderQuery(view?.query) &&
|
||||
view.query.getQuery()?.folder.id === folders[0][0];
|
||||
const folderID = folders[0][0];
|
||||
const isSelected = !!view?.query?.getFolderQueries(folderID).length;
|
||||
const folder = folders[0][1];
|
||||
|
||||
return {
|
||||
@@ -684,9 +787,7 @@ export class MenuButtonController {
|
||||
}
|
||||
|
||||
const submenuItems = folders.map(([id, folder]) => {
|
||||
const isSelected =
|
||||
QueryClassifier.isFolderQuery(view?.query) &&
|
||||
view.query.getQuery()?.folder.id === id;
|
||||
const isSelected = !!view?.query?.getFolderQueries(id).length;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
@@ -736,6 +837,7 @@ export class MenuButtonController {
|
||||
button: MenuItem,
|
||||
options?: MenuButtonControllerOptions,
|
||||
): StyleInfo {
|
||||
// Review
|
||||
for (const actionSet of [
|
||||
button.tap_action,
|
||||
button.double_tap_action,
|
||||
@@ -752,7 +854,9 @@ export class MenuButtonController {
|
||||
VIEWS_USER_SPECIFIED.some(
|
||||
(viewName) =>
|
||||
viewName === action.advanced_camera_card_action &&
|
||||
options?.view?.is(action.advanced_camera_card_action),
|
||||
options?.view?.is(
|
||||
action.advanced_camera_card_action as AdvancedCameraCardView,
|
||||
),
|
||||
) ||
|
||||
(action.advanced_camera_card_action === 'default' &&
|
||||
options?.view?.is(config.view.default)) ||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { NonEmptyTuple } from 'type-fest';
|
||||
import { FolderPathComponent, FolderQuery } from '../card-controller/folders/types';
|
||||
import { ViewManagerEpoch, ViewModifier } from '../card-controller/view/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { ViewFolder, ViewMedia } from '../view/item';
|
||||
import { UnifiedQuery } from '../view/unified-query';
|
||||
import { UnifiedQueryBuilder } from '../view/unified-query-builder';
|
||||
|
||||
export interface FolderNavigationParamaters {
|
||||
viewManagerEpoch: ViewManagerEpoch;
|
||||
builder: UnifiedQueryBuilder;
|
||||
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface MediaNavigationParamaters {
|
||||
viewManagerEpoch: ViewManagerEpoch;
|
||||
|
||||
modifiers?: ViewModifier[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a navigable-up folder query from a UnifiedQuery. Returns the query only
|
||||
* if there's exactly one folder query and it has depth > 1.
|
||||
*/
|
||||
const getSingleNavigableUpFolderQuery = (
|
||||
query?: UnifiedQuery | null,
|
||||
): FolderQuery | null => {
|
||||
const folderQueries = query?.getFolderQueries();
|
||||
if (folderQueries?.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const folderQuery = folderQueries[0];
|
||||
return folderQuery.path.length > 1 ? folderQuery : null;
|
||||
};
|
||||
|
||||
export const navigateUp = (options?: FolderNavigationParamaters | null): void => {
|
||||
const folderQuery = getSingleNavigableUpFolderQuery(
|
||||
options?.viewManagerEpoch.manager.getView()?.query,
|
||||
);
|
||||
if (!folderQuery || folderQuery.path.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentPath = folderQuery.path.slice(0, -1);
|
||||
const nonEmptyPath: NonEmptyTuple<FolderPathComponent> = [
|
||||
parentPath[0],
|
||||
...parentPath.slice(1),
|
||||
];
|
||||
const query = options?.builder.buildFolderQueryWithPath(
|
||||
folderQuery.folder,
|
||||
nonEmptyPath,
|
||||
{
|
||||
limit: options?.limit,
|
||||
},
|
||||
);
|
||||
|
||||
options?.viewManagerEpoch.manager.setViewByParametersWithExistingQuery({
|
||||
params: { query },
|
||||
});
|
||||
};
|
||||
|
||||
export const navigateToFolder = (
|
||||
item: ViewFolder,
|
||||
options?: FolderNavigationParamaters | null,
|
||||
): void => {
|
||||
const newPath = [...item.getPath(), { folder: item }];
|
||||
const nonEmptyPath: NonEmptyTuple<FolderPathComponent> = [
|
||||
newPath[0],
|
||||
...newPath.slice(1),
|
||||
];
|
||||
const query = options?.builder.buildFolderQueryWithPath(
|
||||
item.getFolder(),
|
||||
nonEmptyPath,
|
||||
{
|
||||
limit: options?.limit,
|
||||
},
|
||||
);
|
||||
|
||||
options?.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||
params: { query },
|
||||
});
|
||||
};
|
||||
|
||||
export const navigateToMedia = (
|
||||
media: ViewMedia,
|
||||
options?: MediaNavigationParamaters | null,
|
||||
): void => {
|
||||
const manager = options?.viewManagerEpoch.manager;
|
||||
const view = manager?.getView();
|
||||
|
||||
if (!manager || !view?.queryResults || !options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newResults = view.queryResults
|
||||
.clone()
|
||||
.selectResultIfFound((result) => result === media);
|
||||
|
||||
const cameraID = media.getCameraID();
|
||||
manager.setViewByParameters({
|
||||
params: {
|
||||
view: 'media',
|
||||
queryResults: newResults,
|
||||
...(cameraID && { camera: cameraID }),
|
||||
},
|
||||
modifiers: options?.modifiers,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get up-folder item for display. Returns a ViewFolder if there's exactly one
|
||||
* folder query and it's navigable-up.
|
||||
*/
|
||||
export const getUpFolderItem = (query?: UnifiedQuery | null): ViewFolder | null => {
|
||||
const folderQuery = getSingleNavigableUpFolderQuery(query);
|
||||
return folderQuery
|
||||
? new ViewFolder(folderQuery.folder, folderQuery.path, {
|
||||
icon: 'mdi:arrow-up-left',
|
||||
title: localize('common.up'),
|
||||
})
|
||||
: null;
|
||||
};
|
||||
@@ -76,7 +76,7 @@ export class ThumbnailFeatureController {
|
||||
if (thumbnail) {
|
||||
this._thumbnail = thumbnail;
|
||||
this._icon = null;
|
||||
this._thumbnailClass = isBrandUrl(thumbnail) ? 'brand' : null;
|
||||
this._thumbnailClass = isBrandUrl(thumbnail) ? 'placeholder' : null;
|
||||
} else {
|
||||
this._thumbnail = null;
|
||||
this._thumbnailClass = null;
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from 'vis-timeline';
|
||||
import { CameraManager } from '../../camera-manager/manager';
|
||||
import { rangesOverlap } from '../../camera-manager/range';
|
||||
import { MediaQuery } from '../../camera-manager/types';
|
||||
import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly';
|
||||
import { FoldersManager } from '../../card-controller/folders/manager';
|
||||
import { ViewItemManager } from '../../card-controller/view/item-manager';
|
||||
@@ -41,22 +40,12 @@ import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
|
||||
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
|
||||
import { ViewMedia } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
import {
|
||||
EventMediaQuery,
|
||||
FolderViewQuery,
|
||||
Query,
|
||||
RecordingMediaQuery,
|
||||
} from '../../view/query';
|
||||
import { QueryClassifier, QueryType } from '../../view/query-classifier';
|
||||
import { QueryResults } from '../../view/query-results';
|
||||
import { UnifiedQuery } from '../../view/unified-query';
|
||||
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer';
|
||||
import { mergeViewContext } from '../../view/view';
|
||||
import { AdvancedCameraCardTimelineItem, TimelineDataSource } from './source';
|
||||
import {
|
||||
ExtendedTimeline,
|
||||
TimelineItemClickAction,
|
||||
TimelineKeys,
|
||||
TimelineRangeChange,
|
||||
} from './types';
|
||||
import { ExtendedTimeline, TimelineItemClickAction, TimelineRangeChange } from './types';
|
||||
|
||||
// An event used to fetch data required for thumbnail rendering. See special
|
||||
// note below on why this is necessary.
|
||||
@@ -81,7 +70,7 @@ interface TimelineControllerOptions {
|
||||
timelineConfig?: TimelineCoreConfig;
|
||||
mini?: boolean;
|
||||
thumbnailConfig?: ThumbnailsControlBaseConfig;
|
||||
keys?: TimelineKeys;
|
||||
query?: UnifiedQuery;
|
||||
}
|
||||
|
||||
const TIMELINE_TARGET_BAR_ID = 'target_bar';
|
||||
@@ -94,10 +83,14 @@ export class TimelineController {
|
||||
private _timeline: ExtendedTimeline | null = null;
|
||||
|
||||
private _hass: HomeAssistant | null = null;
|
||||
|
||||
private _cameraManager: CameraManager | null = null;
|
||||
private _foldersManager: FoldersManager | null = null;
|
||||
|
||||
private _viewItemManager: ViewItemManager | null = null;
|
||||
private _viewManagerEpoch: ViewManagerEpoch | null = null;
|
||||
private _timelineConfig: TimelineCoreConfig | null = null;
|
||||
|
||||
private _mini = false;
|
||||
|
||||
private _panMode: TimelinePanMode | null = null;
|
||||
@@ -136,26 +129,56 @@ export class TimelineController {
|
||||
this._pointerHeld = null;
|
||||
}
|
||||
|
||||
public setOptions(options: TimelineControllerOptions): void {
|
||||
this.destroyTimeline();
|
||||
/**
|
||||
* Extract the "shape" of a query - a clone without time ranges.
|
||||
* Shape determines timeline structure (groups).
|
||||
*/
|
||||
private _getQueryShape(query: UnifiedQuery): UnifiedQuery {
|
||||
return UnifiedQueryTransformer.stripTimeRange(query);
|
||||
}
|
||||
|
||||
if (
|
||||
options.keys &&
|
||||
options.cameraManager &&
|
||||
options.foldersManager &&
|
||||
options.conditionStateManager &&
|
||||
options.timelineConfig
|
||||
) {
|
||||
this._source = new TimelineDataSource(
|
||||
options.cameraManager,
|
||||
options.foldersManager,
|
||||
options.conditionStateManager,
|
||||
options.keys,
|
||||
options.timelineConfig.events_media_type,
|
||||
options.timelineConfig.show_recordings,
|
||||
);
|
||||
} else {
|
||||
this._source = null;
|
||||
private _hasSameShape(a?: UnifiedQuery | null, b?: UnifiedQuery | null): boolean {
|
||||
if (!a && !b) {
|
||||
return true;
|
||||
}
|
||||
if (!a || !b) {
|
||||
return false;
|
||||
}
|
||||
return a.isEqual(b);
|
||||
}
|
||||
|
||||
public setOptions(options: TimelineControllerOptions): void {
|
||||
// Extract the shape (query without time ranges) for comparison.
|
||||
const newShape = options.query ? this._getQueryShape(options.query) : null;
|
||||
|
||||
// Rebuild source if config, dependencies, or shape changed.
|
||||
const needsRebuild =
|
||||
!this._source ||
|
||||
this._cameraManager !== (options.cameraManager ?? null) ||
|
||||
this._foldersManager !== (options.foldersManager ?? null) ||
|
||||
!isEqual(this._timelineConfig, options.timelineConfig ?? null) ||
|
||||
!this._hasSameShape(this._source?.shape, newShape);
|
||||
|
||||
if (needsRebuild) {
|
||||
this.destroyTimeline();
|
||||
|
||||
if (
|
||||
newShape &&
|
||||
options.cameraManager &&
|
||||
options.foldersManager &&
|
||||
options.conditionStateManager &&
|
||||
options.timelineConfig
|
||||
) {
|
||||
this._source = new TimelineDataSource(
|
||||
options.cameraManager,
|
||||
options.foldersManager,
|
||||
options.conditionStateManager,
|
||||
newShape,
|
||||
options.timelineConfig.show_recordings,
|
||||
);
|
||||
} else {
|
||||
this._source = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._thumbnailConfig !== (options.thumbnailConfig ?? null)) {
|
||||
@@ -187,6 +210,7 @@ export class TimelineController {
|
||||
|
||||
this._thumbnailConfig = options?.thumbnailConfig ?? null;
|
||||
this._cameraManager = options?.cameraManager ?? null;
|
||||
this._foldersManager = options?.foldersManager ?? null;
|
||||
this._viewItemManager = options?.viewItemManager ?? null;
|
||||
this._timelineConfig = options?.timelineConfig ?? null;
|
||||
this._mini = options?.mini ?? false;
|
||||
@@ -268,13 +292,13 @@ export class TimelineController {
|
||||
this._timeline = new Timeline(
|
||||
this._timelineElement,
|
||||
this._source.dataset,
|
||||
this._source.groups,
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
this._timeline = new Timeline(
|
||||
this._timelineElement,
|
||||
this._source.dataset,
|
||||
this._source.groups,
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -500,16 +524,15 @@ export class TimelineController {
|
||||
}
|
||||
|
||||
const view = this._viewManagerEpoch?.manager.getView();
|
||||
const id = String(properties.item);
|
||||
const item = this._source?.dataset.get(id) ?? null;
|
||||
const id = properties.item ? String(properties.item) : null;
|
||||
const item = id ? this._source?.dataset.get(id) ?? null : null;
|
||||
|
||||
if (
|
||||
this._ignoreClick ||
|
||||
!view ||
|
||||
!this._viewManagerEpoch ||
|
||||
!this._source ||
|
||||
!properties.what ||
|
||||
!item
|
||||
!properties.what
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -519,9 +542,15 @@ export class TimelineController {
|
||||
if (
|
||||
this._timelineConfig?.show_recordings &&
|
||||
properties.time &&
|
||||
['background', 'axis'].includes(properties.what)
|
||||
['background', 'axis'].includes(properties.what) &&
|
||||
this._source &&
|
||||
this._timeline
|
||||
) {
|
||||
const query = this._createQuery('recording');
|
||||
const query = this._source.buildRecordingsWindowedQuery(
|
||||
convertRangeToCacheFriendlyTimes(
|
||||
this._getPrefetchWindow(this._timeline.getWindow()),
|
||||
),
|
||||
);
|
||||
if (query) {
|
||||
await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||
baseView: view,
|
||||
@@ -533,9 +562,14 @@ export class TimelineController {
|
||||
},
|
||||
},
|
||||
},
|
||||
modifiers: [
|
||||
new MergeContextViewModifier({
|
||||
mediaViewer: { seek: properties.time },
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
} else if (properties.item && properties.what === 'item') {
|
||||
} else if (item && properties.what === 'item') {
|
||||
const cameraID = String(properties.group);
|
||||
|
||||
const criteria = {
|
||||
@@ -566,49 +600,16 @@ export class TimelineController {
|
||||
// - If a folder media was loaded into the timeline from a prior folder
|
||||
// query other than the one stored in the view (e.g. user navigated to
|
||||
// a different folder in the thumbnails carousel).
|
||||
if (item.query) {
|
||||
// Item has a reference query (e.g. folders), use that.
|
||||
const media = this._source.dataset
|
||||
.get({
|
||||
filter: (timelineItem) => item.query === timelineItem.query,
|
||||
})
|
||||
.map((timelineItem) => timelineItem.media)
|
||||
.filter(isTruthy);
|
||||
const selectedIndex = media.findIndex((m) => m.getID() === id);
|
||||
|
||||
if (selectedIndex >= 0) {
|
||||
const queryResults = new QueryResults({ results: media, selectedIndex });
|
||||
this._viewManagerEpoch?.manager.setViewByParameters({
|
||||
params: {
|
||||
view: 'media',
|
||||
query: item.query,
|
||||
queryResults,
|
||||
},
|
||||
modifiers: [new MergeContextViewModifier(context)],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const currentQueryType =
|
||||
QueryClassifier.getQueryType(view.query) ??
|
||||
this._source.getKeyType() === 'camera'
|
||||
? 'event'
|
||||
: this._source.getKeyType() === 'folder'
|
||||
? 'folder'
|
||||
: null;
|
||||
|
||||
const query = currentQueryType ? this._createQuery(currentQueryType) : null;
|
||||
if (query) {
|
||||
await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||
params: { view: 'media', query: query },
|
||||
queryExecutorOptions: {
|
||||
selectResult: {
|
||||
id,
|
||||
},
|
||||
rejectResults: (results) => !results.hasResults(),
|
||||
},
|
||||
modifiers: [new MergeContextViewModifier(context)],
|
||||
});
|
||||
}
|
||||
const queryResults = this._buildQueryResultsFromExistingItem(item);
|
||||
if (item && item.query && queryResults) {
|
||||
this._viewManagerEpoch?.manager.setViewByParameters({
|
||||
params: {
|
||||
view: 'media',
|
||||
query: item.query,
|
||||
queryResults,
|
||||
},
|
||||
modifiers: [new MergeContextViewModifier(context)],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this._viewManagerEpoch.manager.setViewByParameters({
|
||||
@@ -630,6 +631,25 @@ export class TimelineController {
|
||||
this._ignoreClick = false;
|
||||
}
|
||||
|
||||
private _buildQueryResultsFromExistingItem(
|
||||
item: AdvancedCameraCardTimelineItem,
|
||||
): QueryResults | null {
|
||||
const query = item.query;
|
||||
if (!query || !this._source) {
|
||||
return null;
|
||||
}
|
||||
const media = this._source.dataset
|
||||
.get({
|
||||
filter: (timelineItem) => !!timelineItem.query && query === timelineItem.query,
|
||||
})
|
||||
.map((timelineItem) => timelineItem.media)
|
||||
.filter(isTruthy);
|
||||
const selectedIndex = media.findIndex((m) => m.getID() === item.id);
|
||||
return selectedIndex >= 0
|
||||
? new QueryResults({ results: media, selectedIndex })
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a broader prefetch window from a start and end basis.
|
||||
* @param window The window to broaden.
|
||||
@@ -643,31 +663,19 @@ export class TimelineController {
|
||||
};
|
||||
}
|
||||
|
||||
private _createQuery(
|
||||
type: QueryType,
|
||||
options?: {
|
||||
window?: TimelineWindow;
|
||||
},
|
||||
): Query | null {
|
||||
if (!this._timeline || !this._source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(
|
||||
this._getPrefetchWindow(options?.window ?? this._timeline.getWindow()),
|
||||
);
|
||||
|
||||
if (type === 'event') {
|
||||
const queries = this._source.getTimelineEventQueries(cacheFriendlyWindow);
|
||||
return queries ? new EventMediaQuery(queries) : null;
|
||||
} else if (type === 'recording') {
|
||||
const queries = this._source.getTimelineRecordingQueries(cacheFriendlyWindow);
|
||||
return queries ? new RecordingMediaQuery(queries) : null;
|
||||
} else if (type === 'folder') {
|
||||
const queries = this._source.getTimelineFolderQuery();
|
||||
return queries ? new FolderViewQuery(queries) : null;
|
||||
}
|
||||
return null;
|
||||
/**
|
||||
* Apply a cache-friendly prefetch window to all media queries.
|
||||
*/
|
||||
private _applyWindowToQuery(
|
||||
query: UnifiedQuery,
|
||||
window: TimelineWindow,
|
||||
): UnifiedQuery {
|
||||
const prefetchWindow = this._getPrefetchWindow(window);
|
||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(prefetchWindow);
|
||||
return UnifiedQueryTransformer.rebuildQuery(query, {
|
||||
start: cacheFriendlyWindow.start,
|
||||
end: cacheFriendlyWindow.end,
|
||||
});
|
||||
}
|
||||
|
||||
private _timelineRangeChangedHandler = async (properties: {
|
||||
@@ -690,16 +698,14 @@ export class TimelineController {
|
||||
return;
|
||||
}
|
||||
|
||||
await this._source?.refresh(this._getPrefetchWindow(properties), {
|
||||
view,
|
||||
});
|
||||
await this._source?.refresh(this._getPrefetchWindow(properties));
|
||||
|
||||
const queryType = QueryClassifier.getQueryType(view.query);
|
||||
if (!queryType) {
|
||||
if (!view.query) {
|
||||
return;
|
||||
}
|
||||
const query = this._createQuery(queryType);
|
||||
if (!query || this._alreadyHasAcceptableMediaQuery(query)) {
|
||||
const query = this._applyWindowToQuery(view.query, properties);
|
||||
|
||||
if (this._alreadyHasAcceptableMediaQuery(query)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -720,27 +726,22 @@ export class TimelineController {
|
||||
});
|
||||
};
|
||||
|
||||
private _alreadyHasAcceptableMediaQuery(freshQuery: Query): boolean {
|
||||
private _alreadyHasAcceptableMediaQuery(freshQuery: UnifiedQuery): boolean {
|
||||
const view = this._viewManagerEpoch?.manager.getView();
|
||||
const query = view?.query;
|
||||
|
||||
if (!this._cameraManager || !query) {
|
||||
if (!this._source || !query) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentResultTimestamp = view?.queryResults?.getResultsTimestamp();
|
||||
if (!currentResultTimestamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!query?.getQuery() &&
|
||||
!!currentResultTimestamp &&
|
||||
((QueryClassifier.isFolderQuery(query) && query.isEqual(freshQuery)) ||
|
||||
(QueryClassifier.isMediaQuery(query) &&
|
||||
QueryClassifier.isMediaQuery(freshQuery) &&
|
||||
query.isSupersetOf(freshQuery) &&
|
||||
this._cameraManager.areMediaQueriesResultsFresh<MediaQuery>(
|
||||
currentResultTimestamp,
|
||||
query.getQuery(),
|
||||
)))
|
||||
query.isSupersetOf(freshQuery) &&
|
||||
this._source.areResultsFresh(currentResultTimestamp, query)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -787,16 +788,14 @@ export class TimelineController {
|
||||
}
|
||||
const prefetchedWindow = this._getPrefetchWindow(desiredWindow);
|
||||
|
||||
if (!this._pointerHeld) {
|
||||
if (!this._pointerHeld && view.query) {
|
||||
// Don't fetch any data or touch the timeline in any way if the user is
|
||||
// currently interacting with it. Without this the subsequent data fetches
|
||||
// (via fetchIfNecessary) may update the timeline contents which causes
|
||||
// the visjs timeline to stop dragging/panning operations which is very
|
||||
// disruptive to the user.
|
||||
await this._source?.refresh(prefetchedWindow, {
|
||||
view,
|
||||
});
|
||||
this._source.addEventMediaToDataset(view.queryResults?.getResults(), view.query);
|
||||
await this._source?.refresh(prefetchedWindow);
|
||||
this._source.addMediaToDataset(view.query, view.queryResults?.getResults());
|
||||
}
|
||||
|
||||
const currentSelection = this._timeline.getSelection();
|
||||
@@ -845,14 +844,11 @@ export class TimelineController {
|
||||
//
|
||||
// Also don't generate thumbnails in mini-timelines (they will already have
|
||||
// been generated).
|
||||
const queryType = QueryClassifier.getQueryType(view.query);
|
||||
if (!queryType) {
|
||||
if (!view.query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const freshMediaQuery = this._createQuery(queryType, {
|
||||
window: desiredWindow,
|
||||
});
|
||||
const freshMediaQuery = this._applyWindowToQuery(view.query, desiredWindow);
|
||||
|
||||
if (
|
||||
!this._mini &&
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
import { add, sub } from 'date-fns';
|
||||
import { DataSet } from 'vis-data';
|
||||
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
|
||||
import { EqualityCache } from '../../cache/equality-cache';
|
||||
import { CameraManager } from '../../camera-manager/manager';
|
||||
import {
|
||||
compressRanges,
|
||||
ExpiringMemoryRangeSet,
|
||||
MemoryRangeSet,
|
||||
} from '../../camera-manager/range';
|
||||
import {
|
||||
EventQuery,
|
||||
RecordingQuery,
|
||||
RecordingSegment,
|
||||
} from '../../camera-manager/types';
|
||||
import { RecordingSegment } from '../../camera-manager/types';
|
||||
import { capEndDate } from '../../camera-manager/utils/cap-end-date';
|
||||
import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly';
|
||||
import { FoldersManager } from '../../card-controller/folders/manager';
|
||||
import { FolderQuery } from '../../card-controller/folders/types';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
|
||||
import { FolderConfig } from '../../config/schema/folders';
|
||||
import { ClipsOrSnapshotsOrAll } from '../../types';
|
||||
import { errorToConsole, ModifyInterface } from '../../utils/basic.js';
|
||||
import { errorToConsole } from '../../utils/basic.js';
|
||||
import { ViewItem, ViewMedia } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
import { FolderViewQuery, Query } from '../../view/query';
|
||||
import { View } from '../../view/view';
|
||||
import { TimelineKeys } from './types';
|
||||
import { UnifiedQuery } from '../../view/unified-query';
|
||||
import { UnifiedQueryBuilder } from '../../view/unified-query-builder';
|
||||
import { UnifiedQueryRunner } from '../../view/unified-query-runner';
|
||||
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer';
|
||||
|
||||
// Allow timeline freshness to be at least this number of seconds out of date
|
||||
// (caching times in the data-engine may increase the effective delay).
|
||||
@@ -37,9 +31,7 @@ const TIMELINE_FRESHNESS_TOLERANCE_SECONDS = 30;
|
||||
// instead of clean recording blocks.
|
||||
const TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS = 60;
|
||||
|
||||
type TimelineViewQuery = Query;
|
||||
|
||||
export interface AdvancedCameraCardTimelineItem extends TimelineItem {
|
||||
export type AdvancedCameraCardTimelineItem = TimelineItem & {
|
||||
// Use numbers to avoid significant volumes of Date object construction (for
|
||||
// high-quantity recording segments).
|
||||
start: number;
|
||||
@@ -48,25 +40,33 @@ export interface AdvancedCameraCardTimelineItem extends TimelineItem {
|
||||
// DataSet requires string (not HTMLElement) content.
|
||||
content: string;
|
||||
|
||||
media?: ViewMedia;
|
||||
|
||||
// View query object from which this timeline item is associated with.
|
||||
query?: TimelineViewQuery;
|
||||
}
|
||||
// Severity is duplicated here (also available via media.getSeverity())
|
||||
// because vis-timeline's dataAttributes option requires properties to exist
|
||||
// directly on the item object to render them as data-* HTML attributes for
|
||||
// CSS styling.
|
||||
severity?: string;
|
||||
} & ( // Ensure that if there's a media item there is a query it is associated with.
|
||||
| {
|
||||
media: ViewMedia;
|
||||
query: UnifiedQuery;
|
||||
}
|
||||
| {
|
||||
media?: never;
|
||||
query?: never;
|
||||
}
|
||||
);
|
||||
|
||||
interface AdvancedCameraCardGroup {
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface RefreshOptions {
|
||||
view?: View;
|
||||
}
|
||||
|
||||
export class TimelineDataSource {
|
||||
private _cameraManager: CameraManager;
|
||||
private _foldersManager: FoldersManager;
|
||||
private _conditionStateManager: ConditionStateManagerReadonlyInterface;
|
||||
|
||||
private _builder: UnifiedQueryBuilder;
|
||||
private _runner: UnifiedQueryRunner;
|
||||
|
||||
private _dataset: DataSet<AdvancedCameraCardTimelineItem> = new DataSet();
|
||||
private _groups: DataSet<AdvancedCameraCardGroup>;
|
||||
|
||||
@@ -76,33 +76,34 @@ export class TimelineDataSource {
|
||||
// high-N segments into a smaller number of consecutive recording blocks).
|
||||
private _recordingRanges = new MemoryRangeSet();
|
||||
|
||||
// Cache event ranges since re-adding the same events is a timeline
|
||||
// performance killer (even if the request results are cached).
|
||||
private _eventRanges = new ExpiringMemoryRangeSet();
|
||||
private _folderCache = new EqualityCache<FolderQuery, Date>();
|
||||
// Cache for all query results in this source instance.
|
||||
// Uses a single range set since shape determines content type.
|
||||
private _cache = new ExpiringMemoryRangeSet();
|
||||
|
||||
private _eventsMediaType: ClipsOrSnapshotsOrAll;
|
||||
private _showRecordings: boolean;
|
||||
|
||||
private _keys: TimelineKeys;
|
||||
// The "shape" of the query, a UnifiedQuery without time ranges. Determines
|
||||
// the groups/structure of the timeline.
|
||||
private _shape: UnifiedQuery;
|
||||
|
||||
constructor(
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
conditionStateManager: ConditionStateManagerReadonlyInterface,
|
||||
keys: TimelineKeys,
|
||||
eventsMediaType: ClipsOrSnapshotsOrAll,
|
||||
shape: UnifiedQuery,
|
||||
showRecordings: boolean,
|
||||
) {
|
||||
this._cameraManager = cameraManager;
|
||||
this._foldersManager = foldersManager;
|
||||
this._conditionStateManager = conditionStateManager;
|
||||
this._keys = keys;
|
||||
|
||||
this._groups = this._generateGroups(keys);
|
||||
|
||||
this._eventsMediaType = eventsMediaType;
|
||||
this._builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
this._runner = new UnifiedQueryRunner(
|
||||
cameraManager,
|
||||
foldersManager,
|
||||
conditionStateManager,
|
||||
);
|
||||
this._shape = shape;
|
||||
this._showRecordings = showRecordings;
|
||||
|
||||
this._groups = this._generateGroups();
|
||||
}
|
||||
|
||||
get dataset(): DataSet<AdvancedCameraCardTimelineItem> {
|
||||
@@ -113,8 +114,12 @@ export class TimelineDataSource {
|
||||
return this._groups;
|
||||
}
|
||||
|
||||
public getKeyType(): 'camera' | 'folder' {
|
||||
return this._keys.type;
|
||||
get shape(): UnifiedQuery {
|
||||
return this._shape;
|
||||
}
|
||||
|
||||
public areResultsFresh(resultsTimestamp: Date, query: UnifiedQuery): boolean {
|
||||
return this._runner.areResultsFresh(resultsTimestamp, query);
|
||||
}
|
||||
|
||||
private _getGroupIDForCamera(cameraID: string): string {
|
||||
@@ -122,30 +127,32 @@ export class TimelineDataSource {
|
||||
}
|
||||
|
||||
private _getGroupIDForFolder(folderConfig: FolderConfig): string {
|
||||
return folderConfig.id;
|
||||
return `folder/${folderConfig.id}`;
|
||||
}
|
||||
|
||||
private _generateGroups(keys: TimelineKeys): DataSet<AdvancedCameraCardGroup> {
|
||||
private _generateGroups(): DataSet<AdvancedCameraCardGroup> {
|
||||
const groups: AdvancedCameraCardGroup[] = [];
|
||||
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (keys.type === 'camera') {
|
||||
keys.cameraIDs?.forEach((cameraID) => {
|
||||
const cameraMetadata = this._cameraManager.getCameraMetadata(cameraID);
|
||||
|
||||
groups.push({
|
||||
id: this._getGroupIDForCamera(cameraID),
|
||||
content: cameraMetadata?.title ?? cameraID,
|
||||
});
|
||||
});
|
||||
} else if (keys.type === 'folder') {
|
||||
const folderID = this._getGroupIDForFolder(keys.folder);
|
||||
// Add folder-based groups
|
||||
const folderQueries = this._shape.getFolderQueries();
|
||||
for (const folderQuery of folderQueries) {
|
||||
const folderID = this._getGroupIDForFolder(folderQuery.folder);
|
||||
groups.push({
|
||||
id: folderID,
|
||||
content: keys.folder?.title ?? folderID,
|
||||
content: folderQuery.folder.title ?? folderID,
|
||||
});
|
||||
}
|
||||
|
||||
// Add camera-based groups
|
||||
const cameraIDs = this._shape.getAllCameraIDs();
|
||||
cameraIDs.forEach((cameraID) => {
|
||||
const cameraMetadata = this._cameraManager.getCameraMetadata(cameraID);
|
||||
groups.push({
|
||||
id: this._getGroupIDForCamera(cameraID),
|
||||
content: cameraMetadata?.title ?? cameraID,
|
||||
});
|
||||
});
|
||||
|
||||
return new DataSet(groups);
|
||||
}
|
||||
|
||||
@@ -164,14 +171,11 @@ export class TimelineDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
public addEventMediaToDataset(
|
||||
mediaArray?: ViewItem[] | null,
|
||||
query?: TimelineViewQuery | null,
|
||||
): void {
|
||||
public addMediaToDataset(query: UnifiedQuery, mediaArray?: ViewItem[] | null): void {
|
||||
const data: AdvancedCameraCardTimelineItem[] = [];
|
||||
|
||||
for (const media of mediaArray ?? []) {
|
||||
if (!ViewItemClassifier.isEvent(media)) {
|
||||
if (!ViewItemClassifier.isEvent(media) && !ViewItemClassifier.isReview(media)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -193,7 +197,10 @@ export class TimelineDataSource {
|
||||
start: startTime.getTime(),
|
||||
type: 'range',
|
||||
end: media.getUsableEndTime()?.getTime(),
|
||||
...(query && { query }),
|
||||
...(ViewItemClassifier.isReview(media) && {
|
||||
severity: media.getSeverity() ?? undefined,
|
||||
}),
|
||||
query,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -201,134 +208,60 @@ export class TimelineDataSource {
|
||||
this._dataset.update(data);
|
||||
}
|
||||
|
||||
private async _refreshEvents(
|
||||
window: TimelineWindow,
|
||||
options?: RefreshOptions,
|
||||
): Promise<void> {
|
||||
await this._refreshEventsFromCamera(window, options);
|
||||
await this._refreshEventsFromFolder();
|
||||
public buildRecordingsWindowedQuery(window: TimelineWindow): UnifiedQuery | null {
|
||||
return this._builder.buildRecordingsQuery(this._shape.getAllCameraIDs(), {
|
||||
start: window.start,
|
||||
end: window.end,
|
||||
});
|
||||
}
|
||||
|
||||
private async _refreshEventsFromCamera(
|
||||
window: TimelineWindow,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_options?: RefreshOptions,
|
||||
): Promise<void> {
|
||||
if (this._keys.type !== 'camera') {
|
||||
return;
|
||||
}
|
||||
private async _refreshQuery(window: TimelineWindow): Promise<void> {
|
||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
|
||||
|
||||
if (
|
||||
this._eventRanges.hasCoverage({
|
||||
start: window.start,
|
||||
end: sub(capEndDate(window.end), {
|
||||
this._cache.hasCoverage({
|
||||
start: cacheFriendlyWindow.start,
|
||||
end: sub(capEndDate(cacheFriendlyWindow.end), {
|
||||
seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS,
|
||||
}),
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
|
||||
const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow);
|
||||
if (!eventQueries) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addEventMediaToDataset(
|
||||
await this._cameraManager.executeMediaQueries(eventQueries),
|
||||
);
|
||||
const query = UnifiedQueryTransformer.rebuildQuery(this._shape, {
|
||||
start: cacheFriendlyWindow.start,
|
||||
end: cacheFriendlyWindow.end,
|
||||
});
|
||||
|
||||
this._eventRanges.add({
|
||||
this.addMediaToDataset(query, await this._runner.execute(query));
|
||||
this._cache.add({
|
||||
...cacheFriendlyWindow,
|
||||
expires: add(new Date(), { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS }),
|
||||
});
|
||||
}
|
||||
|
||||
private async _refreshEventsFromFolder(): Promise<void> {
|
||||
if (this._keys.type !== 'folder') {
|
||||
return;
|
||||
}
|
||||
|
||||
const folderQuery = this.getTimelineFolderQuery();
|
||||
if (!folderQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastDate = this._folderCache.get(folderQuery);
|
||||
const now = new Date();
|
||||
|
||||
if (
|
||||
lastDate &&
|
||||
lastDate >= sub(now, { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addEventMediaToDataset(
|
||||
await this._foldersManager.expandFolder(
|
||||
folderQuery,
|
||||
this._conditionStateManager.getState(),
|
||||
),
|
||||
new FolderViewQuery(folderQuery),
|
||||
);
|
||||
this._folderCache.set(folderQuery, now);
|
||||
}
|
||||
|
||||
public async refresh(window: TimelineWindow, options?: RefreshOptions): Promise<void> {
|
||||
public async refresh(window: TimelineWindow): Promise<void> {
|
||||
try {
|
||||
await Promise.all([
|
||||
this._refreshEvents(window, options),
|
||||
this._refreshQuery(window),
|
||||
...(this._showRecordings ? [this._refreshRecordings(window)] : []),
|
||||
]);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
|
||||
// Intentionally ignore errors here, since it is likely the user will
|
||||
// change the range again and a subsequent call may work. To do otherwise
|
||||
// would be jarring to the timeline experience in the case of transient
|
||||
// errors from the backend.
|
||||
}
|
||||
}
|
||||
|
||||
public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null {
|
||||
if (this._keys.type !== 'camera' || !this._keys.cameraIDs.size) {
|
||||
return null;
|
||||
}
|
||||
return this._cameraManager.generateDefaultEventQueries(this._keys.cameraIDs, {
|
||||
start: window.start,
|
||||
end: window.end,
|
||||
...(this._eventsMediaType === 'clips' && { hasClip: true }),
|
||||
...(this._eventsMediaType === 'snapshots' && { hasSnapshot: true }),
|
||||
});
|
||||
}
|
||||
|
||||
public getTimelineRecordingQueries(window: TimelineWindow): RecordingQuery[] | null {
|
||||
if (this._keys.type !== 'camera' || !this._keys.cameraIDs.size) {
|
||||
return null;
|
||||
}
|
||||
return this._cameraManager.generateDefaultRecordingQueries(this._keys.cameraIDs, {
|
||||
start: window.start,
|
||||
end: window.end,
|
||||
});
|
||||
}
|
||||
|
||||
public getTimelineFolderQuery(): FolderQuery | null {
|
||||
if (this._keys.type !== 'folder') {
|
||||
return null;
|
||||
}
|
||||
return this._foldersManager.generateDefaultFolderQuery(this._keys.folder);
|
||||
}
|
||||
|
||||
private async _refreshRecordings(window: TimelineWindow): Promise<void> {
|
||||
const cameraIDs = this._keys.type === 'camera' ? this._keys.cameraIDs : null;
|
||||
// Recordings only apply to camera-based shapes
|
||||
const cameraIDs = this._shape.getAllCameraIDs();
|
||||
if (!cameraIDs?.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
type AdvancedCameraCardTimelineItemWithEnd = ModifyInterface<
|
||||
AdvancedCameraCardTimelineItem,
|
||||
{ end: number }
|
||||
>;
|
||||
type AdvancedCameraCardTimelineItemWithEnd = AdvancedCameraCardTimelineItem & {
|
||||
end: number;
|
||||
};
|
||||
|
||||
const convertSegmentToRecording = (
|
||||
cameraID: string,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { CameraManager } from '../../camera-manager/manager';
|
||||
import { ViewItemManager } from '../../card-controller/view/item-manager';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types';
|
||||
import { CameraConfig } from '../../config/schema/cameras';
|
||||
import { FolderConfig } from '../../config/schema/folders';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { ViewMedia } from '../../view/item';
|
||||
|
||||
@@ -21,16 +20,6 @@ export interface ThumbnailDataRequest {
|
||||
|
||||
export class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
|
||||
|
||||
export type TimelineKeys =
|
||||
| {
|
||||
type: 'camera';
|
||||
cameraIDs: Set<string>;
|
||||
}
|
||||
| {
|
||||
type: 'folder';
|
||||
folder: FolderConfig;
|
||||
};
|
||||
|
||||
export interface ExtendedTimeline extends Timeline {
|
||||
// setCustomTimeMarker currently missing from Timeline types.
|
||||
setCustomTimeMarker?(time: DateType, id?: IdType): void;
|
||||
|
||||
@@ -52,6 +52,9 @@ export class AdvancedCameraCardCarousel extends LitElement {
|
||||
protected _refRoot: Ref<HTMLElement> = createRef();
|
||||
protected _carousel: CarouselController | null = null;
|
||||
|
||||
// Track slide count to distinguish user-navigation from content changes.
|
||||
protected _previousSlideCount: number | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
@@ -100,6 +103,8 @@ export class AdvancedCameraCardCarousel extends LitElement {
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
const currentSlideCount = this._refParent.value?.assignedElements().length ?? 0;
|
||||
|
||||
if (
|
||||
!this._carousel &&
|
||||
this._refRoot.value &&
|
||||
@@ -127,9 +132,21 @@ export class AdvancedCameraCardCarousel extends LitElement {
|
||||
wheelScrolling: this.wheelScrolling,
|
||||
},
|
||||
);
|
||||
} else if (changedProps.has('selected')) {
|
||||
this._carousel?.selectSlide(this.selected);
|
||||
this._previousSlideCount = currentSlideCount;
|
||||
} else if (changedProps.has('selected') && this._carousel) {
|
||||
// Only scroll to selection if the slide count hasn't changed.
|
||||
// This distinguishes between:
|
||||
// - Navigation (next/prev): count same → scroll to selection
|
||||
// - Content change (item removed): count changed → preserve position
|
||||
const contentChanged =
|
||||
this._previousSlideCount !== null &&
|
||||
this._previousSlideCount !== currentSlideCount;
|
||||
|
||||
if (!contentChanged && this.selected !== this._carousel.getSelectedIndex()) {
|
||||
this._carousel.selectSlide(this.selected);
|
||||
}
|
||||
}
|
||||
this._previousSlideCount = currentSlideCount;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { FoldersManager } from '../../card-controller/folders/manager.js';
|
||||
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import {
|
||||
getUpFolderMediaItem,
|
||||
upFolderClickHandler,
|
||||
} from '../../components-lib/folder/up-folder.js';
|
||||
import { FolderGalleryController } from '../../components-lib/gallery/folder-gallery-controller.js';
|
||||
import { MediaGalleryConfig } from '../../config/schema/media-gallery.js';
|
||||
import { HomeAssistant } from '../../ha/types.js';
|
||||
import { localize } from '../../localize/localize';
|
||||
import folderGalleryStyle from '../../scss/folder-gallery.scss';
|
||||
import { ViewItem } from '../../view/item.js';
|
||||
import '../media-filter';
|
||||
import '../message.js';
|
||||
import { renderMessage } from '../message.js';
|
||||
import '../surround-basic';
|
||||
import '../thumbnail/thumbnail.js';
|
||||
import './gallery-core.js';
|
||||
|
||||
@customElement('advanced-camera-card-folder-gallery')
|
||||
export class AdvancedCameraCardFolderGallery extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public viewManagerEpoch?: ViewManagerEpoch;
|
||||
|
||||
@property({ attribute: false })
|
||||
public viewItemManager?: ViewItemManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public galleryConfig?: MediaGalleryConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public foldersManager?: FoldersManager;
|
||||
|
||||
protected _controller = new FolderGalleryController(this);
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('galleryConfig')) {
|
||||
this._controller.setThumbnailSize(this.galleryConfig?.controls.thumbnails.size);
|
||||
}
|
||||
}
|
||||
|
||||
protected _renderThumbnail(
|
||||
item: ViewItem,
|
||||
selected: boolean,
|
||||
clickCallback: (item: ViewItem, ev: Event) => void,
|
||||
): TemplateResult | void {
|
||||
return html`<advanced-camera-card-thumbnail
|
||||
class=${classMap({
|
||||
selected,
|
||||
})}
|
||||
.hass=${this.hass}
|
||||
.item=${item}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
.viewItemManager=${this.viewItemManager}
|
||||
?selected=${selected}
|
||||
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
|
||||
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
|
||||
.show_favorite_control}
|
||||
?show_timeline_control=${!!this.galleryConfig?.controls.thumbnails
|
||||
.show_timeline_control}
|
||||
?show_download_control=${!!this.galleryConfig?.controls.thumbnails
|
||||
.show_download_control}
|
||||
@click=${(ev: Event) => clickCallback(item, ev)}
|
||||
>
|
||||
</advanced-camera-card-thumbnail>`;
|
||||
}
|
||||
|
||||
protected _renderThumbnails(): TemplateResult | void {
|
||||
const selected = this.viewManagerEpoch?.manager
|
||||
.getView()
|
||||
?.queryResults?.getSelectedResult();
|
||||
|
||||
return html`
|
||||
${this.viewManagerEpoch?.manager
|
||||
.getView()
|
||||
?.queryResults?.getResults()
|
||||
?.map((item) =>
|
||||
this._renderThumbnail(item, item === selected, (item: ViewItem, ev: Event) => {
|
||||
const manager = this.viewManagerEpoch?.manager;
|
||||
if (manager) {
|
||||
this._controller.itemClickHandler(manager, item, ev, this.foldersManager);
|
||||
}
|
||||
}),
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const folderIsLoading =
|
||||
!!this.viewManagerEpoch?.manager.getView()?.context?.loading?.query;
|
||||
const upThumbnail = getUpFolderMediaItem(this.viewManagerEpoch?.manager.getView());
|
||||
|
||||
return html`
|
||||
<advanced-camera-card-surround-basic>
|
||||
${!this.viewManagerEpoch?.manager.getView()?.queryResults?.hasResults() &&
|
||||
(folderIsLoading || !upThumbnail)
|
||||
? renderMessage({
|
||||
type: 'info',
|
||||
message: folderIsLoading
|
||||
? localize('error.awaiting_folder')
|
||||
: localize('common.no_folder'),
|
||||
icon: 'mdi:folder-play',
|
||||
dotdotdot: folderIsLoading,
|
||||
})
|
||||
: html`<advanced-camera-card-gallery-core
|
||||
.hass=${this.hass}
|
||||
.columnWidth=${this._controller.getColumnWidth(
|
||||
this.galleryConfig?.controls.thumbnails,
|
||||
)}
|
||||
.columnCountRoundMethod=${this._controller.getColumnCountRoundMethod(
|
||||
this.galleryConfig?.controls.thumbnails,
|
||||
)}
|
||||
>
|
||||
${upThumbnail
|
||||
? this._renderThumbnail(
|
||||
upThumbnail,
|
||||
false,
|
||||
(item: ViewItem, ev: Event) =>
|
||||
upFolderClickHandler(item, ev, this.viewManagerEpoch),
|
||||
)
|
||||
: ''}
|
||||
${this._renderThumbnails()}
|
||||
</advanced-camera-card-gallery-core>`}
|
||||
</advanced-camera-card-surround-basic>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(folderGalleryStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-folder-gallery': AdvancedCameraCardFolderGallery;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { FoldersManager } from '../../card-controller/folders/manager.js';
|
||||
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
|
||||
import { MergeContextViewModifier } from '../../card-controller/view/modifiers/merge-context.js';
|
||||
import { RemoveContextViewModifier } from '../../card-controller/view/modifiers/remove-context.js';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import { GalleryController } from '../../components-lib/gallery/controller.js';
|
||||
import {
|
||||
FolderNavigationParamaters,
|
||||
getUpFolderItem,
|
||||
navigateToFolder,
|
||||
navigateToMedia,
|
||||
navigateUp,
|
||||
} from '../../components-lib/navigation.js';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types.js';
|
||||
import { MediaGalleryConfig } from '../../config/schema/media-gallery.js';
|
||||
import { CardWideConfig } from '../../config/schema/types.js';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const.js';
|
||||
import { HomeAssistant } from '../../ha/types.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import galleryStyle from '../../scss/media-gallery.scss';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
||||
import { ViewFolder, ViewItem } from '../../view/item.js';
|
||||
import { UnifiedQueryBuilder } from '../../view/unified-query-builder.js';
|
||||
import { UnifiedQueryRunner } from '../../view/unified-query-runner.js';
|
||||
import '../media-filter.js';
|
||||
import '../message.js';
|
||||
import { renderMessage } from '../message.js';
|
||||
import '../surround-basic.js';
|
||||
import '../thumbnail/thumbnail.js';
|
||||
import './gallery-core.js';
|
||||
import { GalleryExtendEvent } from './types.js';
|
||||
|
||||
const GALLERY_FILTER_MENU_ICONS = {
|
||||
closed: 'mdi:filter-cog-outline',
|
||||
open: 'mdi:filter-cog',
|
||||
};
|
||||
|
||||
@customElement('advanced-camera-card-gallery')
|
||||
export class AdvancedCameraCardGallery extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public viewManagerEpoch?: ViewManagerEpoch;
|
||||
|
||||
@property({ attribute: false })
|
||||
public galleryConfig?: MediaGalleryConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public foldersManager?: FoldersManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public viewItemManager?: ViewItemManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public conditionStateManager?: ConditionStateManagerReadonlyInterface;
|
||||
|
||||
protected _controller = new GalleryController(this);
|
||||
protected _upFolderItem: ViewFolder | null = null;
|
||||
protected _builder: UnifiedQueryBuilder | null = null;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (
|
||||
(changedProps.has('cameraManager') || changedProps.has('foldersManager')) &&
|
||||
this.cameraManager &&
|
||||
this.foldersManager
|
||||
) {
|
||||
this._builder = new UnifiedQueryBuilder(this.cameraManager, this.foldersManager);
|
||||
}
|
||||
|
||||
if (changedProps.has('viewManagerEpoch')) {
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
this._controller.setItemsFromView(view, this.viewManagerEpoch?.oldView);
|
||||
this._upFolderItem = getUpFolderItem(view?.query);
|
||||
}
|
||||
|
||||
if (changedProps.has('galleryConfig')) {
|
||||
this._controller.setThumbnailSize(this.galleryConfig?.controls.thumbnails.size);
|
||||
}
|
||||
}
|
||||
|
||||
protected _getLimit(): number {
|
||||
return (
|
||||
this.cardWideConfig?.performance?.features?.media_chunk_size ??
|
||||
MEDIA_CHUNK_SIZE_DEFAULT
|
||||
);
|
||||
}
|
||||
|
||||
protected _getFolderNavigationParameters(): FolderNavigationParamaters | null {
|
||||
return this._builder && this.viewManagerEpoch
|
||||
? {
|
||||
builder: this._builder,
|
||||
viewManagerEpoch: this.viewManagerEpoch,
|
||||
limit: this._getLimit(),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
protected _renderUpFolder(): TemplateResult | void {
|
||||
if (!this._upFolderItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
return html`<advanced-camera-card-thumbnail
|
||||
.hass=${this.hass}
|
||||
.item=${this._upFolderItem}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
navigateUp(this._getFolderNavigationParameters());
|
||||
}}
|
||||
>
|
||||
</advanced-camera-card-thumbnail>`;
|
||||
}
|
||||
|
||||
protected _renderThumbnails(): TemplateResult | void {
|
||||
const selected = this.viewManagerEpoch?.manager
|
||||
.getView()
|
||||
?.queryResults?.getSelectedResult();
|
||||
|
||||
return html`
|
||||
${this._controller.getItems()?.map(
|
||||
(item) =>
|
||||
html`<advanced-camera-card-thumbnail
|
||||
class=${classMap({
|
||||
selected: item === selected,
|
||||
})}
|
||||
.hass=${this.hass}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.viewItemManager=${this.viewItemManager}
|
||||
.item=${item}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
?selected=${item === selected}
|
||||
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
|
||||
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
|
||||
.show_favorite_control}
|
||||
?show_timeline_control=${!!this.galleryConfig?.controls.thumbnails
|
||||
.show_timeline_control}
|
||||
?show_download_control=${!!this.galleryConfig?.controls.thumbnails
|
||||
.show_download_control}
|
||||
?show_review_control=${!!this.galleryConfig?.controls.thumbnails
|
||||
.show_review_control}
|
||||
?show_info_control=${!!this.galleryConfig?.controls.thumbnails
|
||||
.show_info_control}
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (ViewItemClassifier.isMedia(item) && this.viewManagerEpoch) {
|
||||
navigateToMedia(item, {
|
||||
viewManagerEpoch: this.viewManagerEpoch,
|
||||
modifiers: [
|
||||
new RemoveContextViewModifier(['timeline', 'mediaViewer']),
|
||||
new MergeContextViewModifier({
|
||||
gallery: {
|
||||
originView: this.viewManagerEpoch.manager.getView()?.view,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
} else if (ViewItemClassifier.isFolder(item)) {
|
||||
navigateToFolder(item, this._getFolderNavigationParameters());
|
||||
}
|
||||
}}
|
||||
>
|
||||
</advanced-camera-card-thumbnail>`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const isLoading =
|
||||
!!this.viewManagerEpoch?.manager.getView()?.context?.loading?.query;
|
||||
|
||||
const hasItems =
|
||||
(this._controller.getItems()?.length ?? 0) > 0 || !!this._upFolderItem;
|
||||
|
||||
const showFilter =
|
||||
this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none';
|
||||
|
||||
return html`
|
||||
<advanced-camera-card-surround-basic
|
||||
.drawerIcons=${{
|
||||
...(showFilter && {
|
||||
[this.galleryConfig!.controls.filter.mode]: GALLERY_FILTER_MENU_ICONS,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
${showFilter && this.galleryConfig
|
||||
? html` <advanced-camera-card-media-filter
|
||||
.hass=${this.hass}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.foldersManager=${this.foldersManager}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
slot=${this.galleryConfig.controls.filter.mode}
|
||||
>
|
||||
</advanced-camera-card-media-filter>`
|
||||
: ''}
|
||||
${!hasItems
|
||||
? renderMessage({
|
||||
type: 'info',
|
||||
message: isLoading
|
||||
? localize('error.awaiting_media')
|
||||
: localize('common.no_media'),
|
||||
icon: 'mdi:multimedia',
|
||||
dotdotdot: isLoading,
|
||||
})
|
||||
: html`<advanced-camera-card-gallery-core
|
||||
.hass=${this.hass}
|
||||
.columnWidth=${this._controller.getColumnWidth(
|
||||
this.galleryConfig?.controls.thumbnails,
|
||||
)}
|
||||
.columnCountRoundMethod=${this._controller.getColumnCountRoundMethod(
|
||||
this.galleryConfig?.controls.thumbnails,
|
||||
)}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.extendUp=${true}
|
||||
.extendDown=${true}
|
||||
@advanced-camera-card:gallery:extend:up=${(
|
||||
ev: CustomEvent<GalleryExtendEvent>,
|
||||
) =>
|
||||
this._extendGallery(
|
||||
ev,
|
||||
'later',
|
||||
// Avoid use of cache since the user is explicitly looking for
|
||||
// the freshest possible data.
|
||||
false,
|
||||
)}
|
||||
@advanced-camera-card:gallery:extend:down=${(
|
||||
ev: CustomEvent<GalleryExtendEvent>,
|
||||
) => this._extendGallery(ev, 'earlier')}
|
||||
>
|
||||
${this._renderUpFolder()} ${this._renderThumbnails()}
|
||||
</advanced-camera-card-gallery-core>`}
|
||||
</advanced-camera-card-surround-basic>
|
||||
`;
|
||||
}
|
||||
|
||||
protected async _extendGallery(
|
||||
ev: CustomEvent<GalleryExtendEvent>,
|
||||
direction: 'earlier' | 'later',
|
||||
useCache = true,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!this.cameraManager ||
|
||||
!this.foldersManager ||
|
||||
!this.viewManagerEpoch ||
|
||||
!this.conditionStateManager
|
||||
) {
|
||||
ev.detail.resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const runner = new UnifiedQueryRunner(
|
||||
this.cameraManager,
|
||||
this.foldersManager,
|
||||
this.conditionStateManager,
|
||||
);
|
||||
|
||||
await this._controller.extend(runner, this.viewManagerEpoch, direction, useCache);
|
||||
ev.detail.resolve();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(galleryStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-gallery': AdvancedCameraCardGallery;
|
||||
}
|
||||
interface HTMLElementEventMap {
|
||||
'advanced-camera-card:media:reviewed': CustomEvent<ViewItem>;
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import { MediaGalleryController } from '../../components-lib/gallery/media-gallery-controller.js';
|
||||
import { MediaGalleryConfig } from '../../config/schema/media-gallery.js';
|
||||
import { CardWideConfig } from '../../config/schema/types.js';
|
||||
import { HomeAssistant } from '../../ha/types.js';
|
||||
import { localize } from '../../localize/localize';
|
||||
import mediaGalleryStyle from '../../scss/media-gallery.scss';
|
||||
import '../media-filter';
|
||||
import '../message.js';
|
||||
import { renderMessage } from '../message.js';
|
||||
import '../surround-basic';
|
||||
import '../thumbnail/thumbnail.js';
|
||||
import './gallery-core.js';
|
||||
import { GalleryExtendEvent } from './types.js';
|
||||
|
||||
const MEDIA_GALLERY_FILTER_MENU_ICONS = {
|
||||
closed: 'mdi:filter-cog-outline',
|
||||
open: 'mdi:filter-cog',
|
||||
};
|
||||
|
||||
@customElement('advanced-camera-card-media-gallery')
|
||||
export class AdvancedCameraCardMediaGallery extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public viewManagerEpoch?: ViewManagerEpoch;
|
||||
|
||||
@property({ attribute: false })
|
||||
public galleryConfig?: MediaGalleryConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public viewItemManager?: ViewItemManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
protected _controller = new MediaGalleryController(this);
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('viewManagerEpoch')) {
|
||||
this._controller.setMediaFromView(
|
||||
this.viewManagerEpoch?.manager.getView(),
|
||||
this.viewManagerEpoch?.oldView,
|
||||
);
|
||||
}
|
||||
|
||||
if (changedProps.has('galleryConfig')) {
|
||||
this._controller.setThumbnailSize(this.galleryConfig?.controls.thumbnails.size);
|
||||
}
|
||||
}
|
||||
|
||||
protected _renderThumbnails(): TemplateResult | void {
|
||||
const selected = this.viewManagerEpoch?.manager
|
||||
.getView()
|
||||
?.queryResults?.getSelectedResult();
|
||||
|
||||
return html`
|
||||
${this._controller.getMedia()?.map(
|
||||
(media, index) =>
|
||||
html`<advanced-camera-card-thumbnail
|
||||
class=${classMap({
|
||||
selected: media === selected,
|
||||
})}
|
||||
.hass=${this.hass}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.viewItemManager=${this.viewItemManager}
|
||||
.item=${media}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
?selected=${media === selected}
|
||||
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
|
||||
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
|
||||
.show_favorite_control}
|
||||
?show_timeline_control=${!!this.galleryConfig?.controls.thumbnails
|
||||
.show_timeline_control}
|
||||
?show_download_control=${!!this.galleryConfig?.controls.thumbnails
|
||||
.show_download_control}
|
||||
@click=${(ev: Event) => {
|
||||
const manager = this.viewManagerEpoch?.manager;
|
||||
if (manager) {
|
||||
this._controller.itemClickHandler(manager, index, ev);
|
||||
}
|
||||
}}
|
||||
>
|
||||
</advanced-camera-card-thumbnail>`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const mediaIsLoading =
|
||||
!!this.viewManagerEpoch?.manager.getView()?.context?.loading?.query;
|
||||
|
||||
return html`
|
||||
<advanced-camera-card-surround-basic
|
||||
.drawerIcons=${{
|
||||
...(this.galleryConfig &&
|
||||
this.galleryConfig.controls.filter.mode !== 'none' && {
|
||||
[this.galleryConfig.controls.filter.mode]: MEDIA_GALLERY_FILTER_MENU_ICONS,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none'
|
||||
? html` <advanced-camera-card-media-filter
|
||||
.hass=${this.hass}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
slot=${this.galleryConfig.controls.filter.mode}
|
||||
>
|
||||
</advanced-camera-card-media-filter>`
|
||||
: ''}
|
||||
${!this._controller.getMedia()?.length
|
||||
? renderMessage({
|
||||
type: 'info',
|
||||
message: mediaIsLoading
|
||||
? localize('error.awaiting_media')
|
||||
: localize('common.no_media'),
|
||||
icon: 'mdi:multimedia',
|
||||
dotdotdot: mediaIsLoading,
|
||||
})
|
||||
: html`<advanced-camera-card-gallery-core
|
||||
.hass=${this.hass}
|
||||
.columnWidth=${this._controller.getColumnWidth(
|
||||
this.galleryConfig?.controls.thumbnails,
|
||||
)}
|
||||
.columnCountRoundMethod=${this._controller.getColumnCountRoundMethod(
|
||||
this.galleryConfig?.controls.thumbnails,
|
||||
)}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.extendUp=${true}
|
||||
.extendDown=${true}
|
||||
@advanced-camera-card:gallery:extend:up=${(
|
||||
ev: CustomEvent<GalleryExtendEvent>,
|
||||
) =>
|
||||
this._extendGallery(
|
||||
ev,
|
||||
'later',
|
||||
// Avoid use of cache since the user is explicitly looking for
|
||||
// the freshest possible data.
|
||||
false,
|
||||
)}
|
||||
@advanced-camera-card:gallery:extend:down=${(
|
||||
ev: CustomEvent<GalleryExtendEvent>,
|
||||
) => this._extendGallery(ev, 'earlier')}
|
||||
>
|
||||
${this._renderThumbnails()}
|
||||
</advanced-camera-card-gallery-core>`}
|
||||
</advanced-camera-card-surround-basic>
|
||||
`;
|
||||
}
|
||||
|
||||
protected async _extendGallery(
|
||||
ev: CustomEvent<GalleryExtendEvent>,
|
||||
direction: 'earlier' | 'later',
|
||||
useCache = true,
|
||||
): Promise<void> {
|
||||
if (!this.cameraManager || !this.viewManagerEpoch) {
|
||||
return;
|
||||
}
|
||||
await this._controller.extendMediaGallery(
|
||||
this.cameraManager,
|
||||
this.viewManagerEpoch,
|
||||
direction,
|
||||
useCache,
|
||||
);
|
||||
ev.detail.resolve();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(mediaGalleryStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-media-gallery': AdvancedCameraCardMediaGallery;
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,11 @@ import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { FoldersManager } from '../card-controller/folders/manager';
|
||||
import { ViewManagerEpoch } from '../card-controller/view/types';
|
||||
import {
|
||||
MediaFilterController,
|
||||
MediaFilterCoreFavoriteSelection,
|
||||
MediaFilterCoreWhen,
|
||||
MediaFilterMediaType,
|
||||
} from '../components-lib/media-filter-controller';
|
||||
import { CardWideConfig } from '../config/schema/types';
|
||||
import { HomeAssistant } from '../ha/types';
|
||||
@@ -38,6 +37,9 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
@property({ attribute: false })
|
||||
public viewManagerEpoch?: ViewManagerEpoch;
|
||||
|
||||
@property({ attribute: false })
|
||||
public foldersManager?: FoldersManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
@@ -56,6 +58,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
protected _refWhat: Ref<AdvancedCameraCardSelect> = createRef();
|
||||
protected _refWhere: Ref<AdvancedCameraCardSelect> = createRef();
|
||||
protected _refFavorite: Ref<AdvancedCameraCardSelect> = createRef();
|
||||
protected _refReviewed: Ref<AdvancedCameraCardSelect> = createRef();
|
||||
protected _refTags: Ref<AdvancedCameraCardSelect> = createRef();
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
@@ -63,42 +66,56 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
this._mediaFilterController.setViewManager(this.viewManagerEpoch?.manager ?? null);
|
||||
}
|
||||
|
||||
if (changedProps.has('cameraManager') && this.cameraManager) {
|
||||
this._mediaFilterController.computeCameraOptions(this.cameraManager);
|
||||
if (
|
||||
(changedProps.has('cameraManager') || changedProps.has('foldersManager')) &&
|
||||
this.cameraManager &&
|
||||
this.foldersManager
|
||||
) {
|
||||
this._mediaFilterController.computeCameraOptions(
|
||||
this.cameraManager,
|
||||
this.foldersManager,
|
||||
);
|
||||
this._mediaFilterController.computeMetadataOptions(this.cameraManager);
|
||||
}
|
||||
|
||||
// The first time the viewManager is set, compute the initial default selections.
|
||||
if (
|
||||
!changedProps.get('viewManager') &&
|
||||
(!changedProps.get('viewManager') || changedProps.has('foldersManager')) &&
|
||||
this.viewManagerEpoch &&
|
||||
this.cameraManager
|
||||
this.cameraManager &&
|
||||
this.foldersManager
|
||||
) {
|
||||
this._mediaFilterController.computeInitialDefaultsFromView(this.cameraManager);
|
||||
this._mediaFilterController.computeInitialDefaultsFromView(
|
||||
this.cameraManager,
|
||||
this.foldersManager,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const valueChange = async () => {
|
||||
if (!this.cameraManager || !this.viewManagerEpoch || !this.cardWideConfig) {
|
||||
if (
|
||||
!this.cameraManager ||
|
||||
!this.foldersManager ||
|
||||
!this.viewManagerEpoch ||
|
||||
!this.cardWideConfig
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this._mediaFilterController.valueChangeHandler(
|
||||
this.cameraManager,
|
||||
this.foldersManager,
|
||||
this.cardWideConfig,
|
||||
{
|
||||
camera: this._refCamera.value?.value ?? undefined,
|
||||
mediaType: (this._refMediaType.value?.value ?? undefined) as
|
||||
| MediaFilterMediaType
|
||||
| undefined,
|
||||
mediaTypes: this._refMediaType.value?.value ?? undefined,
|
||||
when: {
|
||||
selected: this._refWhen.value?.value ?? undefined,
|
||||
from: this._refWhenFrom.value?.value,
|
||||
to: this._refWhenTo.value?.value,
|
||||
},
|
||||
favorite: (this._refFavorite.value?.value ?? undefined) as
|
||||
| MediaFilterCoreFavoriteSelection
|
||||
| undefined,
|
||||
favorite: this._refFavorite.value?.value ?? undefined,
|
||||
reviewed: this._refReviewed.value?.value ?? undefined,
|
||||
where: this._refWhere.value?.value ?? undefined,
|
||||
what: this._refWhat.value?.value ?? undefined,
|
||||
tags: this._refTags.value?.value ?? undefined,
|
||||
@@ -126,7 +143,6 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controls = this._mediaFilterController.getControlsToShow(this.cameraManager);
|
||||
const defaults = this._mediaFilterController.getDefaults();
|
||||
const whatOptions = this._mediaFilterController.getWhatOptions();
|
||||
const tagsOptions = this._mediaFilterController.getTagsOptions();
|
||||
@@ -137,7 +153,9 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
label=${localize('media_filter.media_type')}
|
||||
placeholder=${localize('media_filter.select_media_type')}
|
||||
.options=${this._mediaFilterController.getMediaTypeOptions()}
|
||||
.initialValue=${defaults?.mediaType}
|
||||
.initialValue=${defaults?.mediaTypes}
|
||||
multiple
|
||||
clearable
|
||||
@advanced-camera-card:select:change=${() => valueChange()}
|
||||
>
|
||||
</advanced-camera-card-select>
|
||||
@@ -184,7 +202,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
@advanced-camera-card:select:change=${() => valueChange()}
|
||||
>
|
||||
</advanced-camera-card-select>
|
||||
${controls.events && whatOptions.length
|
||||
${whatOptions.length
|
||||
? html` <advanced-camera-card-select
|
||||
${ref(this._refWhat)}
|
||||
label=${localize('media_filter.what')}
|
||||
@@ -197,7 +215,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
>
|
||||
</advanced-camera-card-select>`
|
||||
: ''}
|
||||
${controls.events && tagsOptions.length
|
||||
${tagsOptions.length
|
||||
? html` <advanced-camera-card-select
|
||||
${ref(this._refTags)}
|
||||
label=${localize('media_filter.tag')}
|
||||
@@ -210,7 +228,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
>
|
||||
</advanced-camera-card-select>`
|
||||
: ''}
|
||||
${controls.events && whereOptions.length
|
||||
${whereOptions.length
|
||||
? html` <advanced-camera-card-select
|
||||
${ref(this._refWhere)}
|
||||
label=${localize('media_filter.where')}
|
||||
@@ -223,20 +241,26 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
>
|
||||
</advanced-camera-card-select>`
|
||||
: ''}
|
||||
${controls.favorites
|
||||
? html`
|
||||
<advanced-camera-card-select
|
||||
${ref(this._refFavorite)}
|
||||
label=${localize('media_filter.favorite')}
|
||||
placeholder=${localize('media_filter.select_favorite')}
|
||||
.options=${this._mediaFilterController.getFavoriteOptions()}
|
||||
.initialValue=${defaults?.favorite}
|
||||
clearable
|
||||
@advanced-camera-card:select:change=${() => valueChange()}
|
||||
>
|
||||
</advanced-camera-card-select>
|
||||
`
|
||||
: ''}`;
|
||||
<advanced-camera-card-select
|
||||
${ref(this._refFavorite)}
|
||||
label=${localize('media_filter.favorite')}
|
||||
placeholder=${localize('media_filter.select_favorite')}
|
||||
.options=${this._mediaFilterController.getFavoriteOptions()}
|
||||
.initialValue=${defaults?.favorite}
|
||||
clearable
|
||||
@advanced-camera-card:select:change=${() => valueChange()}
|
||||
>
|
||||
</advanced-camera-card-select>
|
||||
<advanced-camera-card-select
|
||||
${ref(this._refReviewed)}
|
||||
label=${localize('media_filter.reviewed')}
|
||||
placeholder=${localize('media_filter.select_reviewed')}
|
||||
.options=${this._mediaFilterController.getReviewedOptions()}
|
||||
.initialValue=${defaults?.reviewed}
|
||||
clearable
|
||||
@advanced-camera-card:select:change=${() => valueChange()}
|
||||
>
|
||||
</advanced-camera-card-select>`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import overlayMessageStyle from '../scss/overlay-message.scss';
|
||||
import { MetadataField, OverlayMessage, OverlayMessageControl } from '../types.js';
|
||||
import { dispatchDismissOverlayMessageEvent } from '../utils/overlay-message.js';
|
||||
import './icon.js';
|
||||
|
||||
@customElement('advanced-camera-card-overlay-message')
|
||||
export class AdvancedCameraCardOverlayMessage extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public message: OverlayMessage | null = null;
|
||||
|
||||
protected _refMessage: Ref<HTMLElement> = createRef();
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('click', this._handleOutsideInteraction);
|
||||
window.addEventListener('focusin', this._handleOutsideInteraction);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
window.removeEventListener('click', this._handleOutsideInteraction);
|
||||
window.removeEventListener('focusin', this._handleOutsideInteraction);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const heading = this.message.heading;
|
||||
const details = this.message.details ?? [];
|
||||
const text = this.message.text;
|
||||
const controls = this.message.controls ?? [];
|
||||
|
||||
return html`
|
||||
<div class="backdrop" @click=${this._dismiss}></div>
|
||||
<div
|
||||
class="message"
|
||||
${ref(this._refMessage)}
|
||||
@animationend=${this._handleAnimationEnd}
|
||||
>
|
||||
<div class="details">
|
||||
${heading ? this._renderDetail(heading, true) : ''}
|
||||
${details.map((detail) => this._renderDetail(detail))}
|
||||
${text ? html`<div class="description">${text}</div>` : ''}
|
||||
</div>
|
||||
${controls.length
|
||||
? html`<div class="controls">
|
||||
${controls.map((control) => this._renderControl(control))}
|
||||
</div>`
|
||||
: ''}
|
||||
<div class="close" @click=${this._dismiss}>
|
||||
<advanced-camera-card-icon
|
||||
.icon=${{ icon: 'mdi:close' }}
|
||||
></advanced-camera-card-icon>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected _renderControl(control: OverlayMessageControl): TemplateResult {
|
||||
const emphasisClass = control.emphasis ? `emphasis-${control.emphasis}` : '';
|
||||
return html`
|
||||
<div
|
||||
class="control ${emphasisClass}"
|
||||
title=${control.title}
|
||||
@click=${async () => this._handleControlClick(control)}
|
||||
>
|
||||
${control.icon
|
||||
? html`<advanced-camera-card-icon
|
||||
.icon=${control.icon}
|
||||
></advanced-camera-card-icon>`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected async _handleControlClick(control: OverlayMessageControl): Promise<void> {
|
||||
const result = await control.callback();
|
||||
if (result === null) {
|
||||
// null = close the message
|
||||
this._dismiss();
|
||||
} else {
|
||||
// Updated message = keep open and refresh
|
||||
this.message = result;
|
||||
}
|
||||
}
|
||||
|
||||
protected _renderDetail(detail: MetadataField, isHeading = false): TemplateResult {
|
||||
return html`
|
||||
<div class="detail ${isHeading ? 'heading' : ''}">
|
||||
${detail.icon
|
||||
? html`<advanced-camera-card-icon
|
||||
title=${detail.hint ?? ''}
|
||||
.icon=${detail.icon}
|
||||
></advanced-camera-card-icon>`
|
||||
: ''}
|
||||
<span title=${detail.title}>${detail.title}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected _dismiss = (): void => {
|
||||
this._refMessage.value?.classList.add('exiting');
|
||||
};
|
||||
|
||||
protected _handleAnimationEnd = (ev: AnimationEvent): void => {
|
||||
if (ev.animationName === 'slideDown') {
|
||||
dispatchDismissOverlayMessageEvent(this);
|
||||
}
|
||||
};
|
||||
|
||||
protected _handleOutsideInteraction = (ev: Event): void => {
|
||||
if (!ev.composedPath().includes(this)) {
|
||||
this._dismiss();
|
||||
}
|
||||
};
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(overlayMessageStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-overlay-message': AdvancedCameraCardOverlayMessage;
|
||||
}
|
||||
}
|
||||
@@ -70,10 +70,11 @@ export class AdvancedCameraCardSelect extends ScopedRegistryHost(LitElement) {
|
||||
const initialValueSet = this.value === null;
|
||||
this.value = value;
|
||||
|
||||
// The underlying gr-select element will call on the first first value set
|
||||
// The underlying gr-select element will call on the first value set
|
||||
// (even when the user has not interacted with the control). Do not
|
||||
// dispatch events for this.
|
||||
if (!initialValueSet) {
|
||||
// dispatch events for this UNLESS the value is actually non-empty
|
||||
// (indicating a user selection).
|
||||
if (!initialValueSet || (Array.isArray(value) ? value.length : !!value)) {
|
||||
fireAdvancedCameraCardEvent(this, 'select:change', value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ export class AdvancedCameraCardStatusBar extends LitElement {
|
||||
return html`<div
|
||||
.actionHandler=${handler}
|
||||
class="${classes}"
|
||||
data-severity=${item.severity ?? ''}
|
||||
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
|
||||
>
|
||||
${item.string}
|
||||
@@ -109,6 +110,7 @@ export class AdvancedCameraCardStatusBar extends LitElement {
|
||||
.actionHandler=${handler}
|
||||
.icon=${{ icon: item.icon }}
|
||||
class="${classes}"
|
||||
data-severity=${item.severity ?? ''}
|
||||
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
|
||||
></advanced-camera-card-icon>`;
|
||||
} else if (item.type === 'custom:advanced-camera-card-status-bar-image') {
|
||||
@@ -116,6 +118,7 @@ export class AdvancedCameraCardStatusBar extends LitElement {
|
||||
.actionHandler=${handler}
|
||||
class="${classes}"
|
||||
src="${item.image}"
|
||||
data-severity=${item.severity ?? ''}
|
||||
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
|
||||
/>`;
|
||||
}
|
||||
|
||||
@@ -7,22 +7,18 @@ import {
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { FoldersManager } from '../card-controller/folders/manager.js';
|
||||
import { ViewItemManager } from '../card-controller/view/item-manager.js';
|
||||
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
||||
import { TimelineKeys } from '../components-lib/timeline/types.js';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js';
|
||||
import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js';
|
||||
import { MiniTimelineControlConfig } from '../config/schema/common/controls/timeline.js';
|
||||
import { FolderConfig } from '../config/schema/folders.js';
|
||||
import { CardWideConfig } from '../config/schema/types.js';
|
||||
import { HomeAssistant } from '../ha/types.js';
|
||||
import basicBlockStyle from '../scss/basic-block.scss';
|
||||
import { contentsChanged } from '../utils/basic.js';
|
||||
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
|
||||
import { QueryClassifier } from '../view/query-classifier.js';
|
||||
import './surround-basic.js';
|
||||
import './thumbnail-carousel';
|
||||
|
||||
@@ -55,11 +51,8 @@ export class AdvancedCameraCardSurround extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
protected _timelineKeys?: TimelineKeys | null = null;
|
||||
|
||||
/**
|
||||
* Determine if a drawer is being used.
|
||||
* @returns `true` if a drawer is used, `false` otherwise.
|
||||
*/
|
||||
protected _hasDrawer(): boolean {
|
||||
return (
|
||||
@@ -67,80 +60,11 @@ export class AdvancedCameraCardSurround extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected willUpdate(_changedProperties: PropertyValues): void {
|
||||
if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') {
|
||||
import('./timeline-core.js');
|
||||
}
|
||||
|
||||
// Only reset the timeline cameraIDs when the media or display mode
|
||||
// materially changes (and not on every view change, since the view will
|
||||
// change frequently when the user is scrubbing video).
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
if (
|
||||
changedProperties.has('viewManagerEpoch') &&
|
||||
(this.viewManagerEpoch?.manager.hasMajorMediaChange(
|
||||
this.viewManagerEpoch?.oldView,
|
||||
) ||
|
||||
this.viewManagerEpoch?.oldView?.displayMode !== view?.displayMode)
|
||||
) {
|
||||
const newKeys = this._getTimelineKeys();
|
||||
// Update only if changed, to avoid unnecessary timeline destructions.
|
||||
if (!isEqual(newKeys, this._timelineKeys)) {
|
||||
this._timelineKeys = newKeys ?? undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected _getTimelineKeys(): TimelineKeys | null {
|
||||
const cameraIDsToKeys = (cameraIDs: Set<string> | null): TimelineKeys | null => {
|
||||
return cameraIDs?.size
|
||||
? {
|
||||
type: 'camera',
|
||||
cameraIDs,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
const folderToKeys = (folderConfig: FolderConfig): TimelineKeys => {
|
||||
return {
|
||||
type: 'folder',
|
||||
folder: folderConfig,
|
||||
};
|
||||
};
|
||||
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
if (!view || !this.cameraManager) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (view.is('live')) {
|
||||
const capabilitySearch = {
|
||||
anyCapabilities: ['clips' as const, 'snapshots' as const, 'recordings' as const],
|
||||
};
|
||||
if (view.supportsMultipleDisplayModes() && view.isGrid()) {
|
||||
return cameraIDsToKeys(
|
||||
this.cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch),
|
||||
);
|
||||
} else {
|
||||
return cameraIDsToKeys(
|
||||
this.cameraManager
|
||||
.getStore()
|
||||
.getAllDependentCameras(view.camera, capabilitySearch),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const queries = view.query;
|
||||
if (view.isViewerView()) {
|
||||
if (QueryClassifier.isMediaQuery(queries)) {
|
||||
return cameraIDsToKeys(queries.getQueryCameraIDs());
|
||||
} else if (QueryClassifier.isFolderQuery(queries)) {
|
||||
const folderConfig = queries.getQuery()?.folder;
|
||||
return folderConfig ? folderToKeys(folderConfig) : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
@@ -175,7 +99,9 @@ export class AdvancedCameraCardSurround extends LitElement {
|
||||
.viewItemManager=${this.viewItemManager}
|
||||
.fadeThumbnails=${view.isViewerView()}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
.foldersManager=${this.foldersManager}
|
||||
.selected=${view.queryResults?.getSelectedIndex() ?? undefined}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
>
|
||||
</advanced-camera-card-thumbnail-carousel>`
|
||||
: ''}
|
||||
@@ -189,7 +115,6 @@ export class AdvancedCameraCardSurround extends LitElement {
|
||||
this.thumbnailConfig?.mode === 'none'
|
||||
? 'play'
|
||||
: 'select'}
|
||||
.keys=${this._timelineKeys}
|
||||
.mini=${true}
|
||||
.timelineConfig=${this.timelineConfig}
|
||||
.thumbnailConfig=${this.thumbnailConfig}
|
||||
|
||||
@@ -9,14 +9,20 @@ import {
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { FoldersManager } from '../card-controller/folders/manager.js';
|
||||
import { ViewItemManager } from '../card-controller/view/item-manager.js';
|
||||
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js';
|
||||
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
||||
import {
|
||||
getUpFolderMediaItem,
|
||||
upFolderClickHandler,
|
||||
} from '../components-lib/folder/up-folder.js';
|
||||
FolderNavigationParamaters,
|
||||
getUpFolderItem,
|
||||
navigateToFolder,
|
||||
navigateToMedia,
|
||||
navigateUp,
|
||||
} from '../components-lib/navigation.js';
|
||||
import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js';
|
||||
import { CardWideConfig } from '../config/schema/types.js';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
|
||||
import { HomeAssistant } from '../ha/types.js';
|
||||
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
@@ -24,7 +30,7 @@ import { CarouselDirection } from '../utils/embla/carousel-controller.js';
|
||||
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
|
||||
import { ViewItemClassifier } from '../view/item-classifier.js';
|
||||
import { ViewItem, ViewMedia } from '../view/item.js';
|
||||
import { QueryClassifier } from '../view/query-classifier.js';
|
||||
import { UnifiedQueryBuilder } from '../view/unified-query-builder.js';
|
||||
import './carousel.js';
|
||||
import './thumbnail/thumbnail.js';
|
||||
|
||||
@@ -43,18 +49,50 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public foldersManager?: FoldersManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public viewItemManager?: ViewItemManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public config?: ThumbnailsControlConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public fadeThumbnails = false;
|
||||
|
||||
protected _thumbnails: TemplateResult[] = [];
|
||||
protected _builder: UnifiedQueryBuilder | null = null;
|
||||
|
||||
protected _getLimit(): number {
|
||||
return (
|
||||
this.cardWideConfig?.performance?.features?.media_chunk_size ??
|
||||
MEDIA_CHUNK_SIZE_DEFAULT
|
||||
);
|
||||
}
|
||||
|
||||
protected _getFolderNavOptions(): FolderNavigationParamaters | undefined {
|
||||
return this._builder && this.viewManagerEpoch
|
||||
? {
|
||||
builder: this._builder,
|
||||
viewManagerEpoch: this.viewManagerEpoch,
|
||||
limit: this._getLimit(),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (
|
||||
(changedProps.has('cameraManager') || changedProps.has('foldersManager')) &&
|
||||
this.cameraManager &&
|
||||
this.foldersManager
|
||||
) {
|
||||
this._builder = new UnifiedQueryBuilder(this.cameraManager, this.foldersManager);
|
||||
}
|
||||
|
||||
if (changedProps.has('config')) {
|
||||
if (this.config?.size) {
|
||||
this.style.setProperty(
|
||||
@@ -94,53 +132,17 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
protected _itemClickCallback(item: ViewItem, ev: Event): void {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
const query = view?.query;
|
||||
const results = view?.queryResults;
|
||||
|
||||
if (!view || !query || !results) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ViewItemClassifier.isMedia(item)) {
|
||||
const newResults = results
|
||||
.clone()
|
||||
.selectResultIfFound((result) => result === item);
|
||||
const cameraID = item.getCameraID();
|
||||
|
||||
fireAdvancedCameraCardEvent<ThumbnailMediaSelect>(
|
||||
this,
|
||||
'thumbnails-carousel:media-select',
|
||||
{ media: item },
|
||||
);
|
||||
this.viewManagerEpoch?.manager.setViewByParameters({
|
||||
params: {
|
||||
view: 'media',
|
||||
queryResults: newResults,
|
||||
...(cameraID && { camera: cameraID }),
|
||||
},
|
||||
protected _handleMediaClick(item: ViewMedia): void {
|
||||
fireAdvancedCameraCardEvent<ThumbnailMediaSelect>(
|
||||
this,
|
||||
'thumbnails-carousel:media-select',
|
||||
{ media: item },
|
||||
);
|
||||
if (this.viewManagerEpoch) {
|
||||
navigateToMedia(item, {
|
||||
viewManagerEpoch: this.viewManagerEpoch,
|
||||
modifiers: [new RemoveContextViewModifier(['timeline', 'mediaViewer'])],
|
||||
});
|
||||
} else if (
|
||||
QueryClassifier.isFolderQuery(query) &&
|
||||
ViewItemClassifier.isFolder(item)
|
||||
) {
|
||||
const rawQuery = query.getQuery();
|
||||
if (!rawQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||
params: {
|
||||
query: query.clone().setQuery({
|
||||
folder: rawQuery.folder,
|
||||
path: [...(rawQuery.path ?? []), { folder: item }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,31 +173,42 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
|
||||
?show_favorite_control=${this.config?.show_favorite_control}
|
||||
?show_timeline_control=${this.config?.show_timeline_control}
|
||||
?show_download_control=${this.config?.show_download_control}
|
||||
?show_review_control=${this.config?.show_review_control}
|
||||
?show_info_control=${this.config?.show_info_control}
|
||||
@click=${(ev: Event) => clickCallback(item, ev)}
|
||||
>
|
||||
</advanced-camera-card-thumbnail>`;
|
||||
}
|
||||
|
||||
protected _renderThumbnails(): TemplateResult[] {
|
||||
const upThumbnail = getUpFolderMediaItem(this.viewManagerEpoch?.manager.getView());
|
||||
const thumbnails: TemplateResult[] = [
|
||||
...(upThumbnail
|
||||
? [
|
||||
this._renderThumbnail(upThumbnail, false, (item: ViewItem, ev: Event) =>
|
||||
upFolderClickHandler(item, ev, this.viewManagerEpoch),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const upFolderItem = getUpFolderItem(
|
||||
this.viewManagerEpoch?.manager.getView()?.query,
|
||||
);
|
||||
const thumbnails: TemplateResult[] = upFolderItem
|
||||
? [
|
||||
this._renderThumbnail(upFolderItem, false, (_item: ViewItem, ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
navigateUp(this._getFolderNavOptions());
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
const selectedIndex = this._getSelectedSlide();
|
||||
|
||||
for (const item of view?.queryResults?.getResults() ?? []) {
|
||||
const clickHandler = (item: ViewItem, ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (ViewItemClassifier.isMedia(item)) {
|
||||
this._handleMediaClick(item);
|
||||
} else if (ViewItemClassifier.isFolder(item)) {
|
||||
navigateToFolder(item, this._getFolderNavOptions());
|
||||
}
|
||||
};
|
||||
thumbnails.push(
|
||||
this._renderThumbnail(
|
||||
item,
|
||||
selectedIndex === thumbnails.length,
|
||||
(item: ViewItem, ev: Event) => this._itemClickCallback(item, ev),
|
||||
clickHandler,
|
||||
view?.context?.mediaViewer?.seek,
|
||||
),
|
||||
);
|
||||
@@ -214,18 +227,18 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const direction = this._getDirection();
|
||||
if (!this._thumbnails.length || !this.config || !direction) {
|
||||
if (!this._thumbnails.length || !this.config?.mode || this.config.mode === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
return html`<advanced-camera-card-carousel
|
||||
direction=${direction}
|
||||
.selected=${this._getSelectedSlide() ?? 0}
|
||||
class="${classMap({ fade: this.fadeThumbnails })}"
|
||||
direction=${this._getDirection() ?? 'horizontal'}
|
||||
.selected=${this._getSelectedSlide()}
|
||||
.dragFree=${true}
|
||||
>
|
||||
${this._thumbnails}
|
||||
</advanced-camera-card-carousel>`;
|
||||
</advanced-camera-card-carousel> `;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { CameraManager } from '../../camera-manager/manager';
|
||||
import { ThumbnailDetailsController } from '../../components-lib/thumbnail/details-controller';
|
||||
import { MediaDetailsController } from '../../components-lib/media/details-controller';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import thumbnailDetailsStyle from '../../scss/thumbnail-details.scss';
|
||||
import { MetadataField } from '../../types';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import '../icon';
|
||||
|
||||
@@ -28,7 +30,7 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public seek?: Date;
|
||||
|
||||
private _controller = new ThumbnailDetailsController();
|
||||
private _controller = new MediaDetailsController();
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
if (['item', 'seek', 'cameraManager'].some((prop) => changedProperties.has(prop))) {
|
||||
@@ -40,28 +42,26 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
|
||||
const heading = this._controller.getHeading();
|
||||
const details = this._controller.getDetails();
|
||||
|
||||
const renderDetail = (detail: MetadataField, heading = false): TemplateResult => {
|
||||
return html`<div
|
||||
class=${classMap({
|
||||
heading,
|
||||
})}
|
||||
>
|
||||
${detail.icon
|
||||
? html` <advanced-camera-card-icon
|
||||
severity=${detail.emphasis}
|
||||
title=${detail.hint ?? ''}
|
||||
.icon=${detail.icon}
|
||||
></advanced-camera-card-icon>`
|
||||
: ''}
|
||||
<span title=${detail.title}>${detail.title}</span>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
return html`
|
||||
${heading
|
||||
? html` <div class="heading">
|
||||
<span title=${heading}>${heading}</span>
|
||||
</div>`
|
||||
: ``}
|
||||
${details
|
||||
? html` <div class="details">
|
||||
${details.map(
|
||||
(detail) =>
|
||||
html`<div>
|
||||
${detail.icon
|
||||
? html` <advanced-camera-card-icon
|
||||
title=${detail.hint ?? ''}
|
||||
.icon=${detail.icon}
|
||||
></advanced-camera-card-icon>`
|
||||
: ''}
|
||||
<span>${detail.title}</span>
|
||||
</div>`,
|
||||
)}
|
||||
</div>`
|
||||
: ''}
|
||||
${heading ? renderDetail(heading, true) : ``}
|
||||
${details.length ? details.map((detail) => renderDetail(detail)) : ``}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,26 @@ import {
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { CameraManager } from '../../../camera-manager/manager';
|
||||
import { ViewItemManager } from '../../../card-controller/view/item-manager';
|
||||
import { ViewManagerEpoch } from '../../../card-controller/view/types';
|
||||
import {
|
||||
MediaDetailsController,
|
||||
OverlayControlsContext,
|
||||
} from '../../../components-lib/media/details-controller';
|
||||
import { ThumbnailFeatureController } from '../../../components-lib/thumbnail/feature/controller';
|
||||
import { HomeAssistant } from '../../../ha/types';
|
||||
import { localize } from '../../../localize/localize';
|
||||
import thumbnailFeatureStyle from '../../../scss/thumbnail-feature.scss';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../../utils/action';
|
||||
import {
|
||||
downloadMedia,
|
||||
navigateToTimeline,
|
||||
toggleFavorite,
|
||||
toggleReviewed,
|
||||
} from '../../../utils/media-actions';
|
||||
import { dispatchShowOverlayMessageEvent } from '../../../utils/overlay-message';
|
||||
import { ViewItem } from '../../../view/item';
|
||||
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||
import '../../icon.js';
|
||||
import './thumbnail.js';
|
||||
|
||||
@@ -24,12 +40,33 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public viewItemManager?: ViewItemManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public viewManagerEpoch?: ViewManagerEpoch;
|
||||
|
||||
@property({ attribute: false })
|
||||
public item?: ViewItem;
|
||||
|
||||
@property({ attribute: false })
|
||||
public hasDetails?: boolean;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public show_favorite_control = false;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public show_timeline_control = false;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public show_download_control = false;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public show_review_control = false;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public show_info_control = false;
|
||||
|
||||
private _controller = new ThumbnailFeatureController();
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
@@ -40,34 +77,158 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _getControlContext(): OverlayControlsContext {
|
||||
return {
|
||||
hass: this.hass,
|
||||
viewItemManager: this.viewItemManager,
|
||||
viewManagerEpoch: this.viewManagerEpoch,
|
||||
capabilities: this.item ? this.viewItemManager?.getCapabilities(this.item) : null,
|
||||
};
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const starClasses = {
|
||||
star: true,
|
||||
starred: ViewItemClassifier.isMedia(this.item) && !!this.item?.isFavorite(),
|
||||
};
|
||||
|
||||
const shouldShowTimelineControl =
|
||||
this.show_timeline_control && ViewItemClassifier.supportsTimeline(this.item);
|
||||
|
||||
const mediaCapabilities = this.viewItemManager?.getCapabilities(this.item) ?? null;
|
||||
|
||||
const shouldShowFavoriteControl =
|
||||
this.show_favorite_control &&
|
||||
this.item &&
|
||||
this.hass &&
|
||||
mediaCapabilities?.canFavorite;
|
||||
|
||||
const shouldShowDownloadControl =
|
||||
this.show_download_control &&
|
||||
this.hass &&
|
||||
this.item.getID() &&
|
||||
mediaCapabilities?.canDownload;
|
||||
|
||||
const isReviewed = ViewItemClassifier.isReview(this.item)
|
||||
? this.item.isReviewed()
|
||||
: null;
|
||||
const shouldShowReviewControl = this.show_review_control && isReviewed !== null;
|
||||
|
||||
const shouldShowInfoControl =
|
||||
this.show_info_control && ViewItemClassifier.isMedia(this.item);
|
||||
|
||||
const title = this._controller.getTitle();
|
||||
const subtitles = this._controller.getSubtitles();
|
||||
const iconClasses = classMap({
|
||||
background: title || subtitles.length,
|
||||
});
|
||||
const hasText = !!title || !!subtitles.length;
|
||||
|
||||
const mainIconClasses = {
|
||||
placeholder: true,
|
||||
};
|
||||
|
||||
const thumbnailClass = this._controller.getThumbnailClass();
|
||||
const thumbnailClasses = classMap({
|
||||
...(thumbnailClass && { [thumbnailClass]: true }),
|
||||
'has-text': hasText,
|
||||
});
|
||||
|
||||
return html`
|
||||
${this._controller.getThumbnail()
|
||||
? html` <advanced-camera-card-thumbnail-feature-thumbnail
|
||||
class="${thumbnailClasses}"
|
||||
.hass=${this.hass}
|
||||
.thumbnail=${this._controller.getThumbnail()}
|
||||
aria-label=${this.item?.getTitle() ?? ''}
|
||||
title=${this.item?.getTitle() ?? ''}
|
||||
></advanced-camera-card-thumbnail-feature-thumbnail>`
|
||||
: this._controller.getIcon()
|
||||
? html`<advanced-camera-card-icon
|
||||
class="${iconClasses}"
|
||||
.icon=${{ icon: this._controller.getIcon() }}
|
||||
<div class=${classMap({ media: true, 'has-text': hasText })}>
|
||||
${this._controller.getThumbnail()
|
||||
? html` <advanced-camera-card-thumbnail-feature-thumbnail
|
||||
class="${thumbnailClasses}"
|
||||
.hass=${this.hass}
|
||||
.thumbnail=${this._controller.getThumbnail()}
|
||||
aria-label=${this.item?.getTitle() ?? ''}
|
||||
title=${this.item?.getTitle() ?? ''}
|
||||
></advanced-camera-card-thumbnail-feature-thumbnail>`
|
||||
: this._controller.getIcon()
|
||||
? html`<advanced-camera-card-icon
|
||||
class=${classMap(mainIconClasses)}
|
||||
.icon=${{ icon: this._controller.getIcon() }}
|
||||
></advanced-camera-card-icon>`
|
||||
: ''}
|
||||
</div>
|
||||
${shouldShowReviewControl
|
||||
? html`<advanced-camera-card-icon
|
||||
class="review"
|
||||
title=${isReviewed
|
||||
? localize('common.set_reviews.unreviewed')
|
||||
: localize('common.set_reviews.reviewed')}
|
||||
.icon=${{
|
||||
icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline',
|
||||
}}
|
||||
@click=${async (ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (this.item) {
|
||||
await toggleReviewed(this.item, this._getControlContext());
|
||||
}
|
||||
}}
|
||||
></advanced-camera-card-icon>`
|
||||
: shouldShowFavoriteControl
|
||||
? html` <advanced-camera-card-icon
|
||||
class="${classMap(starClasses)}"
|
||||
title=${localize('thumbnail.retain_indefinitely')}
|
||||
.icon=${{
|
||||
icon: this.item.isFavorite() ? 'mdi:star' : 'mdi:star-outline',
|
||||
}}
|
||||
@click=${async (ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (
|
||||
this.item &&
|
||||
(await toggleFavorite(this.item, this._getControlContext()))
|
||||
) {
|
||||
this.requestUpdate();
|
||||
}
|
||||
}}
|
||||
></advanced-camera-card-icon>`
|
||||
: ''}
|
||||
${title || subtitles.length
|
||||
: ``}
|
||||
${shouldShowInfoControl
|
||||
? html`<advanced-camera-card-icon
|
||||
class="info"
|
||||
.icon=${{ icon: 'mdi:information-outline' }}
|
||||
title=${this.item?.getDescription() ?? ''}
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
const detailsController = new MediaDetailsController();
|
||||
detailsController.calculate(this.cameraManager, this.item);
|
||||
dispatchShowOverlayMessageEvent(
|
||||
this,
|
||||
detailsController.getMessage(this._getControlContext()),
|
||||
);
|
||||
}}
|
||||
></advanced-camera-card-icon>`
|
||||
: ''}
|
||||
${shouldShowTimelineControl
|
||||
? html`<advanced-camera-card-icon
|
||||
class="timeline"
|
||||
.icon=${{ icon: 'mdi:target' }}
|
||||
title=${localize('thumbnail.timeline')}
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (this.item) {
|
||||
navigateToTimeline(this.item, this._getControlContext());
|
||||
}
|
||||
}}
|
||||
></advanced-camera-card-icon>`
|
||||
: ''}
|
||||
${shouldShowDownloadControl
|
||||
? html` <advanced-camera-card-icon
|
||||
class="download"
|
||||
.icon=${{ icon: 'mdi:download' }}
|
||||
title=${localize('thumbnail.download')}
|
||||
@click=${async (ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (this.item) {
|
||||
await downloadMedia(this.item, this._getControlContext());
|
||||
}
|
||||
}}
|
||||
></advanced-camera-card-icon>`
|
||||
: ``}
|
||||
${hasText
|
||||
? html`
|
||||
${title ? html`<div class="title">${title}</div>` : ''}
|
||||
${subtitles.length
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { FoldersManager } from '../../card-controller/folders/manager.js';
|
||||
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
|
||||
import { RemoveContextViewModifier } from '../../card-controller/view/modifiers/remove-context.js';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import { dispatchAdvancedCameraCardErrorEvent } from '../../components-lib/message/dispatch.js';
|
||||
import { HomeAssistant } from '../../ha/types.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import thumbnailStyle from '../../scss/thumbnail.scss';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||
import { errorToConsole } from '../../utils/basic.js';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
||||
import { ViewItem } from '../../view/item.js';
|
||||
import './details.js';
|
||||
import './feature/feature.js';
|
||||
@@ -56,6 +49,12 @@ export class AdvancedCameraCardThumbnail extends LitElement {
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public show_download_control = false;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public show_review_control = false;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public show_info_control = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public seek?: Date;
|
||||
|
||||
@@ -68,62 +67,21 @@ export class AdvancedCameraCardThumbnail extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const starClasses = {
|
||||
star: true,
|
||||
starred: ViewItemClassifier.isMedia(this.item) && !!this.item?.isFavorite(),
|
||||
};
|
||||
|
||||
const shouldShowTimelineControl =
|
||||
this.show_timeline_control &&
|
||||
((ViewItemClassifier.isEvent(this.item) && this.item.getStartTime()) ||
|
||||
(ViewItemClassifier.isRecording(this.item) &&
|
||||
this.item.getStartTime() &&
|
||||
this.item.getEndTime()));
|
||||
|
||||
const mediaCapabilities = this.viewItemManager?.getCapabilities(this.item) ?? null;
|
||||
|
||||
const shouldShowFavoriteControl =
|
||||
this.show_favorite_control &&
|
||||
this.item &&
|
||||
this.hass &&
|
||||
mediaCapabilities?.canFavorite;
|
||||
|
||||
const shouldShowDownloadControl =
|
||||
this.show_download_control &&
|
||||
this.hass &&
|
||||
this.item.getID() &&
|
||||
mediaCapabilities?.canDownload;
|
||||
|
||||
return html`
|
||||
<advanced-camera-card-thumbnail-feature
|
||||
.cameraManager=${this.cameraManager}
|
||||
.hasDetails=${this.details}
|
||||
.hass=${this.hass}
|
||||
.item=${this.item}
|
||||
.viewItemManager=${this.viewItemManager}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
.show_favorite_control=${this.show_favorite_control}
|
||||
.show_timeline_control=${this.show_timeline_control}
|
||||
.show_download_control=${this.show_download_control}
|
||||
.show_review_control=${this.show_review_control}
|
||||
.show_info_control=${this.show_info_control}
|
||||
>
|
||||
</advanced-camera-card-thumbnail-feature>
|
||||
${shouldShowFavoriteControl
|
||||
? html` <advanced-camera-card-icon
|
||||
class="${classMap(starClasses)}"
|
||||
title=${localize('thumbnail.retain_indefinitely')}
|
||||
.icon=${{ icon: this.item.isFavorite() ? 'mdi:star' : 'mdi:star-outline' }}
|
||||
@click=${async (ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (this.hass && this.item) {
|
||||
try {
|
||||
await this.viewItemManager?.favorite(
|
||||
this.item,
|
||||
!this.item.isFavorite(),
|
||||
);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return;
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
}}
|
||||
/></advanced-camera-card-icon>`
|
||||
: ``}
|
||||
${this.details
|
||||
? html`<advanced-camera-card-thumbnail-details
|
||||
.hass=${this.hass}
|
||||
@@ -132,46 +90,6 @@ export class AdvancedCameraCardThumbnail extends LitElement {
|
||||
.seek=${this.seek}
|
||||
></advanced-camera-card-thumbnail-details>`
|
||||
: ''}
|
||||
${shouldShowTimelineControl
|
||||
? html`<advanced-camera-card-icon
|
||||
class="timeline"
|
||||
.icon=${{ icon: 'mdi:target' }}
|
||||
title=${localize('thumbnail.timeline')}
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (!this.viewManagerEpoch || !this.item) {
|
||||
return;
|
||||
}
|
||||
this.viewManagerEpoch.manager.setViewByParameters({
|
||||
params: {
|
||||
view: 'timeline',
|
||||
queryResults: this.viewManagerEpoch?.manager
|
||||
.getView()
|
||||
?.queryResults?.clone()
|
||||
.selectResultIfFound((media) => media === this.item),
|
||||
},
|
||||
modifiers: [new RemoveContextViewModifier(['timeline'])],
|
||||
});
|
||||
}}
|
||||
></advanced-camera-card-icon>`
|
||||
: ''}
|
||||
${shouldShowDownloadControl
|
||||
? html` <advanced-camera-card-icon
|
||||
class="download"
|
||||
.icon=${{ icon: 'mdi:download' }}
|
||||
title=${localize('thumbnail.download')}
|
||||
@click=${async (ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (this.hass && this.item) {
|
||||
try {
|
||||
this.viewItemManager?.download(this.item);
|
||||
} catch (error: unknown) {
|
||||
dispatchAdvancedCameraCardErrorEvent(this, error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
></advanced-camera-card-icon>`
|
||||
: ``}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
ThumbnailDataRequest,
|
||||
ThumbnailDataRequestEvent,
|
||||
TimelineItemClickAction,
|
||||
TimelineKeys,
|
||||
} from '../components-lib/timeline/types';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
|
||||
import { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails';
|
||||
@@ -120,11 +119,6 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
|
||||
@property({ attribute: true, type: Boolean, reflect: true })
|
||||
public mini = false;
|
||||
|
||||
// Which cameraIDs to include in the timeline. If not specified, all cameraIDs
|
||||
// are shown.
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public keys?: TimelineKeys;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@@ -152,7 +146,22 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.keys) {
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
const isLoading = !!view?.context?.loading?.query;
|
||||
|
||||
if (isLoading) {
|
||||
if (!this.mini) {
|
||||
return renderMessage({
|
||||
message: localize('error.awaiting_media'),
|
||||
icon: 'mdi:chart-gantt',
|
||||
type: 'info',
|
||||
dotdotdot: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!view?.query || !view.query.hasNodes()) {
|
||||
if (!this.mini) {
|
||||
return renderMessage({
|
||||
message: localize('error.no_camera_or_media_for_timeline'),
|
||||
@@ -234,13 +243,14 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
|
||||
|
||||
if (
|
||||
[
|
||||
'viewManagerEpoch',
|
||||
'cameraManager',
|
||||
'viewItemManager',
|
||||
'timelineConfig',
|
||||
'mini',
|
||||
'thumbnailConfig',
|
||||
'keys',
|
||||
'conditionStateManager',
|
||||
'foldersManager',
|
||||
].some((prop) => changedProps.has(prop))
|
||||
) {
|
||||
this._controller.setOptions({
|
||||
@@ -251,7 +261,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
|
||||
timelineConfig: this.timelineConfig,
|
||||
mini: this.mini,
|
||||
thumbnailConfig: this.thumbnailConfig,
|
||||
keys: this.keys,
|
||||
query: this.viewManagerEpoch?.manager.getView()?.query ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ import { CameraManager } from '../camera-manager/manager';
|
||||
import { FoldersManager } from '../card-controller/folders/manager';
|
||||
import { ViewItemManager } from '../card-controller/view/item-manager';
|
||||
import { ViewManagerEpoch } from '../card-controller/view/types';
|
||||
import { TimelineKeys } from '../components-lib/timeline/types';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
|
||||
import { TimelineConfig } from '../config/schema/timeline';
|
||||
import { CardWideConfig } from '../config/schema/types';
|
||||
import { HomeAssistant } from '../ha/types';
|
||||
import basicBlockStyle from '../scss/basic-block.scss';
|
||||
import { QueryClassifier } from '../view/query-classifier';
|
||||
import './surround.js';
|
||||
import './timeline-core.js';
|
||||
|
||||
@@ -40,47 +38,6 @@ export class AdvancedCameraCardTimeline extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
protected _getKeys(): TimelineKeys | undefined {
|
||||
const query = this.viewManagerEpoch?.manager.getView()?.query;
|
||||
|
||||
// If there's a query, try to extract camera IDs or folder info from it.
|
||||
if (QueryClassifier.isMediaQuery(query)) {
|
||||
const cameraIDs = query.getQueryCameraIDs();
|
||||
if (cameraIDs && cameraIDs.size) {
|
||||
return {
|
||||
type: 'camera',
|
||||
cameraIDs,
|
||||
};
|
||||
}
|
||||
} else if (QueryClassifier.isFolderQuery(query)) {
|
||||
const folderConfig = query.getQuery()?.folder;
|
||||
if (folderConfig) {
|
||||
return {
|
||||
type: 'folder',
|
||||
folder: folderConfig,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise fall back to all cameras that support media queries.
|
||||
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability({
|
||||
anyCapabilities: ['clips', 'snapshots', 'recordings'],
|
||||
});
|
||||
const folder = this.foldersManager?.getFolder() ?? null;
|
||||
|
||||
return cameraIDs?.size
|
||||
? {
|
||||
type: 'camera',
|
||||
cameraIDs,
|
||||
}
|
||||
: folder
|
||||
? {
|
||||
type: 'folder',
|
||||
folder,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.timelineConfig) {
|
||||
return html``;
|
||||
@@ -96,7 +53,6 @@ export class AdvancedCameraCardTimeline extends LitElement {
|
||||
.foldersManager=${this.foldersManager}
|
||||
.conditionStateManager=${this.conditionStateManager}
|
||||
.viewItemManager=${this.viewItemManager}
|
||||
.keys=${this._getKeys()}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.itemClickAction=${this.timelineConfig.controls.thumbnails.mode === 'none'
|
||||
? 'play'
|
||||
|
||||
@@ -53,7 +53,7 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
|
||||
);
|
||||
}
|
||||
}}
|
||||
@loadeddata=${(ev: Event) => {
|
||||
@loadeddata="${(ev: Event) => {
|
||||
dispatchMediaLoadedEvent(this, ev, {
|
||||
...(this._mediaPlayerController && {
|
||||
mediaPlayerController: this._mediaPlayerController,
|
||||
@@ -64,7 +64,7 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
|
||||
},
|
||||
technology: ['mp4'],
|
||||
});
|
||||
}}
|
||||
}}"
|
||||
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
|
||||
@play=${() => dispatchMediaPlayEvent(this)}
|
||||
@pause=${() => dispatchMediaPauseEvent(this)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
|
||||
import { CardWideConfig } from '../../config/schema/types.js';
|
||||
@@ -39,6 +40,9 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public viewItemManager?: ViewItemManager;
|
||||
|
||||
protected _renderCarousel(filterCamera?: string): TemplateResult {
|
||||
const selectedCameraID = this.viewManagerEpoch?.manager.getView()?.camera;
|
||||
|
||||
@@ -60,6 +64,7 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.showControls=${!filterCamera || selectedCameraID === filterCamera}
|
||||
.viewItemManager=${this.viewItemManager}
|
||||
>
|
||||
</advanced-camera-card-viewer-carousel>
|
||||
`;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import { CardWideConfig } from '../../config/schema/types.js';
|
||||
import { ViewerConfig } from '../../config/schema/viewer.js';
|
||||
@@ -50,6 +51,9 @@ export class AdvancedCameraCardViewer extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public viewItemManager?: ViewItemManager;
|
||||
|
||||
@property({ attribute: 'empty', reflect: true, type: Boolean })
|
||||
public isEmpty = false;
|
||||
|
||||
@@ -96,6 +100,7 @@ export class AdvancedCameraCardViewer extends LitElement {
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.viewItemManager=${this.viewItemManager}
|
||||
>
|
||||
</advanced-camera-card-viewer-grid>`;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { QueryType } from '../../camera-manager/types.js';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
||||
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
|
||||
@@ -29,7 +30,7 @@ import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../ty
|
||||
import { errorToConsole } from '../../utils/basic.js';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
||||
import { VideoContentType, ViewMedia } from '../../view/item.js';
|
||||
import { QueryClassifier } from '../../view/query-classifier.js';
|
||||
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js';
|
||||
import '../image-player.js';
|
||||
import { renderProgressIndicator } from '../progress-indicator.js';
|
||||
import '../video-player.js';
|
||||
@@ -85,19 +86,13 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
||||
// If this specific media item has no clip, then do nothing (even if all
|
||||
// the other media items do).
|
||||
!ViewItemClassifier.isEvent(this.media) ||
|
||||
!QueryClassifier.isEventQuery(view.query)
|
||||
!view.query?.hasMediaQueriesOfType(QueryType.Event)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert the query to a clips equivalent.
|
||||
const clipQuery = view.query.clone();
|
||||
clipQuery.convertToClipsQueries();
|
||||
|
||||
const queries = clipQuery.getQuery();
|
||||
if (!queries) {
|
||||
return;
|
||||
}
|
||||
const clipQuery = UnifiedQueryTransformer.convertToClips(view.query);
|
||||
|
||||
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||
params: {
|
||||
|
||||
+8
-16
@@ -76,16 +76,14 @@ export class AdvancedCameraCardViews extends LitElement {
|
||||
if (view?.is('live') || this._shouldLivePreload()) {
|
||||
import('./live/index.js');
|
||||
}
|
||||
if (view?.isMediaGalleryView() && !view.is('folders')) {
|
||||
import('./gallery/media-gallery.js');
|
||||
if (view?.isGalleryView()) {
|
||||
import('./gallery/gallery.js');
|
||||
} else if (view?.isViewerView()) {
|
||||
import('./viewer/index.js');
|
||||
} else if (view?.is('image')) {
|
||||
import('./image.js');
|
||||
} else if (view?.is('timeline')) {
|
||||
import('./timeline.js');
|
||||
} else if (view?.is('folders')) {
|
||||
import('./gallery/folder-gallery.js');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,16 +165,18 @@ export class AdvancedCameraCardViews extends LitElement {
|
||||
>
|
||||
</advanced-camera-card-image>`
|
||||
: ``}
|
||||
${!this.hide && view?.isMediaGalleryView() && !view.is('folders')
|
||||
? html` <advanced-camera-card-media-gallery
|
||||
${!this.hide && view?.isGalleryView()
|
||||
? html` <advanced-camera-card-gallery
|
||||
.hass=${this.hass}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
.galleryConfig=${this.config.media_gallery}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.foldersManager=${this.foldersManager}
|
||||
.viewItemManager=${this.viewItemManager}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.conditionStateManager=${this.conditionStateManager}
|
||||
>
|
||||
</advanced-camera-card-media-gallery>`
|
||||
</advanced-camera-card-gallery>`
|
||||
: ``}
|
||||
${!this.hide && view?.isViewerView()
|
||||
? html`
|
||||
@@ -187,6 +187,7 @@ export class AdvancedCameraCardViews extends LitElement {
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.viewItemManager=${this.viewItemManager}
|
||||
>
|
||||
</advanced-camera-card-viewer>
|
||||
`
|
||||
@@ -212,15 +213,6 @@ export class AdvancedCameraCardViews extends LitElement {
|
||||
>
|
||||
</advanced-camera-card-diagnostics>`
|
||||
: ``}
|
||||
${!this.hide && view?.is('folders')
|
||||
? html` <advanced-camera-card-folder-gallery
|
||||
.hass=${this.hass}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
.viewItemManager=${this.viewItemManager}
|
||||
.galleryConfig=${this.config.media_gallery}
|
||||
.foldersManager=${this.foldersManager}
|
||||
></advanced-camera-card-folder-gallery>`
|
||||
: ``}
|
||||
${
|
||||
// Note: Subtle difference in condition below vs the other views in
|
||||
// order to always render the live view for live.preload mode.
|
||||
|
||||
@@ -6,13 +6,9 @@ import {
|
||||
CONF_CAMERAS_GLOBAL_PTZ,
|
||||
CONF_DIMENSIONS_HEIGHT,
|
||||
CONF_ELEMENTS,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_OVERRIDES,
|
||||
CONF_PROFILES,
|
||||
CONF_STATUS_BAR,
|
||||
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
|
||||
CONF_VIEW_DEFAULT_RESET_ENTITIES,
|
||||
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
|
||||
@@ -825,18 +821,19 @@ const UPGRADES = [
|
||||
upgradeWithOverrides('media_viewer.auto_unmute', (data) =>
|
||||
data === 'all' ? ['selected', 'visible'] : data === 'never' ? null : arrayify(data),
|
||||
),
|
||||
|
||||
upgradeMoveToWithOverrides(
|
||||
'live.controls.thumbnails.media',
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
|
||||
'live.controls.thumbnails.events_media_type',
|
||||
),
|
||||
upgradeMoveToWithOverrides('timeline.media', CONF_TIMELINE_EVENTS_MEDIA_TYPE),
|
||||
upgradeMoveToWithOverrides('timeline.media', 'timeline.events_media_type'),
|
||||
upgradeMoveToWithOverrides(
|
||||
'live.controls.timeline.media',
|
||||
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
'live.controls.timeline.events_media_type',
|
||||
),
|
||||
upgradeMoveToWithOverrides(
|
||||
'media_viewer.controls.timeline.media',
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
'media_viewer.controls.timeline.events_media_type',
|
||||
),
|
||||
upgradeMoveToWithOverrides('view.scan', CONF_VIEW_TRIGGERS),
|
||||
upgradeMoveToWithOverrides(
|
||||
@@ -944,4 +941,23 @@ const UPGRADES = [
|
||||
frigateCardToAdvancedCameraCardStyleTransform,
|
||||
),
|
||||
upgradeMoveToWithOverrides('menu.buttons.frigate', 'menu.buttons.iris'),
|
||||
|
||||
// v8.0.0+
|
||||
upgradeMoveToWithOverrides(
|
||||
'live.controls.thumbnails.media_type',
|
||||
'cameras_global.media.type',
|
||||
),
|
||||
upgradeMoveToWithOverrides(
|
||||
'live.controls.thumbnails.events_media_type',
|
||||
'cameras_global.media.events_type',
|
||||
{
|
||||
transform: (val) => {
|
||||
// 'all' is the default, delete it
|
||||
if (val === 'all') {
|
||||
return null;
|
||||
}
|
||||
return val;
|
||||
},
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
@@ -7,6 +7,7 @@ const GENERAL_ACTIONS = [
|
||||
'download',
|
||||
'expand',
|
||||
'fullscreen',
|
||||
'info',
|
||||
'live_substream_off',
|
||||
'live_substream_on',
|
||||
'menu_toggle',
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
import { advancedCameraCardCustomActionsBaseSchema } from './base';
|
||||
|
||||
export const setReviewActionConfigSchema =
|
||||
advancedCameraCardCustomActionsBaseSchema.extend({
|
||||
advanced_camera_card_action: z.literal('set_review'),
|
||||
|
||||
// Mark a review as reviewed or unreviewed.
|
||||
// - true = mark reviewed
|
||||
// - false = mark unreviewed
|
||||
// - undefined = toggle reviewed status
|
||||
reviewed: z.boolean().optional(),
|
||||
});
|
||||
export type SetReviewActionConfig = z.infer<typeof setReviewActionConfigSchema>;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { statusBarItemBaseSchema } from '../common/status-bar';
|
||||
import { severitySchema } from '../common/severity';
|
||||
import { advancedCameraCardCustomActionsBaseSchema } from './custom/base';
|
||||
import { cameraSelectActionConfigSchema } from './custom/camera-select';
|
||||
import { viewDisplayModeActionConfigSchema } from './custom/display-mode';
|
||||
@@ -12,6 +13,7 @@ import { ptzActionConfigSchema } from './custom/ptz';
|
||||
import { ptzControlsActionConfigSchema } from './custom/ptz-controls';
|
||||
import { ptzDigitalActionConfigSchema } from './custom/ptz-digital';
|
||||
import { ptzMultiActionSchema } from './custom/ptz-multi';
|
||||
import { setReviewActionConfigSchema } from './custom/set-review';
|
||||
import { sleepActionConfigSchema } from './custom/sleep';
|
||||
import { substreamSelectActionConfigSchema } from './custom/substream-select';
|
||||
import { viewActionConfigSchema } from './custom/view';
|
||||
@@ -51,6 +53,7 @@ const advancedCameraCardCustomActionSchema = z.union([
|
||||
ptzControlsActionConfigSchema,
|
||||
ptzDigitalActionConfigSchema,
|
||||
ptzMultiActionSchema,
|
||||
setReviewActionConfigSchema,
|
||||
sleepActionConfigSchema,
|
||||
statusBarActionConfigSchema,
|
||||
substreamSelectActionConfigSchema,
|
||||
@@ -106,6 +109,7 @@ const statusBarItemElementsBaseSchema = statusBarItemBaseSchema.extend({
|
||||
sufficient: z.boolean().default(false).optional(),
|
||||
exclusive: z.boolean().default(false).optional(),
|
||||
expand: z.boolean().default(false).optional(),
|
||||
severity: severitySchema.optional(),
|
||||
actions: actionsBaseSchema.optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { capabilityKeys } from '../../types';
|
||||
import { mediaLayoutConfigSchema } from './camera/media-layout';
|
||||
import { ptzCameraConfigDefaults, ptzCameraConfigSchema } from './camera/ptz';
|
||||
import { aspectRatioSchema } from './common/aspect-ratio';
|
||||
import { eventsMediaTypeSchema } from './common/events-media';
|
||||
import { severitySchema } from './common/severity';
|
||||
import { imageBaseConfigSchema, imageConfigDefault } from './common/image';
|
||||
|
||||
const CAMERA_TRIGGER_EVENT_TYPES = [
|
||||
@@ -124,8 +126,12 @@ export const cameraConfigDefault = {
|
||||
triggers: {
|
||||
motion: false,
|
||||
occupancy: false,
|
||||
events: [...CAMERA_TRIGGER_EVENT_TYPES],
|
||||
events: [],
|
||||
entities: [],
|
||||
reviews: {
|
||||
severities: ['high' as const],
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
dynamic: true,
|
||||
@@ -173,6 +179,26 @@ const cameraDimensionsSchema = z.object({
|
||||
});
|
||||
export type CameraDimensionsConfig = z.infer<typeof cameraDimensionsSchema>;
|
||||
|
||||
// Camera media configuration for default media type in live/timeline views.
|
||||
const CAMERA_MEDIA_TYPES = [
|
||||
'auto',
|
||||
'reviews',
|
||||
'events',
|
||||
'recordings',
|
||||
'folder',
|
||||
] as const;
|
||||
export type CameraMediaType = (typeof CAMERA_MEDIA_TYPES)[number];
|
||||
|
||||
const cameraMediaConfigDefault = {
|
||||
type: 'auto' as CameraMediaType,
|
||||
};
|
||||
|
||||
const cameraMediaConfigSchema = z.object({
|
||||
type: z.enum(CAMERA_MEDIA_TYPES).default(cameraMediaConfigDefault.type),
|
||||
events_type: eventsMediaTypeSchema.optional(),
|
||||
folders: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const cameraConfigSchema = z
|
||||
.object({
|
||||
camera_entity: z.string().optional(),
|
||||
@@ -209,6 +235,16 @@ export const cameraConfigSchema = z
|
||||
.enum(CAMERA_TRIGGER_EVENT_TYPES)
|
||||
.array()
|
||||
.default(cameraConfigDefault.triggers.events),
|
||||
reviews: z
|
||||
.object({
|
||||
severities: severitySchema
|
||||
.array()
|
||||
.default([...cameraConfigDefault.triggers.reviews.severities]),
|
||||
description: z
|
||||
.boolean()
|
||||
.default(cameraConfigDefault.triggers.reviews.description),
|
||||
})
|
||||
.default(cameraConfigDefault.triggers.reviews),
|
||||
})
|
||||
.default(cameraConfigDefault.triggers),
|
||||
|
||||
@@ -274,6 +310,8 @@ export const cameraConfigSchema = z
|
||||
|
||||
dimensions: cameraDimensionsSchema.optional(),
|
||||
|
||||
media: cameraMediaConfigSchema.optional(),
|
||||
|
||||
proxy: proxyConfigSchema.default(cameraConfigDefault.proxy),
|
||||
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1650
|
||||
|
||||
@@ -22,6 +22,8 @@ export const VIEWS_USER_SPECIFIED = [
|
||||
'snapshots',
|
||||
'recording',
|
||||
'recordings',
|
||||
'review',
|
||||
'reviews',
|
||||
'image',
|
||||
'timeline',
|
||||
] as const;
|
||||
|
||||
@@ -9,8 +9,10 @@ const thumbnailControlsBaseDefaults = {
|
||||
size: THUMBNAIL_WIDTH_DEFAULT,
|
||||
show_details: true,
|
||||
show_favorite_control: true,
|
||||
show_timeline_control: true,
|
||||
show_download_control: true,
|
||||
show_timeline_control: false,
|
||||
show_download_control: false,
|
||||
show_review_control: true,
|
||||
show_info_control: true,
|
||||
};
|
||||
|
||||
// Configuration for the actual rendered thumbnail.
|
||||
@@ -30,6 +32,12 @@ export const thumbnailsControlBaseSchema = z.object({
|
||||
show_download_control: z
|
||||
.boolean()
|
||||
.default(thumbnailControlsBaseDefaults.show_download_control),
|
||||
show_review_control: z
|
||||
.boolean()
|
||||
.default(thumbnailControlsBaseDefaults.show_review_control),
|
||||
show_info_control: z
|
||||
.boolean()
|
||||
.default(thumbnailControlsBaseDefaults.show_info_control),
|
||||
});
|
||||
export type ThumbnailsControlBaseConfig = z.infer<typeof thumbnailsControlBaseSchema>;
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { eventsMediaTypeSchema } from '../events-media';
|
||||
import { thumbnailControlsDefaults } from './thumbnails';
|
||||
|
||||
const timelineCoreConfigDefault = {
|
||||
export const timelineCoreConfigDefault = {
|
||||
clustering_threshold: 3,
|
||||
events_media_type: 'all' as const,
|
||||
window_seconds: 60 * 60,
|
||||
show_recordings: true,
|
||||
style: 'stack' as const,
|
||||
@@ -26,9 +23,6 @@ export const timelineCoreConfigSchema = z.object({
|
||||
.number()
|
||||
.optional()
|
||||
.default(timelineCoreConfigDefault.clustering_threshold),
|
||||
events_media_type: eventsMediaTypeSchema
|
||||
.optional()
|
||||
.default(timelineCoreConfigDefault.events_media_type),
|
||||
window_seconds: z
|
||||
.number()
|
||||
.min(1 * 60)
|
||||
@@ -58,10 +52,3 @@ export const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({
|
||||
style: timelineCoreConfigSchema.shape.style.default(miniTimelineConfigDefault.style),
|
||||
});
|
||||
export type MiniTimelineControlConfig = z.infer<typeof miniTimelineConfigSchema>;
|
||||
|
||||
export const timelineConfigDefault = {
|
||||
...timelineCoreConfigDefault,
|
||||
controls: {
|
||||
thumbnails: thumbnailControlsDefaults,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import { SEVERITIES } from '../../../types';
|
||||
|
||||
export const severitySchema = z.enum(SEVERITIES);
|
||||
@@ -13,3 +13,4 @@ export const menuBaseSchema = z.object({
|
||||
icon: z.string().optional(),
|
||||
permanent: z.boolean().default(false).optional(),
|
||||
});
|
||||
export type MenuItemBase = z.infer<typeof menuBaseSchema>;
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
miniTimelineConfigSchema,
|
||||
} from './common/controls/timeline';
|
||||
import { viewDisplaySchema } from './common/display';
|
||||
import { eventsMediaTypeSchema } from './common/events-media';
|
||||
import {
|
||||
MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
||||
MEDIA_ACTION_POSITIVE_CONDITIONS,
|
||||
@@ -41,12 +40,6 @@ const microphoneConfigSchema = z
|
||||
.default(microphoneConfigDefault);
|
||||
export type MicrophoneConfig = z.infer<typeof microphoneConfigSchema>;
|
||||
|
||||
const liveThumbnailControlsDefaults = {
|
||||
...thumbnailControlsDefaults,
|
||||
media_type: 'events' as const,
|
||||
events_media_type: 'all' as const,
|
||||
};
|
||||
|
||||
export const liveConfigDefault = {
|
||||
auto_play: [...MEDIA_ACTION_POSITIVE_CONDITIONS],
|
||||
auto_pause: [],
|
||||
@@ -67,7 +60,7 @@ export const liveConfigDefault = {
|
||||
style: 'chevrons' as const,
|
||||
},
|
||||
ptz: ptzControlsDefaults,
|
||||
thumbnails: liveThumbnailControlsDefaults,
|
||||
thumbnails: thumbnailControlsDefaults,
|
||||
timeline: miniTimelineConfigDefault,
|
||||
wheel: true,
|
||||
},
|
||||
@@ -76,15 +69,6 @@ export const liveConfigDefault = {
|
||||
},
|
||||
};
|
||||
|
||||
const livethumbnailsControlSchema = thumbnailsControlSchema.extend({
|
||||
media_type: z
|
||||
.enum(['events', 'recordings'])
|
||||
.default(liveConfigDefault.controls.thumbnails.media_type),
|
||||
events_media_type: eventsMediaTypeSchema.default(
|
||||
liveConfigDefault.controls.thumbnails.events_media_type,
|
||||
),
|
||||
});
|
||||
|
||||
export const liveConfigSchema = z
|
||||
.object({
|
||||
auto_pause: z
|
||||
@@ -118,7 +102,7 @@ export const liveConfigSchema = z
|
||||
})
|
||||
.default(liveConfigDefault.controls.next_previous),
|
||||
ptz: ptzControlsConfigSchema.default(liveConfigDefault.controls.ptz),
|
||||
thumbnails: livethumbnailsControlSchema.default(
|
||||
thumbnails: thumbnailsControlSchema.default(
|
||||
liveConfigDefault.controls.thumbnails,
|
||||
),
|
||||
timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline),
|
||||
|
||||
@@ -29,7 +29,7 @@ export const menuConfigDefault = {
|
||||
buttons: {
|
||||
camera_ui: visibleButtonDefault,
|
||||
cameras: visibleButtonDefault,
|
||||
clips: visibleButtonDefault,
|
||||
clips: hiddenButtonDefault,
|
||||
ptz_home: hiddenButtonDefault,
|
||||
display_mode: visibleButtonDefault,
|
||||
download: visibleButtonDefault,
|
||||
@@ -38,6 +38,7 @@ export const menuConfigDefault = {
|
||||
iris: visibleButtonDefault,
|
||||
fullscreen: visibleButtonDefault,
|
||||
image: hiddenButtonDefault,
|
||||
info: visibleButtonDefault,
|
||||
live: visibleButtonDefault,
|
||||
media_player: visibleButtonDefault,
|
||||
microphone: {
|
||||
@@ -48,8 +49,10 @@ export const menuConfigDefault = {
|
||||
play: hiddenButtonDefault,
|
||||
ptz_controls: hiddenButtonDefault,
|
||||
recordings: hiddenButtonDefault,
|
||||
reviews: visibleButtonDefault,
|
||||
set_review: visibleButtonDefault,
|
||||
screenshot: hiddenButtonDefault,
|
||||
snapshots: visibleButtonDefault,
|
||||
snapshots: hiddenButtonDefault,
|
||||
substreams: visibleButtonDefault,
|
||||
timeline: visibleButtonDefault,
|
||||
},
|
||||
@@ -76,7 +79,7 @@ export const menuConfigSchema = z
|
||||
.object({
|
||||
camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui),
|
||||
cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras),
|
||||
clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips),
|
||||
clips: hiddenButtonSchema.default(menuConfigDefault.buttons.clips),
|
||||
ptz_home: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_home),
|
||||
display_mode: visibleButtonSchema.default(
|
||||
menuConfigDefault.buttons.display_mode,
|
||||
@@ -87,6 +90,7 @@ export const menuConfigSchema = z
|
||||
iris: visibleButtonSchema.default(menuConfigDefault.buttons.iris),
|
||||
fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen),
|
||||
image: hiddenButtonSchema.default(menuConfigDefault.buttons.image),
|
||||
info: visibleButtonSchema.default(menuConfigDefault.buttons.info),
|
||||
live: visibleButtonSchema.default(menuConfigDefault.buttons.live),
|
||||
media_player: visibleButtonSchema.default(
|
||||
menuConfigDefault.buttons.media_player,
|
||||
@@ -102,8 +106,10 @@ export const menuConfigSchema = z
|
||||
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
||||
ptz_controls: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_controls),
|
||||
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
||||
reviews: visibleButtonSchema.default(menuConfigDefault.buttons.reviews),
|
||||
set_review: visibleButtonSchema.default(menuConfigDefault.buttons.set_review),
|
||||
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
|
||||
snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots),
|
||||
snapshots: hiddenButtonSchema.default(menuConfigDefault.buttons.snapshots),
|
||||
substreams: visibleButtonSchema.default(menuConfigDefault.buttons.substreams),
|
||||
timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline),
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ export const statusBarConfigDefault = {
|
||||
items: {
|
||||
engine: statusBarItemDefault,
|
||||
resolution: statusBarItemDefault,
|
||||
severity: statusBarItemDefault,
|
||||
technology: statusBarItemDefault,
|
||||
title: statusBarItemDefault,
|
||||
},
|
||||
@@ -50,6 +51,7 @@ export const statusBarConfigSchema = z
|
||||
resolution: statusBarItemBaseSchema.default(
|
||||
statusBarConfigDefault.items.resolution,
|
||||
),
|
||||
severity: statusBarItemBaseSchema.default(statusBarConfigDefault.items.severity),
|
||||
title: statusBarItemBaseSchema.default(statusBarConfigDefault.items.title),
|
||||
})
|
||||
.default(statusBarConfigDefault.items),
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
import { thumbnailsControlSchema } from './common/controls/thumbnails';
|
||||
import {
|
||||
timelineConfigDefault,
|
||||
thumbnailControlsDefaults,
|
||||
thumbnailsControlSchema,
|
||||
} from './common/controls/thumbnails';
|
||||
import {
|
||||
timelineCoreConfigDefault,
|
||||
timelineCoreConfigSchema,
|
||||
} from './common/controls/timeline';
|
||||
|
||||
export const timelineConfigDefault = {
|
||||
...timelineCoreConfigDefault,
|
||||
controls: {
|
||||
thumbnails: thumbnailControlsDefaults,
|
||||
},
|
||||
};
|
||||
|
||||
export const timelineConfigSchema = timelineCoreConfigSchema
|
||||
.extend({
|
||||
controls: z
|
||||
.object({
|
||||
thumbnails: thumbnailsControlSchema.default(
|
||||
timelineConfigDefault.controls.thumbnails,
|
||||
),
|
||||
thumbnails: thumbnailsControlSchema.default(thumbnailControlsDefaults),
|
||||
})
|
||||
.default(timelineConfigDefault.controls),
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { deepRemoveDefaults } from '../../utils/zod';
|
||||
import { automationsSchema } from './automations';
|
||||
import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './cameras';
|
||||
import { cardIDRegex } from './common/const';
|
||||
import { timelineConfigDefault } from './common/controls/timeline';
|
||||
import { imageConfigDefault } from './common/image';
|
||||
import { DebugConfig, debugConfigDefault, debugConfigSchema } from './debug';
|
||||
import { dimensionsConfigSchema } from './dimensions';
|
||||
@@ -22,7 +21,7 @@ import {
|
||||
import { profilesSchema } from './profiles';
|
||||
import { remoteControlConfigDefault, remoteControlConfigSchema } from './remote-control';
|
||||
import { statusBarConfigDefault, statusBarConfigSchema } from './status-bar';
|
||||
import { timelineConfigSchema } from './timeline';
|
||||
import { timelineConfigDefault, timelineConfigSchema } from './timeline';
|
||||
import { viewConfigDefault, viewConfigSchema } from './view';
|
||||
import { viewerConfigDefault, viewerConfigSchema } from './viewer';
|
||||
|
||||
|
||||
+27
-10
@@ -109,6 +109,15 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES =
|
||||
`${CONF_CAMERAS}.#.triggers.entities` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS =
|
||||
`${CONF_CAMERAS}.#.triggers.events` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_SEVERITIES =
|
||||
`${CONF_CAMERAS}.#.triggers.reviews.severities` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION =
|
||||
`${CONF_CAMERAS}.#.triggers.reviews.description` as const;
|
||||
export const CONF_CAMERAS_ARRAY_MEDIA_TYPE = `${CONF_CAMERAS}.#.media.type` as const;
|
||||
export const CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE =
|
||||
`${CONF_CAMERAS}.#.media.events_type` as const;
|
||||
export const CONF_CAMERAS_ARRAY_MEDIA_FOLDERS =
|
||||
`${CONF_CAMERAS}.#.media.folders` as const;
|
||||
|
||||
const CONF_CAMERAS_GLOBAL = 'cameras_global' as const;
|
||||
export const CONF_CAMERAS_GLOBAL_LIVE_PROVIDER =
|
||||
@@ -190,6 +199,10 @@ export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
|
||||
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_favorite_control` as const;
|
||||
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
||||
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_timeline_control` as const;
|
||||
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL =
|
||||
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_review_control` as const;
|
||||
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL =
|
||||
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_info_control` as const;
|
||||
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE =
|
||||
`${CONF_MEDIA_GALLERY}.controls.thumbnails.size` as const;
|
||||
|
||||
@@ -230,12 +243,15 @@ export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
|
||||
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_favorite_control` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
||||
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_timeline_control` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL =
|
||||
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_review_control` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL =
|
||||
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_info_control` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE =
|
||||
`${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD =
|
||||
`${CONF_MEDIA_VIEWER}.controls.timeline.clustering_threshold` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE =
|
||||
`${CONF_MEDIA_VIEWER}.controls.timeline.events_media_type` as const;
|
||||
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H =
|
||||
`${CONF_MEDIA_VIEWER}.controls.timeline.format.24h` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE =
|
||||
@@ -274,10 +290,7 @@ export const CONF_LIVE_CONTROLS_PTZ_ORIENTATION =
|
||||
export const CONF_LIVE_CONTROLS_PTZ_POSITION =
|
||||
`${CONF_LIVE}.controls.ptz.position` as const;
|
||||
export const CONF_LIVE_CONTROLS_WHEEL = `${CONF_LIVE}.controls.wheel` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE =
|
||||
`${CONF_LIVE}.controls.thumbnails.media_type` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE =
|
||||
`${CONF_LIVE}.controls.thumbnails.events_media_type` as const;
|
||||
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE =
|
||||
`${CONF_LIVE}.controls.thumbnails.mode` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE =
|
||||
@@ -290,10 +303,12 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
|
||||
`${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
||||
`${CONF_LIVE}.controls.thumbnails.show_timeline_control` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL =
|
||||
`${CONF_LIVE}.controls.thumbnails.show_review_control` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL =
|
||||
`${CONF_LIVE}.controls.thumbnails.show_info_control` as const;
|
||||
export const CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD =
|
||||
`${CONF_LIVE}.controls.timeline.clustering_threshold` as const;
|
||||
export const CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE =
|
||||
`${CONF_LIVE}.controls.timeline.events_media_type` as const;
|
||||
export const CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H =
|
||||
`${CONF_LIVE}.controls.timeline.format.24h` as const;
|
||||
export const CONF_LIVE_CONTROLS_TIMELINE_MODE =
|
||||
@@ -341,8 +356,6 @@ const CONF_TIMELINE = 'timeline' as const;
|
||||
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
|
||||
export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
|
||||
`${CONF_TIMELINE}.clustering_threshold` as const;
|
||||
export const CONF_TIMELINE_EVENTS_MEDIA_TYPE =
|
||||
`${CONF_TIMELINE}.events_media_type` as const;
|
||||
export const CONF_TIMELINE_FORMAT_24H = `${CONF_TIMELINE}.format.24h` as const;
|
||||
export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
|
||||
export const CONF_TIMELINE_STYLE = `${CONF_TIMELINE}.style` as const;
|
||||
@@ -358,6 +371,10 @@ export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
|
||||
`${CONF_TIMELINE}.controls.thumbnails.show_favorite_control` as const;
|
||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
||||
`${CONF_TIMELINE}.controls.thumbnails.show_timeline_control` as const;
|
||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL =
|
||||
`${CONF_TIMELINE}.controls.thumbnails.show_review_control` as const;
|
||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL =
|
||||
`${CONF_TIMELINE}.controls.thumbnails.show_info_control` as const;
|
||||
|
||||
const CONF_MENU = 'menu' as const;
|
||||
export const CONF_MENU_ALIGNMENT = `${CONF_MENU}.alignment` as const;
|
||||
|
||||
+169
-52
@@ -71,6 +71,9 @@ import {
|
||||
CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS,
|
||||
CONF_CAMERAS_ARRAY_IMAGE_URL,
|
||||
CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
|
||||
CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE,
|
||||
CONF_CAMERAS_ARRAY_MEDIA_FOLDERS,
|
||||
CONF_CAMERAS_ARRAY_MEDIA_TYPE,
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN,
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN,
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
|
||||
@@ -88,6 +91,8 @@ import {
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS,
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_MOTION,
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION,
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_SEVERITIES,
|
||||
CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
|
||||
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
|
||||
CONF_DIMENSIONS_ASPECT_RATIO,
|
||||
@@ -117,16 +122,15 @@ import {
|
||||
CONF_LIVE_CONTROLS_PTZ_MODE,
|
||||
CONF_LIVE_CONTROLS_PTZ_ORIENTATION,
|
||||
CONF_LIVE_CONTROLS_PTZ_POSITION,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_MODE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE,
|
||||
@@ -153,6 +157,8 @@ import {
|
||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
|
||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
|
||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE,
|
||||
CONF_MEDIA_VIEWER_AUTO_MUTE,
|
||||
@@ -166,10 +172,11 @@ import {
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE,
|
||||
@@ -212,9 +219,10 @@ import {
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
|
||||
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_TIMELINE_FORMAT_24H,
|
||||
CONF_TIMELINE_SHOW_RECORDINGS,
|
||||
CONF_TIMELINE_STYLE,
|
||||
@@ -259,6 +267,7 @@ import { localize } from './localize/localize.js';
|
||||
import editorStyle from './scss/editor.scss';
|
||||
import { arrayMove, prettifyTitle } from './utils/basic.js';
|
||||
import { getCameraID } from './utils/camera.js';
|
||||
import { getFolderID } from './utils/folder.js';
|
||||
|
||||
const MENU_CAMERAS = 'cameras';
|
||||
const MENU_CAMERAS_CAPABILITIES = 'cameras.capabilities';
|
||||
@@ -275,7 +284,9 @@ const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye';
|
||||
const MENU_CAMERAS_PROXY = 'cameras.proxy';
|
||||
const MENU_CAMERAS_REOLINK = 'cameras.reolink';
|
||||
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
|
||||
const MENU_CAMERAS_TRIGGERS_REVIEWS = 'cameras.triggers.reviews';
|
||||
const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
|
||||
const MENU_CAMERAS_MEDIA = 'cameras.media';
|
||||
const MENU_FOLDERS = 'folders';
|
||||
const MENU_FOLDERS_HA = 'folders.ha';
|
||||
const MENU_LIVE_CONTROLS = 'live.controls';
|
||||
@@ -424,6 +435,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
{ value: 'live', label: localize('config.view.views.live') },
|
||||
{ value: 'recording', label: localize('config.view.views.recording') },
|
||||
{ value: 'recordings', label: localize('config.view.views.recordings') },
|
||||
{ value: 'review', label: localize('config.view.views.review') },
|
||||
{ value: 'reviews', label: localize('config.view.views.reviews') },
|
||||
{ value: 'snapshot', label: localize('config.view.views.snapshot') },
|
||||
{ value: 'snapshots', label: localize('config.view.views.snapshots') },
|
||||
{ value: 'timeline', label: localize('config.view.views.timeline') },
|
||||
@@ -535,25 +548,53 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
|
||||
protected _thumbnailMediaTypes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'auto',
|
||||
label: localize('config.common.media_types.auto'),
|
||||
},
|
||||
{
|
||||
value: 'events',
|
||||
label: localize('config.common.controls.thumbnails.media_types.events'),
|
||||
label: localize('config.common.media_types.events'),
|
||||
},
|
||||
{
|
||||
value: 'recordings',
|
||||
label: localize('config.common.controls.thumbnails.media_types.recordings'),
|
||||
label: localize('config.common.media_types.recordings'),
|
||||
},
|
||||
{
|
||||
value: 'reviews',
|
||||
label: localize('config.common.media_types.reviews'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _timelineThumbnailMediaTypes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'auto',
|
||||
label: localize('config.common.media_types.auto'),
|
||||
},
|
||||
{
|
||||
value: 'events',
|
||||
label: localize('config.common.media_types.events'),
|
||||
},
|
||||
{
|
||||
value: 'reviews',
|
||||
label: localize('config.common.media_types.reviews'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _thumbnailEventsMediaTypes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'all',
|
||||
label: localize('config.common.events_media_types.all'),
|
||||
},
|
||||
{
|
||||
value: 'clips',
|
||||
label: localize('config.common.controls.thumbnails.events_media_types.clips'),
|
||||
label: localize('config.common.events_media_types.clips'),
|
||||
},
|
||||
{
|
||||
value: 'snapshots',
|
||||
label: localize('config.common.controls.thumbnails.events_media_types.snapshots'),
|
||||
label: localize('config.common.events_media_types.snapshots'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -573,14 +614,14 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
|
||||
protected _timelineEventsMediaTypes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'all', label: localize('config.common.timeline.events_media_types.all') },
|
||||
{ value: 'all', label: localize('config.common.events_media_types.all') },
|
||||
{
|
||||
value: 'clips',
|
||||
label: localize('config.common.timeline.events_media_types.clips'),
|
||||
label: localize('config.common.events_media_types.clips'),
|
||||
},
|
||||
{
|
||||
value: 'snapshots',
|
||||
label: localize('config.common.timeline.events_media_types.snapshots'),
|
||||
label: localize('config.common.events_media_types.snapshots'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -693,6 +734,15 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
{ value: 'dashboard', label: localize('config.cameras.cast.methods.dashboard') },
|
||||
];
|
||||
|
||||
protected _cameraMediaTypes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'auto', label: localize('config.common.media_types.auto') },
|
||||
{ value: 'events', label: localize('config.common.media_types.events') },
|
||||
{ value: 'recordings', label: localize('config.common.media_types.recordings') },
|
||||
{ value: 'reviews', label: localize('config.common.media_types.reviews') },
|
||||
{ value: 'folder', label: localize('config.common.media_types.folder') },
|
||||
];
|
||||
|
||||
protected _ptzModes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'on', label: localize('config.live.controls.ptz.modes.on') },
|
||||
@@ -795,6 +845,22 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
},
|
||||
];
|
||||
|
||||
protected _severities: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'high',
|
||||
label: localize('common.severities.high'),
|
||||
},
|
||||
{
|
||||
value: 'medium',
|
||||
label: localize('common.severities.medium'),
|
||||
},
|
||||
{
|
||||
value: 'low',
|
||||
label: localize('common.severities.low'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _timelinePanModes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
@@ -1607,7 +1673,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
* @param configPathStyle Timeline style config path.
|
||||
* @param configPathWindowSeconds Timeline window config path.
|
||||
* @param configPathClusteringThreshold Clustering threshold config path.
|
||||
* @param configPathTimelineEventsMediaType Timeline media config path.
|
||||
* @param configPathShowRecordings Show recordings config path.
|
||||
* @param defaultShowRecordings Default value of show_recordings.
|
||||
* @returns A rendered template.
|
||||
@@ -1617,7 +1682,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
configPathStyle: string,
|
||||
configPathWindowSeconds: string,
|
||||
configPathClusteringThreshold: string,
|
||||
configPathTimelineEventsMediaType: string,
|
||||
configPathShowRecordings: string,
|
||||
configPathFormat24h: string,
|
||||
defaultShowRecordings: boolean,
|
||||
@@ -1639,13 +1703,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
${this._renderNumberInput(configPathClusteringThreshold, {
|
||||
label: localize(`config.common.${CONF_TIMELINE_CLUSTERING_THRESHOLD}`),
|
||||
})}
|
||||
${this._renderOptionSelector(
|
||||
configPathTimelineEventsMediaType,
|
||||
this._timelineEventsMediaTypes,
|
||||
{
|
||||
label: localize(`config.common.${CONF_TIMELINE_EVENTS_MEDIA_TYPE}`),
|
||||
},
|
||||
)}
|
||||
${this._renderSwitch(configPathShowRecordings, defaultShowRecordings, {
|
||||
label: localize(`config.common.${CONF_TIMELINE_SHOW_RECORDINGS}`),
|
||||
})}
|
||||
@@ -1668,7 +1725,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
* @param domain The submenu domain.
|
||||
* @param configPathWindowSeconds Timeline window config path.
|
||||
* @param configPathClusteringThreshold Clustering threshold config path.
|
||||
* @param configPathTimelineEventsMediaType Timeline media config path.
|
||||
|
||||
* @param configPathShowRecordings Show recordings config path.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
@@ -1678,7 +1735,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
configPathStyle: string,
|
||||
configPathWindowSeconds: string,
|
||||
configPathClusteringThreshold: string,
|
||||
configPathTimelineEventsMediaType: string,
|
||||
|
||||
configPathShowRecordings: string,
|
||||
configPathFormat24h: string,
|
||||
defaultShowRecordings: boolean,
|
||||
@@ -1698,7 +1755,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
configPathStyle,
|
||||
configPathWindowSeconds,
|
||||
configPathClusteringThreshold,
|
||||
configPathTimelineEventsMediaType,
|
||||
configPathShowRecordings,
|
||||
configPathFormat24h,
|
||||
defaultShowRecordings,
|
||||
@@ -1814,15 +1870,17 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
configPathShowFavoriteControl: string,
|
||||
configPathShowTimelineControl: string,
|
||||
configPathShowDownloadControl: string,
|
||||
configPathShowReviewControl: string,
|
||||
configPathShowInfoControl: string,
|
||||
defaults: {
|
||||
show_details: boolean;
|
||||
show_favorite_control: boolean;
|
||||
show_timeline_control: boolean;
|
||||
show_download_control: boolean;
|
||||
show_review_control: boolean;
|
||||
show_info_control: boolean;
|
||||
},
|
||||
options?: {
|
||||
configPathMediaType?: string;
|
||||
configPathEventsMediaType?: string;
|
||||
configPathMode?: string;
|
||||
},
|
||||
): TemplateResult | void {
|
||||
@@ -1841,24 +1899,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
},
|
||||
)}`
|
||||
: html``}
|
||||
${options?.configPathMediaType
|
||||
? html`${this._renderOptionSelector(
|
||||
options.configPathMediaType,
|
||||
this._thumbnailMediaTypes,
|
||||
{
|
||||
label: localize('config.common.controls.thumbnails.media_type'),
|
||||
},
|
||||
)}`
|
||||
: html``}
|
||||
${options?.configPathEventsMediaType
|
||||
? html`${this._renderOptionSelector(
|
||||
options.configPathEventsMediaType,
|
||||
this._thumbnailEventsMediaTypes,
|
||||
{
|
||||
label: localize('config.common.controls.thumbnails.events_media_type'),
|
||||
},
|
||||
)}`
|
||||
: html``}
|
||||
${this._renderNumberInput(configPathSize, {
|
||||
min: THUMBNAIL_WIDTH_MIN,
|
||||
max: THUMBNAIL_WIDTH_MAX,
|
||||
@@ -1888,6 +1928,12 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
label: localize('config.common.controls.thumbnails.show_download_control'),
|
||||
},
|
||||
)}
|
||||
${this._renderSwitch(configPathShowReviewControl, defaults.show_review_control, {
|
||||
label: localize('config.common.controls.thumbnails.show_review_control'),
|
||||
})}
|
||||
${this._renderSwitch(configPathShowInfoControl, defaults.show_info_control, {
|
||||
label: localize('config.common.controls.thumbnails.show_info_control'),
|
||||
})}
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -2125,6 +2171,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
cameras: RawAdvancedCameraCardConfigArray,
|
||||
cameraIndex: number,
|
||||
entities: string[],
|
||||
folders: RawAdvancedCameraCardConfigArray,
|
||||
addNewCamera?: boolean,
|
||||
): TemplateResult | void {
|
||||
const liveProviders: EditorSelectOption[] = [
|
||||
@@ -2159,6 +2206,14 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
}
|
||||
});
|
||||
|
||||
const folderOptions: EditorSelectOption[] = [];
|
||||
folders.forEach((folder, index) => {
|
||||
folderOptions.push({
|
||||
value: getFolderID(folder, index),
|
||||
label: this._getEditorFolderTitle(index, folder),
|
||||
});
|
||||
});
|
||||
|
||||
const submenuClasses = {
|
||||
submenu: true,
|
||||
selected: this._expandedMenus[MENU_CAMERAS] === cameraIndex,
|
||||
@@ -2441,6 +2496,65 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
label: localize('config.cameras.triggers.events.editor_label'),
|
||||
},
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
MENU_CAMERAS_TRIGGERS_REVIEWS,
|
||||
cameraIndex,
|
||||
'config.cameras.triggers.reviews.editor_label',
|
||||
'mdi:check-circle',
|
||||
html`
|
||||
${this._renderOptionSelector(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_SEVERITIES,
|
||||
cameraIndex,
|
||||
),
|
||||
this._severities,
|
||||
{
|
||||
multiple: true,
|
||||
label: localize('common.severity'),
|
||||
},
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION,
|
||||
cameraIndex,
|
||||
),
|
||||
this._defaults.cameras.triggers.reviews.description,
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
MENU_CAMERAS_MEDIA,
|
||||
cameraIndex,
|
||||
'config.cameras.media.editor_label',
|
||||
'mdi:play-box-outline',
|
||||
html`
|
||||
${this._renderOptionSelector(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_MEDIA_TYPE, cameraIndex),
|
||||
this._cameraMediaTypes,
|
||||
{
|
||||
label: localize('config.cameras.media.type'),
|
||||
},
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE,
|
||||
cameraIndex,
|
||||
),
|
||||
this._thumbnailEventsMediaTypes,
|
||||
{
|
||||
label: localize('config.cameras.media.events_type'),
|
||||
},
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_MEDIA_FOLDERS, cameraIndex),
|
||||
folderOptions,
|
||||
{
|
||||
multiple: true,
|
||||
label: localize('config.cameras.media.folders'),
|
||||
},
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
@@ -2731,9 +2845,9 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
? html`
|
||||
<div class="values">
|
||||
${cameras.map((_, index) =>
|
||||
this._renderCamera(cameras, index, entities),
|
||||
this._renderCamera(cameras, index, entities, folders),
|
||||
)}
|
||||
${this._renderCamera(cameras, cameras.length, entities, true)}
|
||||
${this._renderCamera(cameras, cameras.length, entities, folders, true)}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
@@ -2784,6 +2898,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
${this._renderMenuButton('cameras') /* */}
|
||||
${this._renderMenuButton('substreams') /* */}
|
||||
${this._renderMenuButton('live') /* */}
|
||||
${this._renderMenuButton('reviews') /* */}
|
||||
${this._renderMenuButton('clips') /* */}
|
||||
${this._renderMenuButton('snapshots')}
|
||||
${this._renderMenuButton('recordings')}
|
||||
@@ -2935,11 +3050,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
|
||||
this._defaults.live.controls.thumbnails,
|
||||
{
|
||||
configPathMediaType: CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE,
|
||||
configPathEventsMediaType:
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
|
||||
configPathMode: CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
|
||||
},
|
||||
)}
|
||||
@@ -2949,7 +3063,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
CONF_LIVE_CONTROLS_TIMELINE_STYLE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H,
|
||||
this._defaults.live.controls.timeline.show_recordings,
|
||||
@@ -3035,6 +3148,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
|
||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
|
||||
this._defaults.media_gallery.controls.thumbnails,
|
||||
)}
|
||||
${this._renderFilterControls(
|
||||
@@ -3137,6 +3252,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
|
||||
this._defaults.media_viewer.controls.thumbnails,
|
||||
{
|
||||
configPathMode: CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
|
||||
@@ -3148,7 +3265,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H,
|
||||
this._defaults.media_viewer.controls.timeline.show_recordings,
|
||||
@@ -3179,7 +3295,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
CONF_TIMELINE_STYLE,
|
||||
CONF_TIMELINE_WINDOW_SECONDS,
|
||||
CONF_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_TIMELINE_SHOW_RECORDINGS,
|
||||
CONF_TIMELINE_FORMAT_24H,
|
||||
this._defaults.timeline.show_recordings,
|
||||
@@ -3192,6 +3307,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
|
||||
this._defaults.timeline.controls.thumbnails,
|
||||
{
|
||||
configPathMode: CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user