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:
Dermot Duffy
2026-01-19 13:32:50 -08:00
committed by GitHub
parent 6eb0a87ca8
commit fc32727860
215 changed files with 14491 additions and 6160 deletions
+1 -1
View File
@@ -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).
+52 -14
View File
@@ -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
+60 -1
View File
@@ -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): ![](../../images/browsers/chrome_16x16.png 'Google Chrome :no-zoom') ![](../../images/browsers/chromium_16x16.png 'Chromium :no-zoom') ![](../../images/browsers/edge_16x16.png 'Microsoft Edge :no-zoom') | | `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): ![Chrome](../../images/browsers/chrome_16x16.png 'Google Chrome :no-zoom') ![Chromium](../../images/browsers/chromium_16x16.png 'Chromium :no-zoom') ![Edge](../../images/browsers/edge_16x16.png 'Microsoft Edge :no-zoom') |
| `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:
+5 -5
View File
@@ -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
+7 -7
View File
@@ -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
View File
@@ -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:
+8 -5
View File
@@ -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:
+8 -6
View File
@@ -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:
+13 -7
View File
@@ -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
+10 -6
View File
@@ -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
+3 -3
View File
@@ -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
``` ```
+2
View File
@@ -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. |
+3
View File
@@ -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,
+41
View File
@@ -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(
+102 -8
View File
@@ -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,
});
};
} }
+273 -49
View File
@@ -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);
}
}
}
}
+17 -3
View File
@@ -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;
}
} }
+99 -1
View File
@@ -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),
);
}
} }
+66
View File
@@ -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,
});
}
};
+70
View File
@@ -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[];
}
+89 -22
View File
@@ -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';
};
+108
View File
@@ -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;
} }
+81 -1
View File
@@ -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;
} }
+2 -8
View File
@@ -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
View File
@@ -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;
+12 -12
View File
@@ -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 -1
View File
@@ -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);
} }
+23 -1
View File
@@ -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();
}
};
} }
+10
View File
@@ -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;
} }
+11 -4
View File
@@ -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:
+30 -10
View File
@@ -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 })
);
}
} }
+13 -3
View File
@@ -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);
} }
+9 -2
View File
@@ -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();
}
}
}
+14 -1
View File
@@ -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
? [ ? [
{ {
+26 -10
View File
@@ -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,
},
});
}
} }
} }
+7
View File
@@ -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;
+6
View File
@@ -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;
} }
-185
View File
@@ -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
);
}
}
+1 -6
View File
@@ -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;
+10 -11
View File
@@ -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 {
+149 -55
View File
@@ -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
View File
@@ -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>`,
); );
} }
-49
View File
@@ -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',
});
};
+132
View File
@@ -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,
),
},
});
}
}
+165 -182
View File
@@ -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];
} }
@@ -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;
}
} }
+117 -13
View File
@@ -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)) ||
+123
View File
@@ -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;
+135 -139
View File
@@ -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 &&
+94 -161
View File
@@ -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,
-11
View File
@@ -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;
+19 -2
View File
@@ -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 {
-151
View File
@@ -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;
}
}
+294
View File
@@ -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>;
}
}
-194
View File
@@ -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;
}
}
+57 -33
View File
@@ -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 {
+131
View File
@@ -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;
}
}
+4 -3
View File
@@ -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);
} }
} }
+3
View File
@@ -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)}
/>`; />`;
} }
+4 -79
View File
@@ -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}
+78 -65
View File
@@ -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 {
+23 -23
View File
@@ -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>`
: ''}
`; `;
} }
+178 -17
View File
@@ -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
+13 -95
View File
@@ -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>`
: ``}
`; `;
} }
+19 -9
View File
@@ -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,
}); });
} }
} }
-44
View File
@@ -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'
+2 -2
View File
@@ -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)}
+5
View File
@@ -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>
`; `;
+5
View File
@@ -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>`;
} }
+4 -9
View File
@@ -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
View File
@@ -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.
+24 -8
View File
@@ -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>;
+4
View File
@@ -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(),
}); });
+39 -1
View File
@@ -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
+2
View File
@@ -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 -14
View File
@@ -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,
},
};
+4
View File
@@ -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>;
+2 -18
View File
@@ -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),
+10 -4
View File
@@ -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),
}) })
+2
View File
@@ -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),
+13 -5
View File
@@ -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),
}) })
+1 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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