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.
|
A comprehensive camera card for Home Assistant.
|
||||||
|
|
||||||
- Live viewing of multiple cameras.
|
- 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.
|
- Automatic updating to continually show latest clip / snapshot.
|
||||||
- Support for filtering events by zone and label.
|
- Support for filtering events by zone and label.
|
||||||
- Arbitrary entity access via menu (e.g. motion sensor access).
|
- 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
|
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`
|
## `live`
|
||||||
|
|
||||||
Change to the `live` view.
|
Change to the `live` view.
|
||||||
@@ -385,14 +394,14 @@ advanced_camera_card_action: ptz_digital
|
|||||||
# [...]
|
# [...]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Default | Description |
|
| Parameter | Default | Description |
|
||||||
| ----------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `action` | | Must be `custom:advanced-camera-card-action`. |
|
| `action` | | Must be `custom:advanced-camera-card-action`. |
|
||||||
| `advanced_camera_card_action` | | Must be `ptz_digital`. |
|
| `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). |
|
| `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_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. |
|
| `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. |
|
| `absolute` | | Optional parameter to specify exact absolute pan and zoom settings. See below. |
|
||||||
|
|
||||||
> [!NOTE]
|
> [!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.
|
> 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
|
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`
|
## `sleep`
|
||||||
|
|
||||||
Take no action for a given duration. Useful to pause between multiple other actions.
|
Take no action for a given duration. Useful to pause between multiple other actions.
|
||||||
@@ -627,6 +652,12 @@ elements:
|
|||||||
tap_action:
|
tap_action:
|
||||||
action: custom:advanced-camera-card-action
|
action: custom:advanced-camera-card-action
|
||||||
advanced_camera_card_action: live
|
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
|
- type: custom:advanced-camera-card-menu-icon
|
||||||
icon: mdi:alpha-n-circle
|
icon: mdi:alpha-n-circle
|
||||||
title: Turn off substream
|
title: Turn off substream
|
||||||
@@ -794,18 +825,25 @@ elements:
|
|||||||
advanced_camera_card_action: snapshots
|
advanced_camera_card_action: snapshots
|
||||||
- type: custom:advanced-camera-card-menu-icon
|
- type: custom:advanced-camera-card-menu-icon
|
||||||
icon: mdi:alpha-h-circle-outline
|
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
|
title: Show timeline
|
||||||
tap_action:
|
tap_action:
|
||||||
action: custom:advanced-camera-card-action
|
action: custom:advanced-camera-card-action
|
||||||
advanced_camera_card_action: timeline
|
advanced_camera_card_action: timeline
|
||||||
- type: custom:advanced-camera-card-menu-icon
|
- type: custom:advanced-camera-card-menu-icon
|
||||||
icon: mdi:alpha-i-circle-outline
|
icon: mdi:alpha-j-circle-outline
|
||||||
title: Unmute
|
title: Unmute
|
||||||
tap_action:
|
tap_action:
|
||||||
action: custom:advanced-camera-card-action
|
action: custom:advanced-camera-card-action
|
||||||
advanced_camera_card_action: unmute
|
advanced_camera_card_action: unmute
|
||||||
- type: custom:advanced-camera-card-menu-icon
|
- type: custom:advanced-camera-card-menu-icon
|
||||||
icon: mdi:alpha-j-circle-outline
|
icon: mdi:alpha-k-circle-outline
|
||||||
title: Add status bar contents
|
title: Add status bar contents
|
||||||
tap_action:
|
tap_action:
|
||||||
- action: custom:advanced-camera-card-action
|
- action: custom:advanced-camera-card-action
|
||||||
@@ -834,7 +872,7 @@ elements:
|
|||||||
priority: 50
|
priority: 50
|
||||||
sufficient: false
|
sufficient: false
|
||||||
- type: custom:advanced-camera-card-menu-icon
|
- type: custom:advanced-camera-card-menu-icon
|
||||||
icon: mdi:alpha-k-circle-outline
|
icon: mdi:alpha-l-circle-outline
|
||||||
title: Remove status bar contents
|
title: Remove status bar contents
|
||||||
tap_action:
|
tap_action:
|
||||||
- action: custom:advanced-camera-card-action
|
- action: custom:advanced-camera-card-action
|
||||||
@@ -863,21 +901,21 @@ elements:
|
|||||||
priority: 50
|
priority: 50
|
||||||
sufficient: false
|
sufficient: false
|
||||||
- type: custom:advanced-camera-card-menu-icon
|
- type: custom:advanced-camera-card-menu-icon
|
||||||
icon: mdi:alpha-l-circle-outline
|
icon: mdi:alpha-m-circle-outline
|
||||||
title: Reset status bar contents
|
title: Reset status bar contents
|
||||||
tap_action:
|
tap_action:
|
||||||
- action: custom:advanced-camera-card-action
|
- action: custom:advanced-camera-card-action
|
||||||
advanced_camera_card_action: status_bar
|
advanced_camera_card_action: status_bar
|
||||||
status_bar_action: reset
|
status_bar_action: reset
|
||||||
- type: custom:advanced-camera-card-menu-icon
|
- type: custom:advanced-camera-card-menu-icon
|
||||||
icon: mdi:alpha-m-circle-outline
|
icon: mdi:alpha-n-circle-outline
|
||||||
title: View Folder Media
|
title: View Folder Media
|
||||||
tap_action:
|
tap_action:
|
||||||
- action: custom:advanced-camera-card-action
|
- action: custom:advanced-camera-card-action
|
||||||
advanced_camera_card_action: folder
|
advanced_camera_card_action: folder
|
||||||
folder: my-folder
|
folder: my-folder
|
||||||
- type: custom:advanced-camera-card-menu-icon
|
- type: custom:advanced-camera-card-menu-icon
|
||||||
icon: mdi:alpha-n-circle-outline
|
icon: mdi:alpha-o-circle-outline
|
||||||
title: View Folders Gallery
|
title: View Folders Gallery
|
||||||
tap_action:
|
tap_action:
|
||||||
- action: custom:advanced-camera-card-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. |
|
| `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. |
|
| `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). |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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%. |
|
| `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
|
### 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) ).
|
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). |
|
| `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. |
|
| `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. |
|
| `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). |
|
| `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]
|
> [!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). |
|
| `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. |
|
| `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. |
|
| `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
|
## Fully expanded reference
|
||||||
|
|
||||||
@@ -378,6 +429,14 @@ cameras:
|
|||||||
occupancy: true
|
occupancy: true
|
||||||
entities:
|
entities:
|
||||||
- binary_sensor.front_door_sensor
|
- binary_sensor.front_door_sensor
|
||||||
|
events:
|
||||||
|
- events
|
||||||
|
- clips
|
||||||
|
- snapshots
|
||||||
|
reviews:
|
||||||
|
severities:
|
||||||
|
- high
|
||||||
|
description: true
|
||||||
cast:
|
cast:
|
||||||
method: standard
|
method: standard
|
||||||
dimensions:
|
dimensions:
|
||||||
|
|||||||
@@ -88,11 +88,11 @@ cameras:
|
|||||||
# [...]
|
# [...]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `entity` | | The RTSP camera entity to pass to the WebRTC Card for this camera. |
|
| `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` |
|
| `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. |
|
| `*` | | 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
|
## Fully expanded reference
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ folders:
|
|||||||
# [...]
|
# [...]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| 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. |
|
| `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. |
|
| `ha` | | Options for `ha` folder types. See below. |
|
||||||
| `icon` | | An optional folder icon. |
|
| `icon` | | An optional folder icon. |
|
||||||
| `title` | | An optional folder title. |
|
| `title` | | An optional folder title. |
|
||||||
| `type` | `ha` | The type of folder, `ha` for Home Assistant media folders (currently the only supported type of folder). |
|
| `type` | `ha` | The type of folder, `ha` for Home Assistant media folders (currently the only supported type of folder). |
|
||||||
|
|
||||||
## `ha`
|
## `ha`
|
||||||
|
|
||||||
|
|||||||
+12
-16
@@ -93,16 +93,15 @@ live:
|
|||||||
# [...]
|
# [...]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| 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`. |
|
| `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`). |
|
||||||
| `media_type` | `events` | Whether to load `events` or `recordings` media. |
|
| `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
|
||||||
| `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_download_control` | `false` | Whether to show the download control on each thumbnail. |
|
||||||
| `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
|
| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. |
|
||||||
| `show_download_control` | `true` | Whether to show the download control on each thumbnail. |
|
| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. |
|
||||||
| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. |
|
| `show_timeline_control` | `false` | Whether to show the timeline ('target') 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`. |
|
||||||
| `size` | `100` | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `300`. |
|
|
||||||
|
|
||||||
### `timeline`
|
### `timeline`
|
||||||
|
|
||||||
@@ -118,7 +117,6 @@ live:
|
|||||||
| Option | Default | Description |
|
| 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. |
|
| `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. |
|
| `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`). |
|
| `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). |
|
| `pan_mode` | `pan` | See [timeline pan mode](timeline-pan-mode.md). |
|
||||||
@@ -219,20 +217,18 @@ live:
|
|||||||
# Optionally override the default style.
|
# Optionally override the default style.
|
||||||
right: 5%
|
right: 5%
|
||||||
thumbnails:
|
thumbnails:
|
||||||
media_type: events
|
|
||||||
events_media_type: all
|
|
||||||
size: 100
|
size: 100
|
||||||
show_details: false
|
show_details: false
|
||||||
show_download_control: true
|
show_download_control: false
|
||||||
show_favorite_control: true
|
show_favorite_control: true
|
||||||
show_timeline_control: true
|
show_review_control: true
|
||||||
|
show_timeline_control: false
|
||||||
mode: none
|
mode: none
|
||||||
timeline:
|
timeline:
|
||||||
style: ribbon
|
style: ribbon
|
||||||
mode: none
|
mode: none
|
||||||
pan_mode: pan
|
pan_mode: pan
|
||||||
clustering_threshold: 3
|
clustering_threshold: 3
|
||||||
events_media_type: all
|
|
||||||
show_recordings: true
|
show_recordings: true
|
||||||
window_seconds: 3600
|
window_seconds: 3600
|
||||||
format:
|
format:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# `media_gallery`
|
# `media_gallery`
|
||||||
|
|
||||||
The `media_gallery` is used for providing an overview of all `clips`,
|
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
|
```yaml
|
||||||
media_gallery:
|
media_gallery:
|
||||||
@@ -44,9 +45,10 @@ media_gallery:
|
|||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ----------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
|
||||||
| `show_details` | `false` | Whether to show media details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
|
| `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_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`. |
|
| `size` | `100` | The size of the thumbnails in the gallery. Must be >= `75` and <= `300`. |
|
||||||
|
|
||||||
## Fully expanded reference
|
## Fully expanded reference
|
||||||
@@ -61,9 +63,10 @@ media_gallery:
|
|||||||
thumbnails:
|
thumbnails:
|
||||||
size: 100
|
size: 100
|
||||||
show_details: false
|
show_details: false
|
||||||
show_download_control: true
|
show_download_control: false
|
||||||
show_favorite_control: true
|
show_favorite_control: true
|
||||||
show_timeline_control: true
|
show_review_control: true
|
||||||
|
show_timeline_control: false
|
||||||
actions:
|
actions:
|
||||||
entity: light.office_main_lights
|
entity: light.office_main_lights
|
||||||
tap_action:
|
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`). |
|
| `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_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_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`. |
|
| `size` | `100` | The size of the thumbnails in the thumbnail carousel pixels. Must be >= `75` and <= `300`. |
|
||||||
|
|
||||||
### `timeline`
|
### `timeline`
|
||||||
@@ -112,7 +114,6 @@ media_viewer:
|
|||||||
| Option | Default | Description |
|
| 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. |
|
| `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. |
|
| `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`). |
|
| `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). |
|
| `pan_mode` | `pan` | See [timeline pan mode](timeline-pan-mode.md). |
|
||||||
@@ -199,15 +200,16 @@ media_viewer:
|
|||||||
size: 100
|
size: 100
|
||||||
mode: none
|
mode: none
|
||||||
show_details: false
|
show_details: false
|
||||||
show_download_control: true
|
show_download_control: false
|
||||||
show_favorite_control: true
|
show_favorite_control: true
|
||||||
show_timeline_control: true
|
show_info_control: true
|
||||||
|
show_review_control: true
|
||||||
|
show_timeline_control: false
|
||||||
timeline:
|
timeline:
|
||||||
style: ribbon
|
style: ribbon
|
||||||
mode: none
|
mode: none
|
||||||
pan_mode: pan
|
pan_mode: pan
|
||||||
clustering_threshold: 3
|
clustering_threshold: 3
|
||||||
events_media_type: all
|
|
||||||
show_recordings: true
|
show_recordings: true
|
||||||
window_seconds: 3600
|
window_seconds: 3600
|
||||||
format:
|
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. |
|
| `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). |
|
| `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. |
|
| `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` . |
|
| `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. |
|
| `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. |
|
| `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
|
### Options for each button
|
||||||
|
|
||||||
| Option | Default | Description |
|
| 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`. |
|
| `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. |
|
| `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). |
|
| `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). |
|
| `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`. |
|
| `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`
|
### Additional options: `microphone`
|
||||||
|
|
||||||
@@ -132,6 +133,11 @@ menu:
|
|||||||
enabled: false
|
enabled: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:image
|
icon: mdi:image
|
||||||
|
info:
|
||||||
|
priority: 50
|
||||||
|
enabled: true
|
||||||
|
alignment: matching
|
||||||
|
icon: mdi:information-outline
|
||||||
iris:
|
iris:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -28,12 +28,13 @@ status_bar:
|
|||||||
|
|
||||||
### Available Items
|
### Available Items
|
||||||
|
|
||||||
| Button name | Description |
|
| Button name | Description |
|
||||||
| ------------ | ------------------------------------------------------ |
|
| ------------ | ------------------------------------------------------------------------------------------- |
|
||||||
| `engine` | The icon of the camera engine for the relevant camera. |
|
| `engine` | The icon of the camera engine for the relevant camera. |
|
||||||
| `resolution` | The detected media resolution (if any). |
|
| `resolution` | The detected media resolution (if any). |
|
||||||
| `technology` | The detected media technology (if any). |
|
| `severity` | The media severity indicator (if any) for review severity (e.g. Frigate alerts/detections). |
|
||||||
| `title` | The media title. |
|
| `technology` | The detected media technology (if any). |
|
||||||
|
| `title` | The media title. |
|
||||||
|
|
||||||
### Options for each item
|
### Options for each item
|
||||||
|
|
||||||
@@ -75,6 +76,9 @@ status_bar:
|
|||||||
resolution:
|
resolution:
|
||||||
enabled: true
|
enabled: true
|
||||||
priority: 50
|
priority: 50
|
||||||
|
severity:
|
||||||
|
enabled: true
|
||||||
|
priority: 50
|
||||||
technology:
|
technology:
|
||||||
enabled: true
|
enabled: true
|
||||||
priority: 50
|
priority: 50
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ timeline:
|
|||||||
|
|
||||||
You can interact with the timeline in a number of ways:
|
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 "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).
|
- 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. |
|
| `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. |
|
| `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. |
|
| `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). |
|
| `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`. |
|
| `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_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` | `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_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. |
|
| `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`. |
|
| `size` | `100` | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `300`. |
|
||||||
|
|
||||||
@@ -80,7 +80,6 @@ timeline:
|
|||||||
timeline:
|
timeline:
|
||||||
style: stack
|
style: stack
|
||||||
clustering_threshold: 3
|
clustering_threshold: 3
|
||||||
events_media_type: all
|
|
||||||
show_recordings: true
|
show_recordings: true
|
||||||
window_seconds: 3600
|
window_seconds: 3600
|
||||||
format:
|
format:
|
||||||
@@ -92,5 +91,6 @@ timeline:
|
|||||||
show_details: true
|
show_details: true
|
||||||
show_download_control: true
|
show_download_control: true
|
||||||
show_favorite_control: true
|
show_favorite_control: true
|
||||||
|
show_review_control: true
|
||||||
show_timeline_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). |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `snapshots` | Shows a gallery of snapshots for this camera. |
|
||||||
| `timeline` | Shows an event timeline. |
|
| `timeline` | Shows an event timeline. |
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
{
|
{
|
||||||
"pattern": "vscode://"
|
"pattern": "vscode://"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "https://www.npmjs.com/"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getMediaDownloadPath } from '../../ha/download';
|
|||||||
import { EntityRegistryManager } from '../../ha/registry/entity/types';
|
import { EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||||
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
|
import { QuerySource } from '../../query-source.js';
|
||||||
import { Endpoint } from '../../types';
|
import { Endpoint } from '../../types';
|
||||||
import { ViewMedia } from '../../view/item';
|
import { ViewMedia } from '../../view/item';
|
||||||
import { ViewItemCapabilities } from '../../view/types';
|
import { ViewItemCapabilities } from '../../view/types';
|
||||||
@@ -55,6 +56,7 @@ export class BrowseMediaCameraManagerEngine
|
|||||||
): EventQuery[] | null {
|
): EventQuery[] | null {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
source: QuerySource.Camera,
|
||||||
type: QueryType.Event,
|
type: QueryType.Event,
|
||||||
cameraIDs: cameraIDs,
|
cameraIDs: cameraIDs,
|
||||||
...query,
|
...query,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CameraManagerReadOnlyConfigStore } from './store';
|
|||||||
import {
|
import {
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
CameraQuery,
|
CameraQuery,
|
||||||
|
DefaultQueryParameters,
|
||||||
Engine,
|
Engine,
|
||||||
EngineOptions,
|
EngineOptions,
|
||||||
EventQuery,
|
EventQuery,
|
||||||
@@ -17,11 +18,15 @@ import {
|
|||||||
PartialEventQuery,
|
PartialEventQuery,
|
||||||
PartialRecordingQuery,
|
PartialRecordingQuery,
|
||||||
PartialRecordingSegmentsQuery,
|
PartialRecordingSegmentsQuery,
|
||||||
|
PartialReviewQuery,
|
||||||
QueryReturnType,
|
QueryReturnType,
|
||||||
|
QueryType,
|
||||||
RecordingQuery,
|
RecordingQuery,
|
||||||
RecordingQueryResultsMap,
|
RecordingQueryResultsMap,
|
||||||
RecordingSegmentsQuery,
|
RecordingSegmentsQuery,
|
||||||
RecordingSegmentsQueryResultsMap,
|
RecordingSegmentsQueryResultsMap,
|
||||||
|
ReviewQuery,
|
||||||
|
ReviewQueryResultsMap,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
|
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
|
||||||
@@ -31,6 +36,15 @@ export interface CameraManagerEngine {
|
|||||||
|
|
||||||
createCamera(hass: HomeAssistant, cameraConfig: CameraConfig): Promise<Camera>;
|
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(
|
generateDefaultEventQuery(
|
||||||
store: CameraManagerReadOnlyConfigStore,
|
store: CameraManagerReadOnlyConfigStore,
|
||||||
cameraIDs: Set<string>,
|
cameraIDs: Set<string>,
|
||||||
@@ -70,6 +84,19 @@ export interface CameraManagerEngine {
|
|||||||
engineOptions?: EngineOptions,
|
engineOptions?: EngineOptions,
|
||||||
): Promise<RecordingSegmentsQueryResultsMap | null>;
|
): 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(
|
generateMediaFromEvents(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
store: CameraManagerReadOnlyConfigStore,
|
store: CameraManagerReadOnlyConfigStore,
|
||||||
@@ -84,6 +111,13 @@ export interface CameraManagerEngine {
|
|||||||
results: QueryReturnType<RecordingQuery>,
|
results: QueryReturnType<RecordingQuery>,
|
||||||
): ViewMedia[] | null;
|
): ViewMedia[] | null;
|
||||||
|
|
||||||
|
generateMediaFromReviews(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
store: CameraManagerReadOnlyConfigStore,
|
||||||
|
query: ReviewQuery,
|
||||||
|
results: QueryReturnType<ReviewQuery>,
|
||||||
|
): ViewMedia[] | null;
|
||||||
|
|
||||||
getMediaDownloadPath(
|
getMediaDownloadPath(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
@@ -97,6 +131,13 @@ export interface CameraManagerEngine {
|
|||||||
favorite: boolean,
|
favorite: boolean,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
|
reviewMedia(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
media: ViewMedia,
|
||||||
|
reviewed: boolean,
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
getQueryResultMaxAge(query: CameraQuery): number | null;
|
getQueryResultMaxAge(query: CameraQuery): number | null;
|
||||||
|
|
||||||
getMediaSeekTime(
|
getMediaSeekTime(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Endpoint,
|
Endpoint,
|
||||||
PTZCapabilities,
|
PTZCapabilities,
|
||||||
PTZMovementType,
|
PTZMovementType,
|
||||||
|
SEVERITIES,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { errorToConsole } from '../../utils/basic';
|
import { errorToConsole } from '../../utils/basic';
|
||||||
import { Camera, CameraInitializationOptions } from '../camera';
|
import { Camera, CameraInitializationOptions } from '../camera';
|
||||||
@@ -22,18 +23,21 @@ import {
|
|||||||
getGo2RTCStreamEndpoint,
|
getGo2RTCStreamEndpoint,
|
||||||
} from '../utils/go2rtc/endpoint';
|
} from '../utils/go2rtc/endpoint';
|
||||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||||
import {
|
|
||||||
FrigateEventWatcherRequest,
|
|
||||||
FrigateEventWatcherSubscriptionInterface,
|
|
||||||
} from './event-watcher';
|
|
||||||
import { getPTZInfo } from './requests';
|
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;
|
const CAMERA_BIRDSEYE = 'birdseye' as const;
|
||||||
|
|
||||||
interface FrigateCameraInitializationOptions extends CameraInitializationOptions {
|
interface FrigateCameraInitializationOptions extends CameraInitializationOptions {
|
||||||
entityRegistryManager: EntityRegistryManager;
|
entityRegistryManager: EntityRegistryManager;
|
||||||
frigateEventWatcher: FrigateEventWatcherSubscriptionInterface;
|
frigateEventWatcher: FrigateWatcherSubscriptionInterface<FrigateEventChange>;
|
||||||
|
frigateReviewWatcher: FrigateWatcherSubscriptionInterface<FrigateReviewChange>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
|
export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
|
||||||
@@ -47,6 +51,7 @@ export class FrigateCamera extends Camera {
|
|||||||
|
|
||||||
if (this._capabilities?.has('trigger')) {
|
if (this._capabilities?.has('trigger')) {
|
||||||
await this._subscribeToEvents(options.hass, options.frigateEventWatcher);
|
await this._subscribeToEvents(options.hass, options.frigateEventWatcher);
|
||||||
|
await this._subscribeToReviews(options.hass, options.frigateReviewWatcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
@@ -183,6 +188,7 @@ export class FrigateCamera extends Camera {
|
|||||||
clips: !birdseye,
|
clips: !birdseye,
|
||||||
snapshots: !birdseye,
|
snapshots: !birdseye,
|
||||||
recordings: !birdseye,
|
recordings: !birdseye,
|
||||||
|
reviews: !birdseye,
|
||||||
...(combinedPTZ && { ptz: combinedPTZ }),
|
...(combinedPTZ && { ptz: combinedPTZ }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -415,7 +421,7 @@ export class FrigateCamera extends Camera {
|
|||||||
|
|
||||||
protected async _subscribeToEvents(
|
protected async _subscribeToEvents(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
frigateEventWatcher: FrigateEventWatcherSubscriptionInterface,
|
frigateEventWatcher: FrigateWatcherSubscriptionInterface<FrigateEventChange>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const config = this.getConfig();
|
const config = this.getConfig();
|
||||||
if (!config.triggers.events.length || !config.frigate.camera_name) {
|
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
|
/* istanbul ignore next -- exercising the matcher is not possible when the
|
||||||
test uses an event watcher -- @preserve */
|
test uses an event watcher -- @preserve */
|
||||||
const request: FrigateEventWatcherRequest = {
|
const request: FrigateWatcherRequest<FrigateEventChange> = {
|
||||||
instanceID: config.frigate.client_id,
|
instanceID: config.frigate.client_id,
|
||||||
callback: (event: FrigateEventChange) => this._frigateEventHandler(event),
|
callback: (event: FrigateEventChange) => this._frigateEventHandler(event),
|
||||||
matcher: (event: FrigateEventChange): boolean =>
|
matcher: (event: FrigateEventChange): boolean =>
|
||||||
@@ -480,4 +486,92 @@ export class FrigateCamera extends Camera {
|
|||||||
snapshot: snapshotChange && eventsToTriggerOn.includes('snapshots'),
|
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 { getEntityTitle } from '../../ha/get-entity-title';
|
||||||
import { EntityRegistryManager } from '../../ha/registry/entity/types';
|
import { EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
|
import { QuerySource, hasUnsupportedFilters } from '../../query-source.js';
|
||||||
import { Endpoint } from '../../types';
|
import { Endpoint } from '../../types';
|
||||||
import {
|
import {
|
||||||
allPromises,
|
allPromises,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
CameraManagerRequestCache,
|
CameraManagerRequestCache,
|
||||||
CameraQuery,
|
CameraQuery,
|
||||||
|
DefaultQueryParameters,
|
||||||
Engine,
|
Engine,
|
||||||
EngineOptions,
|
EngineOptions,
|
||||||
EventQuery,
|
EventQuery,
|
||||||
@@ -40,6 +42,7 @@ import {
|
|||||||
PartialEventQuery,
|
PartialEventQuery,
|
||||||
PartialRecordingQuery,
|
PartialRecordingQuery,
|
||||||
PartialRecordingSegmentsQuery,
|
PartialRecordingSegmentsQuery,
|
||||||
|
PartialReviewQuery,
|
||||||
QueryResults,
|
QueryResults,
|
||||||
QueryResultsType,
|
QueryResultsType,
|
||||||
QueryReturnType,
|
QueryReturnType,
|
||||||
@@ -50,30 +53,38 @@ import {
|
|||||||
RecordingSegment,
|
RecordingSegment,
|
||||||
RecordingSegmentsQuery,
|
RecordingSegmentsQuery,
|
||||||
RecordingSegmentsQueryResultsMap,
|
RecordingSegmentsQueryResultsMap,
|
||||||
|
ReviewQuery,
|
||||||
|
ReviewQueryResultsMap,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { FrigateCamera, isBirdseye } from './camera';
|
import { FrigateCamera, isBirdseye } from './camera';
|
||||||
import { FrigateEventWatcher } from './event-watcher';
|
|
||||||
import { FrigateViewMediaFactory } from './media';
|
import { FrigateViewMediaFactory } from './media';
|
||||||
import { FrigateViewMediaClassifier } from './media-classifier';
|
import { FrigateViewMediaClassifier } from './media-classifier';
|
||||||
import {
|
import {
|
||||||
NativeFrigateEventQuery,
|
NativeFrigateEventQuery,
|
||||||
NativeFrigateRecordingSegmentsQuery,
|
NativeFrigateRecordingSegmentsQuery,
|
||||||
|
NativeFrigateReviewQuery,
|
||||||
getEventSummary,
|
getEventSummary,
|
||||||
getEvents,
|
getEvents,
|
||||||
getRecordingSegments,
|
getRecordingSegments,
|
||||||
getRecordingsSummary,
|
getRecordingsSummary,
|
||||||
|
getReviews,
|
||||||
retainEvent,
|
retainEvent,
|
||||||
|
setReviewsReviewed,
|
||||||
} from './requests';
|
} from './requests';
|
||||||
import {
|
import {
|
||||||
|
FRIGATE_SEVERITY_MAP,
|
||||||
FrigateEventQueryResults,
|
FrigateEventQueryResults,
|
||||||
FrigateRecording,
|
FrigateRecording,
|
||||||
FrigateRecordingQueryResults,
|
FrigateRecordingQueryResults,
|
||||||
FrigateRecordingSegmentsQueryResults,
|
FrigateRecordingSegmentsQueryResults,
|
||||||
|
FrigateReviewQueryResults,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { FrigateEventWatcher, FrigateReviewWatcher } from './watcher';
|
||||||
|
|
||||||
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||||
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||||
const MEDIA_METADATA_REQUEST_CACHE_AGE_SECONDS = 60;
|
const MEDIA_METADATA_REQUEST_CACHE_AGE_SECONDS = 60;
|
||||||
|
const REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||||
|
|
||||||
class FrigateQueryResultsClassifier {
|
class FrigateQueryResultsClassifier {
|
||||||
public static isFrigateEventQueryResults(
|
public static isFrigateEventQueryResults(
|
||||||
@@ -98,6 +109,12 @@ class FrigateQueryResultsClassifier {
|
|||||||
results.type === QueryResultsType.RecordingSegments
|
results.type === QueryResultsType.RecordingSegments
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static isFrigateReviewQueryResults(
|
||||||
|
results: QueryResults,
|
||||||
|
): results is FrigateReviewQueryResults {
|
||||||
|
return results.engine === Engine.Frigate && results.type === QueryResultsType.Review;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FrigateCameraManagerEngine
|
export class FrigateCameraManagerEngine
|
||||||
@@ -106,6 +123,7 @@ export class FrigateCameraManagerEngine
|
|||||||
{
|
{
|
||||||
protected _entityRegistryManager: EntityRegistryManager;
|
protected _entityRegistryManager: EntityRegistryManager;
|
||||||
protected _frigateEventWatcher: FrigateEventWatcher;
|
protected _frigateEventWatcher: FrigateEventWatcher;
|
||||||
|
protected _frigateReviewWatcher: FrigateReviewWatcher;
|
||||||
protected _recordingSegmentsCache: RecordingSegmentsCache;
|
protected _recordingSegmentsCache: RecordingSegmentsCache;
|
||||||
protected _requestCache: CameraManagerRequestCache;
|
protected _requestCache: CameraManagerRequestCache;
|
||||||
|
|
||||||
@@ -126,6 +144,7 @@ export class FrigateCameraManagerEngine
|
|||||||
super(stateWatcher, eventCallback);
|
super(stateWatcher, eventCallback);
|
||||||
this._entityRegistryManager = entityRegistryManager;
|
this._entityRegistryManager = entityRegistryManager;
|
||||||
this._frigateEventWatcher = new FrigateEventWatcher();
|
this._frigateEventWatcher = new FrigateEventWatcher();
|
||||||
|
this._frigateReviewWatcher = new FrigateReviewWatcher();
|
||||||
this._recordingSegmentsCache = recordingSegmentsCache;
|
this._recordingSegmentsCache = recordingSegmentsCache;
|
||||||
this._requestCache = requestCache;
|
this._requestCache = requestCache;
|
||||||
}
|
}
|
||||||
@@ -146,9 +165,25 @@ export class FrigateCameraManagerEngine
|
|||||||
entityRegistryManager: this._entityRegistryManager,
|
entityRegistryManager: this._entityRegistryManager,
|
||||||
stateWatcher: this._stateWatcher,
|
stateWatcher: this._stateWatcher,
|
||||||
frigateEventWatcher: this._frigateEventWatcher,
|
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(
|
public async getMediaDownloadPath(
|
||||||
_hass: HomeAssistant,
|
_hass: HomeAssistant,
|
||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
@@ -182,51 +217,10 @@ export class FrigateCameraManagerEngine
|
|||||||
cameraIDs: Set<string>,
|
cameraIDs: Set<string>,
|
||||||
query?: PartialEventQuery,
|
query?: PartialEventQuery,
|
||||||
): EventQuery[] | null {
|
): EventQuery[] | null {
|
||||||
const relevantCameraConfigs = [...store.getCameraConfigs(cameraIDs)];
|
return this._generateBatchableQuery(store, cameraIDs, {
|
||||||
|
type: QueryType.Event,
|
||||||
// If all cameras specify exactly the same zones or labels (incl. none), we
|
...query,
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public generateDefaultRecordingQuery(
|
public generateDefaultRecordingQuery(
|
||||||
@@ -236,6 +230,7 @@ export class FrigateCameraManagerEngine
|
|||||||
): RecordingQuery[] {
|
): RecordingQuery[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
source: QuerySource.Camera,
|
||||||
type: QueryType.Recording,
|
type: QueryType.Recording,
|
||||||
cameraIDs: cameraIDs,
|
cameraIDs: cameraIDs,
|
||||||
...query,
|
...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(
|
public async favoriteMedia(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
@@ -276,6 +351,24 @@ export class FrigateCameraManagerEngine
|
|||||||
media.setFavorite(favorite);
|
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(
|
protected _buildInstanceToCameraIDMapFromQuery(
|
||||||
store: CameraManagerReadOnlyConfigStore,
|
store: CameraManagerReadOnlyConfigStore,
|
||||||
cameraIDs: Set<string>,
|
cameraIDs: Set<string>,
|
||||||
@@ -314,6 +407,17 @@ export class FrigateCameraManagerEngine
|
|||||||
query: EventQuery,
|
query: EventQuery,
|
||||||
engineOptions?: EngineOptions,
|
engineOptions?: EngineOptions,
|
||||||
): Promise<EventQueryResultsMap | null> {
|
): Promise<EventQueryResultsMap | null> {
|
||||||
|
if (
|
||||||
|
hasUnsupportedFilters(query, {
|
||||||
|
favorite: true,
|
||||||
|
tags: true,
|
||||||
|
what: true,
|
||||||
|
where: true,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const output: EventQueryResultsMap = new Map();
|
const output: EventQueryResultsMap = new Map();
|
||||||
|
|
||||||
const processInstanceQuery = async (
|
const processInstanceQuery = async (
|
||||||
@@ -374,12 +478,80 @@ export class FrigateCameraManagerEngine
|
|||||||
return output.size ? output : null;
|
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(
|
public async getRecordings(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
store: CameraManagerReadOnlyConfigStore,
|
store: CameraManagerReadOnlyConfigStore,
|
||||||
query: RecordingQuery,
|
query: RecordingQuery,
|
||||||
engineOptions?: EngineOptions,
|
engineOptions?: EngineOptions,
|
||||||
): Promise<RecordingQueryResultsMap | null> {
|
): Promise<RecordingQueryResultsMap | null> {
|
||||||
|
if (hasUnsupportedFilters(query)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const output: RecordingQueryResultsMap = new Map();
|
const output: RecordingQueryResultsMap = new Map();
|
||||||
|
|
||||||
const processQuery = async (
|
const processQuery = async (
|
||||||
@@ -641,11 +813,50 @@ export class FrigateCameraManagerEngine
|
|||||||
return output;
|
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 {
|
public getQueryResultMaxAge(query: CameraQuery): number | null {
|
||||||
if (query.type === QueryType.Event) {
|
if (query.type === QueryType.Event) {
|
||||||
return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
|
return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
|
||||||
} else if (query.type === QueryType.Recording) {
|
} else if (query.type === QueryType.Recording) {
|
||||||
return RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS;
|
return RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS;
|
||||||
|
} else if (query.type === QueryType.Review) {
|
||||||
|
return REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -657,11 +868,22 @@ export class FrigateCameraManagerEngine
|
|||||||
target: Date,
|
target: Date,
|
||||||
engineOptions?: EngineOptions,
|
engineOptions?: EngineOptions,
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
const start = media.getStartTime();
|
const mediaStart = media.getStartTime();
|
||||||
const end = media.getEndTime();
|
const mediaEnd = media.getEndTime();
|
||||||
const cameraID = media.getCameraID();
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,6 +984,7 @@ export class FrigateCameraManagerEngine
|
|||||||
hass,
|
hass,
|
||||||
store,
|
store,
|
||||||
{
|
{
|
||||||
|
source: QuerySource.Camera,
|
||||||
type: QueryType.Recording,
|
type: QueryType.Recording,
|
||||||
cameraIDs: cameraIDs,
|
cameraIDs: cameraIDs,
|
||||||
},
|
},
|
||||||
@@ -822,6 +1045,7 @@ export class FrigateCameraManagerEngine
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const cameraIDs = this._recordingSegmentsCache.getCameraIDs();
|
const cameraIDs = this._recordingSegmentsCache.getCameraIDs();
|
||||||
const recordingQuery: RecordingQuery = {
|
const recordingQuery: RecordingQuery = {
|
||||||
|
source: QuerySource.Camera,
|
||||||
cameraIDs: new Set(cameraIDs),
|
cameraIDs: new Set(cameraIDs),
|
||||||
type: QueryType.Recording,
|
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 { ViewMedia } from '../../view/item';
|
||||||
import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media';
|
import {
|
||||||
|
FrigateEventViewMedia,
|
||||||
|
FrigateRecordingViewMedia,
|
||||||
|
FrigateReviewViewMedia,
|
||||||
|
} from './media';
|
||||||
|
|
||||||
export class FrigateViewMediaClassifier {
|
export class FrigateViewMediaClassifier {
|
||||||
public static isFrigateMedia(
|
public static isFrigateMedia(
|
||||||
media: ViewMedia,
|
media: ViewMedia,
|
||||||
): media is FrigateEventViewMedia | FrigateRecordingViewMedia {
|
): media is
|
||||||
return this.isFrigateEvent(media) || this.isFrigateRecording(media);
|
| FrigateEventViewMedia
|
||||||
|
| FrigateRecordingViewMedia
|
||||||
|
| FrigateReviewViewMedia {
|
||||||
|
return (
|
||||||
|
this.isFrigateEvent(media) ||
|
||||||
|
this.isFrigateRecording(media) ||
|
||||||
|
this.isFrigateReview(media)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia {
|
public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia {
|
||||||
return media instanceof FrigateEventViewMedia;
|
return media instanceof FrigateEventViewMedia;
|
||||||
@@ -15,4 +26,7 @@ export class FrigateViewMediaClassifier {
|
|||||||
): media is FrigateRecordingViewMedia {
|
): media is FrigateRecordingViewMedia {
|
||||||
return media instanceof 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 { fromUnixTime } from 'date-fns';
|
||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
import { CameraConfig } from '../../config/schema/cameras';
|
import { CameraConfig } from '../../config/schema/cameras';
|
||||||
|
import { Severity } from '../../types';
|
||||||
import {
|
import {
|
||||||
EventViewMedia,
|
EventViewMedia,
|
||||||
RecordingViewMedia,
|
RecordingViewMedia,
|
||||||
|
ReviewViewMedia,
|
||||||
VideoContentType,
|
VideoContentType,
|
||||||
ViewMedia,
|
ViewMedia,
|
||||||
ViewMediaType,
|
ViewMediaType,
|
||||||
} from '../../view/item';
|
} from '../../view/item';
|
||||||
import { FrigateEvent, FrigateRecording } from './types';
|
import { FrigateEvent, FrigateRecording, FrigateReview } from './types';
|
||||||
import {
|
import {
|
||||||
getEventMediaContentID,
|
getEventMediaContentID,
|
||||||
getEventThumbnailURL,
|
getEventThumbnailURL,
|
||||||
@@ -16,6 +18,10 @@ import {
|
|||||||
getRecordingID,
|
getRecordingID,
|
||||||
getRecordingMediaContentID,
|
getRecordingMediaContentID,
|
||||||
getRecordingTitle,
|
getRecordingTitle,
|
||||||
|
getReviewMediaContentID,
|
||||||
|
getReviewSeverity,
|
||||||
|
getReviewThumbnailURL,
|
||||||
|
getReviewTitle,
|
||||||
} from './util';
|
} from './util';
|
||||||
|
|
||||||
export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
|
export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
|
||||||
@@ -66,6 +72,9 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
|
|||||||
public getTitle(): string | null {
|
public getTitle(): string | null {
|
||||||
return getEventTitle(this._event);
|
return getEventTitle(this._event);
|
||||||
}
|
}
|
||||||
|
public getDescription(): string | null {
|
||||||
|
return this._event.data?.description ?? null;
|
||||||
|
}
|
||||||
public getThumbnail(): string | null {
|
public getThumbnail(): string | null {
|
||||||
return this._thumbnail;
|
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 {
|
export class FrigateViewMediaFactory {
|
||||||
static createEventViewMedia(
|
static createEventViewMedia(
|
||||||
mediaType: ViewMediaType,
|
mediaType: ViewMediaType,
|
||||||
@@ -201,4 +278,25 @@ export class FrigateViewMediaFactory {
|
|||||||
getRecordingTitle(cameraTitle, recording),
|
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,
|
eventSummarySchema,
|
||||||
FrigateEvent,
|
FrigateEvent,
|
||||||
frigateEventsSchema,
|
frigateEventsSchema,
|
||||||
|
FrigateReview,
|
||||||
|
frigateReviewsSchema,
|
||||||
PTZInfo,
|
PTZInfo,
|
||||||
ptzInfoSchema,
|
ptzInfoSchema,
|
||||||
recordingSegmentsSchema,
|
recordingSegmentsSchema,
|
||||||
@@ -15,6 +17,8 @@ import {
|
|||||||
recordingSummarySchema,
|
recordingSummarySchema,
|
||||||
RetainResult,
|
RetainResult,
|
||||||
retainResultSchema,
|
retainResultSchema,
|
||||||
|
ReviewResult,
|
||||||
|
reviewResultSchema,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,3 +180,65 @@ export const getPTZInfo = async (
|
|||||||
true,
|
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,
|
EventQueryResults,
|
||||||
RecordingQueryResults,
|
RecordingQueryResults,
|
||||||
RecordingSegmentsQueryResults,
|
RecordingSegmentsQueryResults,
|
||||||
|
ReviewQueryResults,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const dayStringToDate = (arg: unknown): Date | unknown => {
|
const dayStringToDate = (arg: unknown): Date | unknown => {
|
||||||
@@ -24,6 +25,12 @@ export const eventSchema = z.object({
|
|||||||
top_score: z.number().nullable(),
|
top_score: z.number().nullable(),
|
||||||
zones: z.string().array(),
|
zones: z.string().array(),
|
||||||
retain_indefinitely: z.boolean().optional(),
|
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();
|
export const frigateEventsSchema = eventSchema.array();
|
||||||
|
|
||||||
@@ -57,6 +64,12 @@ export const retainResultSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type RetainResult = z.infer<typeof retainResultSchema>;
|
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 {
|
export interface FrigateRecording {
|
||||||
cameraID: string;
|
cameraID: string;
|
||||||
startTime: Date;
|
startTime: Date;
|
||||||
@@ -124,3 +137,60 @@ export interface FrigateRecordingSegmentsQueryResults
|
|||||||
engine: Engine.Frigate;
|
engine: Engine.Frigate;
|
||||||
instanceID: string;
|
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 { toZonedTime } from 'date-fns-tz';
|
||||||
import { CameraConfig } from '../../config/schema/cameras';
|
import { CameraConfig } from '../../config/schema/cameras';
|
||||||
import { ClipsOrSnapshots } from '../../types';
|
import { Severity } from '../../types';
|
||||||
import { formatDateAndTime, prettifyTitle } from '../../utils/basic';
|
import { formatDateAndTime, prettifyTitle } from '../../utils/basic';
|
||||||
import { FrigateEvent, FrigateRecording } from './types';
|
import {
|
||||||
|
FrigateEvent,
|
||||||
|
FrigateRecording,
|
||||||
|
FrigateReview,
|
||||||
|
FrigateReviewSeverity,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given an event generate a title.
|
* Given an event generate a title.
|
||||||
@@ -51,36 +56,36 @@ export const getEventMediaContentID = (
|
|||||||
clientId: string,
|
clientId: string,
|
||||||
cameraName: string,
|
cameraName: string,
|
||||||
event: FrigateEvent,
|
event: FrigateEvent,
|
||||||
mediaType: ClipsOrSnapshots,
|
mediaType: 'clips' | 'snapshots',
|
||||||
): string => {
|
): string => {
|
||||||
return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`;
|
return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a recording identifier.
|
* Build a recording media content ID from a start time.
|
||||||
* @param clientId The Frigate client id.
|
*/
|
||||||
* @param cameraName The Frigate camera name.
|
const buildRecordingMediaContentID = (
|
||||||
* @param recording The Frigate recording.
|
clientId: string,
|
||||||
* @returns A recording identifier.
|
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 = (
|
export const getRecordingMediaContentID = (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
cameraName: string,
|
cameraName: string,
|
||||||
recording: FrigateRecording,
|
recording: FrigateRecording,
|
||||||
): string => {
|
): string => buildRecordingMediaContentID(clientId, cameraName, recording.startTime);
|
||||||
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('/');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a recording ID for internal de-duping.
|
* Get a recording ID for internal de-duping.
|
||||||
@@ -96,3 +101,65 @@ export const getRecordingID = (
|
|||||||
cameraConfig.frigate.camera_name ?? ''
|
cameraConfig.frigate.camera_name ?? ''
|
||||||
}/${recording.startTime.getTime()}/${recording.endTime.getTime()}`;
|
}/${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,
|
CameraEventCallback,
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
CameraQuery,
|
CameraQuery,
|
||||||
|
DefaultQueryParameters,
|
||||||
Engine,
|
Engine,
|
||||||
EngineOptions,
|
EngineOptions,
|
||||||
EventQuery,
|
EventQuery,
|
||||||
@@ -23,11 +24,15 @@ import {
|
|||||||
PartialEventQuery,
|
PartialEventQuery,
|
||||||
PartialRecordingQuery,
|
PartialRecordingQuery,
|
||||||
PartialRecordingSegmentsQuery,
|
PartialRecordingSegmentsQuery,
|
||||||
|
PartialReviewQuery,
|
||||||
QueryReturnType,
|
QueryReturnType,
|
||||||
|
QueryType,
|
||||||
RecordingQuery,
|
RecordingQuery,
|
||||||
RecordingQueryResultsMap,
|
RecordingQueryResultsMap,
|
||||||
RecordingSegmentsQuery,
|
RecordingSegmentsQuery,
|
||||||
RecordingSegmentsQueryResultsMap,
|
RecordingSegmentsQueryResultsMap,
|
||||||
|
ReviewQuery,
|
||||||
|
ReviewQueryResultsMap,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
|
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
|
||||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||||
@@ -67,6 +72,13 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getDefaultQueryParameters(
|
||||||
|
_camera: Camera,
|
||||||
|
_queryType: QueryType,
|
||||||
|
): DefaultQueryParameters {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public generateDefaultEventQuery(
|
public generateDefaultEventQuery(
|
||||||
_store: CameraManagerReadOnlyConfigStore,
|
_store: CameraManagerReadOnlyConfigStore,
|
||||||
_cameraIDs: Set<string>,
|
_cameraIDs: Set<string>,
|
||||||
@@ -118,6 +130,23 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
|||||||
return null;
|
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(
|
public generateMediaFromEvents(
|
||||||
_hass: HomeAssistant,
|
_hass: HomeAssistant,
|
||||||
_store: CameraManagerReadOnlyConfigStore,
|
_store: CameraManagerReadOnlyConfigStore,
|
||||||
@@ -136,6 +165,15 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public generateMediaFromReviews(
|
||||||
|
_hass: HomeAssistant,
|
||||||
|
_store: CameraManagerReadOnlyConfigStore,
|
||||||
|
_query: ReviewQuery,
|
||||||
|
_results: QueryReturnType<ReviewQuery>,
|
||||||
|
): ViewMedia[] | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public async getMediaDownloadPath(
|
public async getMediaDownloadPath(
|
||||||
_hass: HomeAssistant,
|
_hass: HomeAssistant,
|
||||||
_cameraConfig: CameraConfig,
|
_cameraConfig: CameraConfig,
|
||||||
@@ -153,6 +191,15 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async reviewMedia(
|
||||||
|
_hass: HomeAssistant,
|
||||||
|
_cameraConfig: CameraConfig,
|
||||||
|
_media: ViewMedia,
|
||||||
|
_reviewed: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
public getQueryResultMaxAge(_query: CameraQuery): number | null {
|
public getQueryResultMaxAge(_query: CameraQuery): number | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
CameraEndpointsContext,
|
CameraEndpointsContext,
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
CameraQuery,
|
CameraQuery,
|
||||||
|
DefaultQueryParameters,
|
||||||
Engine,
|
Engine,
|
||||||
EngineOptions,
|
EngineOptions,
|
||||||
EventQuery,
|
EventQuery,
|
||||||
@@ -49,6 +50,7 @@ import {
|
|||||||
PartialQueryConcreteType,
|
PartialQueryConcreteType,
|
||||||
PartialRecordingQuery,
|
PartialRecordingQuery,
|
||||||
PartialRecordingSegmentsQuery,
|
PartialRecordingSegmentsQuery,
|
||||||
|
PartialReviewQuery,
|
||||||
QueryResults,
|
QueryResults,
|
||||||
QueryResultsType,
|
QueryResultsType,
|
||||||
QueryReturnType,
|
QueryReturnType,
|
||||||
@@ -60,6 +62,8 @@ import {
|
|||||||
RecordingSegmentsQueryResults,
|
RecordingSegmentsQueryResults,
|
||||||
RecordingSegmentsQueryResultsMap,
|
RecordingSegmentsQueryResultsMap,
|
||||||
ResultsMap,
|
ResultsMap,
|
||||||
|
ReviewQuery,
|
||||||
|
ReviewQueryResults,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
export class CameraQueryClassifier {
|
export class CameraQueryClassifier {
|
||||||
@@ -83,6 +87,11 @@ export class CameraQueryClassifier {
|
|||||||
): query is MediaMetadataQuery {
|
): query is MediaMetadataQuery {
|
||||||
return query.type === QueryType.MediaMetadata;
|
return query.type === QueryType.MediaMetadata;
|
||||||
}
|
}
|
||||||
|
public static isReviewQuery(
|
||||||
|
query: CameraQuery | PartialCameraQuery,
|
||||||
|
): query is ReviewQuery {
|
||||||
|
return query.type === QueryType.Review;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class QueryResultClassifier {
|
export class QueryResultClassifier {
|
||||||
@@ -106,9 +115,14 @@ export class QueryResultClassifier {
|
|||||||
): queryResults is MediaMetadataQueryResults {
|
): queryResults is MediaMetadataQueryResults {
|
||||||
return queryResults?.type === QueryResultsType.MediaMetadata;
|
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[];
|
queries: T[];
|
||||||
results: ViewItem[];
|
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(
|
public generateDefaultRecordingQueries(
|
||||||
cameraIDs: string | Set<string>,
|
cameraIDs: string | Set<string>,
|
||||||
partialQuery?: PartialRecordingQuery,
|
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>(
|
protected _generateDefaultQueries<PQT extends PartialCameraQuery>(
|
||||||
cameraIDs: string | Set<string>,
|
cameraIDs: string | Set<string>,
|
||||||
partialQuery: PQT,
|
partialQuery: PQT,
|
||||||
@@ -356,6 +391,12 @@ export class CameraManager {
|
|||||||
cameraIDs,
|
cameraIDs,
|
||||||
partialQuery,
|
partialQuery,
|
||||||
);
|
);
|
||||||
|
} else if (CameraQueryClassifier.isReviewQuery(partialQuery)) {
|
||||||
|
queries = engine.generateDefaultReviewQuery(
|
||||||
|
this._store,
|
||||||
|
cameraIDs,
|
||||||
|
partialQuery,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const query of queries ?? []) {
|
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>(
|
public areMediaQueriesResultsFresh<T extends MediaQuery>(
|
||||||
resultsTimestamp: Date,
|
resultsTimestamp: Date,
|
||||||
queries: T[] | null,
|
queries: T[] | null,
|
||||||
@@ -667,6 +735,13 @@ export class CameraManager {
|
|||||||
query,
|
query,
|
||||||
engineOptions,
|
engineOptions,
|
||||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
)) 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));
|
engineResult?.forEach((value, key) => results.set(key, value));
|
||||||
@@ -739,6 +814,11 @@ export class CameraManager {
|
|||||||
QueryResultClassifier.isRecordingQueryResult(result)
|
QueryResultClassifier.isRecordingQueryResult(result)
|
||||||
) {
|
) {
|
||||||
media = engine.generateMediaFromRecordings(hass, this._store, query, 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) {
|
if (media) {
|
||||||
mediaArray.push(...media);
|
mediaArray.push(...media);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { BrowseMediaStep, BrowseMediaTarget } from '../../ha/browse-media/walker';
|
import { BrowseMediaStep, BrowseMediaTarget } from '../../ha/browse-media/walker';
|
||||||
import { isMediaWithinDates } from '../../ha/browse-media/within-dates';
|
import { isMediaWithinDates } from '../../ha/browse-media/within-dates';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
|
import { hasUnsupportedFilters } from '../../query-source.js';
|
||||||
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||||
import { ViewMedia } from '../../view/item';
|
import { ViewMedia } from '../../view/item';
|
||||||
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
||||||
@@ -224,8 +225,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
|||||||
query: EventQuery,
|
query: EventQuery,
|
||||||
engineOptions?: EngineOptions,
|
engineOptions?: EngineOptions,
|
||||||
): Promise<EventQueryResultsMap | null> {
|
): Promise<EventQueryResultsMap | null> {
|
||||||
// MotionEye does not support these query types and they will never match.
|
if (hasUnsupportedFilters(query)) {
|
||||||
if (query.favorite || query.tags?.size || query.what?.size || query.where?.size) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { DeviceRegistryManager } from '../../ha/registry/device';
|
|||||||
import { EntityRegistryManager } from '../../ha/registry/entity/types';
|
import { EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||||
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
|
import { hasUnsupportedFilters } from '../../query-source.js';
|
||||||
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||||
import { ViewMedia } from '../../view/item';
|
import { ViewMedia } from '../../view/item';
|
||||||
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
||||||
@@ -255,14 +256,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
|||||||
query: EventQuery,
|
query: EventQuery,
|
||||||
engineOptions?: EngineOptions,
|
engineOptions?: EngineOptions,
|
||||||
): Promise<EventQueryResultsMap | null> {
|
): Promise<EventQueryResultsMap | null> {
|
||||||
// Reolink does not support these query types and they will never match.
|
if (hasUnsupportedFilters(query) || query.hasSnapshot) {
|
||||||
if (
|
|
||||||
query.favorite ||
|
|
||||||
query.tags?.size ||
|
|
||||||
query.what?.size ||
|
|
||||||
query.where?.size ||
|
|
||||||
query.hasSnapshot
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-16
@@ -1,7 +1,8 @@
|
|||||||
import { ExpiringEqualityCache } from '../cache/expiring-cache';
|
import { ExpiringEqualityCache } from '../cache/expiring-cache';
|
||||||
import { SSLCiphers } from '../config/schema/cameras';
|
import { SSLCiphers } from '../config/schema/cameras';
|
||||||
import { AdvancedCameraCardView } from '../config/schema/common/const';
|
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';
|
import { ViewMedia } from '../view/item';
|
||||||
|
|
||||||
// ====
|
// ====
|
||||||
@@ -13,6 +14,7 @@ export enum QueryType {
|
|||||||
Recording = 'recording-query',
|
Recording = 'recording-query',
|
||||||
RecordingSegments = 'recording-segments-query',
|
RecordingSegments = 'recording-segments-query',
|
||||||
MediaMetadata = 'media-metadata',
|
MediaMetadata = 'media-metadata',
|
||||||
|
Review = 'review-query',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QueryResultsType {
|
export enum QueryResultsType {
|
||||||
@@ -20,6 +22,7 @@ export enum QueryResultsType {
|
|||||||
Recording = 'recording-results',
|
Recording = 'recording-results',
|
||||||
RecordingSegments = 'recording-segments-results',
|
RecordingSegments = 'recording-segments-results',
|
||||||
MediaMetadata = 'media-metadata-results',
|
MediaMetadata = 'media-metadata-results',
|
||||||
|
Review = 'review-results',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum Engine {
|
export enum Engine {
|
||||||
@@ -46,10 +49,12 @@ interface LimitedDataQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaQuery
|
export interface MediaQuery
|
||||||
extends CameraQuery,
|
extends BaseQuery,
|
||||||
|
CameraQuery,
|
||||||
|
QueryFilters,
|
||||||
Partial<TimeBasedDataQuery>,
|
Partial<TimeBasedDataQuery>,
|
||||||
Partial<LimitedDataQuery> {
|
Partial<LimitedDataQuery> {
|
||||||
favorite?: boolean;
|
source: QuerySource.Camera;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueryResults {
|
export interface QueryResults {
|
||||||
@@ -74,14 +79,18 @@ export type QueryReturnType<QT> = QT extends EventQuery
|
|||||||
? RecordingSegmentsQueryResults
|
? RecordingSegmentsQueryResults
|
||||||
: QT extends MediaMetadataQuery
|
: QT extends MediaMetadataQuery
|
||||||
? MediaMetadataQueryResults
|
? MediaMetadataQueryResults
|
||||||
: never;
|
: QT extends ReviewQuery
|
||||||
|
? ReviewQueryResults
|
||||||
|
: never;
|
||||||
export type PartialQueryConcreteType<PQT> = PQT extends PartialEventQuery
|
export type PartialQueryConcreteType<PQT> = PQT extends PartialEventQuery
|
||||||
? EventQuery
|
? EventQuery
|
||||||
: PQT extends PartialRecordingQuery
|
: PQT extends PartialRecordingQuery
|
||||||
? RecordingQuery
|
? RecordingQuery
|
||||||
: PQT extends PartialRecordingSegmentsQuery
|
: PQT extends PartialRecordingSegmentsQuery
|
||||||
? RecordingSegmentsQuery
|
? RecordingSegmentsQuery
|
||||||
: never;
|
: PQT extends PartialReviewQuery
|
||||||
|
? ReviewQuery
|
||||||
|
: never;
|
||||||
|
|
||||||
export type ResultsMap<QT> = Map<QT, QueryReturnType<QT>>;
|
export type ResultsMap<QT> = Map<QT, QueryReturnType<QT>>;
|
||||||
export type EventQueryResultsMap = ResultsMap<EventQuery>;
|
export type EventQueryResultsMap = ResultsMap<EventQuery>;
|
||||||
@@ -140,16 +149,21 @@ export interface EngineOptions {
|
|||||||
export interface CameraEvent {
|
export interface CameraEvent {
|
||||||
cameraID: string;
|
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
|
// 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
|
// what new media is available. Otherwise all media types are assumed to be
|
||||||
// possibly newly available.
|
// possibly newly available.
|
||||||
fidelity?: 'high' | 'low';
|
fidelity?: 'high' | 'low';
|
||||||
|
|
||||||
// Whether a new clip/snapshot/recording may be available.
|
// Whether new media may be available.
|
||||||
clip?: boolean;
|
clip?: boolean;
|
||||||
snapshot?: boolean;
|
snapshot?: boolean;
|
||||||
|
review?: boolean;
|
||||||
}
|
}
|
||||||
export type CameraEventCallback = (ev: CameraEvent) => void;
|
export type CameraEventCallback = (ev: CameraEvent) => void;
|
||||||
|
|
||||||
@@ -158,6 +172,21 @@ export class CameraManagerRequestCache extends ExpiringEqualityCache<
|
|||||||
QueryResults
|
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
|
// Event Query
|
||||||
// ===========
|
// ===========
|
||||||
@@ -170,15 +199,6 @@ export interface EventQuery extends MediaQuery {
|
|||||||
|
|
||||||
// Frigate equivalent: has_clip
|
// Frigate equivalent: has_clip
|
||||||
hasClip?: boolean;
|
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>;
|
export type PartialEventQuery = Partial<EventQuery>;
|
||||||
|
|
||||||
@@ -225,3 +245,20 @@ export interface MediaMetadataQueryResults extends QueryResults {
|
|||||||
type: QueryResultsType.MediaMetadata;
|
type: QueryResultsType.MediaMetadata;
|
||||||
metadata: 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;
|
let specificActions: Actions | undefined = undefined;
|
||||||
if (view?.is('live')) {
|
if (view?.is('live')) {
|
||||||
specificActions = config?.live.actions;
|
specificActions = config?.live.actions;
|
||||||
} else if (view?.isMediaGalleryView()) {
|
} else if (view?.isGalleryView()) {
|
||||||
specificActions = config?.media_gallery?.actions;
|
specificActions = config?.media_gallery?.actions;
|
||||||
} else if (view?.isViewerView()) {
|
} else if (view?.isViewerView()) {
|
||||||
specificActions = config?.media_viewer.actions;
|
specificActions = config?.media_viewer.actions;
|
||||||
|
|||||||
@@ -21,26 +21,26 @@ export class BaseAction<T extends ActionConfig> implements Action {
|
|||||||
|
|
||||||
protected _shouldSeekConfirmation(api: CardActionsAPI): boolean {
|
protected _shouldSeekConfirmation(api: CardActionsAPI): boolean {
|
||||||
const hass = api.getHASSManager().getHASS();
|
const hass = api.getHASSManager().getHASS();
|
||||||
|
const action: ActionConfig = this._action;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(typeof this._action.confirmation === 'boolean' && this._action.confirmation) ||
|
(typeof action.confirmation === 'boolean' && action.confirmation) ||
|
||||||
(typeof this._action.confirmation === 'object' &&
|
(typeof action.confirmation === 'object' &&
|
||||||
(!this._action.confirmation.exemptions ||
|
(!action.confirmation.exemptions ||
|
||||||
!this._action.confirmation.exemptions.some(
|
!action.confirmation.exemptions.some((entry) => entry.user === hass?.user.id)))
|
||||||
(entry) => entry.user === hass?.user.id,
|
|
||||||
)))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execute(api: CardActionsAPI): Promise<void> {
|
public async execute(api: CardActionsAPI): Promise<void> {
|
||||||
if (this._shouldSeekConfirmation(api)) {
|
if (this._shouldSeekConfirmation(api)) {
|
||||||
const actionName = isAdvancedCameraCardCustomAction(this._action)
|
const action: ActionConfig = this._action;
|
||||||
? this._action.advanced_camera_card_action
|
const baseAction = action.action;
|
||||||
: this._action.action;
|
const actionName = isAdvancedCameraCardCustomAction(action)
|
||||||
|
? action.advanced_camera_card_action
|
||||||
|
: baseAction;
|
||||||
const text =
|
const text =
|
||||||
(typeof this._action.confirmation === 'object'
|
(typeof action.confirmation === 'object' ? action.confirmation.text : null) ??
|
||||||
? this._action.confirmation.text
|
`${localize('actions.confirmation')}: ${actionName}`;
|
||||||
: null) ?? `${localize('actions.confirmation')}: ${actionName}`;
|
|
||||||
if (!confirm(text)) {
|
if (!confirm(text)) {
|
||||||
throw new ActionAbortError(localize('actions.abort'));
|
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 { DefaultAction } from './actions/default';
|
||||||
import { DisplayModeSelectAction } from './actions/display-mode-select';
|
import { DisplayModeSelectAction } from './actions/display-mode-select';
|
||||||
import { DownloadAction } from './actions/download';
|
import { DownloadAction } from './actions/download';
|
||||||
import { ExpandAction } from './actions/expand';
|
|
||||||
import { EffectAction } from './actions/effect';
|
import { EffectAction } from './actions/effect';
|
||||||
|
import { ExpandAction } from './actions/expand';
|
||||||
import { FullscreenAction } from './actions/fullscreen';
|
import { FullscreenAction } from './actions/fullscreen';
|
||||||
|
import { InfoAction } from './actions/info';
|
||||||
import { InternalCallbackAction } from './actions/internal-callback';
|
import { InternalCallbackAction } from './actions/internal-callback';
|
||||||
import { LogAction } from './actions/log';
|
import { LogAction } from './actions/log';
|
||||||
import { MediaPlayerAction } from './actions/media-player';
|
import { MediaPlayerAction } from './actions/media-player';
|
||||||
@@ -33,6 +34,7 @@ import { PTZDigitalAction } from './actions/ptz-digital';
|
|||||||
import { PTZMultiAction } from './actions/ptz-multi';
|
import { PTZMultiAction } from './actions/ptz-multi';
|
||||||
import { ReloadAction } from './actions/reload';
|
import { ReloadAction } from './actions/reload';
|
||||||
import { ScreenshotAction } from './actions/screenshot';
|
import { ScreenshotAction } from './actions/screenshot';
|
||||||
|
import { SetReviewAction } from './actions/set-review';
|
||||||
import { SleepAction } from './actions/sleep';
|
import { SleepAction } from './actions/sleep';
|
||||||
import { StatusBarAction } from './actions/status-bar';
|
import { StatusBarAction } from './actions/status-bar';
|
||||||
import { SubstreamOffAction } from './actions/substream-off';
|
import { SubstreamOffAction } from './actions/substream-off';
|
||||||
@@ -93,6 +95,8 @@ export class ActionFactory {
|
|||||||
case 'live':
|
case 'live':
|
||||||
case 'recording':
|
case 'recording':
|
||||||
case 'recordings':
|
case 'recordings':
|
||||||
|
case 'review':
|
||||||
|
case 'reviews':
|
||||||
case 'snapshot':
|
case 'snapshot':
|
||||||
case 'snapshots':
|
case 'snapshots':
|
||||||
case 'timeline':
|
case 'timeline':
|
||||||
@@ -110,6 +114,8 @@ export class ActionFactory {
|
|||||||
return new ExpandAction(context, action, options?.config);
|
return new ExpandAction(context, action, options?.config);
|
||||||
case 'fullscreen':
|
case 'fullscreen':
|
||||||
return new FullscreenAction(context, action, options?.config);
|
return new FullscreenAction(context, action, options?.config);
|
||||||
|
case 'info':
|
||||||
|
return new InfoAction(context, action, options?.config);
|
||||||
case 'menu_toggle':
|
case 'menu_toggle':
|
||||||
return new MenuToggleAction(context, action, options?.config);
|
return new MenuToggleAction(context, action, options?.config);
|
||||||
case 'camera_select':
|
case 'camera_select':
|
||||||
@@ -156,6 +162,8 @@ export class ActionFactory {
|
|||||||
return new StatusBarAction(context, action, options?.config);
|
return new StatusBarAction(context, action, options?.config);
|
||||||
case 'reload':
|
case 'reload':
|
||||||
return new ReloadAction(context, action, options?.config);
|
return new ReloadAction(context, action, options?.config);
|
||||||
|
case 'set_review':
|
||||||
|
return new SetReviewAction(context, action, options?.config);
|
||||||
case INTERNAL_CALLBACK_ACTION:
|
case INTERNAL_CALLBACK_ACTION:
|
||||||
return new InternalCallbackAction(context, action, options?.config);
|
return new InternalCallbackAction(context, action, options?.config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { ActionEventTarget } from '../action-handler-directive';
|
|||||||
import { isCardInPanel } from '../ha/panel';
|
import { isCardInPanel } from '../ha/panel';
|
||||||
import { setOrRemoveAttribute } from '../utils/basic';
|
import { setOrRemoveAttribute } from '../utils/basic';
|
||||||
import { isBeingCasted } from '../utils/casting';
|
import { isBeingCasted } from '../utils/casting';
|
||||||
|
import { CardMediaReviewEventTarget } from '../utils/review';
|
||||||
|
import { ViewItem } from '../view/item';
|
||||||
import { ActionExecutionRequestEventTarget } from './actions/utils/execution-request';
|
import { ActionExecutionRequestEventTarget } from './actions/utils/execution-request';
|
||||||
import { InitializationAspect } from './initialization-manager';
|
import { InitializationAspect } from './initialization-manager';
|
||||||
import { CardElementAPI } from './types';
|
import { CardElementAPI } from './types';
|
||||||
@@ -13,7 +15,8 @@ export type MenuToggleCallback = () => void;
|
|||||||
export type CardHTMLElement = LitElement &
|
export type CardHTMLElement = LitElement &
|
||||||
ReactiveControllerHost &
|
ReactiveControllerHost &
|
||||||
ActionEventTarget &
|
ActionEventTarget &
|
||||||
ActionExecutionRequestEventTarget;
|
ActionExecutionRequestEventTarget &
|
||||||
|
CardMediaReviewEventTarget;
|
||||||
|
|
||||||
export class CardElementManager {
|
export class CardElementManager {
|
||||||
protected _api: CardElementAPI;
|
protected _api: CardElementAPI;
|
||||||
@@ -120,6 +123,10 @@ export class CardElementManager {
|
|||||||
'advanced-camera-card:action:execution-request',
|
'advanced-camera-card:action:execution-request',
|
||||||
this._api.getActionsManager().handleActionExecutionRequestEvent,
|
this._api.getActionsManager().handleActionExecutionRequestEvent,
|
||||||
);
|
);
|
||||||
|
this._element.addEventListener(
|
||||||
|
'advanced-camera-card:media:reviewed',
|
||||||
|
this._handleMediaReviewed,
|
||||||
|
);
|
||||||
|
|
||||||
// Listen for HA `navigate` actions.
|
// Listen for HA `navigate` actions.
|
||||||
// See: https://github.com/home-assistant/frontend/blob/273992c8e9c3062c6e49481b6d7d688a07067232/src/common/navigate.ts#L43
|
// 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',
|
'advanced-camera-card:action:execution-request',
|
||||||
this._api.getActionsManager().handleActionExecutionRequestEvent,
|
this._api.getActionsManager().handleActionExecutionRequestEvent,
|
||||||
);
|
);
|
||||||
|
this._element.removeEventListener(
|
||||||
|
'advanced-camera-card:media:reviewed',
|
||||||
|
this._handleMediaReviewed,
|
||||||
|
);
|
||||||
|
|
||||||
window.removeEventListener(
|
window.removeEventListener(
|
||||||
'location-changed',
|
'location-changed',
|
||||||
@@ -208,4 +219,15 @@ export class CardElementManager {
|
|||||||
this._api.getQueryStringManager().requestExecution,
|
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 { InteractionManager } from './interaction-manager';
|
||||||
import { KeyboardStateManager } from './keyboard-state-manager';
|
import { KeyboardStateManager } from './keyboard-state-manager';
|
||||||
import { MediaLoadedInfoManager } from './media-info-manager';
|
import { MediaLoadedInfoManager } from './media-info-manager';
|
||||||
|
|
||||||
import { MediaPlayerManager } from './media-player-manager';
|
import { MediaPlayerManager } from './media-player-manager';
|
||||||
import { MessageManager } from './message-manager';
|
import { MessageManager } from './message-manager';
|
||||||
import { MicrophoneManager } from './microphone-manager';
|
import { MicrophoneManager } from './microphone-manager';
|
||||||
|
import { OverlayMessageManager } from './overlay-message-manager';
|
||||||
import { QueryStringManager } from './query-string-manager';
|
import { QueryStringManager } from './query-string-manager';
|
||||||
import { StatusBarItemManager } from './status-bar-item-manager';
|
import { StatusBarItemManager } from './status-bar-item-manager';
|
||||||
import { StyleManager } from './style-manager';
|
import { StyleManager } from './style-manager';
|
||||||
@@ -56,6 +58,7 @@ import {
|
|||||||
CardMediaPlayerAPI,
|
CardMediaPlayerAPI,
|
||||||
CardMessageAPI,
|
CardMessageAPI,
|
||||||
CardMicrophoneAPI,
|
CardMicrophoneAPI,
|
||||||
|
CardOverlayMessageAPI,
|
||||||
CardQueryStringAPI,
|
CardQueryStringAPI,
|
||||||
CardStyleAPI,
|
CardStyleAPI,
|
||||||
CardTriggersAPI,
|
CardTriggersAPI,
|
||||||
@@ -87,6 +90,7 @@ export class CardController
|
|||||||
CardMediaPlayerAPI,
|
CardMediaPlayerAPI,
|
||||||
CardMessageAPI,
|
CardMessageAPI,
|
||||||
CardMicrophoneAPI,
|
CardMicrophoneAPI,
|
||||||
|
CardOverlayMessageAPI,
|
||||||
CardQueryStringAPI,
|
CardQueryStringAPI,
|
||||||
CardStyleAPI,
|
CardStyleAPI,
|
||||||
CardTriggersAPI,
|
CardTriggersAPI,
|
||||||
@@ -118,9 +122,11 @@ export class CardController
|
|||||||
protected _interactionManager = new InteractionManager(this);
|
protected _interactionManager = new InteractionManager(this);
|
||||||
protected _keyboardStateManager = new KeyboardStateManager(this);
|
protected _keyboardStateManager = new KeyboardStateManager(this);
|
||||||
protected _mediaLoadedInfoManager = new MediaLoadedInfoManager(this);
|
protected _mediaLoadedInfoManager = new MediaLoadedInfoManager(this);
|
||||||
|
|
||||||
protected _mediaPlayerManager = new MediaPlayerManager(this);
|
protected _mediaPlayerManager = new MediaPlayerManager(this);
|
||||||
protected _messageManager = new MessageManager(this);
|
protected _messageManager = new MessageManager(this);
|
||||||
protected _microphoneManager = new MicrophoneManager(this);
|
protected _microphoneManager = new MicrophoneManager(this);
|
||||||
|
protected _overlayMessageManager = new OverlayMessageManager(this);
|
||||||
protected _queryStringManager = new QueryStringManager(this);
|
protected _queryStringManager = new QueryStringManager(this);
|
||||||
protected _statusBarItemManager = new StatusBarItemManager(this);
|
protected _statusBarItemManager = new StatusBarItemManager(this);
|
||||||
protected _styleManager = new StyleManager(this);
|
protected _styleManager = new StyleManager(this);
|
||||||
@@ -248,6 +254,10 @@ export class CardController
|
|||||||
this._microphoneManager = new MicrophoneManager(this);
|
this._microphoneManager = new MicrophoneManager(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getOverlayMessageManager(): OverlayMessageManager {
|
||||||
|
return this._overlayMessageManager;
|
||||||
|
}
|
||||||
|
|
||||||
public getQueryStringManager(): QueryStringManager {
|
public getQueryStringManager(): QueryStringManager {
|
||||||
return this._queryStringManager;
|
return this._queryStringManager;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,8 @@ export class FoldersExecutor {
|
|||||||
this._ha = engines?.ha ?? new HAFoldersEngine();
|
this._ha = engines?.ha ?? new HAFoldersEngine();
|
||||||
}
|
}
|
||||||
|
|
||||||
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
|
public getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null {
|
||||||
return (
|
return this._getFolderEngine(folder.type)?.getDefaultQueryParameters(folder) ?? null;
|
||||||
this._getFolderEngine(folder.type)?.generateDefaultFolderQuery(folder) ?? null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public generateChildFolderQuery(
|
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 {
|
private _getFolderEngine(type?: FolderType): FoldersEngine | null {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case folderTypeSchema.enum.ha:
|
case folderTypeSchema.enum.ha:
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
|
import { sub } from 'date-fns';
|
||||||
import { NonEmptyTuple } from 'type-fest';
|
import { NonEmptyTuple } from 'type-fest';
|
||||||
import { ConditionState } from '../../../conditions/types';
|
import { ConditionState } from '../../../conditions/types';
|
||||||
import {
|
import {
|
||||||
FolderConfig,
|
FolderConfig,
|
||||||
folderTypeSchema,
|
folderTypeSchema,
|
||||||
HA_MEDIA_SOURCE_ROOT,
|
HA_MEDIA_SOURCE_ROOT,
|
||||||
HAFolderPathComponent,
|
|
||||||
HAFolderConfig,
|
HAFolderConfig,
|
||||||
|
HAFolderPathComponent,
|
||||||
} from '../../../config/schema/folders';
|
} from '../../../config/schema/folders';
|
||||||
import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media';
|
import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media';
|
||||||
import { BrowseMediaViewFolder } from '../../../ha/browse-media/item';
|
import { BrowseMediaViewFolder } from '../../../ha/browse-media/item';
|
||||||
import {
|
import {
|
||||||
BrowseMedia,
|
BROWSE_MEDIA_CACHE_SECONDS,
|
||||||
BrowseMediaCache,
|
BrowseMediaCache,
|
||||||
BrowseMediaMetadata,
|
BrowseMediaMetadata,
|
||||||
RichBrowseMedia,
|
RichBrowseMedia,
|
||||||
@@ -22,16 +23,18 @@ import {
|
|||||||
} from '../../../ha/browse-media/walker';
|
} from '../../../ha/browse-media/walker';
|
||||||
import { getMediaDownloadPath } from '../../../ha/download';
|
import { getMediaDownloadPath } from '../../../ha/download';
|
||||||
import { HomeAssistant } from '../../../ha/types';
|
import { HomeAssistant } from '../../../ha/types';
|
||||||
|
import { QuerySource } from '../../../query-source.js';
|
||||||
import { Endpoint } from '../../../types';
|
import { Endpoint } from '../../../types';
|
||||||
|
|
||||||
import { ViewFolder, ViewItem } from '../../../view/item';
|
import { ViewFolder, ViewItem } from '../../../view/item';
|
||||||
import { ViewItemClassifier } from '../../../view/item-classifier';
|
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||||
import { ViewItemCapabilities } from '../../../view/types';
|
import { ViewItemCapabilities } from '../../../view/types';
|
||||||
import {
|
import {
|
||||||
DownloadHelpers,
|
DownloadHelpers,
|
||||||
EngineOptions,
|
EngineOptions,
|
||||||
|
FolderPathComponent,
|
||||||
FolderQuery,
|
FolderQuery,
|
||||||
FoldersEngine,
|
FoldersEngine,
|
||||||
FolderPathComponent,
|
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { MediaMatcher } from './media-matcher';
|
import { MediaMatcher } from './media-matcher';
|
||||||
import { MetadataGenerator } from './metadata-generator.js';
|
import { MetadataGenerator } from './metadata-generator.js';
|
||||||
@@ -83,17 +86,18 @@ export class HAFoldersEngine implements FoldersEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
|
public getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null {
|
||||||
if (folder.type !== folderTypeSchema.enum.ha) {
|
if (folder.type !== folderTypeSchema.enum.ha) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
source: QuerySource.Folder,
|
||||||
folder,
|
folder,
|
||||||
path: this.getDefaultFolderPathComponents(folder.ha),
|
path: this._getDefaultPathComponents(folder.ha),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultFolderPathComponents(
|
private _getDefaultPathComponents(
|
||||||
haFolderConfig?: HAFolderConfig,
|
haFolderConfig?: HAFolderConfig,
|
||||||
): NonEmptyTuple<FolderPathComponent> {
|
): NonEmptyTuple<FolderPathComponent> {
|
||||||
const shouldAddDefaultRoot =
|
const shouldAddDefaultRoot =
|
||||||
@@ -158,14 +162,21 @@ export class HAFoldersEngine implements FoldersEngine {
|
|||||||
targets: BrowseMediaTarget<BrowseMediaMetadata>[],
|
targets: BrowseMediaTarget<BrowseMediaMetadata>[],
|
||||||
): BrowseMediaStep<BrowseMediaMetadata>[] => {
|
): BrowseMediaStep<BrowseMediaMetadata>[] => {
|
||||||
const nextComponent = pathComponents.shift();
|
const nextComponent = pathComponents.shift();
|
||||||
|
const limit = query.limit ?? null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
targets,
|
targets,
|
||||||
metadataGenerator: (media: BrowseMedia, parent?: BrowseMedia) =>
|
metadataGenerator: (media, parent) =>
|
||||||
this._metadataGenerator.generate(media, parent, nextComponent?.ha?.parsers),
|
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 && {
|
...(nextComponent && {
|
||||||
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
matcher: (media) =>
|
||||||
this._mediaMatcher.match(hass, media, {
|
this._mediaMatcher.match(hass, media, {
|
||||||
matchers: nextComponent.ha?.matchers,
|
matchers: nextComponent.ha?.matchers,
|
||||||
// Set foldersOnly to true if there are more stages in the path,
|
// 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,
|
folder: query.folder,
|
||||||
|
path: query.path,
|
||||||
});
|
});
|
||||||
|
return query.limit ? results.slice(0, query.limit) : results;
|
||||||
}
|
}
|
||||||
|
|
||||||
public generateChildFolderQuery(
|
public generateChildFolderQuery(
|
||||||
@@ -202,7 +215,7 @@ export class HAFoldersEngine implements FoldersEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the full configured path to find parsers/matchers for this depth.
|
// 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];
|
const nextConfiguredComponent = fullPath[query.path.length];
|
||||||
|
|
||||||
// Use the configured component's parsers/matchers if available, otherwise
|
// Use the configured component's parsers/matchers if available, otherwise
|
||||||
@@ -214,4 +227,11 @@ export class HAFoldersEngine implements FoldersEngine {
|
|||||||
path: [...query.path, { folder, ha }],
|
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 { ConditionState } from '../../conditions/types';
|
||||||
import { FolderConfig, FolderConfigWithoutID } from '../../config/schema/folders';
|
import { FolderConfig, FolderConfigWithoutID } from '../../config/schema/folders';
|
||||||
import { localize } from '../../localize/localize';
|
import { localize } from '../../localize/localize';
|
||||||
|
import { hasUnsupportedFilters } from '../../query-source.js';
|
||||||
import { Endpoint } from '../../types';
|
import { Endpoint } from '../../types';
|
||||||
|
import { getFolderID } from '../../utils/folder';
|
||||||
import { ViewFolder, ViewItem } from '../../view/item';
|
import { ViewFolder, ViewItem } from '../../view/item';
|
||||||
import { ViewItemCapabilities } from '../../view/types';
|
import { ViewItemCapabilities } from '../../view/types';
|
||||||
import { CardFoldersAPI } from '../types';
|
import { CardFoldersAPI } from '../types';
|
||||||
@@ -26,7 +28,7 @@ export class FoldersManager {
|
|||||||
public addFolders(folders: FolderConfigWithoutID[]): void {
|
public addFolders(folders: FolderConfigWithoutID[]): void {
|
||||||
for (const folder of folders) {
|
for (const folder of folders) {
|
||||||
const folderNumber = this._folders.size;
|
const folderNumber = this._folders.size;
|
||||||
const id = folder.id ?? `folder/${folderNumber.toString()}`;
|
const id = getFolderID(folder, folderNumber);
|
||||||
if (this._folders.has(id)) {
|
if (this._folders.has(id)) {
|
||||||
throw new FolderInitializationError(
|
throw new FolderInitializationError(
|
||||||
localize('error.duplicate_folder_id'),
|
localize('error.duplicate_folder_id'),
|
||||||
@@ -57,9 +59,9 @@ export class FoldersManager {
|
|||||||
: this._folders.values().next().value ?? null;
|
: this._folders.values().next().value ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public generateDefaultFolderQuery(folder?: FolderConfig): FolderQuery | null {
|
public getDefaultQueryParameters(folder?: FolderConfig): FolderQuery | null {
|
||||||
const _folder = folder ?? this.getFolder();
|
const _folder = folder ?? this.getFolder();
|
||||||
return _folder ? this._executor.generateDefaultFolderQuery(_folder) : null;
|
return _folder ? this._executor.getDefaultQueryParameters(_folder) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public generateChildFolderQuery(
|
public generateChildFolderQuery(
|
||||||
@@ -74,12 +76,20 @@ export class FoldersManager {
|
|||||||
conditionState?: ConditionState,
|
conditionState?: ConditionState,
|
||||||
engineOptions?: EngineOptions,
|
engineOptions?: EngineOptions,
|
||||||
): Promise<ViewItem[] | null> {
|
): Promise<ViewItem[] | null> {
|
||||||
|
if (hasUnsupportedFilters(query)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const hass = this._api.getHASSManager().getHASS();
|
const hass = this._api.getHASSManager().getHASS();
|
||||||
return hass
|
return hass
|
||||||
? this._executor.expandFolder(hass, query, conditionState, engineOptions)
|
? this._executor.expandFolder(hass, query, conditionState, engineOptions)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean {
|
||||||
|
return this._executor.areResultsFresh(resultsTimestamp, query);
|
||||||
|
}
|
||||||
|
|
||||||
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
|
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
|
||||||
return this._executor.getItemCapabilities(item);
|
return this._executor.getItemCapabilities(item);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConditionState } from '../../conditions/types';
|
|||||||
import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
|
import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
|
||||||
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
|
import { BaseQuery, QueryFilters, QuerySource } from '../../query-source';
|
||||||
import { Endpoint } from '../../types';
|
import { Endpoint } from '../../types';
|
||||||
import { AdvancedCameraCardError } from '../../types.js';
|
import { AdvancedCameraCardError } from '../../types.js';
|
||||||
import { ViewFolder, ViewItem } from '../../view/item';
|
import { ViewFolder, ViewItem } from '../../view/item';
|
||||||
@@ -30,12 +31,14 @@ export interface FolderPathComponent extends FolderPathComponentMetadata {
|
|||||||
folder?: ViewFolder;
|
folder?: ViewFolder;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FolderQuery {
|
export interface FolderQuery extends BaseQuery, QueryFilters {
|
||||||
|
source: QuerySource.Folder;
|
||||||
folder: FolderConfig;
|
folder: FolderConfig;
|
||||||
|
|
||||||
// A trail of paths to navigate back to the "root", with the last path being
|
// A trail of paths to navigate back to the "root", with the last path being
|
||||||
// the path that this query directly refers to.
|
// the path that this query directly refers to.
|
||||||
path: NonEmptyTuple<FolderPathComponent>;
|
path: NonEmptyTuple<FolderPathComponent>;
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===============
|
// ===============
|
||||||
@@ -47,7 +50,8 @@ export interface DownloadHelpers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FoldersEngine {
|
export interface FoldersEngine {
|
||||||
generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null;
|
getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null;
|
||||||
|
|
||||||
generateChildFolderQuery(query: FolderQuery, folder: ViewFolder): FolderQuery | null;
|
generateChildFolderQuery(query: FolderQuery, folder: ViewFolder): FolderQuery | null;
|
||||||
|
|
||||||
expandFolder(
|
expandFolder(
|
||||||
@@ -63,5 +67,8 @@ export interface FoldersEngine {
|
|||||||
item: ViewItem,
|
item: ViewItem,
|
||||||
options?: DownloadHelpers,
|
options?: DownloadHelpers,
|
||||||
): Promise<Endpoint | null>;
|
): Promise<Endpoint | null>;
|
||||||
|
|
||||||
favorite(hass: HomeAssistant | null, item: ViewItem, favorite: boolean): Promise<void>;
|
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)
|
? options?.cameraManager?.getCameraMetadata(options?.view?.camera)
|
||||||
: null;
|
: null;
|
||||||
const engineIcon = cameraMetadata?.engineIcon ?? null;
|
const engineIcon = cameraMetadata?.engineIcon ?? null;
|
||||||
|
const selectedResult = options?.view?.queryResults?.getSelectedResult();
|
||||||
|
const severity = selectedResult?.getSeverity() ?? null;
|
||||||
const title = options?.view?.is('live')
|
const title = options?.view?.is('live')
|
||||||
? cameraMetadata?.title ?? null
|
? cameraMetadata?.title ?? null
|
||||||
: options?.view?.isViewerView()
|
: options?.view?.isViewerView()
|
||||||
? options?.view.queryResults?.getSelectedResult()?.getTitle() ?? null
|
? selectedResult?.getTitle() ?? null
|
||||||
: null;
|
: null;
|
||||||
const resolution = options?.mediaLoadedInfo
|
const resolution = options?.mediaLoadedInfo
|
||||||
? this._calculateResolution(options?.mediaLoadedInfo)
|
? this._calculateResolution(options?.mediaLoadedInfo)
|
||||||
@@ -60,6 +62,17 @@ export class StatusBarItemManager {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
...(severity
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'custom:advanced-camera-card-status-bar-icon' as const,
|
||||||
|
icon: 'mdi:circle-medium',
|
||||||
|
severity,
|
||||||
|
...options?.statusConfig?.items.severity,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
|
||||||
...(title
|
...(title
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -104,14 +104,16 @@ export class TriggersManager {
|
|||||||
.actions.trigger;
|
.actions.trigger;
|
||||||
const defaultView = this._api.getConfigManager().getConfig()?.view.default;
|
const defaultView = this._api.getConfigManager().getConfig()?.view.default;
|
||||||
|
|
||||||
// If this is a high-fidelity event where we are certain about new media,
|
// Early exit guard: If this is a high-fidelity event where we are certain
|
||||||
// don't take action unless it's to change to live (Frigate engine may pump
|
// about new media, don't take action unless it's to change to live (Frigate
|
||||||
// out events where there's no new media to show). Other trigger actions
|
// engine may pump out events where there's no new media to show). Other
|
||||||
// (e.g. media, update) do not make sense without having some new media.
|
// trigger actions (e.g. media, update) do not make sense without having
|
||||||
|
// some new media.
|
||||||
if (
|
if (
|
||||||
ev.fidelity === 'high' &&
|
ev.fidelity === 'high' &&
|
||||||
!ev.snapshot &&
|
!ev.snapshot &&
|
||||||
!ev.clip &&
|
!ev.clip &&
|
||||||
|
!ev.review &&
|
||||||
!(
|
!(
|
||||||
triggerAction === 'live' ||
|
triggerAction === 'live' ||
|
||||||
(triggerAction === 'default' && defaultView === 'live')
|
(triggerAction === 'default' && defaultView === 'live')
|
||||||
@@ -139,12 +141,26 @@ export class TriggersManager {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (ev.fidelity === 'high' && triggerAction === 'media') {
|
} else if (ev.fidelity === 'high' && triggerAction === 'media') {
|
||||||
await this._api.getViewManager().setViewByParametersWithNewQuery({
|
// Choose the most appropriate media view based on what's available.
|
||||||
params: {
|
// Priority: review > clip > snapshot
|
||||||
view: ev.clip ? 'clip' : 'snapshot',
|
const view = ev.review
|
||||||
camera: ev.cameraID,
|
? '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 { MediaPlayerManager } from './media-player-manager';
|
||||||
import type { MessageManager } from './message-manager';
|
import type { MessageManager } from './message-manager';
|
||||||
import type { MicrophoneManager } from './microphone-manager';
|
import type { MicrophoneManager } from './microphone-manager';
|
||||||
|
import type { OverlayMessageManager } from './overlay-message-manager';
|
||||||
import type { QueryStringManager } from './query-string-manager';
|
import type { QueryStringManager } from './query-string-manager';
|
||||||
import type { StatusBarItemManager } from './status-bar-item-manager';
|
import type { StatusBarItemManager } from './status-bar-item-manager';
|
||||||
import type { StyleManager } from './style-manager';
|
import type { StyleManager } from './style-manager';
|
||||||
@@ -52,6 +53,7 @@ export interface CardActionsAPI {
|
|||||||
getMediaPlayerManager(): MediaPlayerManager;
|
getMediaPlayerManager(): MediaPlayerManager;
|
||||||
getMessageManager(): MessageManager;
|
getMessageManager(): MessageManager;
|
||||||
getMicrophoneManager(): MicrophoneManager;
|
getMicrophoneManager(): MicrophoneManager;
|
||||||
|
getOverlayMessageManager(): OverlayMessageManager;
|
||||||
getStatusBarItemManager(): StatusBarItemManager;
|
getStatusBarItemManager(): StatusBarItemManager;
|
||||||
getTriggersManager(): TriggersManager;
|
getTriggersManager(): TriggersManager;
|
||||||
getViewItemManager(): ViewItemManager;
|
getViewItemManager(): ViewItemManager;
|
||||||
@@ -149,6 +151,7 @@ export interface CardElementAPI {
|
|||||||
getMediaPlayerManager(): MediaPlayerManager;
|
getMediaPlayerManager(): MediaPlayerManager;
|
||||||
getMicrophoneManager(): MicrophoneManager;
|
getMicrophoneManager(): MicrophoneManager;
|
||||||
getQueryStringManager(): QueryStringManager;
|
getQueryStringManager(): QueryStringManager;
|
||||||
|
getViewManager(): ViewManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardExpandAPI {
|
export interface CardExpandAPI {
|
||||||
@@ -245,6 +248,10 @@ export interface CardMessageAPI {
|
|||||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CardOverlayMessageAPI {
|
||||||
|
getCardElementManager(): CardElementManager;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CardMicrophoneAPI {
|
export interface CardMicrophoneAPI {
|
||||||
getCardElementManager(): CardElementManager;
|
getCardElementManager(): CardElementManager;
|
||||||
getConditionStateManager(): ConditionStateManager;
|
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 {
|
private _getMediaSource(item: ViewItem): ViewMediaSource | null {
|
||||||
if (ViewItemClassifier.isMedia(item) && item.getCameraID()) {
|
if (ViewItemClassifier.isMedia(item) && item.getCameraID()) {
|
||||||
return ViewMediaSource.Camera;
|
return ViewMediaSource.Camera;
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { Query } from '../../../view/query';
|
|
||||||
import { QueryResults } from '../../../view/query-results';
|
import { QueryResults } from '../../../view/query-results';
|
||||||
|
import { UnifiedQuery } from '../../../view/unified-query';
|
||||||
import { View } from '../../../view/view';
|
import { View } from '../../../view/view';
|
||||||
import { ViewModifier } from '../types';
|
import { ViewModifier } from '../types';
|
||||||
|
|
||||||
export class SetQueryViewModifier implements ViewModifier {
|
export class SetQueryViewModifier implements ViewModifier {
|
||||||
protected _query?: Query | null;
|
protected _query?: UnifiedQuery | null;
|
||||||
protected _queryResults?: QueryResults | 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._query = options?.query;
|
||||||
this._queryResults = options?.queryResults;
|
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 { AdvancedCameraCardError } from '../../types.js';
|
||||||
import { ViewItem } from '../../view/item.js';
|
import { ViewItem } from '../../view/item.js';
|
||||||
import { QueryResults } from '../../view/query-results.js';
|
import { QueryResults } from '../../view/query-results.js';
|
||||||
import { Query } from '../../view/query.js';
|
|
||||||
import { View, ViewParameters } from '../../view/view.js';
|
import { View, ViewParameters } from '../../view/view.js';
|
||||||
|
|
||||||
export interface ViewModifier {
|
export interface ViewModifier {
|
||||||
@@ -26,11 +26,6 @@ export interface QueryExecutorOptions {
|
|||||||
useCache?: boolean;
|
useCache?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueryExecutorResult {
|
|
||||||
query: Query;
|
|
||||||
queryResults: QueryResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ViewFactoryOptions {
|
export interface ViewFactoryOptions {
|
||||||
// An existing view to evolve from.
|
// An existing view to evolve from.
|
||||||
baseView?: View | null;
|
baseView?: View | null;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ViewContext } from 'view';
|
import { ViewContext } from 'view';
|
||||||
import { log } from '../../utils/debug';
|
import { log } from '../../utils/debug';
|
||||||
import { getStreamCameraID } from '../../utils/substream';
|
import { getStreamCameraID } from '../../utils/substream';
|
||||||
import { QueryClassifier } from '../../view/query-classifier';
|
|
||||||
import { View } from '../../view/view';
|
import { View } from '../../view/view';
|
||||||
import { InitializationAspect } from '../initialization-manager';
|
import { InitializationAspect } from '../initialization-manager';
|
||||||
import { CardViewAPI } from '../types';
|
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
|
// 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
|
// 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
|
// 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
|
// selected media can be centered in the gallery.
|
||||||
// `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.
|
|
||||||
//
|
//
|
||||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
|
||||||
|
|
||||||
const switchingFromViewerToGallery =
|
if (!this._view?.isViewerView() || !newView?.isGalleryView()) {
|
||||||
this._view?.isViewerView() && newView?.isMediaGalleryView();
|
return false;
|
||||||
const newMediaType = newView?.getDefaultMediaType();
|
}
|
||||||
const alreadyHasMatchingQuery =
|
|
||||||
QueryClassifier.getMediaType(this._view?.query) === newMediaType;
|
// Check if the viewer came from this gallery type. If they did, we preserve
|
||||||
return !!switchingFromViewerToGallery && alreadyHasMatchingQuery;
|
// 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 {
|
public setViewWithMergedContext(context: ViewContext | null): void {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { sub } from 'date-fns';
|
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 { View } from '../../view/view';
|
||||||
import { CardViewAPI } from '../types';
|
import { CardViewAPI } from '../types';
|
||||||
import { MergeContextViewModifier } from './modifiers/merge-context';
|
import { MergeContextViewModifier } from './modifiers/merge-context';
|
||||||
import { RemoveContextPropertyViewModifier } from './modifiers/remove-context-property';
|
import { RemoveContextPropertyViewModifier } from './modifiers/remove-context-property';
|
||||||
import { SetQueryViewModifier } from './modifiers/set-query';
|
import { SetQueryViewModifier } from './modifiers/set-query';
|
||||||
import { QueryExecutor } from './query-executor';
|
|
||||||
import { QueryExecutorOptions, ViewModifier } from './types';
|
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.
|
* and if a query is made as part of this view the result can be applied later.
|
||||||
*/
|
*/
|
||||||
export class ViewQueryExecutor {
|
export class ViewQueryExecutor {
|
||||||
protected _api: CardViewAPI;
|
private _api: CardViewAPI;
|
||||||
protected _executor: QueryExecutor;
|
|
||||||
|
|
||||||
constructor(api: CardViewAPI, executor?: QueryExecutor) {
|
constructor(api: CardViewAPI) {
|
||||||
this._api = api;
|
this._api = api;
|
||||||
this._executor = executor ?? new QueryExecutor(api);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getExistingQueryModifiers(
|
public async getExistingQueryModifiers(
|
||||||
view: View,
|
view: View,
|
||||||
queryExecutorOptions?: QueryExecutorOptions,
|
queryExecutorOptions?: QueryExecutorOptions,
|
||||||
): Promise<ViewModifier[] | null> {
|
): 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({
|
new SetQueryViewModifier({
|
||||||
queryResults: (
|
queryResults,
|
||||||
await this._executor.executeQuery(view.query, queryExecutorOptions)
|
|
||||||
)?.queryResults,
|
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
: [];
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getNewQueryModifiers(
|
public async getNewQueryModifiers(
|
||||||
@@ -47,7 +66,7 @@ export class ViewQueryExecutor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _executeNewQuery(
|
private async _executeNewQuery(
|
||||||
view: View,
|
view: View,
|
||||||
queryExecutorOptions?: QueryExecutorOptions,
|
queryExecutorOptions?: QueryExecutorOptions,
|
||||||
): Promise<ViewModifier[] | null> {
|
): Promise<ViewModifier[] | null> {
|
||||||
@@ -56,71 +75,111 @@ export class ViewQueryExecutor {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaType = view?.getDefaultMediaType();
|
|
||||||
const viewModifiers: ViewModifier[] = [];
|
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 (
|
const executeQuery = async (query: UnifiedQuery | null): Promise<ViewModifier[]> => {
|
||||||
mediaType: ClipsOrSnapshotsOrAll | 'recordings' | null,
|
if (!query) {
|
||||||
): Promise<ViewModifier[]> => {
|
|
||||||
/* istanbul ignore if: this path cannot be reached -- @preserve */
|
|
||||||
if (!mediaType) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const results =
|
const items = await runner.execute(query, {
|
||||||
mediaType === 'recordings'
|
useCache: queryExecutorOptions?.useCache,
|
||||||
? 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;
|
|
||||||
|
|
||||||
return results ? [new SetQueryViewModifier(results)] : [];
|
return [
|
||||||
|
new SetQueryViewModifier({
|
||||||
|
query,
|
||||||
|
queryResults: new QueryResults({ results: items }),
|
||||||
|
}),
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
const executeFolderQuery = async (): Promise<ViewModifier[]> => {
|
const cameraForQuery = view.isGrid() ? undefined : view.camera;
|
||||||
const results = await this._executor.executeFolderQuery(queryExecutorOptions);
|
|
||||||
return results ? [new SetQueryViewModifier(results)] : [];
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (view.view) {
|
switch (view.view) {
|
||||||
case 'live':
|
case 'live':
|
||||||
if (config.live.controls.thumbnails.mode !== 'none') {
|
if (config.live.controls.thumbnails.mode !== 'none') {
|
||||||
viewModifiers.push(
|
const defaultQuery = builder.buildDefaultCameraQuery(cameraForQuery);
|
||||||
...(await executeMediaQuery(
|
if (defaultQuery) {
|
||||||
config.live.controls.thumbnails.media_type === 'recordings'
|
viewModifiers.push(...(await executeQuery(defaultQuery)));
|
||||||
? 'recordings'
|
}
|
||||||
: config.live.controls.thumbnails.events_media_type,
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'timeline':
|
||||||
case 'media':
|
// Timeline view always queries all cameras with media capabilities.
|
||||||
// If the user is looking at media in the `media` view and then
|
viewModifiers.push(...(await executeQuery(builder.buildDefaultCameraQuery())));
|
||||||
// changes camera (via the menu) it should default to showing clips
|
|
||||||
// for the new camera.
|
|
||||||
viewModifiers.push(...(await executeMediaQuery('clips')));
|
|
||||||
break;
|
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 'clip':
|
||||||
case 'clips':
|
case 'clips':
|
||||||
|
viewModifiers.push(
|
||||||
|
...(await executeQuery(
|
||||||
|
builder.buildCameraMediaQuery(MediaTypeSpec.clips(), {
|
||||||
|
cameraID: cameraForQuery,
|
||||||
|
limit: this._getLimit(),
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
case 'snapshot':
|
case 'snapshot':
|
||||||
case 'snapshots':
|
case 'snapshots':
|
||||||
|
viewModifiers.push(
|
||||||
|
...(await executeQuery(
|
||||||
|
builder.buildCameraMediaQuery(MediaTypeSpec.snapshots(), {
|
||||||
|
cameraID: cameraForQuery,
|
||||||
|
limit: this._getLimit(),
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'recording':
|
case 'recording':
|
||||||
case 'recordings':
|
case 'recordings':
|
||||||
viewModifiers.push(...(await executeMediaQuery(mediaType)));
|
viewModifiers.push(
|
||||||
|
...(await executeQuery(
|
||||||
|
builder.buildCameraMediaQuery(MediaTypeSpec.recordings(), {
|
||||||
|
cameraID: cameraForQuery,
|
||||||
|
limit: this._getLimit(),
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'review':
|
||||||
|
case 'reviews':
|
||||||
|
viewModifiers.push(
|
||||||
|
...(await executeQuery(
|
||||||
|
builder.buildCameraMediaQuery(MediaTypeSpec.reviews(), {
|
||||||
|
cameraID: cameraForQuery,
|
||||||
|
limit: this._getLimit(),
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'folder':
|
case 'folder':
|
||||||
case 'folders':
|
case 'folders':
|
||||||
viewModifiers.push(...(await executeFolderQuery()));
|
viewModifiers.push(
|
||||||
|
...(await executeQuery(
|
||||||
|
builder.buildDefaultFolderQuery(queryExecutorOptions?.folder, {
|
||||||
|
limit: this._getLimit(),
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +190,7 @@ export class ViewQueryExecutor {
|
|||||||
return viewModifiers;
|
return viewModifiers;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _getTimelineWindowViewModifier(view: View): ViewModifier[] {
|
private _getTimelineWindowViewModifier(view: View): ViewModifier[] {
|
||||||
if (view.is('live')) {
|
if (view.is('live')) {
|
||||||
// For live views, always force the timeline to now, regardless of
|
// For live views, always force the timeline to now, regardless of
|
||||||
// presence or not of events.
|
// presence or not of events.
|
||||||
@@ -166,7 +225,7 @@ export class ViewQueryExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _getSeekTimeModifier(time?: Date): ViewModifier[] {
|
private _getSeekTimeModifier(time?: Date): ViewModifier[] {
|
||||||
if (time) {
|
if (time) {
|
||||||
return [
|
return [
|
||||||
new MergeContextViewModifier({
|
new MergeContextViewModifier({
|
||||||
@@ -179,4 +238,39 @@ export class ViewQueryExecutor {
|
|||||||
return [new RemoveContextPropertyViewModifier('mediaViewer', 'seek')];
|
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 { AdvancedCameraCardMenu } from './components/menu.js';
|
||||||
import './components/message.js';
|
import './components/message.js';
|
||||||
import { renderMessage } from './components/message.js';
|
import { renderMessage } from './components/message.js';
|
||||||
|
import './components/overlay-message.js';
|
||||||
import './components/overlay.js';
|
import './components/overlay.js';
|
||||||
import { AdvancedCameraCardOverlay } from './components/overlay.js';
|
import { AdvancedCameraCardOverlay } from './components/overlay.js';
|
||||||
import './components/status-bar';
|
import './components/status-bar';
|
||||||
@@ -32,7 +33,7 @@ import { REPO_URL } from './const.js';
|
|||||||
import { HomeAssistant, LovelaceCardEditor } from './ha/types.js';
|
import { HomeAssistant, LovelaceCardEditor } from './ha/types.js';
|
||||||
import { localize } from './localize/localize.js';
|
import { localize } from './localize/localize.js';
|
||||||
import cardStyle from './scss/card.scss';
|
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 { hasAction } from './utils/action.js';
|
||||||
import { getReleaseVersion } from './utils/diagnostics';
|
import { getReleaseVersion } from './utils/diagnostics';
|
||||||
|
|
||||||
@@ -235,9 +236,9 @@ class AdvancedCameraCard extends LitElement {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
${position === 'overlay'
|
${position === 'overlay'
|
||||||
? html`<advanced-camera-card-overlay
|
? html`<advanced-camera-card-overlay>
|
||||||
>${getContents('overlay')}</advanced-camera-card-overlay
|
${getContents('overlay')}
|
||||||
>`
|
</advanced-camera-card-overlay>`
|
||||||
: html`<div class="outerlay" data-position="${position}">
|
: html`<div class="outerlay" data-position="${position}">
|
||||||
${getContents('outerlay')}
|
${getContents('outerlay')}
|
||||||
</div>`}
|
</div>`}
|
||||||
@@ -360,8 +361,9 @@ class AdvancedCameraCard extends LitElement {
|
|||||||
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
|
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
|
||||||
@advanced-camera-card:message=${(ev: CustomEvent<Message>) =>
|
@advanced-camera-card:message=${(ev: CustomEvent<Message>) =>
|
||||||
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
|
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
|
||||||
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
|
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
|
||||||
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
|
this._controller.getMediaLoadedInfoManager().set(ev.detail);
|
||||||
|
}}
|
||||||
@advanced-camera-card:media:unloaded=${() =>
|
@advanced-camera-card:media:unloaded=${() =>
|
||||||
this._controller.getMediaLoadedInfoManager().clear()}
|
this._controller.getMediaLoadedInfoManager().clear()}
|
||||||
@advanced-camera-card:media:volumechange=${
|
@advanced-camera-card:media:volumechange=${
|
||||||
@@ -374,6 +376,11 @@ class AdvancedCameraCard extends LitElement {
|
|||||||
() => this.requestUpdate() /* Refresh play/pause menu button */
|
() => this.requestUpdate() /* Refresh play/pause menu button */
|
||||||
}
|
}
|
||||||
@advanced-camera-card:focus=${() => this.focus()}
|
@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
|
${showLoading
|
||||||
? html`<advanced-camera-card-loading
|
? html`<advanced-camera-card-loading
|
||||||
@@ -454,6 +461,11 @@ class AdvancedCameraCard extends LitElement {
|
|||||||
>
|
>
|
||||||
</advanced-camera-card-elements>`
|
</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>`,
|
</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';
|
} from 'date-fns';
|
||||||
import { LitElement } from 'lit';
|
import { LitElement } from 'lit';
|
||||||
import { isEqual, orderBy, uniqWith } from 'lodash-es';
|
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 { 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 { ViewManagerInterface } from '../card-controller/view/types';
|
||||||
import { SelectOption, SelectValues } from '../components/select';
|
import { SelectOption, SelectValues } from '../components/select';
|
||||||
import { CardWideConfig } from '../config/schema/types';
|
import { CardWideConfig } from '../config/schema/types';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
|
import { ViewMediaType } from '../types';
|
||||||
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
|
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
|
||||||
import { EventMediaQuery, RecordingMediaQuery } from '../view/query';
|
import { UnifiedQueryBuilder } from '../view/unified-query-builder';
|
||||||
import { QueryClassifier } from '../view/query-classifier';
|
|
||||||
|
|
||||||
interface MediaFilterControls {
|
|
||||||
events: boolean;
|
|
||||||
recordings: boolean;
|
|
||||||
favorites: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaFilterCoreDefaults {
|
export interface MediaFilterCoreDefaults {
|
||||||
cameraIDs?: string[];
|
cameraIDs?: string[];
|
||||||
favorite?: MediaFilterCoreFavoriteSelection;
|
favorite?: MediaFilterCoreFavoriteSelection;
|
||||||
mediaType?: MediaFilterMediaType;
|
reviewed?: MediaFilterCoreReviewedSelection;
|
||||||
|
mediaTypes?: MediaFilterMediaType[];
|
||||||
what?: string[];
|
what?: string[];
|
||||||
when?: string;
|
when?: string;
|
||||||
where?: string[];
|
where?: string[];
|
||||||
@@ -44,6 +45,11 @@ export enum MediaFilterCoreFavoriteSelection {
|
|||||||
NotFavorite = 'not-favorite',
|
NotFavorite = 'not-favorite',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum MediaFilterCoreReviewedSelection {
|
||||||
|
Reviewed = 'reviewed',
|
||||||
|
NotReviewed = 'not-reviewed',
|
||||||
|
}
|
||||||
|
|
||||||
export enum MediaFilterCoreWhen {
|
export enum MediaFilterCoreWhen {
|
||||||
Today = 'today',
|
Today = 'today',
|
||||||
Yesterday = 'yesterday',
|
Yesterday = 'yesterday',
|
||||||
@@ -56,6 +62,7 @@ export enum MediaFilterMediaType {
|
|||||||
Clips = 'clips',
|
Clips = 'clips',
|
||||||
Snapshots = 'snapshots',
|
Snapshots = 'snapshots',
|
||||||
Recordings = 'recordings',
|
Recordings = 'recordings',
|
||||||
|
Reviews = 'reviews',
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MediaFilterController {
|
export class MediaFilterController {
|
||||||
@@ -72,6 +79,7 @@ export class MediaFilterController {
|
|||||||
protected _whereOptions: SelectOption[] = [];
|
protected _whereOptions: SelectOption[] = [];
|
||||||
protected _tagsOptions: SelectOption[] = [];
|
protected _tagsOptions: SelectOption[] = [];
|
||||||
protected _favoriteOptions: SelectOption[];
|
protected _favoriteOptions: SelectOption[];
|
||||||
|
protected _reviewedOptions: SelectOption[];
|
||||||
|
|
||||||
protected _defaults: MediaFilterCoreDefaults | null = null;
|
protected _defaults: MediaFilterCoreDefaults | null = null;
|
||||||
protected _viewManager: ViewManagerInterface | null = null;
|
protected _viewManager: ViewManagerInterface | null = null;
|
||||||
@@ -102,6 +110,20 @@ export class MediaFilterController {
|
|||||||
value: MediaFilterMediaType.Recordings,
|
value: MediaFilterMediaType.Recordings,
|
||||||
label: localize('media_filter.media_types.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 = [
|
this._staticWhenOptions = [
|
||||||
{
|
{
|
||||||
@@ -149,6 +171,9 @@ export class MediaFilterController {
|
|||||||
public getFavoriteOptions(): SelectOption[] {
|
public getFavoriteOptions(): SelectOption[] {
|
||||||
return this._favoriteOptions;
|
return this._favoriteOptions;
|
||||||
}
|
}
|
||||||
|
public getReviewedOptions(): SelectOption[] {
|
||||||
|
return this._reviewedOptions;
|
||||||
|
}
|
||||||
public getDefaults(): MediaFilterCoreDefaults | null {
|
public getDefaults(): MediaFilterCoreDefaults | null {
|
||||||
return this._defaults;
|
return this._defaults;
|
||||||
}
|
}
|
||||||
@@ -158,229 +183,204 @@ export class MediaFilterController {
|
|||||||
|
|
||||||
public async valueChangeHandler(
|
public async valueChangeHandler(
|
||||||
cameraManager: CameraManager,
|
cameraManager: CameraManager,
|
||||||
|
foldersManager: FoldersManager,
|
||||||
cardWideConfig: CardWideConfig,
|
cardWideConfig: CardWideConfig,
|
||||||
values: {
|
values: {
|
||||||
camera?: string | string[];
|
camera?: SelectValues;
|
||||||
mediaType?: MediaFilterMediaType;
|
mediaTypes?: SelectValues;
|
||||||
when: {
|
when: {
|
||||||
selected?: string | string[];
|
selected?: SelectValues;
|
||||||
from?: Date | null;
|
from?: Date | null;
|
||||||
to?: Date | null;
|
to?: Date | null;
|
||||||
};
|
};
|
||||||
favorite?: MediaFilterCoreFavoriteSelection;
|
favorite?: SelectValues;
|
||||||
where?: string | string[];
|
reviewed?: SelectValues;
|
||||||
what?: string | string[];
|
where?: SelectValues;
|
||||||
tags?: string | string[];
|
what?: SelectValues;
|
||||||
|
tags?: SelectValues;
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
_ev?: unknown,
|
_ev?: unknown,
|
||||||
): Promise<void> {
|
): 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
|
// The reported value may be '' if the field is clearable (i.e. the user
|
||||||
// can click 'x').
|
// can click 'x').
|
||||||
if (val && Array.isArray(val) && val.length && !val.includes('')) {
|
if (val && Array.isArray(val) && val.length && !val.includes('')) {
|
||||||
return new Set([...val]);
|
return new Set([...val]) as Set<T>;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const cameraIDs =
|
|
||||||
getArrayValueAsSet(values.camera) ?? this._getAllCameraIDs(cameraManager);
|
|
||||||
if (!cameraIDs.size || !values.mediaType) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const when = this._getWhen(values.when);
|
const when = this._getWhen(values.when);
|
||||||
const favorite = values.favorite
|
const favorite = values.favorite
|
||||||
? values.favorite === MediaFilterCoreFavoriteSelection.Favorite
|
? values.favorite === MediaFilterCoreFavoriteSelection.Favorite
|
||||||
: null;
|
: null;
|
||||||
|
const reviewed = values.reviewed
|
||||||
// A note on views:
|
? values.reviewed === MediaFilterCoreReviewedSelection.Reviewed
|
||||||
// - In the below, if the user selects a camera to view media for, the main
|
: null;
|
||||||
// view camera is also set to that value (e.g. a user browsing the
|
const where = getArrayValueAsSet(values.where);
|
||||||
// gallery, chooses a different camera in the media filter, then
|
const what = getArrayValueAsSet(values.what);
|
||||||
// subsequently chooses the live button -- they would expect the live view
|
const tags = getArrayValueAsSet(values.tags);
|
||||||
// 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 limit = cardWideConfig.performance?.features.media_chunk_size;
|
const limit = cardWideConfig.performance?.features.media_chunk_size;
|
||||||
|
|
||||||
if (
|
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||||
values.mediaType === MediaFilterMediaType.Clips ||
|
const query = builder.buildFilterQuery(
|
||||||
values.mediaType === MediaFilterMediaType.Snapshots
|
getArrayValueAsSet(values.camera),
|
||||||
) {
|
getArrayValueAsSet<ViewMediaType>(values.mediaTypes),
|
||||||
const where = getArrayValueAsSet(values.where);
|
{
|
||||||
const what = getArrayValueAsSet(values.what);
|
...(when?.start && { start: when.start }),
|
||||||
const tags = getArrayValueAsSet(values.tags);
|
...(when?.end && { end: when.end }),
|
||||||
|
...(limit && { limit }),
|
||||||
|
...(favorite !== null && { favorite }),
|
||||||
|
...(reviewed !== null && { reviewed }),
|
||||||
|
...(tags && { tags }),
|
||||||
|
...(what && { what }),
|
||||||
|
...(where && { where }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const queries = new EventMediaQuery([
|
if (!query) {
|
||||||
{
|
return;
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Need to ensure we update the element as the date-picker selections may
|
||||||
// have changed, and we need to un/set the selected class.
|
// have changed, and we need to un/set the selected class.
|
||||||
this._host.requestUpdate();
|
this._host.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _getAllCameraIDs(cameraManager: CameraManager): Set<string> {
|
public computeInitialDefaultsFromView(
|
||||||
return cameraManager.getStore().getCameraIDsWithCapability({
|
cameraManager: CameraManager,
|
||||||
anyCapabilities: ['clips', 'snapshots', 'recordings'],
|
foldersManager: FoldersManager,
|
||||||
});
|
): void {
|
||||||
}
|
|
||||||
|
|
||||||
public computeInitialDefaultsFromView(cameraManager: CameraManager): void {
|
|
||||||
const view = this._viewManager?.getView();
|
const view = this._viewManager?.getView();
|
||||||
const query = view?.query;
|
const query = view?.query;
|
||||||
const allCameraIDs = this._getAllCameraIDs(cameraManager);
|
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||||
if (!view || !QueryClassifier.isMediaQuery(query) || !allCameraIDs.size) {
|
const allCameraIDs = builder.getAllMediaCapableCameraIDs();
|
||||||
|
if (!view || !query?.hasNodes() || !allCameraIDs.size) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queries = query.getQuery();
|
const mediaQueries = query.getMediaQueries();
|
||||||
if (!queries) {
|
const mediaTypes: MediaFilterMediaType[] = [];
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mediaType: MediaFilterMediaType | undefined;
|
|
||||||
let cameraIDs: string[] | undefined;
|
let cameraIDs: string[] | undefined;
|
||||||
let what: string[] | undefined;
|
let what: string[] | undefined;
|
||||||
let where: string[] | undefined;
|
let where: string[] | undefined;
|
||||||
let favorite: MediaFilterCoreFavoriteSelection | undefined;
|
let favorite: MediaFilterCoreFavoriteSelection | undefined;
|
||||||
|
let reviewed: MediaFilterCoreReviewedSelection | undefined;
|
||||||
let tags: string[] | undefined;
|
let tags: string[] | undefined;
|
||||||
|
|
||||||
const cameraIDSets = uniqWith(
|
const cameraIDsFromQuery = query.getAllCameraIDs();
|
||||||
queries.map((query: CameraQuery) => query.cameraIDs),
|
|
||||||
isEqual,
|
// Note: With folder filtering, selecting all cameras is NOT the same as
|
||||||
);
|
// selecting none. "All cameras" means just camera media, while "none"
|
||||||
// Special note: If all visible cameras are selected, this is the same as no
|
// would include folder media too.
|
||||||
// selector at all.
|
if (cameraIDsFromQuery.size > 0) {
|
||||||
if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, allCameraIDs)) {
|
cameraIDs = [...cameraIDsFromQuery];
|
||||||
cameraIDs = [...queries[0].cameraIDs];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const favoriteValues = uniqWith(
|
// Extract favorite from all media queries (only if explicitly set to true/false)
|
||||||
queries.map((query) => query.favorite),
|
const favoriteValues = new Set(
|
||||||
isEqual,
|
mediaQueries.map((mediaQuery) =>
|
||||||
|
CameraQueryClassifier.isEventQuery(mediaQuery) ? mediaQuery.favorite : undefined,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (favoriteValues.length === 1 && queries[0].favorite !== undefined) {
|
if (favoriteValues.size === 1) {
|
||||||
favorite = queries[0].favorite
|
const fav = [...favoriteValues][0];
|
||||||
? MediaFilterCoreFavoriteSelection.Favorite
|
if (fav !== undefined) {
|
||||||
: MediaFilterCoreFavoriteSelection.NotFavorite;
|
favorite = fav
|
||||||
|
? MediaFilterCoreFavoriteSelection.Favorite
|
||||||
|
: MediaFilterCoreFavoriteSelection.NotFavorite;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
// Detect media types from queries
|
||||||
if (QueryClassifier.isEventQuery(view.query)) {
|
const eventQueries = query.getMediaQueries<EventQuery>({ type: QueryType.Event });
|
||||||
const queries = view.query.getQuery();
|
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 (hasClips || hasNeither) {
|
||||||
if (!queries) {
|
mediaTypes.push(MediaFilterMediaType.Clips);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
if (hasSnapshots || hasNeither) {
|
||||||
const hasClips = uniqWith(
|
mediaTypes.push(MediaFilterMediaType.Snapshots);
|
||||||
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 (query.hasMediaQueriesOfType(QueryType.Recording)) {
|
||||||
|
mediaTypes.push(MediaFilterMediaType.Recordings);
|
||||||
|
}
|
||||||
|
if (query.hasMediaQueriesOfType(QueryType.Review)) {
|
||||||
|
mediaTypes.push(MediaFilterMediaType.Reviews);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventQueries.length > 0) {
|
||||||
const whatSets = uniqWith(
|
const whatSets = uniqWith(
|
||||||
queries.map((query) => query.what),
|
eventQueries.map((q) => q.what),
|
||||||
isEqual,
|
isEqual,
|
||||||
);
|
);
|
||||||
if (whatSets.length === 1 && queries[0].what?.size) {
|
if (whatSets.length === 1 && eventQueries[0].what?.size) {
|
||||||
what = [...queries[0].what];
|
what = [...eventQueries[0].what];
|
||||||
}
|
}
|
||||||
const whereSets = uniqWith(
|
const whereSets = uniqWith(
|
||||||
queries.map((query) => query.where),
|
eventQueries.map((q) => q.where),
|
||||||
isEqual,
|
isEqual,
|
||||||
);
|
);
|
||||||
if (whereSets.length === 1 && queries[0].where?.size) {
|
if (whereSets.length === 1 && eventQueries[0].where?.size) {
|
||||||
where = [...queries[0].where];
|
where = [...eventQueries[0].where];
|
||||||
}
|
}
|
||||||
const tagsSets = uniqWith(
|
const tagsSets = uniqWith(
|
||||||
queries.map((query) => query.tags),
|
eventQueries.map((q) => q.tags),
|
||||||
isEqual,
|
isEqual,
|
||||||
);
|
);
|
||||||
if (tagsSets.length === 1 && queries[0].tags?.size) {
|
if (tagsSets.length === 1 && eventQueries[0].tags?.size) {
|
||||||
tags = [...queries[0].tags];
|
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 = {
|
this._defaults = {
|
||||||
...(mediaType && { mediaType: mediaType }),
|
...(mediaTypes.length && { mediaTypes }),
|
||||||
...(cameraIDs && { cameraIDs: cameraIDs }),
|
...(cameraIDs && { cameraIDs }),
|
||||||
...(what && { what: what }),
|
...(what && { what }),
|
||||||
...(where && { where: where }),
|
...(where && { where }),
|
||||||
...(favorite !== undefined && { favorite: favorite }),
|
...(favorite !== undefined && { favorite }),
|
||||||
...(tags && { tags: tags }),
|
...(reviewed !== undefined && { reviewed }),
|
||||||
|
...(tags && { tags }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public computeCameraOptions(cameraManager: CameraManager): void {
|
public computeCameraOptions(
|
||||||
this._cameraOptions = [...this._getAllCameraIDs(cameraManager)].map((cameraID) => ({
|
cameraManager: CameraManager,
|
||||||
|
foldersManager: FoldersManager,
|
||||||
|
): void {
|
||||||
|
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||||
|
this._cameraOptions = [...builder.getAllMediaCapableCameraIDs()].map((cameraID) => ({
|
||||||
value: cameraID,
|
value: cameraID,
|
||||||
label: cameraManager.getCameraMetadata(cameraID)?.title ?? cameraID,
|
label: cameraManager.getCameraMetadata(cameraID)?.title ?? cameraID,
|
||||||
}));
|
}));
|
||||||
@@ -441,23 +441,6 @@ export class MediaFilterController {
|
|||||||
this._host.requestUpdate();
|
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 {
|
protected _computeWhenOptions(): void {
|
||||||
this._whenOptions = [...this._staticWhenOptions, ...this._metaDataWhenOptions];
|
this._whenOptions = [...this._staticWhenOptions, ...this._metaDataWhenOptions];
|
||||||
}
|
}
|
||||||
|
|||||||
+130
-13
@@ -1,27 +1,40 @@
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { CameraManager } from '../../camera-manager/manager';
|
import { CameraManager } from '../../camera-manager/manager';
|
||||||
import { CameraManagerCameraMetadata } from '../../camera-manager/types';
|
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 { localize } from '../../localize/localize';
|
||||||
import { Icon } from '../../types';
|
import { MetadataField, OverlayMessage, OverlayMessageControl } from '../../types';
|
||||||
import { getDurationString, prettifyTitle } from '../../utils/basic';
|
import { getDurationString, prettifyTitle } from '../../utils/basic';
|
||||||
|
import {
|
||||||
|
downloadMedia,
|
||||||
|
navigateToTimeline,
|
||||||
|
toggleFavorite,
|
||||||
|
toggleReviewed,
|
||||||
|
} from '../../utils/media-actions';
|
||||||
import { ViewItem } from '../../view/item';
|
import { ViewItem } from '../../view/item';
|
||||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||||
|
import { ViewItemCapabilities } from '../../view/types';
|
||||||
|
|
||||||
interface Detail {
|
export interface OverlayControlsContext {
|
||||||
icon?: Icon;
|
hass?: HomeAssistant;
|
||||||
hint?: string;
|
viewItemManager?: ViewItemManager;
|
||||||
title: string;
|
viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
capabilities?: ViewItemCapabilities | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ThumbnailDetailsController {
|
export class MediaDetailsController {
|
||||||
private _details: Detail[] = [];
|
private _details: MetadataField[] = [];
|
||||||
private _heading: string | null = null;
|
private _heading: MetadataField | null = null;
|
||||||
|
private _item: ViewItem | null = null;
|
||||||
|
|
||||||
public calculate(
|
public calculate(
|
||||||
cameraManager?: CameraManager | null,
|
cameraManager?: CameraManager | null,
|
||||||
item?: ViewItem,
|
item?: ViewItem,
|
||||||
seek?: Date,
|
seek?: Date,
|
||||||
): void {
|
): void {
|
||||||
|
this._item = item ?? null;
|
||||||
const cameraID = ViewItemClassifier.isMedia(item) ? item.getCameraID() : null;
|
const cameraID = ViewItemClassifier.isMedia(item) ? item.getCameraID() : null;
|
||||||
const cameraMetadata = cameraID
|
const cameraMetadata = cameraID
|
||||||
? cameraManager?.getCameraMetadata(cameraID) ?? null
|
? cameraManager?.getCameraMetadata(cameraID) ?? null
|
||||||
@@ -43,12 +56,34 @@ export class ThumbnailDetailsController {
|
|||||||
const rawScore = item.getScore();
|
const rawScore = item.getScore();
|
||||||
const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cameraMetadata?.title) {
|
if (cameraMetadata?.title) {
|
||||||
this._heading = cameraMetadata.title;
|
this._heading = {
|
||||||
|
title: cameraMetadata.title,
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +172,9 @@ export class ThumbnailDetailsController {
|
|||||||
|
|
||||||
// To avoid duplication, if the event has a starttime, the title is omitted
|
// To avoid duplication, if the event has a starttime, the title is omitted
|
||||||
// from the details.
|
// from the details.
|
||||||
const includeTitle = !ViewItemClassifier.isEvent(item) || !startTime;
|
const includeTitle =
|
||||||
|
(!ViewItemClassifier.isEvent(item) && !ViewItemClassifier.isReview(item)) ||
|
||||||
|
!startTime;
|
||||||
this._details = [
|
this._details = [
|
||||||
...(includeTitle && itemTitle
|
...(includeTitle && itemTitle
|
||||||
? [
|
? [
|
||||||
@@ -154,11 +191,91 @@ export class ThumbnailDetailsController {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public getHeading(): string | null {
|
public getHeading(): MetadataField | null {
|
||||||
return this._heading;
|
return this._heading;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDetails(): Detail[] {
|
public getDetails(): MetadataField[] {
|
||||||
return this._details;
|
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 { MediaPlayerManager } from '../card-controller/media-player-manager';
|
||||||
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
||||||
import { ViewManager } from '../card-controller/view/view-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 { MenuItem } from '../config/schema/elements/custom/menu/types';
|
||||||
import { AdvancedCameraCardConfig } from '../config/schema/types';
|
import { AdvancedCameraCardConfig } from '../config/schema/types';
|
||||||
import { getEntityTitle } from '../ha/get-entity-title';
|
import { getEntityTitle } from '../ha/get-entity-title';
|
||||||
@@ -19,6 +23,7 @@ import {
|
|||||||
createMediaPlayerAction,
|
createMediaPlayerAction,
|
||||||
createPTZControlsAction,
|
createPTZControlsAction,
|
||||||
createPTZMultiAction,
|
createPTZMultiAction,
|
||||||
|
createSetReviewAction,
|
||||||
createViewAction,
|
createViewAction,
|
||||||
isAdvancedCameraCardCustomAction,
|
isAdvancedCameraCardCustomAction,
|
||||||
} from '../utils/action';
|
} from '../utils/action';
|
||||||
@@ -27,7 +32,6 @@ import { isBeingCasted } from '../utils/casting';
|
|||||||
import { getPTZTarget } from '../utils/ptz';
|
import { getPTZTarget } from '../utils/ptz';
|
||||||
import { getStreamCameraID, hasSubstream } from '../utils/substream';
|
import { getStreamCameraID, hasSubstream } from '../utils/substream';
|
||||||
import { ViewItemClassifier } from '../view/item-classifier';
|
import { ViewItemClassifier } from '../view/item-classifier';
|
||||||
import { QueryClassifier } from '../view/query-classifier';
|
|
||||||
import { View } from '../view/view';
|
import { View } from '../view/view';
|
||||||
import { getCameraIDsForViewName, isViewSupportedByCamera } from '../view/view-support';
|
import { getCameraIDsForViewName, isViewSupportedByCamera } from '../view/view-support';
|
||||||
|
|
||||||
@@ -77,9 +81,12 @@ export class MenuButtonController {
|
|||||||
this._getClipsButton(config, cameraManager, foldersManager, options?.view),
|
this._getClipsButton(config, cameraManager, foldersManager, options?.view),
|
||||||
this._getSnapshotsButton(config, cameraManager, foldersManager, options?.view),
|
this._getSnapshotsButton(config, cameraManager, foldersManager, options?.view),
|
||||||
this._getRecordingsButton(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._getImageButton(config, cameraManager, foldersManager, options?.view),
|
||||||
this._getTimelineButton(config, cameraManager, foldersManager, options?.view),
|
this._getTimelineButton(config, cameraManager, foldersManager, options?.view),
|
||||||
this._getDownloadButton(config, cameraManager, 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._getCameraUIButton(config, options?.showCameraUIButton),
|
||||||
this._getMicrophoneButton(
|
this._getMicrophoneButton(
|
||||||
config,
|
config,
|
||||||
@@ -245,17 +252,45 @@ export class MenuButtonController {
|
|||||||
: null;
|
: 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(
|
protected _getClipsButton(
|
||||||
config: AdvancedCameraCardConfig,
|
config: AdvancedCameraCardConfig,
|
||||||
cameraManager: CameraManager,
|
cameraManager: CameraManager,
|
||||||
foldersManager: FoldersManager,
|
foldersManager: FoldersManager,
|
||||||
view?: View | null,
|
view?: View | null,
|
||||||
): MenuItem | null {
|
): MenuItem | null {
|
||||||
return view &&
|
return this._shouldShowEventMediaButton(
|
||||||
isViewSupportedByCamera('clips', cameraManager, foldersManager, view.camera)
|
'clips',
|
||||||
|
config.menu.buttons.clips,
|
||||||
|
cameraManager,
|
||||||
|
foldersManager,
|
||||||
|
view,
|
||||||
|
)
|
||||||
? {
|
? {
|
||||||
icon: 'mdi:filmstrip',
|
icon: 'mdi:filmstrip',
|
||||||
...config.menu.buttons.clips,
|
...config.menu.buttons.clips,
|
||||||
|
enabled: true,
|
||||||
type: 'custom:advanced-camera-card-menu-icon',
|
type: 'custom:advanced-camera-card-menu-icon',
|
||||||
title: localize('config.view.views.clips'),
|
title: localize('config.view.views.clips'),
|
||||||
style: view?.is('clips') ? this._getEmphasizedStyle() : {},
|
style: view?.is('clips') ? this._getEmphasizedStyle() : {},
|
||||||
@@ -271,11 +306,17 @@ export class MenuButtonController {
|
|||||||
foldersManager: FoldersManager,
|
foldersManager: FoldersManager,
|
||||||
view?: View | null,
|
view?: View | null,
|
||||||
): MenuItem | null {
|
): MenuItem | null {
|
||||||
return view &&
|
return this._shouldShowEventMediaButton(
|
||||||
isViewSupportedByCamera('snapshots', cameraManager, foldersManager, view.camera)
|
'snapshots',
|
||||||
|
config.menu.buttons.snapshots,
|
||||||
|
cameraManager,
|
||||||
|
foldersManager,
|
||||||
|
view,
|
||||||
|
)
|
||||||
? {
|
? {
|
||||||
icon: 'mdi:camera',
|
icon: 'mdi:camera',
|
||||||
...config.menu.buttons.snapshots,
|
...config.menu.buttons.snapshots,
|
||||||
|
enabled: true,
|
||||||
type: 'custom:advanced-camera-card-menu-icon',
|
type: 'custom:advanced-camera-card-menu-icon',
|
||||||
title: localize('config.view.views.snapshots'),
|
title: localize('config.view.views.snapshots'),
|
||||||
style: view?.is('snapshots') ? this._getEmphasizedStyle() : {},
|
style: view?.is('snapshots') ? this._getEmphasizedStyle() : {},
|
||||||
@@ -305,6 +346,26 @@ export class MenuButtonController {
|
|||||||
: null;
|
: 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(
|
protected _getImageButton(
|
||||||
config: AdvancedCameraCardConfig,
|
config: AdvancedCameraCardConfig,
|
||||||
cameraManager: CameraManager,
|
cameraManager: CameraManager,
|
||||||
@@ -365,6 +426,49 @@ export class MenuButtonController {
|
|||||||
return null;
|
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(
|
protected _getCameraUIButton(
|
||||||
config: AdvancedCameraCardConfig,
|
config: AdvancedCameraCardConfig,
|
||||||
showCameraUIButton?: boolean,
|
showCameraUIButton?: boolean,
|
||||||
@@ -667,9 +771,8 @@ export class MenuButtonController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (folders.length === 1) {
|
if (folders.length === 1) {
|
||||||
const isSelected =
|
const folderID = folders[0][0];
|
||||||
QueryClassifier.isFolderQuery(view?.query) &&
|
const isSelected = !!view?.query?.getFolderQueries(folderID).length;
|
||||||
view.query.getQuery()?.folder.id === folders[0][0];
|
|
||||||
const folder = folders[0][1];
|
const folder = folders[0][1];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -684,9 +787,7 @@ export class MenuButtonController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const submenuItems = folders.map(([id, folder]) => {
|
const submenuItems = folders.map(([id, folder]) => {
|
||||||
const isSelected =
|
const isSelected = !!view?.query?.getFolderQueries(id).length;
|
||||||
QueryClassifier.isFolderQuery(view?.query) &&
|
|
||||||
view.query.getQuery()?.folder.id === id;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -736,6 +837,7 @@ export class MenuButtonController {
|
|||||||
button: MenuItem,
|
button: MenuItem,
|
||||||
options?: MenuButtonControllerOptions,
|
options?: MenuButtonControllerOptions,
|
||||||
): StyleInfo {
|
): StyleInfo {
|
||||||
|
// Review
|
||||||
for (const actionSet of [
|
for (const actionSet of [
|
||||||
button.tap_action,
|
button.tap_action,
|
||||||
button.double_tap_action,
|
button.double_tap_action,
|
||||||
@@ -752,7 +854,9 @@ export class MenuButtonController {
|
|||||||
VIEWS_USER_SPECIFIED.some(
|
VIEWS_USER_SPECIFIED.some(
|
||||||
(viewName) =>
|
(viewName) =>
|
||||||
viewName === action.advanced_camera_card_action &&
|
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' &&
|
(action.advanced_camera_card_action === 'default' &&
|
||||||
options?.view?.is(config.view.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) {
|
if (thumbnail) {
|
||||||
this._thumbnail = thumbnail;
|
this._thumbnail = thumbnail;
|
||||||
this._icon = null;
|
this._icon = null;
|
||||||
this._thumbnailClass = isBrandUrl(thumbnail) ? 'brand' : null;
|
this._thumbnailClass = isBrandUrl(thumbnail) ? 'placeholder' : null;
|
||||||
} else {
|
} else {
|
||||||
this._thumbnail = null;
|
this._thumbnail = null;
|
||||||
this._thumbnailClass = null;
|
this._thumbnailClass = null;
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
} from 'vis-timeline';
|
} from 'vis-timeline';
|
||||||
import { CameraManager } from '../../camera-manager/manager';
|
import { CameraManager } from '../../camera-manager/manager';
|
||||||
import { rangesOverlap } from '../../camera-manager/range';
|
import { rangesOverlap } from '../../camera-manager/range';
|
||||||
import { MediaQuery } from '../../camera-manager/types';
|
|
||||||
import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly';
|
import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly';
|
||||||
import { FoldersManager } from '../../card-controller/folders/manager';
|
import { FoldersManager } from '../../card-controller/folders/manager';
|
||||||
import { ViewItemManager } from '../../card-controller/view/item-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 { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
|
||||||
import { ViewMedia } from '../../view/item';
|
import { ViewMedia } from '../../view/item';
|
||||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
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 { QueryResults } from '../../view/query-results';
|
||||||
|
import { UnifiedQuery } from '../../view/unified-query';
|
||||||
|
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer';
|
||||||
import { mergeViewContext } from '../../view/view';
|
import { mergeViewContext } from '../../view/view';
|
||||||
import { AdvancedCameraCardTimelineItem, TimelineDataSource } from './source';
|
import { AdvancedCameraCardTimelineItem, TimelineDataSource } from './source';
|
||||||
import {
|
import { ExtendedTimeline, TimelineItemClickAction, TimelineRangeChange } from './types';
|
||||||
ExtendedTimeline,
|
|
||||||
TimelineItemClickAction,
|
|
||||||
TimelineKeys,
|
|
||||||
TimelineRangeChange,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
// An event used to fetch data required for thumbnail rendering. See special
|
// An event used to fetch data required for thumbnail rendering. See special
|
||||||
// note below on why this is necessary.
|
// note below on why this is necessary.
|
||||||
@@ -81,7 +70,7 @@ interface TimelineControllerOptions {
|
|||||||
timelineConfig?: TimelineCoreConfig;
|
timelineConfig?: TimelineCoreConfig;
|
||||||
mini?: boolean;
|
mini?: boolean;
|
||||||
thumbnailConfig?: ThumbnailsControlBaseConfig;
|
thumbnailConfig?: ThumbnailsControlBaseConfig;
|
||||||
keys?: TimelineKeys;
|
query?: UnifiedQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIMELINE_TARGET_BAR_ID = 'target_bar';
|
const TIMELINE_TARGET_BAR_ID = 'target_bar';
|
||||||
@@ -94,10 +83,14 @@ export class TimelineController {
|
|||||||
private _timeline: ExtendedTimeline | null = null;
|
private _timeline: ExtendedTimeline | null = null;
|
||||||
|
|
||||||
private _hass: HomeAssistant | null = null;
|
private _hass: HomeAssistant | null = null;
|
||||||
|
|
||||||
private _cameraManager: CameraManager | null = null;
|
private _cameraManager: CameraManager | null = null;
|
||||||
|
private _foldersManager: FoldersManager | null = null;
|
||||||
|
|
||||||
private _viewItemManager: ViewItemManager | null = null;
|
private _viewItemManager: ViewItemManager | null = null;
|
||||||
private _viewManagerEpoch: ViewManagerEpoch | null = null;
|
private _viewManagerEpoch: ViewManagerEpoch | null = null;
|
||||||
private _timelineConfig: TimelineCoreConfig | null = null;
|
private _timelineConfig: TimelineCoreConfig | null = null;
|
||||||
|
|
||||||
private _mini = false;
|
private _mini = false;
|
||||||
|
|
||||||
private _panMode: TimelinePanMode | null = null;
|
private _panMode: TimelinePanMode | null = null;
|
||||||
@@ -136,26 +129,56 @@ export class TimelineController {
|
|||||||
this._pointerHeld = null;
|
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 (
|
private _hasSameShape(a?: UnifiedQuery | null, b?: UnifiedQuery | null): boolean {
|
||||||
options.keys &&
|
if (!a && !b) {
|
||||||
options.cameraManager &&
|
return true;
|
||||||
options.foldersManager &&
|
}
|
||||||
options.conditionStateManager &&
|
if (!a || !b) {
|
||||||
options.timelineConfig
|
return false;
|
||||||
) {
|
}
|
||||||
this._source = new TimelineDataSource(
|
return a.isEqual(b);
|
||||||
options.cameraManager,
|
}
|
||||||
options.foldersManager,
|
|
||||||
options.conditionStateManager,
|
public setOptions(options: TimelineControllerOptions): void {
|
||||||
options.keys,
|
// Extract the shape (query without time ranges) for comparison.
|
||||||
options.timelineConfig.events_media_type,
|
const newShape = options.query ? this._getQueryShape(options.query) : null;
|
||||||
options.timelineConfig.show_recordings,
|
|
||||||
);
|
// Rebuild source if config, dependencies, or shape changed.
|
||||||
} else {
|
const needsRebuild =
|
||||||
this._source = null;
|
!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)) {
|
if (this._thumbnailConfig !== (options.thumbnailConfig ?? null)) {
|
||||||
@@ -187,6 +210,7 @@ export class TimelineController {
|
|||||||
|
|
||||||
this._thumbnailConfig = options?.thumbnailConfig ?? null;
|
this._thumbnailConfig = options?.thumbnailConfig ?? null;
|
||||||
this._cameraManager = options?.cameraManager ?? null;
|
this._cameraManager = options?.cameraManager ?? null;
|
||||||
|
this._foldersManager = options?.foldersManager ?? null;
|
||||||
this._viewItemManager = options?.viewItemManager ?? null;
|
this._viewItemManager = options?.viewItemManager ?? null;
|
||||||
this._timelineConfig = options?.timelineConfig ?? null;
|
this._timelineConfig = options?.timelineConfig ?? null;
|
||||||
this._mini = options?.mini ?? false;
|
this._mini = options?.mini ?? false;
|
||||||
@@ -268,13 +292,13 @@ export class TimelineController {
|
|||||||
this._timeline = new Timeline(
|
this._timeline = new Timeline(
|
||||||
this._timelineElement,
|
this._timelineElement,
|
||||||
this._source.dataset,
|
this._source.dataset,
|
||||||
|
this._source.groups,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this._timeline = new Timeline(
|
this._timeline = new Timeline(
|
||||||
this._timelineElement,
|
this._timelineElement,
|
||||||
this._source.dataset,
|
this._source.dataset,
|
||||||
this._source.groups,
|
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -500,16 +524,15 @@ export class TimelineController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const view = this._viewManagerEpoch?.manager.getView();
|
const view = this._viewManagerEpoch?.manager.getView();
|
||||||
const id = String(properties.item);
|
const id = properties.item ? String(properties.item) : null;
|
||||||
const item = this._source?.dataset.get(id) ?? null;
|
const item = id ? this._source?.dataset.get(id) ?? null : null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this._ignoreClick ||
|
this._ignoreClick ||
|
||||||
!view ||
|
!view ||
|
||||||
!this._viewManagerEpoch ||
|
!this._viewManagerEpoch ||
|
||||||
!this._source ||
|
!this._source ||
|
||||||
!properties.what ||
|
!properties.what
|
||||||
!item
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -519,9 +542,15 @@ export class TimelineController {
|
|||||||
if (
|
if (
|
||||||
this._timelineConfig?.show_recordings &&
|
this._timelineConfig?.show_recordings &&
|
||||||
properties.time &&
|
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) {
|
if (query) {
|
||||||
await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||||
baseView: view,
|
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 cameraID = String(properties.group);
|
||||||
|
|
||||||
const criteria = {
|
const criteria = {
|
||||||
@@ -566,49 +600,16 @@ export class TimelineController {
|
|||||||
// - If a folder media was loaded into the timeline from a prior folder
|
// - 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
|
// query other than the one stored in the view (e.g. user navigated to
|
||||||
// a different folder in the thumbnails carousel).
|
// a different folder in the thumbnails carousel).
|
||||||
if (item.query) {
|
const queryResults = this._buildQueryResultsFromExistingItem(item);
|
||||||
// Item has a reference query (e.g. folders), use that.
|
if (item && item.query && queryResults) {
|
||||||
const media = this._source.dataset
|
this._viewManagerEpoch?.manager.setViewByParameters({
|
||||||
.get({
|
params: {
|
||||||
filter: (timelineItem) => item.query === timelineItem.query,
|
view: 'media',
|
||||||
})
|
query: item.query,
|
||||||
.map((timelineItem) => timelineItem.media)
|
queryResults,
|
||||||
.filter(isTruthy);
|
},
|
||||||
const selectedIndex = media.findIndex((m) => m.getID() === id);
|
modifiers: [new MergeContextViewModifier(context)],
|
||||||
|
});
|
||||||
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)],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this._viewManagerEpoch.manager.setViewByParameters({
|
this._viewManagerEpoch.manager.setViewByParameters({
|
||||||
@@ -630,6 +631,25 @@ export class TimelineController {
|
|||||||
this._ignoreClick = false;
|
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.
|
* Get a broader prefetch window from a start and end basis.
|
||||||
* @param window The window to broaden.
|
* @param window The window to broaden.
|
||||||
@@ -643,31 +663,19 @@ export class TimelineController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createQuery(
|
/**
|
||||||
type: QueryType,
|
* Apply a cache-friendly prefetch window to all media queries.
|
||||||
options?: {
|
*/
|
||||||
window?: TimelineWindow;
|
private _applyWindowToQuery(
|
||||||
},
|
query: UnifiedQuery,
|
||||||
): Query | null {
|
window: TimelineWindow,
|
||||||
if (!this._timeline || !this._source) {
|
): UnifiedQuery {
|
||||||
return null;
|
const prefetchWindow = this._getPrefetchWindow(window);
|
||||||
}
|
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(prefetchWindow);
|
||||||
|
return UnifiedQueryTransformer.rebuildQuery(query, {
|
||||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(
|
start: cacheFriendlyWindow.start,
|
||||||
this._getPrefetchWindow(options?.window ?? this._timeline.getWindow()),
|
end: cacheFriendlyWindow.end,
|
||||||
);
|
});
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _timelineRangeChangedHandler = async (properties: {
|
private _timelineRangeChangedHandler = async (properties: {
|
||||||
@@ -690,16 +698,14 @@ export class TimelineController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._source?.refresh(this._getPrefetchWindow(properties), {
|
await this._source?.refresh(this._getPrefetchWindow(properties));
|
||||||
view,
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryType = QueryClassifier.getQueryType(view.query);
|
if (!view.query) {
|
||||||
if (!queryType) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const query = this._createQuery(queryType);
|
const query = this._applyWindowToQuery(view.query, properties);
|
||||||
if (!query || this._alreadyHasAcceptableMediaQuery(query)) {
|
|
||||||
|
if (this._alreadyHasAcceptableMediaQuery(query)) {
|
||||||
return;
|
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 view = this._viewManagerEpoch?.manager.getView();
|
||||||
const query = view?.query;
|
const query = view?.query;
|
||||||
|
|
||||||
if (!this._cameraManager || !query) {
|
if (!this._source || !query) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentResultTimestamp = view?.queryResults?.getResultsTimestamp();
|
const currentResultTimestamp = view?.queryResults?.getResultsTimestamp();
|
||||||
|
if (!currentResultTimestamp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
!!query?.getQuery() &&
|
query.isSupersetOf(freshQuery) &&
|
||||||
!!currentResultTimestamp &&
|
this._source.areResultsFresh(currentResultTimestamp, query)
|
||||||
((QueryClassifier.isFolderQuery(query) && query.isEqual(freshQuery)) ||
|
|
||||||
(QueryClassifier.isMediaQuery(query) &&
|
|
||||||
QueryClassifier.isMediaQuery(freshQuery) &&
|
|
||||||
query.isSupersetOf(freshQuery) &&
|
|
||||||
this._cameraManager.areMediaQueriesResultsFresh<MediaQuery>(
|
|
||||||
currentResultTimestamp,
|
|
||||||
query.getQuery(),
|
|
||||||
)))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,16 +788,14 @@ export class TimelineController {
|
|||||||
}
|
}
|
||||||
const prefetchedWindow = this._getPrefetchWindow(desiredWindow);
|
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
|
// 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
|
// currently interacting with it. Without this the subsequent data fetches
|
||||||
// (via fetchIfNecessary) may update the timeline contents which causes
|
// (via fetchIfNecessary) may update the timeline contents which causes
|
||||||
// the visjs timeline to stop dragging/panning operations which is very
|
// the visjs timeline to stop dragging/panning operations which is very
|
||||||
// disruptive to the user.
|
// disruptive to the user.
|
||||||
await this._source?.refresh(prefetchedWindow, {
|
await this._source?.refresh(prefetchedWindow);
|
||||||
view,
|
this._source.addMediaToDataset(view.query, view.queryResults?.getResults());
|
||||||
});
|
|
||||||
this._source.addEventMediaToDataset(view.queryResults?.getResults(), view.query);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSelection = this._timeline.getSelection();
|
const currentSelection = this._timeline.getSelection();
|
||||||
@@ -845,14 +844,11 @@ export class TimelineController {
|
|||||||
//
|
//
|
||||||
// Also don't generate thumbnails in mini-timelines (they will already have
|
// Also don't generate thumbnails in mini-timelines (they will already have
|
||||||
// been generated).
|
// been generated).
|
||||||
const queryType = QueryClassifier.getQueryType(view.query);
|
if (!view.query) {
|
||||||
if (!queryType) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const freshMediaQuery = this._createQuery(queryType, {
|
const freshMediaQuery = this._applyWindowToQuery(view.query, desiredWindow);
|
||||||
window: desiredWindow,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!this._mini &&
|
!this._mini &&
|
||||||
|
|||||||
@@ -1,31 +1,25 @@
|
|||||||
import { add, sub } from 'date-fns';
|
import { add, sub } from 'date-fns';
|
||||||
import { DataSet } from 'vis-data';
|
import { DataSet } from 'vis-data';
|
||||||
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
|
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
|
||||||
import { EqualityCache } from '../../cache/equality-cache';
|
|
||||||
import { CameraManager } from '../../camera-manager/manager';
|
import { CameraManager } from '../../camera-manager/manager';
|
||||||
import {
|
import {
|
||||||
compressRanges,
|
compressRanges,
|
||||||
ExpiringMemoryRangeSet,
|
ExpiringMemoryRangeSet,
|
||||||
MemoryRangeSet,
|
MemoryRangeSet,
|
||||||
} from '../../camera-manager/range';
|
} from '../../camera-manager/range';
|
||||||
import {
|
import { RecordingSegment } from '../../camera-manager/types';
|
||||||
EventQuery,
|
|
||||||
RecordingQuery,
|
|
||||||
RecordingSegment,
|
|
||||||
} from '../../camera-manager/types';
|
|
||||||
import { capEndDate } from '../../camera-manager/utils/cap-end-date';
|
import { capEndDate } from '../../camera-manager/utils/cap-end-date';
|
||||||
import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly';
|
import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly';
|
||||||
import { FoldersManager } from '../../card-controller/folders/manager';
|
import { FoldersManager } from '../../card-controller/folders/manager';
|
||||||
import { FolderQuery } from '../../card-controller/folders/types';
|
|
||||||
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
|
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
|
||||||
import { FolderConfig } from '../../config/schema/folders';
|
import { FolderConfig } from '../../config/schema/folders';
|
||||||
import { ClipsOrSnapshotsOrAll } from '../../types';
|
import { errorToConsole } from '../../utils/basic.js';
|
||||||
import { errorToConsole, ModifyInterface } from '../../utils/basic.js';
|
|
||||||
import { ViewItem, ViewMedia } from '../../view/item';
|
import { ViewItem, ViewMedia } from '../../view/item';
|
||||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||||
import { FolderViewQuery, Query } from '../../view/query';
|
import { UnifiedQuery } from '../../view/unified-query';
|
||||||
import { View } from '../../view/view';
|
import { UnifiedQueryBuilder } from '../../view/unified-query-builder';
|
||||||
import { TimelineKeys } from './types';
|
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
|
// 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).
|
// (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.
|
// instead of clean recording blocks.
|
||||||
const TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS = 60;
|
const TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS = 60;
|
||||||
|
|
||||||
type TimelineViewQuery = Query;
|
export type AdvancedCameraCardTimelineItem = TimelineItem & {
|
||||||
|
|
||||||
export interface AdvancedCameraCardTimelineItem extends TimelineItem {
|
|
||||||
// Use numbers to avoid significant volumes of Date object construction (for
|
// Use numbers to avoid significant volumes of Date object construction (for
|
||||||
// high-quantity recording segments).
|
// high-quantity recording segments).
|
||||||
start: number;
|
start: number;
|
||||||
@@ -48,25 +40,33 @@ export interface AdvancedCameraCardTimelineItem extends TimelineItem {
|
|||||||
// DataSet requires string (not HTMLElement) content.
|
// DataSet requires string (not HTMLElement) content.
|
||||||
content: string;
|
content: string;
|
||||||
|
|
||||||
media?: ViewMedia;
|
// Severity is duplicated here (also available via media.getSeverity())
|
||||||
|
// because vis-timeline's dataAttributes option requires properties to exist
|
||||||
// View query object from which this timeline item is associated with.
|
// directly on the item object to render them as data-* HTML attributes for
|
||||||
query?: TimelineViewQuery;
|
// 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 {
|
interface AdvancedCameraCardGroup {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RefreshOptions {
|
|
||||||
view?: View;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TimelineDataSource {
|
export class TimelineDataSource {
|
||||||
private _cameraManager: CameraManager;
|
private _cameraManager: CameraManager;
|
||||||
private _foldersManager: FoldersManager;
|
|
||||||
private _conditionStateManager: ConditionStateManagerReadonlyInterface;
|
private _builder: UnifiedQueryBuilder;
|
||||||
|
private _runner: UnifiedQueryRunner;
|
||||||
|
|
||||||
private _dataset: DataSet<AdvancedCameraCardTimelineItem> = new DataSet();
|
private _dataset: DataSet<AdvancedCameraCardTimelineItem> = new DataSet();
|
||||||
private _groups: DataSet<AdvancedCameraCardGroup>;
|
private _groups: DataSet<AdvancedCameraCardGroup>;
|
||||||
|
|
||||||
@@ -76,33 +76,34 @@ export class TimelineDataSource {
|
|||||||
// high-N segments into a smaller number of consecutive recording blocks).
|
// high-N segments into a smaller number of consecutive recording blocks).
|
||||||
private _recordingRanges = new MemoryRangeSet();
|
private _recordingRanges = new MemoryRangeSet();
|
||||||
|
|
||||||
// Cache event ranges since re-adding the same events is a timeline
|
// Cache for all query results in this source instance.
|
||||||
// performance killer (even if the request results are cached).
|
// Uses a single range set since shape determines content type.
|
||||||
private _eventRanges = new ExpiringMemoryRangeSet();
|
private _cache = new ExpiringMemoryRangeSet();
|
||||||
private _folderCache = new EqualityCache<FolderQuery, Date>();
|
|
||||||
|
|
||||||
private _eventsMediaType: ClipsOrSnapshotsOrAll;
|
|
||||||
private _showRecordings: boolean;
|
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(
|
constructor(
|
||||||
cameraManager: CameraManager,
|
cameraManager: CameraManager,
|
||||||
foldersManager: FoldersManager,
|
foldersManager: FoldersManager,
|
||||||
conditionStateManager: ConditionStateManagerReadonlyInterface,
|
conditionStateManager: ConditionStateManagerReadonlyInterface,
|
||||||
keys: TimelineKeys,
|
shape: UnifiedQuery,
|
||||||
eventsMediaType: ClipsOrSnapshotsOrAll,
|
|
||||||
showRecordings: boolean,
|
showRecordings: boolean,
|
||||||
) {
|
) {
|
||||||
this._cameraManager = cameraManager;
|
this._cameraManager = cameraManager;
|
||||||
this._foldersManager = foldersManager;
|
this._builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||||
this._conditionStateManager = conditionStateManager;
|
this._runner = new UnifiedQueryRunner(
|
||||||
this._keys = keys;
|
cameraManager,
|
||||||
|
foldersManager,
|
||||||
this._groups = this._generateGroups(keys);
|
conditionStateManager,
|
||||||
|
);
|
||||||
this._eventsMediaType = eventsMediaType;
|
this._shape = shape;
|
||||||
this._showRecordings = showRecordings;
|
this._showRecordings = showRecordings;
|
||||||
|
|
||||||
|
this._groups = this._generateGroups();
|
||||||
}
|
}
|
||||||
|
|
||||||
get dataset(): DataSet<AdvancedCameraCardTimelineItem> {
|
get dataset(): DataSet<AdvancedCameraCardTimelineItem> {
|
||||||
@@ -113,8 +114,12 @@ export class TimelineDataSource {
|
|||||||
return this._groups;
|
return this._groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getKeyType(): 'camera' | 'folder' {
|
get shape(): UnifiedQuery {
|
||||||
return this._keys.type;
|
return this._shape;
|
||||||
|
}
|
||||||
|
|
||||||
|
public areResultsFresh(resultsTimestamp: Date, query: UnifiedQuery): boolean {
|
||||||
|
return this._runner.areResultsFresh(resultsTimestamp, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getGroupIDForCamera(cameraID: string): string {
|
private _getGroupIDForCamera(cameraID: string): string {
|
||||||
@@ -122,30 +127,32 @@ export class TimelineDataSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _getGroupIDForFolder(folderConfig: FolderConfig): string {
|
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[] = [];
|
const groups: AdvancedCameraCardGroup[] = [];
|
||||||
|
|
||||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
// Add folder-based groups
|
||||||
if (keys.type === 'camera') {
|
const folderQueries = this._shape.getFolderQueries();
|
||||||
keys.cameraIDs?.forEach((cameraID) => {
|
for (const folderQuery of folderQueries) {
|
||||||
const cameraMetadata = this._cameraManager.getCameraMetadata(cameraID);
|
const folderID = this._getGroupIDForFolder(folderQuery.folder);
|
||||||
|
|
||||||
groups.push({
|
|
||||||
id: this._getGroupIDForCamera(cameraID),
|
|
||||||
content: cameraMetadata?.title ?? cameraID,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else if (keys.type === 'folder') {
|
|
||||||
const folderID = this._getGroupIDForFolder(keys.folder);
|
|
||||||
groups.push({
|
groups.push({
|
||||||
id: folderID,
|
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);
|
return new DataSet(groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,14 +171,11 @@ export class TimelineDataSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public addEventMediaToDataset(
|
public addMediaToDataset(query: UnifiedQuery, mediaArray?: ViewItem[] | null): void {
|
||||||
mediaArray?: ViewItem[] | null,
|
|
||||||
query?: TimelineViewQuery | null,
|
|
||||||
): void {
|
|
||||||
const data: AdvancedCameraCardTimelineItem[] = [];
|
const data: AdvancedCameraCardTimelineItem[] = [];
|
||||||
|
|
||||||
for (const media of mediaArray ?? []) {
|
for (const media of mediaArray ?? []) {
|
||||||
if (!ViewItemClassifier.isEvent(media)) {
|
if (!ViewItemClassifier.isEvent(media) && !ViewItemClassifier.isReview(media)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +197,10 @@ export class TimelineDataSource {
|
|||||||
start: startTime.getTime(),
|
start: startTime.getTime(),
|
||||||
type: 'range',
|
type: 'range',
|
||||||
end: media.getUsableEndTime()?.getTime(),
|
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);
|
this._dataset.update(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _refreshEvents(
|
public buildRecordingsWindowedQuery(window: TimelineWindow): UnifiedQuery | null {
|
||||||
window: TimelineWindow,
|
return this._builder.buildRecordingsQuery(this._shape.getAllCameraIDs(), {
|
||||||
options?: RefreshOptions,
|
start: window.start,
|
||||||
): Promise<void> {
|
end: window.end,
|
||||||
await this._refreshEventsFromCamera(window, options);
|
});
|
||||||
await this._refreshEventsFromFolder();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _refreshEventsFromCamera(
|
private async _refreshQuery(window: TimelineWindow): Promise<void> {
|
||||||
window: TimelineWindow,
|
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
_options?: RefreshOptions,
|
|
||||||
): Promise<void> {
|
|
||||||
if (this._keys.type !== 'camera') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this._eventRanges.hasCoverage({
|
this._cache.hasCoverage({
|
||||||
start: window.start,
|
start: cacheFriendlyWindow.start,
|
||||||
end: sub(capEndDate(window.end), {
|
end: sub(capEndDate(cacheFriendlyWindow.end), {
|
||||||
seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS,
|
seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
|
|
||||||
const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow);
|
|
||||||
if (!eventQueries) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.addEventMediaToDataset(
|
const query = UnifiedQueryTransformer.rebuildQuery(this._shape, {
|
||||||
await this._cameraManager.executeMediaQueries(eventQueries),
|
start: cacheFriendlyWindow.start,
|
||||||
);
|
end: cacheFriendlyWindow.end,
|
||||||
|
});
|
||||||
|
|
||||||
this._eventRanges.add({
|
this.addMediaToDataset(query, await this._runner.execute(query));
|
||||||
|
this._cache.add({
|
||||||
...cacheFriendlyWindow,
|
...cacheFriendlyWindow,
|
||||||
expires: add(new Date(), { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS }),
|
expires: add(new Date(), { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _refreshEventsFromFolder(): Promise<void> {
|
public async refresh(window: TimelineWindow): 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> {
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this._refreshEvents(window, options),
|
this._refreshQuery(window),
|
||||||
...(this._showRecordings ? [this._refreshRecordings(window)] : []),
|
...(this._showRecordings ? [this._refreshRecordings(window)] : []),
|
||||||
]);
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToConsole(e as Error);
|
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> {
|
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) {
|
if (!cameraIDs?.size) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdvancedCameraCardTimelineItemWithEnd = ModifyInterface<
|
type AdvancedCameraCardTimelineItemWithEnd = AdvancedCameraCardTimelineItem & {
|
||||||
AdvancedCameraCardTimelineItem,
|
end: number;
|
||||||
{ end: number }
|
};
|
||||||
>;
|
|
||||||
|
|
||||||
const convertSegmentToRecording = (
|
const convertSegmentToRecording = (
|
||||||
cameraID: string,
|
cameraID: string,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { CameraManager } from '../../camera-manager/manager';
|
|||||||
import { ViewItemManager } from '../../card-controller/view/item-manager';
|
import { ViewItemManager } from '../../card-controller/view/item-manager';
|
||||||
import { ViewManagerEpoch } from '../../card-controller/view/types';
|
import { ViewManagerEpoch } from '../../card-controller/view/types';
|
||||||
import { CameraConfig } from '../../config/schema/cameras';
|
import { CameraConfig } from '../../config/schema/cameras';
|
||||||
import { FolderConfig } from '../../config/schema/folders';
|
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { ViewMedia } from '../../view/item';
|
import { ViewMedia } from '../../view/item';
|
||||||
|
|
||||||
@@ -21,16 +20,6 @@ export interface ThumbnailDataRequest {
|
|||||||
|
|
||||||
export class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
|
export class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
|
||||||
|
|
||||||
export type TimelineKeys =
|
|
||||||
| {
|
|
||||||
type: 'camera';
|
|
||||||
cameraIDs: Set<string>;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'folder';
|
|
||||||
folder: FolderConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ExtendedTimeline extends Timeline {
|
export interface ExtendedTimeline extends Timeline {
|
||||||
// setCustomTimeMarker currently missing from Timeline types.
|
// setCustomTimeMarker currently missing from Timeline types.
|
||||||
setCustomTimeMarker?(time: DateType, id?: IdType): void;
|
setCustomTimeMarker?(time: DateType, id?: IdType): void;
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ export class AdvancedCameraCardCarousel extends LitElement {
|
|||||||
protected _refRoot: Ref<HTMLElement> = createRef();
|
protected _refRoot: Ref<HTMLElement> = createRef();
|
||||||
protected _carousel: CarouselController | null = null;
|
protected _carousel: CarouselController | null = null;
|
||||||
|
|
||||||
|
// Track slide count to distinguish user-navigation from content changes.
|
||||||
|
protected _previousSlideCount: number | null = null;
|
||||||
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
@@ -100,6 +103,8 @@ export class AdvancedCameraCardCarousel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected updated(changedProps: PropertyValues): void {
|
protected updated(changedProps: PropertyValues): void {
|
||||||
|
const currentSlideCount = this._refParent.value?.assignedElements().length ?? 0;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!this._carousel &&
|
!this._carousel &&
|
||||||
this._refRoot.value &&
|
this._refRoot.value &&
|
||||||
@@ -127,9 +132,21 @@ export class AdvancedCameraCardCarousel extends LitElement {
|
|||||||
wheelScrolling: this.wheelScrolling,
|
wheelScrolling: this.wheelScrolling,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else if (changedProps.has('selected')) {
|
this._previousSlideCount = currentSlideCount;
|
||||||
this._carousel?.selectSlide(this.selected);
|
} 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 {
|
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 { classMap } from 'lit/directives/class-map.js';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
import { CameraManager } from '../camera-manager/manager';
|
import { CameraManager } from '../camera-manager/manager';
|
||||||
|
import { FoldersManager } from '../card-controller/folders/manager';
|
||||||
import { ViewManagerEpoch } from '../card-controller/view/types';
|
import { ViewManagerEpoch } from '../card-controller/view/types';
|
||||||
import {
|
import {
|
||||||
MediaFilterController,
|
MediaFilterController,
|
||||||
MediaFilterCoreFavoriteSelection,
|
|
||||||
MediaFilterCoreWhen,
|
MediaFilterCoreWhen,
|
||||||
MediaFilterMediaType,
|
|
||||||
} from '../components-lib/media-filter-controller';
|
} from '../components-lib/media-filter-controller';
|
||||||
import { CardWideConfig } from '../config/schema/types';
|
import { CardWideConfig } from '../config/schema/types';
|
||||||
import { HomeAssistant } from '../ha/types';
|
import { HomeAssistant } from '../ha/types';
|
||||||
@@ -38,6 +37,9 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public viewManagerEpoch?: ViewManagerEpoch;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public foldersManager?: FoldersManager;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
|||||||
protected _refWhat: Ref<AdvancedCameraCardSelect> = createRef();
|
protected _refWhat: Ref<AdvancedCameraCardSelect> = createRef();
|
||||||
protected _refWhere: Ref<AdvancedCameraCardSelect> = createRef();
|
protected _refWhere: Ref<AdvancedCameraCardSelect> = createRef();
|
||||||
protected _refFavorite: Ref<AdvancedCameraCardSelect> = createRef();
|
protected _refFavorite: Ref<AdvancedCameraCardSelect> = createRef();
|
||||||
|
protected _refReviewed: Ref<AdvancedCameraCardSelect> = createRef();
|
||||||
protected _refTags: Ref<AdvancedCameraCardSelect> = createRef();
|
protected _refTags: Ref<AdvancedCameraCardSelect> = createRef();
|
||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
@@ -63,42 +66,56 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
|||||||
this._mediaFilterController.setViewManager(this.viewManagerEpoch?.manager ?? null);
|
this._mediaFilterController.setViewManager(this.viewManagerEpoch?.manager ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedProps.has('cameraManager') && this.cameraManager) {
|
if (
|
||||||
this._mediaFilterController.computeCameraOptions(this.cameraManager);
|
(changedProps.has('cameraManager') || changedProps.has('foldersManager')) &&
|
||||||
|
this.cameraManager &&
|
||||||
|
this.foldersManager
|
||||||
|
) {
|
||||||
|
this._mediaFilterController.computeCameraOptions(
|
||||||
|
this.cameraManager,
|
||||||
|
this.foldersManager,
|
||||||
|
);
|
||||||
this._mediaFilterController.computeMetadataOptions(this.cameraManager);
|
this._mediaFilterController.computeMetadataOptions(this.cameraManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The first time the viewManager is set, compute the initial default selections.
|
// The first time the viewManager is set, compute the initial default selections.
|
||||||
if (
|
if (
|
||||||
!changedProps.get('viewManager') &&
|
(!changedProps.get('viewManager') || changedProps.has('foldersManager')) &&
|
||||||
this.viewManagerEpoch &&
|
this.viewManagerEpoch &&
|
||||||
this.cameraManager
|
this.cameraManager &&
|
||||||
|
this.foldersManager
|
||||||
) {
|
) {
|
||||||
this._mediaFilterController.computeInitialDefaultsFromView(this.cameraManager);
|
this._mediaFilterController.computeInitialDefaultsFromView(
|
||||||
|
this.cameraManager,
|
||||||
|
this.foldersManager,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
const valueChange = async () => {
|
const valueChange = async () => {
|
||||||
if (!this.cameraManager || !this.viewManagerEpoch || !this.cardWideConfig) {
|
if (
|
||||||
|
!this.cameraManager ||
|
||||||
|
!this.foldersManager ||
|
||||||
|
!this.viewManagerEpoch ||
|
||||||
|
!this.cardWideConfig
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this._mediaFilterController.valueChangeHandler(
|
await this._mediaFilterController.valueChangeHandler(
|
||||||
this.cameraManager,
|
this.cameraManager,
|
||||||
|
this.foldersManager,
|
||||||
this.cardWideConfig,
|
this.cardWideConfig,
|
||||||
{
|
{
|
||||||
camera: this._refCamera.value?.value ?? undefined,
|
camera: this._refCamera.value?.value ?? undefined,
|
||||||
mediaType: (this._refMediaType.value?.value ?? undefined) as
|
mediaTypes: this._refMediaType.value?.value ?? undefined,
|
||||||
| MediaFilterMediaType
|
|
||||||
| undefined,
|
|
||||||
when: {
|
when: {
|
||||||
selected: this._refWhen.value?.value ?? undefined,
|
selected: this._refWhen.value?.value ?? undefined,
|
||||||
from: this._refWhenFrom.value?.value,
|
from: this._refWhenFrom.value?.value,
|
||||||
to: this._refWhenTo.value?.value,
|
to: this._refWhenTo.value?.value,
|
||||||
},
|
},
|
||||||
favorite: (this._refFavorite.value?.value ?? undefined) as
|
favorite: this._refFavorite.value?.value ?? undefined,
|
||||||
| MediaFilterCoreFavoriteSelection
|
reviewed: this._refReviewed.value?.value ?? undefined,
|
||||||
| undefined,
|
|
||||||
where: this._refWhere.value?.value ?? undefined,
|
where: this._refWhere.value?.value ?? undefined,
|
||||||
what: this._refWhat.value?.value ?? undefined,
|
what: this._refWhat.value?.value ?? undefined,
|
||||||
tags: this._refTags.value?.value ?? undefined,
|
tags: this._refTags.value?.value ?? undefined,
|
||||||
@@ -126,7 +143,6 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const controls = this._mediaFilterController.getControlsToShow(this.cameraManager);
|
|
||||||
const defaults = this._mediaFilterController.getDefaults();
|
const defaults = this._mediaFilterController.getDefaults();
|
||||||
const whatOptions = this._mediaFilterController.getWhatOptions();
|
const whatOptions = this._mediaFilterController.getWhatOptions();
|
||||||
const tagsOptions = this._mediaFilterController.getTagsOptions();
|
const tagsOptions = this._mediaFilterController.getTagsOptions();
|
||||||
@@ -137,7 +153,9 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
|||||||
label=${localize('media_filter.media_type')}
|
label=${localize('media_filter.media_type')}
|
||||||
placeholder=${localize('media_filter.select_media_type')}
|
placeholder=${localize('media_filter.select_media_type')}
|
||||||
.options=${this._mediaFilterController.getMediaTypeOptions()}
|
.options=${this._mediaFilterController.getMediaTypeOptions()}
|
||||||
.initialValue=${defaults?.mediaType}
|
.initialValue=${defaults?.mediaTypes}
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
@advanced-camera-card:select:change=${() => valueChange()}
|
@advanced-camera-card:select:change=${() => valueChange()}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-select>
|
</advanced-camera-card-select>
|
||||||
@@ -184,7 +202,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
|||||||
@advanced-camera-card:select:change=${() => valueChange()}
|
@advanced-camera-card:select:change=${() => valueChange()}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-select>
|
</advanced-camera-card-select>
|
||||||
${controls.events && whatOptions.length
|
${whatOptions.length
|
||||||
? html` <advanced-camera-card-select
|
? html` <advanced-camera-card-select
|
||||||
${ref(this._refWhat)}
|
${ref(this._refWhat)}
|
||||||
label=${localize('media_filter.what')}
|
label=${localize('media_filter.what')}
|
||||||
@@ -197,7 +215,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
|||||||
>
|
>
|
||||||
</advanced-camera-card-select>`
|
</advanced-camera-card-select>`
|
||||||
: ''}
|
: ''}
|
||||||
${controls.events && tagsOptions.length
|
${tagsOptions.length
|
||||||
? html` <advanced-camera-card-select
|
? html` <advanced-camera-card-select
|
||||||
${ref(this._refTags)}
|
${ref(this._refTags)}
|
||||||
label=${localize('media_filter.tag')}
|
label=${localize('media_filter.tag')}
|
||||||
@@ -210,7 +228,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
|||||||
>
|
>
|
||||||
</advanced-camera-card-select>`
|
</advanced-camera-card-select>`
|
||||||
: ''}
|
: ''}
|
||||||
${controls.events && whereOptions.length
|
${whereOptions.length
|
||||||
? html` <advanced-camera-card-select
|
? html` <advanced-camera-card-select
|
||||||
${ref(this._refWhere)}
|
${ref(this._refWhere)}
|
||||||
label=${localize('media_filter.where')}
|
label=${localize('media_filter.where')}
|
||||||
@@ -223,20 +241,26 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
|||||||
>
|
>
|
||||||
</advanced-camera-card-select>`
|
</advanced-camera-card-select>`
|
||||||
: ''}
|
: ''}
|
||||||
${controls.favorites
|
<advanced-camera-card-select
|
||||||
? html`
|
${ref(this._refFavorite)}
|
||||||
<advanced-camera-card-select
|
label=${localize('media_filter.favorite')}
|
||||||
${ref(this._refFavorite)}
|
placeholder=${localize('media_filter.select_favorite')}
|
||||||
label=${localize('media_filter.favorite')}
|
.options=${this._mediaFilterController.getFavoriteOptions()}
|
||||||
placeholder=${localize('media_filter.select_favorite')}
|
.initialValue=${defaults?.favorite}
|
||||||
.options=${this._mediaFilterController.getFavoriteOptions()}
|
clearable
|
||||||
.initialValue=${defaults?.favorite}
|
@advanced-camera-card:select:change=${() => valueChange()}
|
||||||
clearable
|
>
|
||||||
@advanced-camera-card:select:change=${() => valueChange()}
|
</advanced-camera-card-select>
|
||||||
>
|
<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 {
|
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;
|
const initialValueSet = this.value === null;
|
||||||
this.value = value;
|
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
|
// (even when the user has not interacted with the control). Do not
|
||||||
// dispatch events for this.
|
// dispatch events for this UNLESS the value is actually non-empty
|
||||||
if (!initialValueSet) {
|
// (indicating a user selection).
|
||||||
|
if (!initialValueSet || (Array.isArray(value) ? value.length : !!value)) {
|
||||||
fireAdvancedCameraCardEvent(this, 'select:change', value);
|
fireAdvancedCameraCardEvent(this, 'select:change', value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export class AdvancedCameraCardStatusBar extends LitElement {
|
|||||||
return html`<div
|
return html`<div
|
||||||
.actionHandler=${handler}
|
.actionHandler=${handler}
|
||||||
class="${classes}"
|
class="${classes}"
|
||||||
|
data-severity=${item.severity ?? ''}
|
||||||
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
|
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
|
||||||
>
|
>
|
||||||
${item.string}
|
${item.string}
|
||||||
@@ -109,6 +110,7 @@ export class AdvancedCameraCardStatusBar extends LitElement {
|
|||||||
.actionHandler=${handler}
|
.actionHandler=${handler}
|
||||||
.icon=${{ icon: item.icon }}
|
.icon=${{ icon: item.icon }}
|
||||||
class="${classes}"
|
class="${classes}"
|
||||||
|
data-severity=${item.severity ?? ''}
|
||||||
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
|
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
|
||||||
></advanced-camera-card-icon>`;
|
></advanced-camera-card-icon>`;
|
||||||
} else if (item.type === 'custom:advanced-camera-card-status-bar-image') {
|
} else if (item.type === 'custom:advanced-camera-card-status-bar-image') {
|
||||||
@@ -116,6 +118,7 @@ export class AdvancedCameraCardStatusBar extends LitElement {
|
|||||||
.actionHandler=${handler}
|
.actionHandler=${handler}
|
||||||
class="${classes}"
|
class="${classes}"
|
||||||
src="${item.image}"
|
src="${item.image}"
|
||||||
|
data-severity=${item.severity ?? ''}
|
||||||
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
|
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
|
||||||
/>`;
|
/>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,22 +7,18 @@ import {
|
|||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { isEqual } from 'lodash-es';
|
|
||||||
import { CameraManager } from '../camera-manager/manager.js';
|
import { CameraManager } from '../camera-manager/manager.js';
|
||||||
import { FoldersManager } from '../card-controller/folders/manager.js';
|
import { FoldersManager } from '../card-controller/folders/manager.js';
|
||||||
import { ViewItemManager } from '../card-controller/view/item-manager.js';
|
import { ViewItemManager } from '../card-controller/view/item-manager.js';
|
||||||
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
||||||
import { TimelineKeys } from '../components-lib/timeline/types.js';
|
|
||||||
import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js';
|
import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js';
|
||||||
import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js';
|
import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js';
|
||||||
import { MiniTimelineControlConfig } from '../config/schema/common/controls/timeline.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 { CardWideConfig } from '../config/schema/types.js';
|
||||||
import { HomeAssistant } from '../ha/types.js';
|
import { HomeAssistant } from '../ha/types.js';
|
||||||
import basicBlockStyle from '../scss/basic-block.scss';
|
import basicBlockStyle from '../scss/basic-block.scss';
|
||||||
import { contentsChanged } from '../utils/basic.js';
|
import { contentsChanged } from '../utils/basic.js';
|
||||||
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
|
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
|
||||||
import { QueryClassifier } from '../view/query-classifier.js';
|
|
||||||
import './surround-basic.js';
|
import './surround-basic.js';
|
||||||
import './thumbnail-carousel';
|
import './thumbnail-carousel';
|
||||||
|
|
||||||
@@ -55,11 +51,8 @@ export class AdvancedCameraCardSurround extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
protected _timelineKeys?: TimelineKeys | null = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if a drawer is being used.
|
* Determine if a drawer is being used.
|
||||||
* @returns `true` if a drawer is used, `false` otherwise.
|
|
||||||
*/
|
*/
|
||||||
protected _hasDrawer(): boolean {
|
protected _hasDrawer(): boolean {
|
||||||
return (
|
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') {
|
if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') {
|
||||||
import('./timeline-core.js');
|
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 {
|
protected render(): TemplateResult | void {
|
||||||
@@ -175,7 +99,9 @@ export class AdvancedCameraCardSurround extends LitElement {
|
|||||||
.viewItemManager=${this.viewItemManager}
|
.viewItemManager=${this.viewItemManager}
|
||||||
.fadeThumbnails=${view.isViewerView()}
|
.fadeThumbnails=${view.isViewerView()}
|
||||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
|
.foldersManager=${this.foldersManager}
|
||||||
.selected=${view.queryResults?.getSelectedIndex() ?? undefined}
|
.selected=${view.queryResults?.getSelectedIndex() ?? undefined}
|
||||||
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-thumbnail-carousel>`
|
</advanced-camera-card-thumbnail-carousel>`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -189,7 +115,6 @@ export class AdvancedCameraCardSurround extends LitElement {
|
|||||||
this.thumbnailConfig?.mode === 'none'
|
this.thumbnailConfig?.mode === 'none'
|
||||||
? 'play'
|
? 'play'
|
||||||
: 'select'}
|
: 'select'}
|
||||||
.keys=${this._timelineKeys}
|
|
||||||
.mini=${true}
|
.mini=${true}
|
||||||
.timelineConfig=${this.timelineConfig}
|
.timelineConfig=${this.timelineConfig}
|
||||||
.thumbnailConfig=${this.thumbnailConfig}
|
.thumbnailConfig=${this.thumbnailConfig}
|
||||||
|
|||||||
@@ -9,14 +9,20 @@ import {
|
|||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import { CameraManager } from '../camera-manager/manager.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 { ViewItemManager } from '../card-controller/view/item-manager.js';
|
||||||
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js';
|
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js';
|
||||||
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
||||||
import {
|
import {
|
||||||
getUpFolderMediaItem,
|
FolderNavigationParamaters,
|
||||||
upFolderClickHandler,
|
getUpFolderItem,
|
||||||
} from '../components-lib/folder/up-folder.js';
|
navigateToFolder,
|
||||||
|
navigateToMedia,
|
||||||
|
navigateUp,
|
||||||
|
} from '../components-lib/navigation.js';
|
||||||
import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.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 { HomeAssistant } from '../ha/types.js';
|
||||||
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
|
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
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 { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
|
||||||
import { ViewItemClassifier } from '../view/item-classifier.js';
|
import { ViewItemClassifier } from '../view/item-classifier.js';
|
||||||
import { ViewItem, ViewMedia } from '../view/item.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 './carousel.js';
|
||||||
import './thumbnail/thumbnail.js';
|
import './thumbnail/thumbnail.js';
|
||||||
|
|
||||||
@@ -43,18 +49,50 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public foldersManager?: FoldersManager;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public viewItemManager?: ViewItemManager;
|
public viewItemManager?: ViewItemManager;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public config?: ThumbnailsControlConfig;
|
public config?: ThumbnailsControlConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public fadeThumbnails = false;
|
public fadeThumbnails = false;
|
||||||
|
|
||||||
protected _thumbnails: TemplateResult[] = [];
|
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 {
|
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 (changedProps.has('config')) {
|
||||||
if (this.config?.size) {
|
if (this.config?.size) {
|
||||||
this.style.setProperty(
|
this.style.setProperty(
|
||||||
@@ -94,53 +132,17 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _itemClickCallback(item: ViewItem, ev: Event): void {
|
protected _handleMediaClick(item: ViewMedia): void {
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
fireAdvancedCameraCardEvent<ThumbnailMediaSelect>(
|
||||||
|
this,
|
||||||
const view = this.viewManagerEpoch?.manager.getView();
|
'thumbnails-carousel:media-select',
|
||||||
const query = view?.query;
|
{ media: item },
|
||||||
const results = view?.queryResults;
|
);
|
||||||
|
if (this.viewManagerEpoch) {
|
||||||
if (!view || !query || !results) {
|
navigateToMedia(item, {
|
||||||
return;
|
viewManagerEpoch: this.viewManagerEpoch,
|
||||||
}
|
|
||||||
|
|
||||||
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 }),
|
|
||||||
},
|
|
||||||
modifiers: [new RemoveContextViewModifier(['timeline', 'mediaViewer'])],
|
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_favorite_control=${this.config?.show_favorite_control}
|
||||||
?show_timeline_control=${this.config?.show_timeline_control}
|
?show_timeline_control=${this.config?.show_timeline_control}
|
||||||
?show_download_control=${this.config?.show_download_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)}
|
@click=${(ev: Event) => clickCallback(item, ev)}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-thumbnail>`;
|
</advanced-camera-card-thumbnail>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _renderThumbnails(): TemplateResult[] {
|
protected _renderThumbnails(): TemplateResult[] {
|
||||||
const upThumbnail = getUpFolderMediaItem(this.viewManagerEpoch?.manager.getView());
|
const upFolderItem = getUpFolderItem(
|
||||||
const thumbnails: TemplateResult[] = [
|
this.viewManagerEpoch?.manager.getView()?.query,
|
||||||
...(upThumbnail
|
);
|
||||||
? [
|
const thumbnails: TemplateResult[] = upFolderItem
|
||||||
this._renderThumbnail(upThumbnail, false, (item: ViewItem, ev: Event) =>
|
? [
|
||||||
upFolderClickHandler(item, ev, this.viewManagerEpoch),
|
this._renderThumbnail(upFolderItem, false, (_item: ViewItem, ev: Event) => {
|
||||||
),
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
]
|
navigateUp(this._getFolderNavOptions());
|
||||||
: []),
|
}),
|
||||||
];
|
]
|
||||||
|
: [];
|
||||||
const view = this.viewManagerEpoch?.manager.getView();
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
const selectedIndex = this._getSelectedSlide();
|
const selectedIndex = this._getSelectedSlide();
|
||||||
|
|
||||||
for (const item of view?.queryResults?.getResults() ?? []) {
|
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(
|
thumbnails.push(
|
||||||
this._renderThumbnail(
|
this._renderThumbnail(
|
||||||
item,
|
item,
|
||||||
selectedIndex === thumbnails.length,
|
selectedIndex === thumbnails.length,
|
||||||
(item: ViewItem, ev: Event) => this._itemClickCallback(item, ev),
|
clickHandler,
|
||||||
view?.context?.mediaViewer?.seek,
|
view?.context?.mediaViewer?.seek,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -214,18 +227,18 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
const direction = this._getDirection();
|
if (!this._thumbnails.length || !this.config?.mode || this.config.mode === 'none') {
|
||||||
if (!this._thumbnails.length || !this.config || !direction) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`<advanced-camera-card-carousel
|
return html`<advanced-camera-card-carousel
|
||||||
direction=${direction}
|
class="${classMap({ fade: this.fadeThumbnails })}"
|
||||||
.selected=${this._getSelectedSlide() ?? 0}
|
direction=${this._getDirection() ?? 'horizontal'}
|
||||||
|
.selected=${this._getSelectedSlide()}
|
||||||
.dragFree=${true}
|
.dragFree=${true}
|
||||||
>
|
>
|
||||||
${this._thumbnails}
|
${this._thumbnails}
|
||||||
</advanced-camera-card-carousel>`;
|
</advanced-camera-card-carousel> `;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import { CameraManager } from '../../camera-manager/manager';
|
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 { HomeAssistant } from '../../ha/types';
|
||||||
import thumbnailDetailsStyle from '../../scss/thumbnail-details.scss';
|
import thumbnailDetailsStyle from '../../scss/thumbnail-details.scss';
|
||||||
|
import { MetadataField } from '../../types';
|
||||||
import { ViewItem } from '../../view/item';
|
import { ViewItem } from '../../view/item';
|
||||||
import '../icon';
|
import '../icon';
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public seek?: Date;
|
public seek?: Date;
|
||||||
|
|
||||||
private _controller = new ThumbnailDetailsController();
|
private _controller = new MediaDetailsController();
|
||||||
|
|
||||||
protected willUpdate(changedProperties: PropertyValues): void {
|
protected willUpdate(changedProperties: PropertyValues): void {
|
||||||
if (['item', 'seek', 'cameraManager'].some((prop) => changedProperties.has(prop))) {
|
if (['item', 'seek', 'cameraManager'].some((prop) => changedProperties.has(prop))) {
|
||||||
@@ -40,28 +42,26 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
|
|||||||
const heading = this._controller.getHeading();
|
const heading = this._controller.getHeading();
|
||||||
const details = this._controller.getDetails();
|
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`
|
return html`
|
||||||
${heading
|
${heading ? renderDetail(heading, true) : ``}
|
||||||
? html` <div class="heading">
|
${details.length ? details.map((detail) => renderDetail(detail)) : ``}
|
||||||
<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>`
|
|
||||||
: ''}
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,26 @@ import {
|
|||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import { CameraManager } from '../../../camera-manager/manager';
|
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 { ThumbnailFeatureController } from '../../../components-lib/thumbnail/feature/controller';
|
||||||
import { HomeAssistant } from '../../../ha/types';
|
import { HomeAssistant } from '../../../ha/types';
|
||||||
|
import { localize } from '../../../localize/localize';
|
||||||
import thumbnailFeatureStyle from '../../../scss/thumbnail-feature.scss';
|
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 { ViewItem } from '../../../view/item';
|
||||||
|
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||||
import '../../icon.js';
|
import '../../icon.js';
|
||||||
import './thumbnail.js';
|
import './thumbnail.js';
|
||||||
|
|
||||||
@@ -24,12 +40,33 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public viewItemManager?: ViewItemManager;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public item?: ViewItem;
|
public item?: ViewItem;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public hasDetails?: boolean;
|
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();
|
private _controller = new ThumbnailFeatureController();
|
||||||
|
|
||||||
protected willUpdate(changedProperties: PropertyValues): void {
|
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 {
|
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 title = this._controller.getTitle();
|
||||||
const subtitles = this._controller.getSubtitles();
|
const subtitles = this._controller.getSubtitles();
|
||||||
const iconClasses = classMap({
|
const hasText = !!title || !!subtitles.length;
|
||||||
background: title || subtitles.length,
|
|
||||||
});
|
const mainIconClasses = {
|
||||||
|
placeholder: true,
|
||||||
|
};
|
||||||
|
|
||||||
const thumbnailClass = this._controller.getThumbnailClass();
|
const thumbnailClass = this._controller.getThumbnailClass();
|
||||||
const thumbnailClasses = classMap({
|
const thumbnailClasses = classMap({
|
||||||
...(thumbnailClass && { [thumbnailClass]: true }),
|
...(thumbnailClass && { [thumbnailClass]: true }),
|
||||||
|
'has-text': hasText,
|
||||||
});
|
});
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
${this._controller.getThumbnail()
|
<div class=${classMap({ media: true, 'has-text': hasText })}>
|
||||||
? html` <advanced-camera-card-thumbnail-feature-thumbnail
|
${this._controller.getThumbnail()
|
||||||
class="${thumbnailClasses}"
|
? html` <advanced-camera-card-thumbnail-feature-thumbnail
|
||||||
.hass=${this.hass}
|
class="${thumbnailClasses}"
|
||||||
.thumbnail=${this._controller.getThumbnail()}
|
.hass=${this.hass}
|
||||||
aria-label=${this.item?.getTitle() ?? ''}
|
.thumbnail=${this._controller.getThumbnail()}
|
||||||
title=${this.item?.getTitle() ?? ''}
|
aria-label=${this.item?.getTitle() ?? ''}
|
||||||
></advanced-camera-card-thumbnail-feature-thumbnail>`
|
title=${this.item?.getTitle() ?? ''}
|
||||||
: this._controller.getIcon()
|
></advanced-camera-card-thumbnail-feature-thumbnail>`
|
||||||
? html`<advanced-camera-card-icon
|
: this._controller.getIcon()
|
||||||
class="${iconClasses}"
|
? html`<advanced-camera-card-icon
|
||||||
.icon=${{ icon: this._controller.getIcon() }}
|
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>`
|
></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`
|
? html`
|
||||||
${title ? html`<div class="title">${title}</div>` : ''}
|
${title ? html`<div class="title">${title}</div>` : ''}
|
||||||
${subtitles.length
|
${subtitles.length
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
|
||||||
import { CameraManager } from '../../camera-manager/manager.js';
|
import { CameraManager } from '../../camera-manager/manager.js';
|
||||||
import { FoldersManager } from '../../card-controller/folders/manager.js';
|
import { FoldersManager } from '../../card-controller/folders/manager.js';
|
||||||
import { ViewItemManager } from '../../card-controller/view/item-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 { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||||
import { dispatchAdvancedCameraCardErrorEvent } from '../../components-lib/message/dispatch.js';
|
|
||||||
import { HomeAssistant } from '../../ha/types.js';
|
import { HomeAssistant } from '../../ha/types.js';
|
||||||
import { localize } from '../../localize/localize.js';
|
|
||||||
import thumbnailStyle from '../../scss/thumbnail.scss';
|
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 { ViewItem } from '../../view/item.js';
|
||||||
import './details.js';
|
import './details.js';
|
||||||
import './feature/feature.js';
|
import './feature/feature.js';
|
||||||
@@ -56,6 +49,12 @@ export class AdvancedCameraCardThumbnail extends LitElement {
|
|||||||
@property({ attribute: true, type: Boolean })
|
@property({ attribute: true, type: Boolean })
|
||||||
public show_download_control = false;
|
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 })
|
@property({ attribute: false })
|
||||||
public seek?: Date;
|
public seek?: Date;
|
||||||
|
|
||||||
@@ -68,62 +67,21 @@ export class AdvancedCameraCardThumbnail extends LitElement {
|
|||||||
return;
|
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`
|
return html`
|
||||||
<advanced-camera-card-thumbnail-feature
|
<advanced-camera-card-thumbnail-feature
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.hasDetails=${this.details}
|
.hasDetails=${this.details}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.item=${this.item}
|
.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>
|
</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
|
${this.details
|
||||||
? html`<advanced-camera-card-thumbnail-details
|
? html`<advanced-camera-card-thumbnail-details
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
@@ -132,46 +90,6 @@ export class AdvancedCameraCardThumbnail extends LitElement {
|
|||||||
.seek=${this.seek}
|
.seek=${this.seek}
|
||||||
></advanced-camera-card-thumbnail-details>`
|
></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,
|
ThumbnailDataRequest,
|
||||||
ThumbnailDataRequestEvent,
|
ThumbnailDataRequestEvent,
|
||||||
TimelineItemClickAction,
|
TimelineItemClickAction,
|
||||||
TimelineKeys,
|
|
||||||
} from '../components-lib/timeline/types';
|
} from '../components-lib/timeline/types';
|
||||||
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
|
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
|
||||||
import { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails';
|
import { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails';
|
||||||
@@ -120,11 +119,6 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
|
|||||||
@property({ attribute: true, type: Boolean, reflect: true })
|
@property({ attribute: true, type: Boolean, reflect: true })
|
||||||
public mini = false;
|
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 })
|
@property({ attribute: false })
|
||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
@@ -152,7 +146,22 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
|
|||||||
return;
|
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) {
|
if (!this.mini) {
|
||||||
return renderMessage({
|
return renderMessage({
|
||||||
message: localize('error.no_camera_or_media_for_timeline'),
|
message: localize('error.no_camera_or_media_for_timeline'),
|
||||||
@@ -234,13 +243,14 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
|
'viewManagerEpoch',
|
||||||
'cameraManager',
|
'cameraManager',
|
||||||
'viewItemManager',
|
'viewItemManager',
|
||||||
'timelineConfig',
|
'timelineConfig',
|
||||||
'mini',
|
'mini',
|
||||||
'thumbnailConfig',
|
'thumbnailConfig',
|
||||||
'keys',
|
|
||||||
'conditionStateManager',
|
'conditionStateManager',
|
||||||
|
'foldersManager',
|
||||||
].some((prop) => changedProps.has(prop))
|
].some((prop) => changedProps.has(prop))
|
||||||
) {
|
) {
|
||||||
this._controller.setOptions({
|
this._controller.setOptions({
|
||||||
@@ -251,7 +261,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
|
|||||||
timelineConfig: this.timelineConfig,
|
timelineConfig: this.timelineConfig,
|
||||||
mini: this.mini,
|
mini: this.mini,
|
||||||
thumbnailConfig: this.thumbnailConfig,
|
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 { FoldersManager } from '../card-controller/folders/manager';
|
||||||
import { ViewItemManager } from '../card-controller/view/item-manager';
|
import { ViewItemManager } from '../card-controller/view/item-manager';
|
||||||
import { ViewManagerEpoch } from '../card-controller/view/types';
|
import { ViewManagerEpoch } from '../card-controller/view/types';
|
||||||
import { TimelineKeys } from '../components-lib/timeline/types';
|
|
||||||
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
|
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
|
||||||
import { TimelineConfig } from '../config/schema/timeline';
|
import { TimelineConfig } from '../config/schema/timeline';
|
||||||
import { CardWideConfig } from '../config/schema/types';
|
import { CardWideConfig } from '../config/schema/types';
|
||||||
import { HomeAssistant } from '../ha/types';
|
import { HomeAssistant } from '../ha/types';
|
||||||
import basicBlockStyle from '../scss/basic-block.scss';
|
import basicBlockStyle from '../scss/basic-block.scss';
|
||||||
import { QueryClassifier } from '../view/query-classifier';
|
|
||||||
import './surround.js';
|
import './surround.js';
|
||||||
import './timeline-core.js';
|
import './timeline-core.js';
|
||||||
|
|
||||||
@@ -40,47 +38,6 @@ export class AdvancedCameraCardTimeline extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
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 {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.timelineConfig) {
|
if (!this.timelineConfig) {
|
||||||
return html``;
|
return html``;
|
||||||
@@ -96,7 +53,6 @@ export class AdvancedCameraCardTimeline extends LitElement {
|
|||||||
.foldersManager=${this.foldersManager}
|
.foldersManager=${this.foldersManager}
|
||||||
.conditionStateManager=${this.conditionStateManager}
|
.conditionStateManager=${this.conditionStateManager}
|
||||||
.viewItemManager=${this.viewItemManager}
|
.viewItemManager=${this.viewItemManager}
|
||||||
.keys=${this._getKeys()}
|
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
.itemClickAction=${this.timelineConfig.controls.thumbnails.mode === 'none'
|
.itemClickAction=${this.timelineConfig.controls.thumbnails.mode === 'none'
|
||||||
? 'play'
|
? 'play'
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@loadeddata=${(ev: Event) => {
|
@loadeddata="${(ev: Event) => {
|
||||||
dispatchMediaLoadedEvent(this, ev, {
|
dispatchMediaLoadedEvent(this, ev, {
|
||||||
...(this._mediaPlayerController && {
|
...(this._mediaPlayerController && {
|
||||||
mediaPlayerController: this._mediaPlayerController,
|
mediaPlayerController: this._mediaPlayerController,
|
||||||
@@ -64,7 +64,7 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
|
|||||||
},
|
},
|
||||||
technology: ['mp4'],
|
technology: ['mp4'],
|
||||||
});
|
});
|
||||||
}}
|
}}"
|
||||||
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
|
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
|
||||||
@play=${() => dispatchMediaPlayEvent(this)}
|
@play=${() => dispatchMediaPlayEvent(this)}
|
||||||
@pause=${() => dispatchMediaPauseEvent(this)}
|
@pause=${() => dispatchMediaPauseEvent(this)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
import { CameraManager } from '../../camera-manager/manager.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 { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||||
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
|
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
|
||||||
import { CardWideConfig } from '../../config/schema/types.js';
|
import { CardWideConfig } from '../../config/schema/types.js';
|
||||||
@@ -39,6 +40,9 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public viewItemManager?: ViewItemManager;
|
||||||
|
|
||||||
protected _renderCarousel(filterCamera?: string): TemplateResult {
|
protected _renderCarousel(filterCamera?: string): TemplateResult {
|
||||||
const selectedCameraID = this.viewManagerEpoch?.manager.getView()?.camera;
|
const selectedCameraID = this.viewManagerEpoch?.manager.getView()?.camera;
|
||||||
|
|
||||||
@@ -60,6 +64,7 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
|
|||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
.showControls=${!filterCamera || selectedCameraID === filterCamera}
|
.showControls=${!filterCamera || selectedCameraID === filterCamera}
|
||||||
|
.viewItemManager=${this.viewItemManager}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-viewer-carousel>
|
</advanced-camera-card-viewer-carousel>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { CameraManager } from '../../camera-manager/manager.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 { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||||
import { CardWideConfig } from '../../config/schema/types.js';
|
import { CardWideConfig } from '../../config/schema/types.js';
|
||||||
import { ViewerConfig } from '../../config/schema/viewer.js';
|
import { ViewerConfig } from '../../config/schema/viewer.js';
|
||||||
@@ -50,6 +51,9 @@ export class AdvancedCameraCardViewer extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public viewItemManager?: ViewItemManager;
|
||||||
|
|
||||||
@property({ attribute: 'empty', reflect: true, type: Boolean })
|
@property({ attribute: 'empty', reflect: true, type: Boolean })
|
||||||
public isEmpty = false;
|
public isEmpty = false;
|
||||||
|
|
||||||
@@ -96,6 +100,7 @@ export class AdvancedCameraCardViewer extends LitElement {
|
|||||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
|
.viewItemManager=${this.viewItemManager}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-viewer-grid>`;
|
</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 { guard } from 'lit/directives/guard.js';
|
||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
import { CameraManager } from '../../camera-manager/manager.js';
|
import { CameraManager } from '../../camera-manager/manager.js';
|
||||||
|
import { QueryType } from '../../camera-manager/types.js';
|
||||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||||
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
||||||
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.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 { errorToConsole } from '../../utils/basic.js';
|
||||||
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
||||||
import { VideoContentType, ViewMedia } from '../../view/item.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 '../image-player.js';
|
||||||
import { renderProgressIndicator } from '../progress-indicator.js';
|
import { renderProgressIndicator } from '../progress-indicator.js';
|
||||||
import '../video-player.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
|
// If this specific media item has no clip, then do nothing (even if all
|
||||||
// the other media items do).
|
// the other media items do).
|
||||||
!ViewItemClassifier.isEvent(this.media) ||
|
!ViewItemClassifier.isEvent(this.media) ||
|
||||||
!QueryClassifier.isEventQuery(view.query)
|
!view.query?.hasMediaQueriesOfType(QueryType.Event)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert the query to a clips equivalent.
|
// Convert the query to a clips equivalent.
|
||||||
const clipQuery = view.query.clone();
|
const clipQuery = UnifiedQueryTransformer.convertToClips(view.query);
|
||||||
clipQuery.convertToClipsQueries();
|
|
||||||
|
|
||||||
const queries = clipQuery.getQuery();
|
|
||||||
if (!queries) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
+8
-16
@@ -76,16 +76,14 @@ export class AdvancedCameraCardViews extends LitElement {
|
|||||||
if (view?.is('live') || this._shouldLivePreload()) {
|
if (view?.is('live') || this._shouldLivePreload()) {
|
||||||
import('./live/index.js');
|
import('./live/index.js');
|
||||||
}
|
}
|
||||||
if (view?.isMediaGalleryView() && !view.is('folders')) {
|
if (view?.isGalleryView()) {
|
||||||
import('./gallery/media-gallery.js');
|
import('./gallery/gallery.js');
|
||||||
} else if (view?.isViewerView()) {
|
} else if (view?.isViewerView()) {
|
||||||
import('./viewer/index.js');
|
import('./viewer/index.js');
|
||||||
} else if (view?.is('image')) {
|
} else if (view?.is('image')) {
|
||||||
import('./image.js');
|
import('./image.js');
|
||||||
} else if (view?.is('timeline')) {
|
} else if (view?.is('timeline')) {
|
||||||
import('./timeline.js');
|
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>`
|
</advanced-camera-card-image>`
|
||||||
: ``}
|
: ``}
|
||||||
${!this.hide && view?.isMediaGalleryView() && !view.is('folders')
|
${!this.hide && view?.isGalleryView()
|
||||||
? html` <advanced-camera-card-media-gallery
|
? html` <advanced-camera-card-gallery
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.galleryConfig=${this.config.media_gallery}
|
.galleryConfig=${this.config.media_gallery}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
|
.foldersManager=${this.foldersManager}
|
||||||
.viewItemManager=${this.viewItemManager}
|
.viewItemManager=${this.viewItemManager}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
|
.conditionStateManager=${this.conditionStateManager}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-media-gallery>`
|
</advanced-camera-card-gallery>`
|
||||||
: ``}
|
: ``}
|
||||||
${!this.hide && view?.isViewerView()
|
${!this.hide && view?.isViewerView()
|
||||||
? html`
|
? html`
|
||||||
@@ -187,6 +187,7 @@ export class AdvancedCameraCardViews extends LitElement {
|
|||||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
|
.viewItemManager=${this.viewItemManager}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-viewer>
|
</advanced-camera-card-viewer>
|
||||||
`
|
`
|
||||||
@@ -212,15 +213,6 @@ export class AdvancedCameraCardViews extends LitElement {
|
|||||||
>
|
>
|
||||||
</advanced-camera-card-diagnostics>`
|
</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
|
// Note: Subtle difference in condition below vs the other views in
|
||||||
// order to always render the live view for live.preload mode.
|
// order to always render the live view for live.preload mode.
|
||||||
|
|||||||
@@ -6,13 +6,9 @@ import {
|
|||||||
CONF_CAMERAS_GLOBAL_PTZ,
|
CONF_CAMERAS_GLOBAL_PTZ,
|
||||||
CONF_DIMENSIONS_HEIGHT,
|
CONF_DIMENSIONS_HEIGHT,
|
||||||
CONF_ELEMENTS,
|
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_OVERRIDES,
|
||||||
CONF_PROFILES,
|
CONF_PROFILES,
|
||||||
CONF_STATUS_BAR,
|
CONF_STATUS_BAR,
|
||||||
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
|
|
||||||
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
|
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
|
||||||
CONF_VIEW_DEFAULT_RESET_ENTITIES,
|
CONF_VIEW_DEFAULT_RESET_ENTITIES,
|
||||||
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
|
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
|
||||||
@@ -825,18 +821,19 @@ const UPGRADES = [
|
|||||||
upgradeWithOverrides('media_viewer.auto_unmute', (data) =>
|
upgradeWithOverrides('media_viewer.auto_unmute', (data) =>
|
||||||
data === 'all' ? ['selected', 'visible'] : data === 'never' ? null : arrayify(data),
|
data === 'all' ? ['selected', 'visible'] : data === 'never' ? null : arrayify(data),
|
||||||
),
|
),
|
||||||
|
|
||||||
upgradeMoveToWithOverrides(
|
upgradeMoveToWithOverrides(
|
||||||
'live.controls.thumbnails.media',
|
'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(
|
upgradeMoveToWithOverrides(
|
||||||
'live.controls.timeline.media',
|
'live.controls.timeline.media',
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
'live.controls.timeline.events_media_type',
|
||||||
),
|
),
|
||||||
upgradeMoveToWithOverrides(
|
upgradeMoveToWithOverrides(
|
||||||
'media_viewer.controls.timeline.media',
|
'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('view.scan', CONF_VIEW_TRIGGERS),
|
||||||
upgradeMoveToWithOverrides(
|
upgradeMoveToWithOverrides(
|
||||||
@@ -944,4 +941,23 @@ const UPGRADES = [
|
|||||||
frigateCardToAdvancedCameraCardStyleTransform,
|
frigateCardToAdvancedCameraCardStyleTransform,
|
||||||
),
|
),
|
||||||
upgradeMoveToWithOverrides('menu.buttons.frigate', 'menu.buttons.iris'),
|
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',
|
'download',
|
||||||
'expand',
|
'expand',
|
||||||
'fullscreen',
|
'fullscreen',
|
||||||
|
'info',
|
||||||
'live_substream_off',
|
'live_substream_off',
|
||||||
'live_substream_on',
|
'live_substream_on',
|
||||||
'menu_toggle',
|
'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 { z } from 'zod';
|
||||||
import { statusBarItemBaseSchema } from '../common/status-bar';
|
import { statusBarItemBaseSchema } from '../common/status-bar';
|
||||||
|
import { severitySchema } from '../common/severity';
|
||||||
import { advancedCameraCardCustomActionsBaseSchema } from './custom/base';
|
import { advancedCameraCardCustomActionsBaseSchema } from './custom/base';
|
||||||
import { cameraSelectActionConfigSchema } from './custom/camera-select';
|
import { cameraSelectActionConfigSchema } from './custom/camera-select';
|
||||||
import { viewDisplayModeActionConfigSchema } from './custom/display-mode';
|
import { viewDisplayModeActionConfigSchema } from './custom/display-mode';
|
||||||
@@ -12,6 +13,7 @@ import { ptzActionConfigSchema } from './custom/ptz';
|
|||||||
import { ptzControlsActionConfigSchema } from './custom/ptz-controls';
|
import { ptzControlsActionConfigSchema } from './custom/ptz-controls';
|
||||||
import { ptzDigitalActionConfigSchema } from './custom/ptz-digital';
|
import { ptzDigitalActionConfigSchema } from './custom/ptz-digital';
|
||||||
import { ptzMultiActionSchema } from './custom/ptz-multi';
|
import { ptzMultiActionSchema } from './custom/ptz-multi';
|
||||||
|
import { setReviewActionConfigSchema } from './custom/set-review';
|
||||||
import { sleepActionConfigSchema } from './custom/sleep';
|
import { sleepActionConfigSchema } from './custom/sleep';
|
||||||
import { substreamSelectActionConfigSchema } from './custom/substream-select';
|
import { substreamSelectActionConfigSchema } from './custom/substream-select';
|
||||||
import { viewActionConfigSchema } from './custom/view';
|
import { viewActionConfigSchema } from './custom/view';
|
||||||
@@ -51,6 +53,7 @@ const advancedCameraCardCustomActionSchema = z.union([
|
|||||||
ptzControlsActionConfigSchema,
|
ptzControlsActionConfigSchema,
|
||||||
ptzDigitalActionConfigSchema,
|
ptzDigitalActionConfigSchema,
|
||||||
ptzMultiActionSchema,
|
ptzMultiActionSchema,
|
||||||
|
setReviewActionConfigSchema,
|
||||||
sleepActionConfigSchema,
|
sleepActionConfigSchema,
|
||||||
statusBarActionConfigSchema,
|
statusBarActionConfigSchema,
|
||||||
substreamSelectActionConfigSchema,
|
substreamSelectActionConfigSchema,
|
||||||
@@ -106,6 +109,7 @@ const statusBarItemElementsBaseSchema = statusBarItemBaseSchema.extend({
|
|||||||
sufficient: z.boolean().default(false).optional(),
|
sufficient: z.boolean().default(false).optional(),
|
||||||
exclusive: z.boolean().default(false).optional(),
|
exclusive: z.boolean().default(false).optional(),
|
||||||
expand: z.boolean().default(false).optional(),
|
expand: z.boolean().default(false).optional(),
|
||||||
|
severity: severitySchema.optional(),
|
||||||
actions: actionsBaseSchema.optional(),
|
actions: actionsBaseSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { capabilityKeys } from '../../types';
|
|||||||
import { mediaLayoutConfigSchema } from './camera/media-layout';
|
import { mediaLayoutConfigSchema } from './camera/media-layout';
|
||||||
import { ptzCameraConfigDefaults, ptzCameraConfigSchema } from './camera/ptz';
|
import { ptzCameraConfigDefaults, ptzCameraConfigSchema } from './camera/ptz';
|
||||||
import { aspectRatioSchema } from './common/aspect-ratio';
|
import { aspectRatioSchema } from './common/aspect-ratio';
|
||||||
|
import { eventsMediaTypeSchema } from './common/events-media';
|
||||||
|
import { severitySchema } from './common/severity';
|
||||||
import { imageBaseConfigSchema, imageConfigDefault } from './common/image';
|
import { imageBaseConfigSchema, imageConfigDefault } from './common/image';
|
||||||
|
|
||||||
const CAMERA_TRIGGER_EVENT_TYPES = [
|
const CAMERA_TRIGGER_EVENT_TYPES = [
|
||||||
@@ -124,8 +126,12 @@ export const cameraConfigDefault = {
|
|||||||
triggers: {
|
triggers: {
|
||||||
motion: false,
|
motion: false,
|
||||||
occupancy: false,
|
occupancy: false,
|
||||||
events: [...CAMERA_TRIGGER_EVENT_TYPES],
|
events: [],
|
||||||
entities: [],
|
entities: [],
|
||||||
|
reviews: {
|
||||||
|
severities: ['high' as const],
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
dynamic: true,
|
dynamic: true,
|
||||||
@@ -173,6 +179,26 @@ const cameraDimensionsSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type CameraDimensionsConfig = z.infer<typeof cameraDimensionsSchema>;
|
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
|
export const cameraConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
camera_entity: z.string().optional(),
|
camera_entity: z.string().optional(),
|
||||||
@@ -209,6 +235,16 @@ export const cameraConfigSchema = z
|
|||||||
.enum(CAMERA_TRIGGER_EVENT_TYPES)
|
.enum(CAMERA_TRIGGER_EVENT_TYPES)
|
||||||
.array()
|
.array()
|
||||||
.default(cameraConfigDefault.triggers.events),
|
.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),
|
.default(cameraConfigDefault.triggers),
|
||||||
|
|
||||||
@@ -274,6 +310,8 @@ export const cameraConfigSchema = z
|
|||||||
|
|
||||||
dimensions: cameraDimensionsSchema.optional(),
|
dimensions: cameraDimensionsSchema.optional(),
|
||||||
|
|
||||||
|
media: cameraMediaConfigSchema.optional(),
|
||||||
|
|
||||||
proxy: proxyConfigSchema.default(cameraConfigDefault.proxy),
|
proxy: proxyConfigSchema.default(cameraConfigDefault.proxy),
|
||||||
|
|
||||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1650
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1650
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export const VIEWS_USER_SPECIFIED = [
|
|||||||
'snapshots',
|
'snapshots',
|
||||||
'recording',
|
'recording',
|
||||||
'recordings',
|
'recordings',
|
||||||
|
'review',
|
||||||
|
'reviews',
|
||||||
'image',
|
'image',
|
||||||
'timeline',
|
'timeline',
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ const thumbnailControlsBaseDefaults = {
|
|||||||
size: THUMBNAIL_WIDTH_DEFAULT,
|
size: THUMBNAIL_WIDTH_DEFAULT,
|
||||||
show_details: true,
|
show_details: true,
|
||||||
show_favorite_control: true,
|
show_favorite_control: true,
|
||||||
show_timeline_control: true,
|
show_timeline_control: false,
|
||||||
show_download_control: true,
|
show_download_control: false,
|
||||||
|
show_review_control: true,
|
||||||
|
show_info_control: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Configuration for the actual rendered thumbnail.
|
// Configuration for the actual rendered thumbnail.
|
||||||
@@ -30,6 +32,12 @@ export const thumbnailsControlBaseSchema = z.object({
|
|||||||
show_download_control: z
|
show_download_control: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.default(thumbnailControlsBaseDefaults.show_download_control),
|
.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>;
|
export type ThumbnailsControlBaseConfig = z.infer<typeof thumbnailsControlBaseSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { eventsMediaTypeSchema } from '../events-media';
|
|
||||||
import { thumbnailControlsDefaults } from './thumbnails';
|
|
||||||
|
|
||||||
const timelineCoreConfigDefault = {
|
export const timelineCoreConfigDefault = {
|
||||||
clustering_threshold: 3,
|
clustering_threshold: 3,
|
||||||
events_media_type: 'all' as const,
|
|
||||||
window_seconds: 60 * 60,
|
window_seconds: 60 * 60,
|
||||||
show_recordings: true,
|
show_recordings: true,
|
||||||
style: 'stack' as const,
|
style: 'stack' as const,
|
||||||
@@ -26,9 +23,6 @@ export const timelineCoreConfigSchema = z.object({
|
|||||||
.number()
|
.number()
|
||||||
.optional()
|
.optional()
|
||||||
.default(timelineCoreConfigDefault.clustering_threshold),
|
.default(timelineCoreConfigDefault.clustering_threshold),
|
||||||
events_media_type: eventsMediaTypeSchema
|
|
||||||
.optional()
|
|
||||||
.default(timelineCoreConfigDefault.events_media_type),
|
|
||||||
window_seconds: z
|
window_seconds: z
|
||||||
.number()
|
.number()
|
||||||
.min(1 * 60)
|
.min(1 * 60)
|
||||||
@@ -58,10 +52,3 @@ export const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({
|
|||||||
style: timelineCoreConfigSchema.shape.style.default(miniTimelineConfigDefault.style),
|
style: timelineCoreConfigSchema.shape.style.default(miniTimelineConfigDefault.style),
|
||||||
});
|
});
|
||||||
export type MiniTimelineControlConfig = z.infer<typeof miniTimelineConfigSchema>;
|
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(),
|
icon: z.string().optional(),
|
||||||
permanent: z.boolean().default(false).optional(),
|
permanent: z.boolean().default(false).optional(),
|
||||||
});
|
});
|
||||||
|
export type MenuItemBase = z.infer<typeof menuBaseSchema>;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
miniTimelineConfigSchema,
|
miniTimelineConfigSchema,
|
||||||
} from './common/controls/timeline';
|
} from './common/controls/timeline';
|
||||||
import { viewDisplaySchema } from './common/display';
|
import { viewDisplaySchema } from './common/display';
|
||||||
import { eventsMediaTypeSchema } from './common/events-media';
|
|
||||||
import {
|
import {
|
||||||
MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
||||||
MEDIA_ACTION_POSITIVE_CONDITIONS,
|
MEDIA_ACTION_POSITIVE_CONDITIONS,
|
||||||
@@ -41,12 +40,6 @@ const microphoneConfigSchema = z
|
|||||||
.default(microphoneConfigDefault);
|
.default(microphoneConfigDefault);
|
||||||
export type MicrophoneConfig = z.infer<typeof microphoneConfigSchema>;
|
export type MicrophoneConfig = z.infer<typeof microphoneConfigSchema>;
|
||||||
|
|
||||||
const liveThumbnailControlsDefaults = {
|
|
||||||
...thumbnailControlsDefaults,
|
|
||||||
media_type: 'events' as const,
|
|
||||||
events_media_type: 'all' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const liveConfigDefault = {
|
export const liveConfigDefault = {
|
||||||
auto_play: [...MEDIA_ACTION_POSITIVE_CONDITIONS],
|
auto_play: [...MEDIA_ACTION_POSITIVE_CONDITIONS],
|
||||||
auto_pause: [],
|
auto_pause: [],
|
||||||
@@ -67,7 +60,7 @@ export const liveConfigDefault = {
|
|||||||
style: 'chevrons' as const,
|
style: 'chevrons' as const,
|
||||||
},
|
},
|
||||||
ptz: ptzControlsDefaults,
|
ptz: ptzControlsDefaults,
|
||||||
thumbnails: liveThumbnailControlsDefaults,
|
thumbnails: thumbnailControlsDefaults,
|
||||||
timeline: miniTimelineConfigDefault,
|
timeline: miniTimelineConfigDefault,
|
||||||
wheel: true,
|
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
|
export const liveConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
auto_pause: z
|
auto_pause: z
|
||||||
@@ -118,7 +102,7 @@ export const liveConfigSchema = z
|
|||||||
})
|
})
|
||||||
.default(liveConfigDefault.controls.next_previous),
|
.default(liveConfigDefault.controls.next_previous),
|
||||||
ptz: ptzControlsConfigSchema.default(liveConfigDefault.controls.ptz),
|
ptz: ptzControlsConfigSchema.default(liveConfigDefault.controls.ptz),
|
||||||
thumbnails: livethumbnailsControlSchema.default(
|
thumbnails: thumbnailsControlSchema.default(
|
||||||
liveConfigDefault.controls.thumbnails,
|
liveConfigDefault.controls.thumbnails,
|
||||||
),
|
),
|
||||||
timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline),
|
timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline),
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const menuConfigDefault = {
|
|||||||
buttons: {
|
buttons: {
|
||||||
camera_ui: visibleButtonDefault,
|
camera_ui: visibleButtonDefault,
|
||||||
cameras: visibleButtonDefault,
|
cameras: visibleButtonDefault,
|
||||||
clips: visibleButtonDefault,
|
clips: hiddenButtonDefault,
|
||||||
ptz_home: hiddenButtonDefault,
|
ptz_home: hiddenButtonDefault,
|
||||||
display_mode: visibleButtonDefault,
|
display_mode: visibleButtonDefault,
|
||||||
download: visibleButtonDefault,
|
download: visibleButtonDefault,
|
||||||
@@ -38,6 +38,7 @@ export const menuConfigDefault = {
|
|||||||
iris: visibleButtonDefault,
|
iris: visibleButtonDefault,
|
||||||
fullscreen: visibleButtonDefault,
|
fullscreen: visibleButtonDefault,
|
||||||
image: hiddenButtonDefault,
|
image: hiddenButtonDefault,
|
||||||
|
info: visibleButtonDefault,
|
||||||
live: visibleButtonDefault,
|
live: visibleButtonDefault,
|
||||||
media_player: visibleButtonDefault,
|
media_player: visibleButtonDefault,
|
||||||
microphone: {
|
microphone: {
|
||||||
@@ -48,8 +49,10 @@ export const menuConfigDefault = {
|
|||||||
play: hiddenButtonDefault,
|
play: hiddenButtonDefault,
|
||||||
ptz_controls: hiddenButtonDefault,
|
ptz_controls: hiddenButtonDefault,
|
||||||
recordings: hiddenButtonDefault,
|
recordings: hiddenButtonDefault,
|
||||||
|
reviews: visibleButtonDefault,
|
||||||
|
set_review: visibleButtonDefault,
|
||||||
screenshot: hiddenButtonDefault,
|
screenshot: hiddenButtonDefault,
|
||||||
snapshots: visibleButtonDefault,
|
snapshots: hiddenButtonDefault,
|
||||||
substreams: visibleButtonDefault,
|
substreams: visibleButtonDefault,
|
||||||
timeline: visibleButtonDefault,
|
timeline: visibleButtonDefault,
|
||||||
},
|
},
|
||||||
@@ -76,7 +79,7 @@ export const menuConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui),
|
camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui),
|
||||||
cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras),
|
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),
|
ptz_home: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_home),
|
||||||
display_mode: visibleButtonSchema.default(
|
display_mode: visibleButtonSchema.default(
|
||||||
menuConfigDefault.buttons.display_mode,
|
menuConfigDefault.buttons.display_mode,
|
||||||
@@ -87,6 +90,7 @@ export const menuConfigSchema = z
|
|||||||
iris: visibleButtonSchema.default(menuConfigDefault.buttons.iris),
|
iris: visibleButtonSchema.default(menuConfigDefault.buttons.iris),
|
||||||
fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen),
|
fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen),
|
||||||
image: hiddenButtonSchema.default(menuConfigDefault.buttons.image),
|
image: hiddenButtonSchema.default(menuConfigDefault.buttons.image),
|
||||||
|
info: visibleButtonSchema.default(menuConfigDefault.buttons.info),
|
||||||
live: visibleButtonSchema.default(menuConfigDefault.buttons.live),
|
live: visibleButtonSchema.default(menuConfigDefault.buttons.live),
|
||||||
media_player: visibleButtonSchema.default(
|
media_player: visibleButtonSchema.default(
|
||||||
menuConfigDefault.buttons.media_player,
|
menuConfigDefault.buttons.media_player,
|
||||||
@@ -102,8 +106,10 @@ export const menuConfigSchema = z
|
|||||||
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
||||||
ptz_controls: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_controls),
|
ptz_controls: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_controls),
|
||||||
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
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),
|
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
|
||||||
snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots),
|
snapshots: hiddenButtonSchema.default(menuConfigDefault.buttons.snapshots),
|
||||||
substreams: visibleButtonSchema.default(menuConfigDefault.buttons.substreams),
|
substreams: visibleButtonSchema.default(menuConfigDefault.buttons.substreams),
|
||||||
timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline),
|
timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const statusBarConfigDefault = {
|
|||||||
items: {
|
items: {
|
||||||
engine: statusBarItemDefault,
|
engine: statusBarItemDefault,
|
||||||
resolution: statusBarItemDefault,
|
resolution: statusBarItemDefault,
|
||||||
|
severity: statusBarItemDefault,
|
||||||
technology: statusBarItemDefault,
|
technology: statusBarItemDefault,
|
||||||
title: statusBarItemDefault,
|
title: statusBarItemDefault,
|
||||||
},
|
},
|
||||||
@@ -50,6 +51,7 @@ export const statusBarConfigSchema = z
|
|||||||
resolution: statusBarItemBaseSchema.default(
|
resolution: statusBarItemBaseSchema.default(
|
||||||
statusBarConfigDefault.items.resolution,
|
statusBarConfigDefault.items.resolution,
|
||||||
),
|
),
|
||||||
|
severity: statusBarItemBaseSchema.default(statusBarConfigDefault.items.severity),
|
||||||
title: statusBarItemBaseSchema.default(statusBarConfigDefault.items.title),
|
title: statusBarItemBaseSchema.default(statusBarConfigDefault.items.title),
|
||||||
})
|
})
|
||||||
.default(statusBarConfigDefault.items),
|
.default(statusBarConfigDefault.items),
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { thumbnailsControlSchema } from './common/controls/thumbnails';
|
|
||||||
import {
|
import {
|
||||||
timelineConfigDefault,
|
thumbnailControlsDefaults,
|
||||||
|
thumbnailsControlSchema,
|
||||||
|
} from './common/controls/thumbnails';
|
||||||
|
import {
|
||||||
|
timelineCoreConfigDefault,
|
||||||
timelineCoreConfigSchema,
|
timelineCoreConfigSchema,
|
||||||
} from './common/controls/timeline';
|
} from './common/controls/timeline';
|
||||||
|
|
||||||
|
export const timelineConfigDefault = {
|
||||||
|
...timelineCoreConfigDefault,
|
||||||
|
controls: {
|
||||||
|
thumbnails: thumbnailControlsDefaults,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const timelineConfigSchema = timelineCoreConfigSchema
|
export const timelineConfigSchema = timelineCoreConfigSchema
|
||||||
.extend({
|
.extend({
|
||||||
controls: z
|
controls: z
|
||||||
.object({
|
.object({
|
||||||
thumbnails: thumbnailsControlSchema.default(
|
thumbnails: thumbnailsControlSchema.default(thumbnailControlsDefaults),
|
||||||
timelineConfigDefault.controls.thumbnails,
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.default(timelineConfigDefault.controls),
|
.default(timelineConfigDefault.controls),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { deepRemoveDefaults } from '../../utils/zod';
|
|||||||
import { automationsSchema } from './automations';
|
import { automationsSchema } from './automations';
|
||||||
import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './cameras';
|
import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './cameras';
|
||||||
import { cardIDRegex } from './common/const';
|
import { cardIDRegex } from './common/const';
|
||||||
import { timelineConfigDefault } from './common/controls/timeline';
|
|
||||||
import { imageConfigDefault } from './common/image';
|
import { imageConfigDefault } from './common/image';
|
||||||
import { DebugConfig, debugConfigDefault, debugConfigSchema } from './debug';
|
import { DebugConfig, debugConfigDefault, debugConfigSchema } from './debug';
|
||||||
import { dimensionsConfigSchema } from './dimensions';
|
import { dimensionsConfigSchema } from './dimensions';
|
||||||
@@ -22,7 +21,7 @@ import {
|
|||||||
import { profilesSchema } from './profiles';
|
import { profilesSchema } from './profiles';
|
||||||
import { remoteControlConfigDefault, remoteControlConfigSchema } from './remote-control';
|
import { remoteControlConfigDefault, remoteControlConfigSchema } from './remote-control';
|
||||||
import { statusBarConfigDefault, statusBarConfigSchema } from './status-bar';
|
import { statusBarConfigDefault, statusBarConfigSchema } from './status-bar';
|
||||||
import { timelineConfigSchema } from './timeline';
|
import { timelineConfigDefault, timelineConfigSchema } from './timeline';
|
||||||
import { viewConfigDefault, viewConfigSchema } from './view';
|
import { viewConfigDefault, viewConfigSchema } from './view';
|
||||||
import { viewerConfigDefault, viewerConfigSchema } from './viewer';
|
import { viewerConfigDefault, viewerConfigSchema } from './viewer';
|
||||||
|
|
||||||
|
|||||||
+27
-10
@@ -109,6 +109,15 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES =
|
|||||||
`${CONF_CAMERAS}.#.triggers.entities` as const;
|
`${CONF_CAMERAS}.#.triggers.entities` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS =
|
export const CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS =
|
||||||
`${CONF_CAMERAS}.#.triggers.events` as const;
|
`${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;
|
const CONF_CAMERAS_GLOBAL = 'cameras_global' as const;
|
||||||
export const CONF_CAMERAS_GLOBAL_LIVE_PROVIDER =
|
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;
|
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_favorite_control` as const;
|
||||||
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
||||||
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_timeline_control` as const;
|
`${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 =
|
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE =
|
||||||
`${CONF_MEDIA_GALLERY}.controls.thumbnails.size` as const;
|
`${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;
|
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_favorite_control` as const;
|
||||||
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
||||||
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_timeline_control` as const;
|
`${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 =
|
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE =
|
||||||
`${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const;
|
`${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const;
|
||||||
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD =
|
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD =
|
||||||
`${CONF_MEDIA_VIEWER}.controls.timeline.clustering_threshold` as const;
|
`${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 =
|
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H =
|
||||||
`${CONF_MEDIA_VIEWER}.controls.timeline.format.24h` as const;
|
`${CONF_MEDIA_VIEWER}.controls.timeline.format.24h` as const;
|
||||||
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE =
|
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 =
|
export const CONF_LIVE_CONTROLS_PTZ_POSITION =
|
||||||
`${CONF_LIVE}.controls.ptz.position` as const;
|
`${CONF_LIVE}.controls.ptz.position` as const;
|
||||||
export const CONF_LIVE_CONTROLS_WHEEL = `${CONF_LIVE}.controls.wheel` 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 =
|
export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE =
|
||||||
`${CONF_LIVE}.controls.thumbnails.mode` as const;
|
`${CONF_LIVE}.controls.thumbnails.mode` as const;
|
||||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE =
|
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;
|
`${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const;
|
||||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
||||||
`${CONF_LIVE}.controls.thumbnails.show_timeline_control` as const;
|
`${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 =
|
export const CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD =
|
||||||
`${CONF_LIVE}.controls.timeline.clustering_threshold` as const;
|
`${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 =
|
export const CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H =
|
||||||
`${CONF_LIVE}.controls.timeline.format.24h` as const;
|
`${CONF_LIVE}.controls.timeline.format.24h` as const;
|
||||||
export const CONF_LIVE_CONTROLS_TIMELINE_MODE =
|
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_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
|
||||||
export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
|
export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
|
||||||
`${CONF_TIMELINE}.clustering_threshold` as const;
|
`${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_FORMAT_24H = `${CONF_TIMELINE}.format.24h` as const;
|
||||||
export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
|
export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
|
||||||
export const CONF_TIMELINE_STYLE = `${CONF_TIMELINE}.style` 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;
|
`${CONF_TIMELINE}.controls.thumbnails.show_favorite_control` as const;
|
||||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
||||||
`${CONF_TIMELINE}.controls.thumbnails.show_timeline_control` as const;
|
`${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;
|
const CONF_MENU = 'menu' as const;
|
||||||
export const CONF_MENU_ALIGNMENT = `${CONF_MENU}.alignment` 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_REFRESH_SECONDS,
|
||||||
CONF_CAMERAS_ARRAY_IMAGE_URL,
|
CONF_CAMERAS_ARRAY_IMAGE_URL,
|
||||||
CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
|
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_DIRECTORY_PATTERN,
|
||||||
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN,
|
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN,
|
||||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
|
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
|
||||||
@@ -88,6 +91,8 @@ import {
|
|||||||
CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS,
|
CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS,
|
||||||
CONF_CAMERAS_ARRAY_TRIGGERS_MOTION,
|
CONF_CAMERAS_ARRAY_TRIGGERS_MOTION,
|
||||||
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
|
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_ENTITY,
|
||||||
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
|
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
|
||||||
CONF_DIMENSIONS_ASPECT_RATIO,
|
CONF_DIMENSIONS_ASPECT_RATIO,
|
||||||
@@ -117,16 +122,15 @@ import {
|
|||||||
CONF_LIVE_CONTROLS_PTZ_MODE,
|
CONF_LIVE_CONTROLS_PTZ_MODE,
|
||||||
CONF_LIVE_CONTROLS_PTZ_ORIENTATION,
|
CONF_LIVE_CONTROLS_PTZ_ORIENTATION,
|
||||||
CONF_LIVE_CONTROLS_PTZ_POSITION,
|
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_MODE,
|
||||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_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_SHOW_TIMELINE_CONTROL,
|
||||||
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
|
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H,
|
CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H,
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_MODE,
|
CONF_LIVE_CONTROLS_TIMELINE_MODE,
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_PAN_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_DETAILS,
|
||||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_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_SHOW_TIMELINE_CONTROL,
|
||||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE,
|
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE,
|
||||||
CONF_MEDIA_VIEWER_AUTO_MUTE,
|
CONF_MEDIA_VIEWER_AUTO_MUTE,
|
||||||
@@ -166,10 +172,11 @@ import {
|
|||||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_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_SHOW_TIMELINE_CONTROL,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE,
|
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
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_FORMAT_24H,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE,
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE,
|
||||||
@@ -212,9 +219,10 @@ import {
|
|||||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_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_SHOW_TIMELINE_CONTROL,
|
||||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
|
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
|
||||||
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
|
|
||||||
CONF_TIMELINE_FORMAT_24H,
|
CONF_TIMELINE_FORMAT_24H,
|
||||||
CONF_TIMELINE_SHOW_RECORDINGS,
|
CONF_TIMELINE_SHOW_RECORDINGS,
|
||||||
CONF_TIMELINE_STYLE,
|
CONF_TIMELINE_STYLE,
|
||||||
@@ -259,6 +267,7 @@ import { localize } from './localize/localize.js';
|
|||||||
import editorStyle from './scss/editor.scss';
|
import editorStyle from './scss/editor.scss';
|
||||||
import { arrayMove, prettifyTitle } from './utils/basic.js';
|
import { arrayMove, prettifyTitle } from './utils/basic.js';
|
||||||
import { getCameraID } from './utils/camera.js';
|
import { getCameraID } from './utils/camera.js';
|
||||||
|
import { getFolderID } from './utils/folder.js';
|
||||||
|
|
||||||
const MENU_CAMERAS = 'cameras';
|
const MENU_CAMERAS = 'cameras';
|
||||||
const MENU_CAMERAS_CAPABILITIES = 'cameras.capabilities';
|
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_PROXY = 'cameras.proxy';
|
||||||
const MENU_CAMERAS_REOLINK = 'cameras.reolink';
|
const MENU_CAMERAS_REOLINK = 'cameras.reolink';
|
||||||
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
|
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_WEBRTC_CARD = 'cameras.webrtc_card';
|
||||||
|
const MENU_CAMERAS_MEDIA = 'cameras.media';
|
||||||
const MENU_FOLDERS = 'folders';
|
const MENU_FOLDERS = 'folders';
|
||||||
const MENU_FOLDERS_HA = 'folders.ha';
|
const MENU_FOLDERS_HA = 'folders.ha';
|
||||||
const MENU_LIVE_CONTROLS = 'live.controls';
|
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: 'live', label: localize('config.view.views.live') },
|
||||||
{ value: 'recording', label: localize('config.view.views.recording') },
|
{ value: 'recording', label: localize('config.view.views.recording') },
|
||||||
{ value: 'recordings', label: localize('config.view.views.recordings') },
|
{ 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: 'snapshot', label: localize('config.view.views.snapshot') },
|
||||||
{ value: 'snapshots', label: localize('config.view.views.snapshots') },
|
{ value: 'snapshots', label: localize('config.view.views.snapshots') },
|
||||||
{ value: 'timeline', label: localize('config.view.views.timeline') },
|
{ value: 'timeline', label: localize('config.view.views.timeline') },
|
||||||
@@ -535,25 +548,53 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
|
|
||||||
protected _thumbnailMediaTypes: EditorSelectOption[] = [
|
protected _thumbnailMediaTypes: EditorSelectOption[] = [
|
||||||
{ value: '', label: '' },
|
{ value: '', label: '' },
|
||||||
|
{
|
||||||
|
value: 'auto',
|
||||||
|
label: localize('config.common.media_types.auto'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'events',
|
value: 'events',
|
||||||
label: localize('config.common.controls.thumbnails.media_types.events'),
|
label: localize('config.common.media_types.events'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'recordings',
|
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[] = [
|
protected _thumbnailEventsMediaTypes: EditorSelectOption[] = [
|
||||||
{ value: '', label: '' },
|
{ value: '', label: '' },
|
||||||
|
{
|
||||||
|
value: 'all',
|
||||||
|
label: localize('config.common.events_media_types.all'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'clips',
|
value: 'clips',
|
||||||
label: localize('config.common.controls.thumbnails.events_media_types.clips'),
|
label: localize('config.common.events_media_types.clips'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'snapshots',
|
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[] = [
|
protected _timelineEventsMediaTypes: EditorSelectOption[] = [
|
||||||
{ value: '', label: '' },
|
{ 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',
|
value: 'clips',
|
||||||
label: localize('config.common.timeline.events_media_types.clips'),
|
label: localize('config.common.events_media_types.clips'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'snapshots',
|
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') },
|
{ 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[] = [
|
protected _ptzModes: EditorSelectOption[] = [
|
||||||
{ value: '', label: '' },
|
{ value: '', label: '' },
|
||||||
{ value: 'on', label: localize('config.live.controls.ptz.modes.on') },
|
{ 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[] = [
|
protected _timelinePanModes: EditorSelectOption[] = [
|
||||||
{ value: '', label: '' },
|
{ value: '', label: '' },
|
||||||
{
|
{
|
||||||
@@ -1607,7 +1673,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
* @param configPathStyle Timeline style config path.
|
* @param configPathStyle Timeline style config path.
|
||||||
* @param configPathWindowSeconds Timeline window config path.
|
* @param configPathWindowSeconds Timeline window config path.
|
||||||
* @param configPathClusteringThreshold Clustering threshold config path.
|
* @param configPathClusteringThreshold Clustering threshold config path.
|
||||||
* @param configPathTimelineEventsMediaType Timeline media config path.
|
|
||||||
* @param configPathShowRecordings Show recordings config path.
|
* @param configPathShowRecordings Show recordings config path.
|
||||||
* @param defaultShowRecordings Default value of show_recordings.
|
* @param defaultShowRecordings Default value of show_recordings.
|
||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
@@ -1617,7 +1682,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
configPathStyle: string,
|
configPathStyle: string,
|
||||||
configPathWindowSeconds: string,
|
configPathWindowSeconds: string,
|
||||||
configPathClusteringThreshold: string,
|
configPathClusteringThreshold: string,
|
||||||
configPathTimelineEventsMediaType: string,
|
|
||||||
configPathShowRecordings: string,
|
configPathShowRecordings: string,
|
||||||
configPathFormat24h: string,
|
configPathFormat24h: string,
|
||||||
defaultShowRecordings: boolean,
|
defaultShowRecordings: boolean,
|
||||||
@@ -1639,13 +1703,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
${this._renderNumberInput(configPathClusteringThreshold, {
|
${this._renderNumberInput(configPathClusteringThreshold, {
|
||||||
label: localize(`config.common.${CONF_TIMELINE_CLUSTERING_THRESHOLD}`),
|
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, {
|
${this._renderSwitch(configPathShowRecordings, defaultShowRecordings, {
|
||||||
label: localize(`config.common.${CONF_TIMELINE_SHOW_RECORDINGS}`),
|
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 domain The submenu domain.
|
||||||
* @param configPathWindowSeconds Timeline window config path.
|
* @param configPathWindowSeconds Timeline window config path.
|
||||||
* @param configPathClusteringThreshold Clustering threshold config path.
|
* @param configPathClusteringThreshold Clustering threshold config path.
|
||||||
* @param configPathTimelineEventsMediaType Timeline media config path.
|
|
||||||
* @param configPathShowRecordings Show recordings config path.
|
* @param configPathShowRecordings Show recordings config path.
|
||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
*/
|
*/
|
||||||
@@ -1678,7 +1735,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
configPathStyle: string,
|
configPathStyle: string,
|
||||||
configPathWindowSeconds: string,
|
configPathWindowSeconds: string,
|
||||||
configPathClusteringThreshold: string,
|
configPathClusteringThreshold: string,
|
||||||
configPathTimelineEventsMediaType: string,
|
|
||||||
configPathShowRecordings: string,
|
configPathShowRecordings: string,
|
||||||
configPathFormat24h: string,
|
configPathFormat24h: string,
|
||||||
defaultShowRecordings: boolean,
|
defaultShowRecordings: boolean,
|
||||||
@@ -1698,7 +1755,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
configPathStyle,
|
configPathStyle,
|
||||||
configPathWindowSeconds,
|
configPathWindowSeconds,
|
||||||
configPathClusteringThreshold,
|
configPathClusteringThreshold,
|
||||||
configPathTimelineEventsMediaType,
|
|
||||||
configPathShowRecordings,
|
configPathShowRecordings,
|
||||||
configPathFormat24h,
|
configPathFormat24h,
|
||||||
defaultShowRecordings,
|
defaultShowRecordings,
|
||||||
@@ -1814,15 +1870,17 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
configPathShowFavoriteControl: string,
|
configPathShowFavoriteControl: string,
|
||||||
configPathShowTimelineControl: string,
|
configPathShowTimelineControl: string,
|
||||||
configPathShowDownloadControl: string,
|
configPathShowDownloadControl: string,
|
||||||
|
configPathShowReviewControl: string,
|
||||||
|
configPathShowInfoControl: string,
|
||||||
defaults: {
|
defaults: {
|
||||||
show_details: boolean;
|
show_details: boolean;
|
||||||
show_favorite_control: boolean;
|
show_favorite_control: boolean;
|
||||||
show_timeline_control: boolean;
|
show_timeline_control: boolean;
|
||||||
show_download_control: boolean;
|
show_download_control: boolean;
|
||||||
|
show_review_control: boolean;
|
||||||
|
show_info_control: boolean;
|
||||||
},
|
},
|
||||||
options?: {
|
options?: {
|
||||||
configPathMediaType?: string;
|
|
||||||
configPathEventsMediaType?: string;
|
|
||||||
configPathMode?: string;
|
configPathMode?: string;
|
||||||
},
|
},
|
||||||
): TemplateResult | void {
|
): TemplateResult | void {
|
||||||
@@ -1841,24 +1899,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
},
|
},
|
||||||
)}`
|
)}`
|
||||||
: html``}
|
: 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, {
|
${this._renderNumberInput(configPathSize, {
|
||||||
min: THUMBNAIL_WIDTH_MIN,
|
min: THUMBNAIL_WIDTH_MIN,
|
||||||
max: THUMBNAIL_WIDTH_MAX,
|
max: THUMBNAIL_WIDTH_MAX,
|
||||||
@@ -1888,6 +1928,12 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
label: localize('config.common.controls.thumbnails.show_download_control'),
|
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,
|
cameras: RawAdvancedCameraCardConfigArray,
|
||||||
cameraIndex: number,
|
cameraIndex: number,
|
||||||
entities: string[],
|
entities: string[],
|
||||||
|
folders: RawAdvancedCameraCardConfigArray,
|
||||||
addNewCamera?: boolean,
|
addNewCamera?: boolean,
|
||||||
): TemplateResult | void {
|
): TemplateResult | void {
|
||||||
const liveProviders: EditorSelectOption[] = [
|
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 = {
|
const submenuClasses = {
|
||||||
submenu: true,
|
submenu: true,
|
||||||
selected: this._expandedMenus[MENU_CAMERAS] === cameraIndex,
|
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'),
|
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(
|
${this._putInSubmenu(
|
||||||
@@ -2731,9 +2845,9 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
? html`
|
? html`
|
||||||
<div class="values">
|
<div class="values">
|
||||||
${cameras.map((_, index) =>
|
${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>
|
</div>
|
||||||
`
|
`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -2784,6 +2898,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
${this._renderMenuButton('cameras') /* */}
|
${this._renderMenuButton('cameras') /* */}
|
||||||
${this._renderMenuButton('substreams') /* */}
|
${this._renderMenuButton('substreams') /* */}
|
||||||
${this._renderMenuButton('live') /* */}
|
${this._renderMenuButton('live') /* */}
|
||||||
|
${this._renderMenuButton('reviews') /* */}
|
||||||
${this._renderMenuButton('clips') /* */}
|
${this._renderMenuButton('clips') /* */}
|
||||||
${this._renderMenuButton('snapshots')}
|
${this._renderMenuButton('snapshots')}
|
||||||
${this._renderMenuButton('recordings')}
|
${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_FAVORITE_CONTROL,
|
||||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_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,
|
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,
|
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_STYLE,
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H,
|
CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H,
|
||||||
this._defaults.live.controls.timeline.show_recordings,
|
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_FAVORITE_CONTROL,
|
||||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_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._defaults.media_gallery.controls.thumbnails,
|
||||||
)}
|
)}
|
||||||
${this._renderFilterControls(
|
${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_FAVORITE_CONTROL,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_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,
|
this._defaults.media_viewer.controls.thumbnails,
|
||||||
{
|
{
|
||||||
configPathMode: CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
|
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_STYLE,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
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_SHOW_RECORDINGS,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H,
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H,
|
||||||
this._defaults.media_viewer.controls.timeline.show_recordings,
|
this._defaults.media_viewer.controls.timeline.show_recordings,
|
||||||
@@ -3179,7 +3295,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
CONF_TIMELINE_STYLE,
|
CONF_TIMELINE_STYLE,
|
||||||
CONF_TIMELINE_WINDOW_SECONDS,
|
CONF_TIMELINE_WINDOW_SECONDS,
|
||||||
CONF_TIMELINE_CLUSTERING_THRESHOLD,
|
CONF_TIMELINE_CLUSTERING_THRESHOLD,
|
||||||
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
|
|
||||||
CONF_TIMELINE_SHOW_RECORDINGS,
|
CONF_TIMELINE_SHOW_RECORDINGS,
|
||||||
CONF_TIMELINE_FORMAT_24H,
|
CONF_TIMELINE_FORMAT_24H,
|
||||||
this._defaults.timeline.show_recordings,
|
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_FAVORITE_CONTROL,
|
||||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_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,
|
this._defaults.timeline.controls.thumbnails,
|
||||||
{
|
{
|
||||||
configPathMode: CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
|
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