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.
- Live viewing of multiple cameras.
- Clips and snapshot browsing via mini-gallery.
- Clips, snapshot and review browsing via mini-gallery.
- Automatic updating to continually show latest clip / snapshot.
- Support for filtering events by zone and label.
- Arbitrary entity access via menu (e.g. motion sensor access).
+52 -14
View File
@@ -175,6 +175,15 @@ action: custom:advanced-camera-card-action
advanced_camera_card_action: image
```
## `info`
Show media metadata (e.g. event time, camera, descriptions).
```yaml
action: custom:advanced-camera-card-action
advanced_camera_card_action: info
```
## `live`
Change to the `live` view.
@@ -385,14 +394,14 @@ advanced_camera_card_action: ptz_digital
# [...]
```
| Parameter | Default | Description |
| ----------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `action` | | Must be `custom:advanced-camera-card-action`. |
| `advanced_camera_card_action` | | Must be `ptz_digital`. |
| `target_id` | The currently selected camera or media | The target (camera or media) to execute a digital PTZ action on. Can be a camera ID, or another media ID (e.g. for Frigate, can specify a media/event ID). |
| `ptz_action` | Optional action that is one of `left`, `right`, `up`, `down`, `zoom_in` or `zoom_out`. |
| `ptz_phase` | Optional parameter that is one of `start` or `stop` to start or stop the movement separately. |
| `absolute` | Optional parameter to specify exact absolute pan and zoom settings. See below. |
| Parameter | Default | Description |
| ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `action` | | Must be `custom:advanced-camera-card-action`. |
| `advanced_camera_card_action` | | Must be `ptz_digital`. |
| `target_id` | The currently selected camera or media | The target (camera or media) to execute a digital PTZ action on. Can be a camera ID, or another media ID (e.g. for Frigate, can specify a media/event ID). |
| `ptz_action` | | Optional action that is one of `left`, `right`, `up`, `down`, `zoom_in` or `zoom_out`. |
| `ptz_phase` | | Optional parameter that is one of `start` or `stop` to start or stop the movement separately. |
| `absolute` | | Optional parameter to specify exact absolute pan and zoom settings. See below. |
> [!NOTE]
> If no `ptz_action` is specified and no `absolute` value is specified, the camera returns to its "home" position. See [Camera layout configuration](../../cameras/README.md?id=layout-configuration) to configure the default "home" position for digital PTZ.
@@ -472,6 +481,22 @@ action: custom:advanced-camera-card-action
advanced_camera_card_action: screenshot
```
## `set_review`
Mark a review as reviewed or unreviewed.
```yaml
action: custom:advanced-camera-card-action
advanced_camera_card_action: set_review
# [...]
```
| Parameter | Default | Description |
| ----------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
| `action` | | Must be `custom:advanced-camera-card-action`. |
| `advanced_camera_card_action` | | Must be `set_review`. |
| `reviewed` | | If `true` marks as reviewed, if `false` marks as unreviewed. If not specified (the default), toggles the reviewed status. |
## `sleep`
Take no action for a given duration. Useful to pause between multiple other actions.
@@ -627,6 +652,12 @@ elements:
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: live
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-i-circle-outline
title: Info
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: info
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-n-circle
title: Turn off substream
@@ -794,18 +825,25 @@ elements:
advanced_camera_card_action: snapshots
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-h-circle-outline
title: Set reviewed
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: set_review
reviewed: true
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-i-circle-outline
title: Show timeline
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: timeline
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-i-circle-outline
icon: mdi:alpha-j-circle-outline
title: Unmute
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: unmute
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-j-circle-outline
icon: mdi:alpha-k-circle-outline
title: Add status bar contents
tap_action:
- action: custom:advanced-camera-card-action
@@ -834,7 +872,7 @@ elements:
priority: 50
sufficient: false
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-k-circle-outline
icon: mdi:alpha-l-circle-outline
title: Remove status bar contents
tap_action:
- action: custom:advanced-camera-card-action
@@ -863,21 +901,21 @@ elements:
priority: 50
sufficient: false
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-l-circle-outline
icon: mdi:alpha-m-circle-outline
title: Reset status bar contents
tap_action:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: status_bar
status_bar_action: reset
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-m-circle-outline
icon: mdi:alpha-n-circle-outline
title: View Folder Media
tap_action:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: folder
folder: my-folder
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-n-circle-outline
icon: mdi:alpha-o-circle-outline
title: View Folders Gallery
tap_action:
- action: custom:advanced-camera-card-action
+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. |
| `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. This `id` may be used in [conditions](../conditions.md), dependencies or custom [actions](../actions/README.md) to refer to a given camera unambiguously. |
| `live_provider` | `auto` | The choice of live stream provider. See [Live Provider](live-provider.md). |
| `media` | | Controls the default media configuration (e.g. thumbnails) for this camera. See below. |
| `proxy` | | Controls whether/how content is proxied via [hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) (must be installed separately). See below. |
| `title` | Autodetected from `camera_entity` if that is specified. | A friendly name for this camera to use in the card. |
| `triggers` | | Define what should cause this camera to update/trigger. See below. |
@@ -165,6 +166,36 @@ cameras:
| -------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `width_factor` | `1` | Width multiplier for this camera in grid mode (minimum: `0.1`). When selected, width becomes `width_factor * grid_selected_width_factor`, capped at 100%. |
## `media`
The `media` block configures the default media options for this camera which
defines which media is shown as thumbnails and on the timeline.
```yaml
cameras:
- camera_entity: camera.office
media:
# [...]
```
| Option | Default | Description |
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `type` | `auto` | The default media type to show for this camera. One of `auto`, `events`, `recordings`, `reviews` or `folder`. See below for description of each. |
| `events_type` | `all` | If `type` is `events`, what subtype of events to show. One of `clips`, `snapshots` or `all` (default). |
| `folders` | | An optional list of folder IDs to use when `type` is `folder`. If not specified, and `type` is `folder`, will default to showing the default (first) configured folder. See [Folder Configuration](../folders.md). |
### Media Types
Not all camera engines support all media types. See [Camera Engines](engine.md) for details.
| Type | Description |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `events` | Typically represents an interesting event recorded from the camera, in either a video clip or image snapshot (see `events_type` parameter). |
| `recordings` | Typically represents continuous video recordings from the camera. |
| `reviews` | Typically represents an alert / detection of some kind that the user can review. |
| `folder` | Arbitrary media from a [folder](../folders.md), e.g. a Home Assistant media folder. |
| `auto` | Automatically prioritize available media based on camera capabilities. Order of precedence: `reviews` > `clips` > `snapshots` > `recordings`. |
### Layout Configuration
The `layout` block configures the fit and position of the media _within_ the camera dimensions (in order to control the dimensions for the whole card see [the card dimensions configuration](../dimensions.md) ).
@@ -182,7 +213,7 @@ cameras:
| `fit` | `contain` | If `contain`, the media is contained within the camera container/card and letterboxed if necessary. If `cover`, the media is expanded proportionally (i.e. maintaining the media aspect ratio) until the camera/card dimensions are fully covered. If `fill`, the media is stretched to fill the camera/card dimensions (i.e. ignoring the media aspect ratio). See [CSS object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) for technical details and a visualization. Note that if `aspect_ratio` is also set, this is controlling the behavior "within" that aspect-ratio, otherwise it's within the container for the camera (which is effectively the whole card for single card configurations). |
| `pan` | | A dictionary that may contain an `x` and `y` percentage (`0` - `100`) to control the position of the media when "digitally zoomed in" (see `zoom` parameter). This can be effectively used to "pan"/cut the media shown. A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See visualizations below. |
| `position` | | A dictionary that may contain an `x` and `y` percentage (`0` - `100`) to control the position of the media when the fit is `cover` (for other values of `fit` this option has no effect). This can be effectively used to "pan"/cut the media shown. At any given time, only one of `x` and `y` will have an effect, depending on whether media width is larger than the camera/card dimensions (in which case `x` controls the position) or the media height is larger than the camera/card dimensions (in which case `y` controls the position). A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See [CSS object-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) for technicals. See visualizations below. |
| `view_box` | | A dictionary that may contain a `top`, `bottom`, `left` and `right` percentage (`0` - `100`) to precisely crop what part of the media to show by specifying a % inset value from each side. Browsers apply this cropping after `position` and `fit` have been applied. Unlike `zoom`, the user cannot dynamically zoom back out -- however the builtin media controls will work as normal. See visualizations below. Limited [browser support](https://caniuse.com/mdn-css_properties_object-view-box): ![](../../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). |
> [!NOTE]
@@ -358,6 +389,26 @@ cameras:
| `events` | `[events, clips, snapshots]` | Whether to trigger the camera when `events` occur (whether or not media is available) or whenever updated `clips` or `snapshots` are detected. Detection support varies by camera [engine](engine.md). |
| `motion` | `false` | Whether to not to trigger the camera by automatically detecting and using the motion `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the motion `binary_sensor` entity has been enabled in Home Assistant. |
| `occupancy` | `false` | Whether to not to trigger the camera by automatically detecting and using the occupancy `binary_sensor` for this camera and its configured zones and labels. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant. If this camera has configured zones, only occupancy sensors for those zones are used -- if the overall _camera_ occupancy sensor is also required, it can be manually added to `entities`. If this camera has configured labels, only occupancy sensors for those labels are used. |
| `reviews` | | Configuration for triggering on Frigate review items. See below. Frigate-specific. |
### `reviews`
The `reviews` block configures triggering based on review items (e.g., alerts and detections from Frigate).
```yaml
cameras:
- camera_entity: camera.office
triggers:
reviews:
severities:
- high
description: true
```
| Option | Default | Description |
| ------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `severities` | `[high]` | An array of severity levels to trigger on. Possible values: `high` (equivalent to Frigate alerts), `medium` (equivalent to Frigate detections), `low`. At least one severity must be configured for review triggers to be active. |
| `description` | `true` | Whether to trigger on review description updates (e.g. Frigate GenAI descriptions). Severity must also match. |
## Fully expanded reference
@@ -378,6 +429,14 @@ cameras:
occupancy: true
entities:
- binary_sensor.front_door_sensor
events:
- events
- clips
- snapshots
reviews:
severities:
- high
description: true
cast:
method: standard
dimensions:
+5 -5
View File
@@ -88,11 +88,11 @@ cameras:
# [...]
```
| Option | Default | Description |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `entity` | | The RTSP camera entity to pass to the WebRTC Card for this camera. |
| `url` | Depends on the camera engine (e.g. Frigate cameras will automatically use the camera name since this is the [recommended setup](https://deploy-preview-4055--frigate-docs.netlify.app/guides/configuring_go2rtc/)). | The RTSP url to pass to the WebRTC Card, e.g. `rtsp://USERNAME:PASSWORD@CAMERA:554/RTSP_PATH` |
| `*` | | Any options specified in the `webrtc_card:` YAML dictionary are silently passed through to the AlexxIT's WebRTC Card. See [WebRTC Configuration](https://github.com/AlexxIT/WebRTC#configuration) for full details this external card provides, e.g. `ui: true` will enable the WebRTC Card UI. |
| Option | Default | Description |
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `entity` | | The RTSP camera entity to pass to the WebRTC Card for this camera. |
| `url` | Depends on the camera engine (e.g. Frigate cameras will automatically use the camera name since this is the [recommended setup](https://docs.frigate.video/guides/configuring_go2rtc/)). | The RTSP url to pass to the WebRTC Card, e.g. `rtsp://USERNAME:PASSWORD@CAMERA:554/RTSP_PATH` |
| `*` | | Any options specified in the `webrtc_card:` YAML dictionary are silently passed through to the AlexxIT's WebRTC Card. See [WebRTC Configuration](https://github.com/AlexxIT/WebRTC#configuration) for full details this external card provides, e.g. `ui: true` will enable the WebRTC Card UI. |
## Fully expanded reference
+7 -7
View File
@@ -12,13 +12,13 @@ folders:
# [...]
```
| Option | Default | Description |
| ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | | An optional folder `id` which can be used by the [`folder` action](./actions/custom/README.md?id=folder) to show a particular folder contents. |
| `ha` | | Options for `ha` folder types. See below. |
| `icon` | | An optional folder icon. |
| `title` | | An optional folder title. |
| `type` | `ha` | The type of folder, `ha` for Home Assistant media folders (currently the only supported type of folder). |
| Option | Default | Description |
| ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | | An optional folder `id` which can be used by [camera `media` configuration](./cameras/README.md#media) or [`folder` actions](./actions/custom/README.md?id=folder) to show a particular folder's contents. |
| `ha` | | Options for `ha` folder types. See below. |
| `icon` | | An optional folder icon. |
| `title` | | An optional folder title. |
| `type` | `ha` | The type of folder, `ha` for Home Assistant media folders (currently the only supported type of folder). |
## `ha`
+12 -16
View File
@@ -93,16 +93,15 @@ live:
# [...]
```
| Option | Default | Description |
| ----------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `events_media_type` | `all` | Whether to show `clips`, `snapshots` or `all` in the thumbnail carousel in the `live` view. This setting is only relevant when the `media_type` parameter is set to `events`. |
| `media_type` | `events` | Whether to load `events` or `recordings` media. |
| `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`). |
| `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
| `show_download_control` | `true` | Whether to show the download control on each thumbnail. |
| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. |
| `show_timeline_control` | `true` | Whether to show the timeline ('target') control on each thumbnail. |
| `size` | `100` | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `300`. |
| Option | Default | Description |
| ----------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`). |
| `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
| `show_download_control` | `false` | Whether to show the download control on each thumbnail. |
| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. |
| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. |
| `show_timeline_control` | `false` | Whether to show the timeline ('target') control on each thumbnail. |
| `size` | `100` | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `300`. |
### `timeline`
@@ -118,7 +117,6 @@ live:
| Option | Default | Description |
| ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clustering_threshold` | `3` | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. |
| `events_media_type` | `all` | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot. |
| `format` | | Configuration for the timeline time & date format. See below. |
| `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`). |
| `pan_mode` | `pan` | See [timeline pan mode](timeline-pan-mode.md). |
@@ -219,20 +217,18 @@ live:
# Optionally override the default style.
right: 5%
thumbnails:
media_type: events
events_media_type: all
size: 100
show_details: false
show_download_control: true
show_download_control: false
show_favorite_control: true
show_timeline_control: true
show_review_control: true
show_timeline_control: false
mode: none
timeline:
style: ribbon
mode: none
pan_mode: pan
clustering_threshold: 3
events_media_type: all
show_recordings: true
window_seconds: 3600
format:
+8 -5
View File
@@ -1,7 +1,8 @@
# `media_gallery`
The `media_gallery` is used for providing an overview of all `clips`,
`snapshots`, `recordings` and `folder` contents in a thumbnail gallery.
`snapshots`, `recordings`, `reviews` and `folder` contents in a thumbnail
gallery.
```yaml
media_gallery:
@@ -44,9 +45,10 @@ media_gallery:
| Option | Default | Description |
| ----------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
| `show_details` | `false` | Whether to show media details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
| `show_download_control` | `true` | Whether to show the download control on each thumbnail. |
| `show_download_control` | `false` | Whether to show the download control on each thumbnail. |
| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. |
| `show_timeline_control` | `true` | Whether to show the timeline ('target') control on each thumbnail. |
| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. |
| `show_timeline_control` | `false` | Whether to show the timeline ('target') control on each thumbnail. |
| `size` | `100` | The size of the thumbnails in the gallery. Must be >= `75` and <= `300`. |
## Fully expanded reference
@@ -61,9 +63,10 @@ media_gallery:
thumbnails:
size: 100
show_details: false
show_download_control: true
show_download_control: false
show_favorite_control: true
show_timeline_control: true
show_review_control: true
show_timeline_control: false
actions:
entity: light.office_main_lights
tap_action:
+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`). |
| `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
| `show_download_control` | `true` | Whether to show the download control on each thumbnail. |
| `show_download_control` | `false` | Whether to show the download control on each thumbnail. |
| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. |
| `show_timeline_control` | `true` | Whether to show the timeline ('target') control on each thumbnail. |
| `show_info_control` | `true` | Whether to show the info ('i') control on each thumbnail. |
| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. |
| `show_timeline_control` | `false` | Whether to show the timeline ('target') control on each thumbnail. |
| `size` | `100` | The size of the thumbnails in the thumbnail carousel pixels. Must be >= `75` and <= `300`. |
### `timeline`
@@ -112,7 +114,6 @@ media_viewer:
| Option | Default | Description |
| ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clustering_threshold` | `3` | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. |
| `events_media_type` | `all` | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot. |
| `format` | | Configuration for the timeline time & date format. See below. |
| `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`). |
| `pan_mode` | `pan` | See [timeline pan mode](timeline-pan-mode.md). |
@@ -199,15 +200,16 @@ media_viewer:
size: 100
mode: none
show_details: false
show_download_control: true
show_download_control: false
show_favorite_control: true
show_timeline_control: true
show_info_control: true
show_review_control: true
show_timeline_control: false
timeline:
style: ribbon
mode: none
pan_mode: pan
clustering_threshold: 3
events_media_type: all
show_recordings: true
window_seconds: 3600
format:
+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. |
| `fullscreen` | The `fullscreen` menu button: expand the card to consume the fullscreen. Please note that fullscreen behavior on iPhone is limited, see [troubleshooting](../troubleshooting.md?id=fullscreen-doesn39t-work-on-iphone). |
| `image` | The `image` view menu button: brings the user to the static `image` view. |
| `info` | The `info` menu button: show media metadata (e.g. event time, camera, description). |
| `iris` | The main Advanced Camera Card `iris` menu button: brings the user to the default configured view (`view.default`), or collapses/expands the menu if the `menu.style` is `hidden` . |
| `live` | The `live` view menu button: brings the user to the `live` view. |
| `media_player` | The `media_player` menu button: sends the visible media to a remote media player. Supports Frigate clips, snapshots and live camera (only for cameras that specify a `camera_entity` and only using the default HA stream (equivalent to the `ha` live provider)). `jsmpeg` or `webrtc-card` are not supported, although live can still be played as long as `camera_entity` is specified. In the player list, a `tap` will send the media to the player, a `hold` will stop the media on the player. |
@@ -50,13 +51,13 @@ menu:
### Options for each button
| Option | Default | Description |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `alignment` | `matching` | Whether this menu item should have an alignment that is `matching` the menu alignment or `opposing` the menu alignment. Can be used to create two separate groups of buttons on the menu. The `priority` option orders buttons within a given `alignment`. |
| `enabled` | `true` for `iris`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode` and `ptz_home`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `ptz_controls` | Whether or not to show the button. |
| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. See also [custom icons](../usage/custom-icons.md). |
| `permanent` | `false` | If `false` the menu item is hidden when the menu has the `hidden` style and the menu is closed, otherwise it is shown (and sorted to the front). |
| `priority` | `50` | The menu item priority. Higher priority items are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`). Priority applies separately to `matching` and `opposing` groups (see `alignment` above). Minimum `0`, maximum `100`. |
| Option | Default | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `alignment` | `matching` | Whether this menu item should have an alignment that is `matching` the menu alignment or `opposing` the menu alignment. Can be used to create two separate groups of buttons on the menu. The `priority` option orders buttons within a given `alignment`. |
| `enabled` | `true` for `iris`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode`, `ptz_home` and `info`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `ptz_controls` | Whether or not to show the button. |
| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. See also [custom icons](../usage/custom-icons.md). |
| `permanent` | `false` | If `false` the menu item is hidden when the menu has the `hidden` style and the menu is closed, otherwise it is shown (and sorted to the front). |
| `priority` | `50` | The menu item priority. Higher priority items are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`). Priority applies separately to `matching` and `opposing` groups (see `alignment` above). Minimum `0`, maximum `100`. |
### Additional options: `microphone`
@@ -132,6 +133,11 @@ menu:
enabled: false
alignment: matching
icon: mdi:image
info:
priority: 50
enabled: true
alignment: matching
icon: mdi:information-outline
iris:
priority: 50
enabled: true
+10 -6
View File
@@ -28,12 +28,13 @@ status_bar:
### Available Items
| Button name | Description |
| ------------ | ------------------------------------------------------ |
| `engine` | The icon of the camera engine for the relevant camera. |
| `resolution` | The detected media resolution (if any). |
| `technology` | The detected media technology (if any). |
| `title` | The media title. |
| Button name | Description |
| ------------ | ------------------------------------------------------------------------------------------- |
| `engine` | The icon of the camera engine for the relevant camera. |
| `resolution` | The detected media resolution (if any). |
| `severity` | The media severity indicator (if any) for review severity (e.g. Frigate alerts/detections). |
| `technology` | The detected media technology (if any). |
| `title` | The media title. |
### Options for each item
@@ -75,6 +76,9 @@ status_bar:
resolution:
enabled: true
priority: 50
severity:
enabled: true
priority: 50
technology:
enabled: true
priority: 50
+3 -3
View File
@@ -10,7 +10,7 @@ timeline:
You can interact with the timeline in a number of ways:
- Clicking on an event will take you to the media viewer for that event.
- Clicking on an event/review will take you to the media viewer for that event/review.
- Clicking on the "background", or a camera title, will take you to the recordings for that camera (seeking to the clicked time).
- Clicking on the time axis will take you to recordings for all cameras (seeking to the clicked time).
@@ -18,7 +18,6 @@ You can interact with the timeline in a number of ways:
| ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clustering_threshold` | `3` | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. |
| `controls` | | Configuration for the timeline controls. See below. |
| `events_media_type` | `all` | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot. |
| `format` | | Configuration for the timeline time & date format. See below. |
| `show_recordings` | `true` | Whether to show recordings on the timeline (specifically: which hours have any recorded content). |
| `style` | `stack` | Whether the timeline should show events as a single flat `ribbon` or a `stack` of events that are clustered using the `clustering_threshold`. |
@@ -69,6 +68,7 @@ timeline:
| `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
| `show_download_control` | `true` | Whether to show the download control on each thumbnail. |
| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. |
| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. |
| `show_timeline_control` | `true` | Whether to show the timeline ('target') control on each thumbnail. |
| `size` | `100` | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `300`. |
@@ -80,7 +80,6 @@ timeline:
timeline:
style: stack
clustering_threshold: 3
events_media_type: all
show_recordings: true
window_seconds: 3600
format:
@@ -92,5 +91,6 @@ timeline:
show_details: true
show_download_control: true
show_favorite_control: true
show_review_control: true
show_timeline_control: true
```
+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). |
| `recording` | Shows a viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` menu icon. |
| `recordings` | Shows a gallery of recent (last day) recordings for this camera and its dependents. |
| `review` | Shows a viewer for the most recent unreviewed review item (e.g. alerts/detections in Frigate). |
| `reviews` | Shows a viewer for the most recent unreviewed review item (e.g. alerts/detections in Frigate). |
| `snapshot` | Shows a viewer for the most recent snapshot for this camera. Can also be accessed by holding down the `snapshots` menu icon. |
| `snapshots` | Shows a gallery of snapshots for this camera. |
| `timeline` | Shows an event timeline. |
+3
View File
@@ -2,6 +2,9 @@
"ignorePatterns": [
{
"pattern": "vscode://"
},
{
"pattern": "https://www.npmjs.com/"
}
]
}
@@ -6,6 +6,7 @@ import { getMediaDownloadPath } from '../../ha/download';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types';
import { QuerySource } from '../../query-source.js';
import { Endpoint } from '../../types';
import { ViewMedia } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
@@ -55,6 +56,7 @@ export class BrowseMediaCameraManagerEngine
): EventQuery[] | null {
return [
{
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: cameraIDs,
...query,
+41
View File
@@ -8,6 +8,7 @@ import { CameraManagerReadOnlyConfigStore } from './store';
import {
CameraManagerCameraMetadata,
CameraQuery,
DefaultQueryParameters,
Engine,
EngineOptions,
EventQuery,
@@ -17,11 +18,15 @@ import {
PartialEventQuery,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
PartialReviewQuery,
QueryReturnType,
QueryType,
RecordingQuery,
RecordingQueryResultsMap,
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
ReviewQuery,
ReviewQueryResultsMap,
} from './types';
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
@@ -31,6 +36,15 @@ export interface CameraManagerEngine {
createCamera(hass: HomeAssistant, cameraConfig: CameraConfig): Promise<Camera>;
/**
* Get default query parameters for a camera based on its configuration.
* Engines read their own config and return generic filter params.
*/
getDefaultQueryParameters(
camera: Camera,
queryType: QueryType,
): DefaultQueryParameters;
generateDefaultEventQuery(
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
@@ -70,6 +84,19 @@ export interface CameraManagerEngine {
engineOptions?: EngineOptions,
): Promise<RecordingSegmentsQueryResultsMap | null>;
generateDefaultReviewQuery(
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query?: PartialReviewQuery,
): ReviewQuery[] | null;
getReviews(
hass: HomeAssistant,
store: CameraManagerReadOnlyConfigStore,
query: ReviewQuery,
engineOptions?: EngineOptions,
): Promise<ReviewQueryResultsMap | null>;
generateMediaFromEvents(
hass: HomeAssistant,
store: CameraManagerReadOnlyConfigStore,
@@ -84,6 +111,13 @@ export interface CameraManagerEngine {
results: QueryReturnType<RecordingQuery>,
): ViewMedia[] | null;
generateMediaFromReviews(
hass: HomeAssistant,
store: CameraManagerReadOnlyConfigStore,
query: ReviewQuery,
results: QueryReturnType<ReviewQuery>,
): ViewMedia[] | null;
getMediaDownloadPath(
hass: HomeAssistant,
cameraConfig: CameraConfig,
@@ -97,6 +131,13 @@ export interface CameraManagerEngine {
favorite: boolean,
): Promise<void>;
reviewMedia(
hass: HomeAssistant,
cameraConfig: CameraConfig,
media: ViewMedia,
reviewed: boolean,
): Promise<void>;
getQueryResultMaxAge(query: CameraQuery): number | null;
getMediaSeekTime(
+102 -8
View File
@@ -11,6 +11,7 @@ import {
Endpoint,
PTZCapabilities,
PTZMovementType,
SEVERITIES,
} from '../../types';
import { errorToConsole } from '../../utils/basic';
import { Camera, CameraInitializationOptions } from '../camera';
@@ -22,18 +23,21 @@ import {
getGo2RTCStreamEndpoint,
} from '../utils/go2rtc/endpoint';
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
import {
FrigateEventWatcherRequest,
FrigateEventWatcherSubscriptionInterface,
} from './event-watcher';
import { getPTZInfo } from './requests';
import { FrigateEventChange, PTZInfo } from './types';
import {
FRIGATE_SEVERITY_MAP,
FrigateEventChange,
FrigateReviewChange,
PTZInfo,
} from './types';
import { FrigateWatcherRequest, FrigateWatcherSubscriptionInterface } from './watcher';
const CAMERA_BIRDSEYE = 'birdseye' as const;
interface FrigateCameraInitializationOptions extends CameraInitializationOptions {
entityRegistryManager: EntityRegistryManager;
frigateEventWatcher: FrigateEventWatcherSubscriptionInterface;
frigateEventWatcher: FrigateWatcherSubscriptionInterface<FrigateEventChange>;
frigateReviewWatcher: FrigateWatcherSubscriptionInterface<FrigateReviewChange>;
}
export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
@@ -47,6 +51,7 @@ export class FrigateCamera extends Camera {
if (this._capabilities?.has('trigger')) {
await this._subscribeToEvents(options.hass, options.frigateEventWatcher);
await this._subscribeToReviews(options.hass, options.frigateReviewWatcher);
}
return this;
@@ -183,6 +188,7 @@ export class FrigateCamera extends Camera {
clips: !birdseye,
snapshots: !birdseye,
recordings: !birdseye,
reviews: !birdseye,
...(combinedPTZ && { ptz: combinedPTZ }),
};
}
@@ -415,7 +421,7 @@ export class FrigateCamera extends Camera {
protected async _subscribeToEvents(
hass: HomeAssistant,
frigateEventWatcher: FrigateEventWatcherSubscriptionInterface,
frigateEventWatcher: FrigateWatcherSubscriptionInterface<FrigateEventChange>,
): Promise<void> {
const config = this.getConfig();
if (!config.triggers.events.length || !config.frigate.camera_name) {
@@ -424,7 +430,7 @@ export class FrigateCamera extends Camera {
/* istanbul ignore next -- exercising the matcher is not possible when the
test uses an event watcher -- @preserve */
const request: FrigateEventWatcherRequest = {
const request: FrigateWatcherRequest<FrigateEventChange> = {
instanceID: config.frigate.client_id,
callback: (event: FrigateEventChange) => this._frigateEventHandler(event),
matcher: (event: FrigateEventChange): boolean =>
@@ -480,4 +486,92 @@ export class FrigateCamera extends Camera {
snapshot: snapshotChange && eventsToTriggerOn.includes('snapshots'),
});
};
protected async _subscribeToReviews(
hass: HomeAssistant,
frigateReviewWatcher: FrigateWatcherSubscriptionInterface<FrigateReviewChange>,
): Promise<void> {
const config = this.getConfig();
const reviewConfig = config.triggers.reviews;
// Must have at least one severity configured and a camera name to subscribe
if (!reviewConfig.severities.length || !config.frigate.camera_name) {
return;
}
/* istanbul ignore next -- exercising the matcher is not possible when the
test uses a review watcher -- @preserve */
const request: FrigateWatcherRequest<FrigateReviewChange> = {
instanceID: config.frigate.client_id,
callback: (review: FrigateReviewChange) => this._frigateReviewHandler(review),
matcher: (review: FrigateReviewChange): boolean =>
review.after.camera === config.frigate.camera_name,
};
await frigateReviewWatcher.subscribe(hass, request);
this._onDestroy(() => frigateReviewWatcher.unsubscribe(request));
}
protected _frigateReviewHandler = (review: FrigateReviewChange): void => {
const config = this.getConfig();
const cameraID = this._config.id;
if (!cameraID) {
return;
}
if (
config.frigate.zones?.length &&
!config.frigate.zones.some((zone) => review.after.data.zones?.includes(zone))
) {
return;
}
if (
config.frigate.labels?.length &&
!config.frigate.labels.some((label) => review.after.data.objects?.includes(label))
) {
return;
}
const reviewConfig = config.triggers.reviews;
// Map Frigate severity to card severity.
const cardSeverity = SEVERITIES.find(
(key) => FRIGATE_SEVERITY_MAP[key] === review.after.severity,
);
// Check if this is a description update (GenAI added/changed title or scene)
const isDescriptionUpdate =
review.type === 'genai' ||
(review.type === 'update' &&
(review.after.data.metadata?.title !== review.before.data.metadata?.title ||
review.after.data.metadata?.scene !== review.before.data.metadata?.scene ||
review.after.data.metadata?.shortSummary !==
review.before.data.metadata?.shortSummary));
const shouldTriggerOnSeverity =
cardSeverity && reviewConfig.severities.includes(cardSeverity);
// Severity must match first - it's the gate condition.
if (!shouldTriggerOnSeverity) {
return;
}
// For 'update' events, only trigger if description changed (when
// description updates are on). For 'new' and 'end' events, always trigger
// if severity matched
const shouldTriggerOnDescription = reviewConfig.description && isDescriptionUpdate;
if (review.type === 'update' && !shouldTriggerOnDescription) {
return;
}
this._eventCallback?.({
cameraID,
fidelity: 'high',
type: review.type,
review: true,
});
};
}
+273 -49
View File
@@ -5,6 +5,7 @@ import { CameraConfig } from '../../config/schema/cameras';
import { getEntityTitle } from '../../ha/get-entity-title';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
import { HomeAssistant } from '../../ha/types';
import { QuerySource, hasUnsupportedFilters } from '../../query-source.js';
import { Endpoint } from '../../types';
import {
allPromises,
@@ -29,6 +30,7 @@ import {
CameraManagerCameraMetadata,
CameraManagerRequestCache,
CameraQuery,
DefaultQueryParameters,
Engine,
EngineOptions,
EventQuery,
@@ -40,6 +42,7 @@ import {
PartialEventQuery,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
PartialReviewQuery,
QueryResults,
QueryResultsType,
QueryReturnType,
@@ -50,30 +53,38 @@ import {
RecordingSegment,
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
ReviewQuery,
ReviewQueryResultsMap,
} from '../types';
import { FrigateCamera, isBirdseye } from './camera';
import { FrigateEventWatcher } from './event-watcher';
import { FrigateViewMediaFactory } from './media';
import { FrigateViewMediaClassifier } from './media-classifier';
import {
NativeFrigateEventQuery,
NativeFrigateRecordingSegmentsQuery,
NativeFrigateReviewQuery,
getEventSummary,
getEvents,
getRecordingSegments,
getRecordingsSummary,
getReviews,
retainEvent,
setReviewsReviewed,
} from './requests';
import {
FRIGATE_SEVERITY_MAP,
FrigateEventQueryResults,
FrigateRecording,
FrigateRecordingQueryResults,
FrigateRecordingSegmentsQueryResults,
FrigateReviewQueryResults,
} from './types';
import { FrigateEventWatcher, FrigateReviewWatcher } from './watcher';
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
const MEDIA_METADATA_REQUEST_CACHE_AGE_SECONDS = 60;
const REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
class FrigateQueryResultsClassifier {
public static isFrigateEventQueryResults(
@@ -98,6 +109,12 @@ class FrigateQueryResultsClassifier {
results.type === QueryResultsType.RecordingSegments
);
}
public static isFrigateReviewQueryResults(
results: QueryResults,
): results is FrigateReviewQueryResults {
return results.engine === Engine.Frigate && results.type === QueryResultsType.Review;
}
}
export class FrigateCameraManagerEngine
@@ -106,6 +123,7 @@ export class FrigateCameraManagerEngine
{
protected _entityRegistryManager: EntityRegistryManager;
protected _frigateEventWatcher: FrigateEventWatcher;
protected _frigateReviewWatcher: FrigateReviewWatcher;
protected _recordingSegmentsCache: RecordingSegmentsCache;
protected _requestCache: CameraManagerRequestCache;
@@ -126,6 +144,7 @@ export class FrigateCameraManagerEngine
super(stateWatcher, eventCallback);
this._entityRegistryManager = entityRegistryManager;
this._frigateEventWatcher = new FrigateEventWatcher();
this._frigateReviewWatcher = new FrigateReviewWatcher();
this._recordingSegmentsCache = recordingSegmentsCache;
this._requestCache = requestCache;
}
@@ -146,9 +165,25 @@ export class FrigateCameraManagerEngine
entityRegistryManager: this._entityRegistryManager,
stateWatcher: this._stateWatcher,
frigateEventWatcher: this._frigateEventWatcher,
frigateReviewWatcher: this._frigateReviewWatcher,
});
}
public override getDefaultQueryParameters(
camera: Camera,
queryType: QueryType,
): DefaultQueryParameters {
if (queryType !== QueryType.Event && queryType !== QueryType.Review) {
return {};
}
const cameraConfig = camera.getConfig();
return {
...(cameraConfig.frigate.labels && { what: new Set(cameraConfig.frigate.labels) }),
...(cameraConfig.frigate.zones && { where: new Set(cameraConfig.frigate.zones) }),
};
}
public async getMediaDownloadPath(
_hass: HomeAssistant,
cameraConfig: CameraConfig,
@@ -182,51 +217,10 @@ export class FrigateCameraManagerEngine
cameraIDs: Set<string>,
query?: PartialEventQuery,
): EventQuery[] | null {
const relevantCameraConfigs = [...store.getCameraConfigs(cameraIDs)];
// If all cameras specify exactly the same zones or labels (incl. none), we
// can use a single batch query which will be better performance wise,
// otherwise we must fan out to multiple queries in order to precisely match
// the user's intent.
const uniqueZoneArrays = uniqWith(
relevantCameraConfigs.map((config) => config?.frigate.zones),
isEqual,
);
const uniqueLabelArrays = uniqWith(
relevantCameraConfigs.map((config) => config?.frigate.labels),
isEqual,
);
if (uniqueZoneArrays.length === 1 && uniqueLabelArrays.length === 1) {
return [
{
type: QueryType.Event,
cameraIDs: cameraIDs,
...(uniqueLabelArrays[0] && { what: new Set(uniqueLabelArrays[0]) }),
...(uniqueZoneArrays[0] && { where: new Set(uniqueZoneArrays[0]) }),
...query,
},
];
}
const output: EventQuery[] = [];
for (const cameraID of cameraIDs) {
const cameraConfig = store.getCameraConfig(cameraID);
if (cameraConfig) {
output.push({
type: QueryType.Event,
cameraIDs: new Set([cameraID]),
...(cameraConfig.frigate.labels && {
what: new Set(cameraConfig.frigate.labels),
}),
...(cameraConfig.frigate.zones && {
where: new Set(cameraConfig.frigate.zones),
}),
...query,
});
}
}
return output.length ? output : null;
return this._generateBatchableQuery(store, cameraIDs, {
type: QueryType.Event,
...query,
});
}
public generateDefaultRecordingQuery(
@@ -236,6 +230,7 @@ export class FrigateCameraManagerEngine
): RecordingQuery[] {
return [
{
source: QuerySource.Camera,
type: QueryType.Recording,
cameraIDs: cameraIDs,
...query,
@@ -262,6 +257,86 @@ export class FrigateCameraManagerEngine
];
}
public generateDefaultReviewQuery(
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query?: PartialReviewQuery,
): ReviewQuery[] | null {
return this._generateBatchableQuery(store, cameraIDs, {
type: QueryType.Review,
...query,
});
}
/**
* Helper to generate batchable queries for events and reviews.
* If all cameras have identical zones/labels config, creates a single batch query.
* Otherwise fans out to per-camera queries.
*/
protected _generateBatchableQuery(
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query: PartialEventQuery & { type: QueryType.Event },
): EventQuery[] | null;
protected _generateBatchableQuery(
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query: PartialReviewQuery & { type: QueryType.Review },
): ReviewQuery[] | null;
protected _generateBatchableQuery(
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query: (PartialEventQuery | PartialReviewQuery) & {
type: QueryType.Event | QueryType.Review;
},
): (EventQuery | ReviewQuery)[] | null {
const relevantCameraConfigs = [...store.getCameraConfigs(cameraIDs)];
// If all cameras specify exactly the same zones or labels (incl. none), we
// can use a single batch query which will be better performance wise,
// otherwise we must fan out to multiple queries in order to precisely match
// the user's intent.
const uniqueZoneArrays = uniqWith(
relevantCameraConfigs.map((config) => config?.frigate.zones),
isEqual,
);
const uniqueLabelArrays = uniqWith(
relevantCameraConfigs.map((config) => config?.frigate.labels),
isEqual,
);
if (uniqueZoneArrays.length === 1 && uniqueLabelArrays.length === 1) {
return [
{
source: QuerySource.Camera,
...query,
cameraIDs: cameraIDs,
...(uniqueLabelArrays[0] && { what: new Set(uniqueLabelArrays[0]) }),
...(uniqueZoneArrays[0] && { where: new Set(uniqueZoneArrays[0]) }),
},
];
}
const output: (EventQuery | ReviewQuery)[] = [];
for (const cameraID of cameraIDs) {
const cameraConfig = store.getCameraConfig(cameraID);
if (cameraConfig) {
output.push({
source: QuerySource.Camera,
...query,
cameraIDs: new Set([cameraID]),
...(cameraConfig.frigate.labels && {
what: new Set(cameraConfig.frigate.labels),
}),
...(cameraConfig.frigate.zones && {
where: new Set(cameraConfig.frigate.zones),
}),
});
}
}
return output.length ? output : null;
}
public async favoriteMedia(
hass: HomeAssistant,
cameraConfig: CameraConfig,
@@ -276,6 +351,24 @@ export class FrigateCameraManagerEngine
media.setFavorite(favorite);
}
public async reviewMedia(
hass: HomeAssistant,
cameraConfig: CameraConfig,
media: ViewMedia,
reviewed: boolean,
): Promise<void> {
if (!FrigateViewMediaClassifier.isFrigateReview(media)) {
return;
}
await setReviewsReviewed(
hass,
cameraConfig.frigate.client_id,
[media.getID()],
reviewed,
);
}
protected _buildInstanceToCameraIDMapFromQuery(
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
@@ -314,6 +407,17 @@ export class FrigateCameraManagerEngine
query: EventQuery,
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null> {
if (
hasUnsupportedFilters(query, {
favorite: true,
tags: true,
what: true,
where: true,
})
) {
return null;
}
const output: EventQueryResultsMap = new Map();
const processInstanceQuery = async (
@@ -374,12 +478,80 @@ export class FrigateCameraManagerEngine
return output.size ? output : null;
}
public async getReviews(
hass: HomeAssistant,
store: CameraManagerReadOnlyConfigStore,
query: ReviewQuery,
engineOptions?: EngineOptions,
): Promise<ReviewQueryResultsMap | null> {
if (hasUnsupportedFilters(query, { what: true, where: true, reviewed: true })) {
return null;
}
const output: ReviewQueryResultsMap = new Map();
const processInstanceQuery = async (
instanceID: string,
cameraIDs?: Set<string>,
): Promise<void> => {
if (!cameraIDs || !cameraIDs.size) {
return;
}
const instanceQuery = { ...query, cameraIDs: cameraIDs };
const cachedResult =
engineOptions?.useCache ?? true ? this._requestCache.get(instanceQuery) : null;
if (cachedResult) {
output.set(query, cachedResult as FrigateReviewQueryResults);
return;
}
const nativeQuery: NativeFrigateReviewQuery = {
instance_id: instanceID,
cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(store, cameraIDs)),
...(query.what && { labels: Array.from(query.what) }),
...(query.where && { zones: Array.from(query.where) }),
...(query.end && { before: Math.floor(query.end.getTime() / 1000) }),
...(query.start && { after: Math.floor(query.start.getTime() / 1000) }),
...(query.limit && { limit: query.limit }),
...(query.severity && { severity: FRIGATE_SEVERITY_MAP[query.severity] }),
...(query.reviewed !== undefined && { reviewed: query.reviewed }),
};
const result: FrigateReviewQueryResults = {
type: QueryResultsType.Review,
engine: Engine.Frigate,
instanceID: instanceID,
reviews: await getReviews(hass, nativeQuery),
expiry: add(new Date(), { seconds: REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS }),
cached: false,
};
if (engineOptions?.useCache ?? true) {
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
}
output.set(instanceQuery, result);
};
const instances = this._buildInstanceToCameraIDMapFromQuery(store, query.cameraIDs);
await Promise.all(
Array.from(instances.keys()).map((instanceID) =>
processInstanceQuery(instanceID, instances.get(instanceID)),
),
);
return output.size ? output : null;
}
public async getRecordings(
hass: HomeAssistant,
store: CameraManagerReadOnlyConfigStore,
query: RecordingQuery,
engineOptions?: EngineOptions,
): Promise<RecordingQueryResultsMap | null> {
if (hasUnsupportedFilters(query)) {
return null;
}
const output: RecordingQueryResultsMap = new Map();
const processQuery = async (
@@ -641,11 +813,50 @@ export class FrigateCameraManagerEngine
return output;
}
public generateMediaFromReviews(
_hass: HomeAssistant,
store: CameraManagerReadOnlyConfigStore,
query: ReviewQuery,
results: QueryReturnType<ReviewQuery>,
): ViewMedia[] | null {
if (!FrigateQueryResultsClassifier.isFrigateReviewQueryResults(results)) {
return null;
}
const output: ViewMedia[] = [];
for (const review of results.reviews) {
const cameraID = this._getCameraIDMatch(
store,
query,
results.instanceID,
review.camera,
);
if (!cameraID) {
continue;
}
const cameraConfig = this._getQueryableCameraConfig(store, cameraID);
if (!cameraConfig) {
continue;
}
const media = FrigateViewMediaFactory.createReviewViewMedia(
cameraID,
review,
cameraConfig,
);
if (media) {
output.push(media);
}
}
return output;
}
public getQueryResultMaxAge(query: CameraQuery): number | null {
if (query.type === QueryType.Event) {
return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
} else if (query.type === QueryType.Recording) {
return RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS;
} else if (query.type === QueryType.Review) {
return REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS;
}
return null;
}
@@ -657,11 +868,22 @@ export class FrigateCameraManagerEngine
target: Date,
engineOptions?: EngineOptions,
): Promise<number | null> {
const start = media.getStartTime();
const end = media.getEndTime();
const mediaStart = media.getStartTime();
const mediaEnd = media.getEndTime();
const cameraID = media.getCameraID();
const mediaType = media.getMediaType();
if (!start || !end || target < start || target > end || !cameraID) {
if (!mediaStart || !cameraID) {
return null;
}
// For recordings/reviews, use hour boundaries since Frigate recordings are
// hour-long. For clips/snapshots, use the actual media time range.
const isRecordingBased = mediaType === 'recording' || mediaType === 'review';
const start = isRecordingBased ? startOfHour(mediaStart) : mediaStart;
const end = isRecordingBased ? endOfHour(mediaStart) : mediaEnd;
if (!end || target < start || target > end) {
return null;
}
@@ -762,6 +984,7 @@ export class FrigateCameraManagerEngine
hass,
store,
{
source: QuerySource.Camera,
type: QueryType.Recording,
cameraIDs: cameraIDs,
},
@@ -822,6 +1045,7 @@ export class FrigateCameraManagerEngine
): Promise<void> {
const cameraIDs = this._recordingSegmentsCache.getCameraIDs();
const recordingQuery: RecordingQuery = {
source: QuerySource.Camera,
cameraIDs: new Set(cameraIDs),
type: QueryType.Recording,
};
@@ -1,77 +0,0 @@
import { HomeAssistant } from '../../ha/types';
import { FrigateEventChange, frigateEventChangeSchema } from './types';
export interface FrigateEventWatcherRequest {
instanceID: string;
matcher?(event: FrigateEventChange): boolean;
callback(event: FrigateEventChange): void;
}
export interface FrigateEventWatcherSubscriptionInterface {
subscribe(hass: HomeAssistant, request: FrigateEventWatcherRequest): Promise<void>;
unsubscribe(callback: FrigateEventWatcherRequest): void;
}
type SubscriptionUnsubscribe = () => Promise<void>;
export class FrigateEventWatcher implements FrigateEventWatcherSubscriptionInterface {
protected _requests: FrigateEventWatcherRequest[] = [];
protected _unsubscribeCallback: Record<string, SubscriptionUnsubscribe> = {};
public async subscribe(
hass: HomeAssistant,
request: FrigateEventWatcherRequest,
): Promise<void> {
const shouldSubscribe = !this._hasSubscribers(request.instanceID);
this._requests.push(request);
if (shouldSubscribe) {
this._unsubscribeCallback[request.instanceID] =
await hass.connection.subscribeMessage<string>(
(data) => this._receiveHandler(request.instanceID, data),
{ type: 'frigate/events/subscribe', instance_id: request.instanceID },
);
}
}
public async unsubscribe(request: FrigateEventWatcherRequest): Promise<void> {
this._requests = this._requests.filter(
(existingRequest) => existingRequest !== request,
);
if (!this._hasSubscribers(request.instanceID)) {
await this._unsubscribeCallback[request.instanceID]();
delete this._unsubscribeCallback[request.instanceID];
}
}
protected _hasSubscribers(instanceID: string): boolean {
return !!this._requests.filter((request) => request.instanceID === instanceID)
.length;
}
protected _receiveHandler(instanceID: string, data: string): void {
let json: unknown;
try {
json = JSON.parse(data);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
console.warn('Received non-JSON payload as Frigate event', data);
return;
}
const parsedEvent = frigateEventChangeSchema.safeParse(json);
if (!parsedEvent.success) {
console.warn('Received malformed Frigate event from Home Assistant', data);
return;
}
for (const request of this._requests) {
if (
request.instanceID === instanceID &&
(!request.matcher || request.matcher(parsedEvent.data))
) {
request.callback(parsedEvent.data);
}
}
}
}
+17 -3
View File
@@ -1,11 +1,22 @@
import { ViewMedia } from '../../view/item';
import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media';
import {
FrigateEventViewMedia,
FrigateRecordingViewMedia,
FrigateReviewViewMedia,
} from './media';
export class FrigateViewMediaClassifier {
public static isFrigateMedia(
media: ViewMedia,
): media is FrigateEventViewMedia | FrigateRecordingViewMedia {
return this.isFrigateEvent(media) || this.isFrigateRecording(media);
): media is
| FrigateEventViewMedia
| FrigateRecordingViewMedia
| FrigateReviewViewMedia {
return (
this.isFrigateEvent(media) ||
this.isFrigateRecording(media) ||
this.isFrigateReview(media)
);
}
public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia {
return media instanceof FrigateEventViewMedia;
@@ -15,4 +26,7 @@ export class FrigateViewMediaClassifier {
): media is FrigateRecordingViewMedia {
return media instanceof FrigateRecordingViewMedia;
}
public static isFrigateReview(media: ViewMedia): media is FrigateReviewViewMedia {
return media instanceof FrigateReviewViewMedia;
}
}
+99 -1
View File
@@ -1,14 +1,16 @@
import { fromUnixTime } from 'date-fns';
import { isEqual } from 'lodash-es';
import { CameraConfig } from '../../config/schema/cameras';
import { Severity } from '../../types';
import {
EventViewMedia,
RecordingViewMedia,
ReviewViewMedia,
VideoContentType,
ViewMedia,
ViewMediaType,
} from '../../view/item';
import { FrigateEvent, FrigateRecording } from './types';
import { FrigateEvent, FrigateRecording, FrigateReview } from './types';
import {
getEventMediaContentID,
getEventThumbnailURL,
@@ -16,6 +18,10 @@ import {
getRecordingID,
getRecordingMediaContentID,
getRecordingTitle,
getReviewMediaContentID,
getReviewSeverity,
getReviewThumbnailURL,
getReviewTitle,
} from './util';
export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
@@ -66,6 +72,9 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
public getTitle(): string | null {
return getEventTitle(this._event);
}
public getDescription(): string | null {
return this._event.data?.description ?? null;
}
public getThumbnail(): string | null {
return this._thumbnail;
}
@@ -146,6 +155,74 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
}
}
export class FrigateReviewViewMedia extends ViewMedia implements ReviewViewMedia {
protected _review: FrigateReview;
protected _contentID: string;
protected _thumbnail: string | null;
protected _title: string;
constructor(
cameraID: string,
review: FrigateReview,
contentID: string,
thumbnail: string | null,
) {
super(ViewMediaType.Review, { cameraID });
this._review = review;
this._contentID = contentID;
this._thumbnail = thumbnail;
this._title = this._review.data.metadata?.title ?? getReviewTitle(review);
}
public getID(): string {
return this._review.id;
}
public getStartTime(): Date {
return fromUnixTime(this._review.start_time);
}
public getEndTime(): Date | null {
return this._review.end_time ? fromUnixTime(this._review.end_time) : null;
}
public inProgress(): boolean | null {
return !this.getEndTime();
}
public getVideoContentType(): VideoContentType | null {
return VideoContentType.HLS;
}
public getContentID(): string | null {
return this._contentID;
}
public getTitle(): string | null {
return this._review.data.metadata?.title ?? this._title;
}
public getDescription(): string | null {
return (
this._review.data.metadata?.scene ??
this._review.data.metadata?.shortSummary ??
null
);
}
public getThumbnail(): string | null {
return this._thumbnail;
}
public getSeverity(): Severity | null {
return getReviewSeverity(this._review.severity);
}
public isReviewed(): boolean {
return !!this._review.has_been_reviewed;
}
public setReviewed(reviewed: boolean): void {
this._review.has_been_reviewed = reviewed;
}
public getWhat(): string[] | null {
return this._review.data.objects ?? null;
}
public getWhere(): string[] | null {
const zones = this._review.data.zones;
return zones?.length ? zones : null;
}
}
export class FrigateViewMediaFactory {
static createEventViewMedia(
mediaType: ViewMediaType,
@@ -201,4 +278,25 @@ export class FrigateViewMediaFactory {
getRecordingTitle(cameraTitle, recording),
);
}
static createReviewViewMedia(
cameraID: string,
review: FrigateReview,
cameraConfig: CameraConfig,
): FrigateReviewViewMedia | null {
if (!cameraConfig.frigate.client_id || !cameraConfig.frigate.camera_name) {
return null;
}
return new FrigateReviewViewMedia(
cameraID,
review,
getReviewMediaContentID(
cameraConfig.frigate.client_id,
cameraConfig.frigate.camera_name,
review,
),
getReviewThumbnailURL(cameraConfig.frigate.client_id, review),
);
}
}
+66
View File
@@ -8,6 +8,8 @@ import {
eventSummarySchema,
FrigateEvent,
frigateEventsSchema,
FrigateReview,
frigateReviewsSchema,
PTZInfo,
ptzInfoSchema,
recordingSegmentsSchema,
@@ -15,6 +17,8 @@ import {
recordingSummarySchema,
RetainResult,
retainResultSchema,
ReviewResult,
reviewResultSchema,
} from './types';
/**
@@ -176,3 +180,65 @@ export const getPTZInfo = async (
true,
);
};
export interface NativeFrigateReviewQuery {
instance_id: string;
cameras?: string[];
labels?: string[];
zones?: string[];
severity?: string;
after?: number;
before?: number;
limit?: number;
reviewed?: boolean;
}
/**
* Get review items over websocket. May throw.
* @param hass The Home Assistant object.
* @param params The review search parameters.
* @returns An array of 'FrigateReview's.
*/
export const getReviews = async (
hass: HomeAssistant,
params: NativeFrigateReviewQuery,
): Promise<FrigateReview[]> => {
return await homeAssistantWSRequest(
hass,
frigateReviewsSchema,
{
type: 'frigate/reviews/get',
...params,
},
true,
);
};
export const setReviewsReviewed = async (
hass: HomeAssistant,
instance_id: string,
ids: string[],
reviewed?: boolean,
): Promise<void> => {
const request = {
type: 'frigate/reviews/viewed',
instance_id,
ids,
// Frigate uses 'viewed' to mean the review has been reviewed.
...(reviewed !== undefined && { viewed: reviewed }),
};
const response = await homeAssistantWSRequest<ReviewResult>(
hass,
reviewResultSchema,
request,
);
if (!response.success) {
throw new AdvancedCameraCardError(localize('error.failed_response'), {
request: request,
response: response,
});
}
};
+70
View File
@@ -5,6 +5,7 @@ import {
EventQueryResults,
RecordingQueryResults,
RecordingSegmentsQueryResults,
ReviewQueryResults,
} from '../types';
const dayStringToDate = (arg: unknown): Date | unknown => {
@@ -24,6 +25,12 @@ export const eventSchema = z.object({
top_score: z.number().nullable(),
zones: z.string().array(),
retain_indefinitely: z.boolean().optional(),
data: z
.object({
// GenAI-generated text description of the object/event
description: z.string().optional(),
})
.optional(),
});
export const frigateEventsSchema = eventSchema.array();
@@ -57,6 +64,12 @@ export const retainResultSchema = z.object({
});
export type RetainResult = z.infer<typeof retainResultSchema>;
export const reviewResultSchema = z.object({
success: z.boolean(),
message: z.string(),
});
export type ReviewResult = z.infer<typeof reviewResultSchema>;
export interface FrigateRecording {
cameraID: string;
startTime: Date;
@@ -124,3 +137,60 @@ export interface FrigateRecordingSegmentsQueryResults
engine: Engine.Frigate;
instanceID: string;
}
// =============
// Review Types
// =============
// Maps card severity to Frigate severity
export const FRIGATE_SEVERITY_MAP = {
high: 'alert',
medium: 'detection',
low: 'significant_motion',
} as const;
export type FrigateReviewSeverity =
(typeof FRIGATE_SEVERITY_MAP)[keyof typeof FRIGATE_SEVERITY_MAP];
// Review data schema (only fields we need for display)
const frigateReviewDataSchema = z.object({
objects: z.string().array().optional(),
zones: z.string().array().optional(),
metadata: z
.object({
title: z.string().optional(),
scene: z.string().optional(),
shortSummary: z.string().optional(),
})
.nullable()
.optional(),
});
// Review item schema
const frigateReviewSchema = z.object({
id: z.string(),
camera: z.string(),
severity: z.enum(['alert', 'detection', 'significant_motion']),
start_time: z.number(),
end_time: z.number().nullable(),
thumb_path: z.string().nullable(),
has_been_reviewed: z.boolean().optional(),
data: frigateReviewDataSchema,
});
export const frigateReviewsSchema = frigateReviewSchema.array();
export type FrigateReview = z.infer<typeof frigateReviewSchema>;
// Review change schema for live WebSocket updates
export const frigateReviewChangeSchema = z.object({
before: frigateReviewSchema,
after: frigateReviewSchema,
type: z.enum(['new', 'update', 'end', 'genai']),
});
export type FrigateReviewChange = z.infer<typeof frigateReviewChangeSchema>;
export interface FrigateReviewQueryResults extends ReviewQueryResults {
engine: Engine.Frigate;
instanceID: string;
reviews: FrigateReview[];
}
+89 -22
View File
@@ -1,8 +1,13 @@
import { toZonedTime } from 'date-fns-tz';
import { CameraConfig } from '../../config/schema/cameras';
import { ClipsOrSnapshots } from '../../types';
import { Severity } from '../../types';
import { formatDateAndTime, prettifyTitle } from '../../utils/basic';
import { FrigateEvent, FrigateRecording } from './types';
import {
FrigateEvent,
FrigateRecording,
FrigateReview,
FrigateReviewSeverity,
} from './types';
/**
* Given an event generate a title.
@@ -51,36 +56,36 @@ export const getEventMediaContentID = (
clientId: string,
cameraName: string,
event: FrigateEvent,
mediaType: ClipsOrSnapshots,
mediaType: 'clips' | 'snapshots',
): string => {
return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`;
};
/**
* Generate a recording identifier.
* @param clientId The Frigate client id.
* @param cameraName The Frigate camera name.
* @param recording The Frigate recording.
* @returns A recording identifier.
* Build a recording media content ID from a start time.
*/
const buildRecordingMediaContentID = (
clientId: string,
cameraName: string,
startTime: Date,
): string =>
[
'media-source://frigate',
clientId,
'recordings',
cameraName,
`${startTime.getFullYear()}-${String(startTime.getMonth() + 1).padStart(2, '0')}-${String(startTime.getDate()).padStart(2, '0')}`,
String(startTime.getHours()).padStart(2, '0'),
].join('/');
/**
* Generate a recording media content ID.
*/
export const getRecordingMediaContentID = (
clientId: string,
cameraName: string,
recording: FrigateRecording,
): string => {
return [
'media-source://frigate',
clientId,
'recordings',
cameraName,
`${recording.startTime.getFullYear()}-${String(
recording.startTime.getMonth() + 1,
).padStart(2, '0')}-${String(
String(recording.startTime.getDate()).padStart(2, '0'),
)}`,
String(recording.startTime.getHours()).padStart(2, '0'),
].join('/');
};
): string => buildRecordingMediaContentID(clientId, cameraName, recording.startTime);
/**
* Get a recording ID for internal de-duping.
@@ -96,3 +101,65 @@ export const getRecordingID = (
cameraConfig.frigate.camera_name ?? ''
}/${recording.startTime.getTime()}/${recording.endTime.getTime()}`;
};
/**
* Given a review generate a title.
* @param review The Frigate review item.
*/
export const getReviewTitle = (review: FrigateReview): string => {
const objects = review.data.objects?.length
? review.data.objects.map((o) => prettifyTitle(o)).join(', ')
: '';
if (objects) {
return objects;
}
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const durationSeconds = Math.round(
review.end_time
? review.end_time - review.start_time
: Date.now() / 1000 - review.start_time,
);
return `${formatDateAndTime(
toZonedTime(review.start_time * 1000, localTimezone),
)} [${durationSeconds}s${objects}]`;
};
/**
* Generate a review media content ID.
*/
export const getReviewMediaContentID = (
clientId: string,
cameraName: string,
review: FrigateReview,
): string =>
buildRecordingMediaContentID(clientId, cameraName, new Date(review.start_time * 1000));
/**
* Get a thumbnail URL for a review.
*/
export const getReviewThumbnailURL = (
clientId: string,
review: FrigateReview,
): string | null => {
if (!review.thumb_path) {
return null;
}
const path = review.thumb_path.replace('/media/frigate/', '');
return `/api/frigate/${clientId}/${path}`;
};
/**
* Get generic review severity.
*/
export const getReviewSeverity = (severity: FrigateReviewSeverity): Severity => {
if (severity === 'alert') {
return 'high';
}
if (severity === 'detection') {
return 'medium';
}
return 'low';
};
+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,
CameraManagerCameraMetadata,
CameraQuery,
DefaultQueryParameters,
Engine,
EngineOptions,
EventQuery,
@@ -23,11 +24,15 @@ import {
PartialEventQuery,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
PartialReviewQuery,
QueryReturnType,
QueryType,
RecordingQuery,
RecordingQueryResultsMap,
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
ReviewQuery,
ReviewQueryResultsMap,
} from '../types';
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
@@ -67,6 +72,13 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
});
}
public getDefaultQueryParameters(
_camera: Camera,
_queryType: QueryType,
): DefaultQueryParameters {
return {};
}
public generateDefaultEventQuery(
_store: CameraManagerReadOnlyConfigStore,
_cameraIDs: Set<string>,
@@ -118,6 +130,23 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
return null;
}
public generateDefaultReviewQuery(
_store: CameraManagerReadOnlyConfigStore,
_cameraIDs: Set<string>,
_query?: PartialReviewQuery,
): ReviewQuery[] | null {
return null;
}
public async getReviews(
_hass: HomeAssistant,
_store: CameraManagerReadOnlyConfigStore,
_query: ReviewQuery,
_engineOptions?: EngineOptions,
): Promise<ReviewQueryResultsMap | null> {
return null;
}
public generateMediaFromEvents(
_hass: HomeAssistant,
_store: CameraManagerReadOnlyConfigStore,
@@ -136,6 +165,15 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
return null;
}
public generateMediaFromReviews(
_hass: HomeAssistant,
_store: CameraManagerReadOnlyConfigStore,
_query: ReviewQuery,
_results: QueryReturnType<ReviewQuery>,
): ViewMedia[] | null {
return null;
}
public async getMediaDownloadPath(
_hass: HomeAssistant,
_cameraConfig: CameraConfig,
@@ -153,6 +191,15 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
return;
}
public async reviewMedia(
_hass: HomeAssistant,
_cameraConfig: CameraConfig,
_media: ViewMedia,
_reviewed: boolean,
): Promise<void> {
return;
}
public getQueryResultMaxAge(_query: CameraQuery): number | null {
return null;
}
+81 -1
View File
@@ -35,6 +35,7 @@ import {
CameraEndpointsContext,
CameraManagerCameraMetadata,
CameraQuery,
DefaultQueryParameters,
Engine,
EngineOptions,
EventQuery,
@@ -49,6 +50,7 @@ import {
PartialQueryConcreteType,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
PartialReviewQuery,
QueryResults,
QueryResultsType,
QueryReturnType,
@@ -60,6 +62,8 @@ import {
RecordingSegmentsQueryResults,
RecordingSegmentsQueryResultsMap,
ResultsMap,
ReviewQuery,
ReviewQueryResults,
} from './types.js';
export class CameraQueryClassifier {
@@ -83,6 +87,11 @@ export class CameraQueryClassifier {
): query is MediaMetadataQuery {
return query.type === QueryType.MediaMetadata;
}
public static isReviewQuery(
query: CameraQuery | PartialCameraQuery,
): query is ReviewQuery {
return query.type === QueryType.Review;
}
}
export class QueryResultClassifier {
@@ -106,9 +115,14 @@ export class QueryResultClassifier {
): queryResults is MediaMetadataQueryResults {
return queryResults?.type === QueryResultsType.MediaMetadata;
}
public static isReviewQueryResult(
queryResults?: QueryResults | null,
): queryResults is ReviewQueryResults {
return queryResults?.type === QueryResultsType.Review;
}
}
export interface ExtendedMediaQueryResult<T extends MediaQuery> {
interface ExtendedMediaQueryResult<T extends MediaQuery> {
queries: T[];
results: ViewItem[];
}
@@ -308,6 +322,17 @@ export class CameraManager {
});
}
public getDefaultQueryParameters(
cameraID: string,
queryType: QueryType,
): DefaultQueryParameters {
const camera = this._store.getCamera(cameraID);
if (!camera) {
return {};
}
return camera.getEngine().getDefaultQueryParameters(camera, queryType);
}
public generateDefaultRecordingQueries(
cameraIDs: string | Set<string>,
partialQuery?: PartialRecordingQuery,
@@ -328,6 +353,16 @@ export class CameraManager {
});
}
public generateDefaultReviewQueries(
cameraIDs: string | Set<string>,
partialQuery?: PartialReviewQuery,
): ReviewQuery[] | null {
return this._generateDefaultQueries(cameraIDs, {
type: QueryType.Review,
...partialQuery,
});
}
protected _generateDefaultQueries<PQT extends PartialCameraQuery>(
cameraIDs: string | Set<string>,
partialQuery: PQT,
@@ -356,6 +391,12 @@ export class CameraManager {
cameraIDs,
partialQuery,
);
} else if (CameraQueryClassifier.isReviewQuery(partialQuery)) {
queries = engine.generateDefaultReviewQuery(
this._store,
cameraIDs,
partialQuery,
);
}
for (const query of queries ?? []) {
@@ -568,6 +609,33 @@ export class CameraManager {
);
}
public async reviewMedia(media: ViewMedia, reviewed: boolean): Promise<void> {
const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media);
const hass = this._api.getHASSManager().getHASS();
if (!cameraConfig || !engine || !hass) {
return;
}
const queryStartTime = new Date();
await this._requestLimit.add(() =>
engine.reviewMedia(hass, cameraConfig, media, reviewed),
);
log(
this._api.getConfigManager().getCardWideConfig(),
'Advanced Camera Card CameraManager review media request (',
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
'Media:',
media.getID(),
', Reviewed:',
reviewed,
')',
);
}
public areMediaQueriesResultsFresh<T extends MediaQuery>(
resultsTimestamp: Date,
queries: T[] | null,
@@ -667,6 +735,13 @@ export class CameraManager {
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (CameraQueryClassifier.isReviewQuery(query)) {
engineResult = (await engine.getReviews(
hass,
this._store,
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
}
engineResult?.forEach((value, key) => results.set(key, value));
@@ -739,6 +814,11 @@ export class CameraManager {
QueryResultClassifier.isRecordingQueryResult(result)
) {
media = engine.generateMediaFromRecordings(hass, this._store, query, result);
} else if (
CameraQueryClassifier.isReviewQuery(query) &&
QueryResultClassifier.isReviewQueryResult(result)
) {
media = engine.generateMediaFromReviews(hass, this._store, query, result);
}
if (media) {
mediaArray.push(...media);
@@ -14,6 +14,7 @@ import {
import { BrowseMediaStep, BrowseMediaTarget } from '../../ha/browse-media/walker';
import { isMediaWithinDates } from '../../ha/browse-media/within-dates';
import { HomeAssistant } from '../../ha/types';
import { hasUnsupportedFilters } from '../../query-source.js';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import { ViewMedia } from '../../view/item';
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
@@ -224,8 +225,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
query: EventQuery,
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null> {
// MotionEye does not support these query types and they will never match.
if (query.favorite || query.tags?.size || query.what?.size || query.where?.size) {
if (hasUnsupportedFilters(query)) {
return null;
}
+2 -8
View File
@@ -18,6 +18,7 @@ import { DeviceRegistryManager } from '../../ha/registry/device';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types';
import { hasUnsupportedFilters } from '../../query-source.js';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import { ViewMedia } from '../../view/item';
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
@@ -255,14 +256,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
query: EventQuery,
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null> {
// Reolink does not support these query types and they will never match.
if (
query.favorite ||
query.tags?.size ||
query.what?.size ||
query.where?.size ||
query.hasSnapshot
) {
if (hasUnsupportedFilters(query) || query.hasSnapshot) {
return null;
}
+53 -16
View File
@@ -1,7 +1,8 @@
import { ExpiringEqualityCache } from '../cache/expiring-cache';
import { SSLCiphers } from '../config/schema/cameras';
import { AdvancedCameraCardView } from '../config/schema/common/const';
import { CapabilityKey, Endpoint, Icon } from '../types';
import { BaseQuery, QueryFilters, QuerySource } from '../query-source';
import { CapabilityKey, Endpoint, Icon, Severity } from '../types';
import { ViewMedia } from '../view/item';
// ====
@@ -13,6 +14,7 @@ export enum QueryType {
Recording = 'recording-query',
RecordingSegments = 'recording-segments-query',
MediaMetadata = 'media-metadata',
Review = 'review-query',
}
export enum QueryResultsType {
@@ -20,6 +22,7 @@ export enum QueryResultsType {
Recording = 'recording-results',
RecordingSegments = 'recording-segments-results',
MediaMetadata = 'media-metadata-results',
Review = 'review-results',
}
export enum Engine {
@@ -46,10 +49,12 @@ interface LimitedDataQuery {
}
export interface MediaQuery
extends CameraQuery,
extends BaseQuery,
CameraQuery,
QueryFilters,
Partial<TimeBasedDataQuery>,
Partial<LimitedDataQuery> {
favorite?: boolean;
source: QuerySource.Camera;
}
export interface QueryResults {
@@ -74,14 +79,18 @@ export type QueryReturnType<QT> = QT extends EventQuery
? RecordingSegmentsQueryResults
: QT extends MediaMetadataQuery
? MediaMetadataQueryResults
: never;
: QT extends ReviewQuery
? ReviewQueryResults
: never;
export type PartialQueryConcreteType<PQT> = PQT extends PartialEventQuery
? EventQuery
: PQT extends PartialRecordingQuery
? RecordingQuery
: PQT extends PartialRecordingSegmentsQuery
? RecordingSegmentsQuery
: never;
: PQT extends PartialReviewQuery
? ReviewQuery
: never;
export type ResultsMap<QT> = Map<QT, QueryReturnType<QT>>;
export type EventQueryResultsMap = ResultsMap<EventQuery>;
@@ -140,16 +149,21 @@ export interface EngineOptions {
export interface CameraEvent {
cameraID: string;
type: 'new' | 'update' | 'end';
type:
| 'new' // A new event has started.
| 'update' // An update for an event is available (except GenAI).
| 'end' // An event has ended.
| 'genai'; // An AI based update is available.
// When fidelity is `high`, the engine is assumed to provide exact details of
// what new media is available. Otherwise all media types are assumed to be
// possibly newly available.
fidelity?: 'high' | 'low';
// Whether a new clip/snapshot/recording may be available.
// Whether new media may be available.
clip?: boolean;
snapshot?: boolean;
review?: boolean;
}
export type CameraEventCallback = (ev: CameraEvent) => void;
@@ -158,6 +172,21 @@ export class CameraManagerRequestCache extends ExpiringEqualityCache<
QueryResults
> {}
// ====================
// Default Query Params
// ====================
/**
* Default query parameters that engines can provide based on camera configuration.
*/
export interface DefaultQueryParameters {
// Object labels (what was detected)
what?: Set<string>;
// Zones (where detection occurred)
where?: Set<string>;
}
// ===========
// Event Query
// ===========
@@ -170,15 +199,6 @@ export interface EventQuery extends MediaQuery {
// Frigate equivalent: has_clip
hasClip?: boolean;
// Frigate equivalent: label
what?: Set<string>;
// Frigate equivalent: sub_label
tags?: Set<string>;
// Frigate equivalent: zone
where?: Set<string>;
}
export type PartialEventQuery = Partial<EventQuery>;
@@ -225,3 +245,20 @@ export interface MediaMetadataQueryResults extends QueryResults {
type: QueryResultsType.MediaMetadata;
metadata: MediaMetadata;
}
// ============
// Review Query
// ============
export interface ReviewQuery extends MediaQuery {
type: QueryType.Review;
severity?: Severity;
}
export type PartialReviewQuery = Partial<ReviewQuery>;
export interface ReviewQueryResults extends QueryResults {
type: QueryResultsType.Review;
}
export type ReviewQueryResultsMap = ResultsMap<ReviewQuery>;
@@ -53,7 +53,7 @@ export class ActionsManager implements ActionsExecutor {
let specificActions: Actions | undefined = undefined;
if (view?.is('live')) {
specificActions = config?.live.actions;
} else if (view?.isMediaGalleryView()) {
} else if (view?.isGalleryView()) {
specificActions = config?.media_gallery?.actions;
} else if (view?.isViewerView()) {
specificActions = config?.media_viewer.actions;
+12 -12
View File
@@ -21,26 +21,26 @@ export class BaseAction<T extends ActionConfig> implements Action {
protected _shouldSeekConfirmation(api: CardActionsAPI): boolean {
const hass = api.getHASSManager().getHASS();
const action: ActionConfig = this._action;
return (
(typeof this._action.confirmation === 'boolean' && this._action.confirmation) ||
(typeof this._action.confirmation === 'object' &&
(!this._action.confirmation.exemptions ||
!this._action.confirmation.exemptions.some(
(entry) => entry.user === hass?.user.id,
)))
(typeof action.confirmation === 'boolean' && action.confirmation) ||
(typeof action.confirmation === 'object' &&
(!action.confirmation.exemptions ||
!action.confirmation.exemptions.some((entry) => entry.user === hass?.user.id)))
);
}
public async execute(api: CardActionsAPI): Promise<void> {
if (this._shouldSeekConfirmation(api)) {
const actionName = isAdvancedCameraCardCustomAction(this._action)
? this._action.advanced_camera_card_action
: this._action.action;
const action: ActionConfig = this._action;
const baseAction = action.action;
const actionName = isAdvancedCameraCardCustomAction(action)
? action.advanced_camera_card_action
: baseAction;
const text =
(typeof this._action.confirmation === 'object'
? this._action.confirmation.text
: null) ?? `${localize('actions.confirmation')}: ${actionName}`;
(typeof action.confirmation === 'object' ? action.confirmation.text : null) ??
`${localize('actions.confirmation')}: ${actionName}`;
if (!confirm(text)) {
throw new ActionAbortError(localize('actions.abort'));
}
@@ -0,0 +1,28 @@
import { MediaDetailsController } from '../../../components-lib/media/details-controller';
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
import { ViewItemClassifier } from '../../../view/item-classifier';
import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class InfoAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const item = api.getViewManager().getView()?.queryResults?.getSelectedResult();
if (!ViewItemClassifier.isMedia(item)) {
return;
}
const controller = new MediaDetailsController();
controller.calculate(api.getCameraManager(), item);
api.getOverlayMessageManager().setMessage(
controller.getMessage({
hass: api.getHASSManager().getHASS() ?? undefined,
viewItemManager: api.getViewItemManager(),
viewManagerEpoch: api.getViewManager().getEpoch(),
capabilities: api.getViewItemManager().getCapabilities(item),
}),
);
}
}
@@ -0,0 +1,34 @@
import { SetReviewActionConfig } from '../../../config/schema/actions/custom/set-review';
import { ViewItemClassifier } from '../../../view/item-classifier';
import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class SetReviewAction extends AdvancedCameraCardAction<SetReviewActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const view = api.getViewManager().getView();
const queryResults = view?.queryResults;
const item = queryResults?.getSelectedResult();
if (!ViewItemClassifier.isReview(item) || !queryResults) {
return;
}
const targetReviewedState = this._action.reviewed ?? !item.isReviewed();
await api.getViewItemManager().reviewMedia(item, targetReviewedState);
// Clone the item to ensure Lit detects the change.
// Test-case: Setting a media item reviewed via the menu, should update the
// reviewed state in a thumbnail.
const clonedItem = item.clone();
clonedItem.setReviewed(targetReviewedState);
api.getViewManager().setViewByParameters({
params: {
queryResults: queryResults.clone().replaceItem(item, clonedItem),
},
});
}
}
+9 -1
View File
@@ -9,9 +9,10 @@ import { CustomAction } from './actions/custom';
import { DefaultAction } from './actions/default';
import { DisplayModeSelectAction } from './actions/display-mode-select';
import { DownloadAction } from './actions/download';
import { ExpandAction } from './actions/expand';
import { EffectAction } from './actions/effect';
import { ExpandAction } from './actions/expand';
import { FullscreenAction } from './actions/fullscreen';
import { InfoAction } from './actions/info';
import { InternalCallbackAction } from './actions/internal-callback';
import { LogAction } from './actions/log';
import { MediaPlayerAction } from './actions/media-player';
@@ -33,6 +34,7 @@ import { PTZDigitalAction } from './actions/ptz-digital';
import { PTZMultiAction } from './actions/ptz-multi';
import { ReloadAction } from './actions/reload';
import { ScreenshotAction } from './actions/screenshot';
import { SetReviewAction } from './actions/set-review';
import { SleepAction } from './actions/sleep';
import { StatusBarAction } from './actions/status-bar';
import { SubstreamOffAction } from './actions/substream-off';
@@ -93,6 +95,8 @@ export class ActionFactory {
case 'live':
case 'recording':
case 'recordings':
case 'review':
case 'reviews':
case 'snapshot':
case 'snapshots':
case 'timeline':
@@ -110,6 +114,8 @@ export class ActionFactory {
return new ExpandAction(context, action, options?.config);
case 'fullscreen':
return new FullscreenAction(context, action, options?.config);
case 'info':
return new InfoAction(context, action, options?.config);
case 'menu_toggle':
return new MenuToggleAction(context, action, options?.config);
case 'camera_select':
@@ -156,6 +162,8 @@ export class ActionFactory {
return new StatusBarAction(context, action, options?.config);
case 'reload':
return new ReloadAction(context, action, options?.config);
case 'set_review':
return new SetReviewAction(context, action, options?.config);
case INTERNAL_CALLBACK_ACTION:
return new InternalCallbackAction(context, action, options?.config);
}
+23 -1
View File
@@ -3,6 +3,8 @@ import { ActionEventTarget } from '../action-handler-directive';
import { isCardInPanel } from '../ha/panel';
import { setOrRemoveAttribute } from '../utils/basic';
import { isBeingCasted } from '../utils/casting';
import { CardMediaReviewEventTarget } from '../utils/review';
import { ViewItem } from '../view/item';
import { ActionExecutionRequestEventTarget } from './actions/utils/execution-request';
import { InitializationAspect } from './initialization-manager';
import { CardElementAPI } from './types';
@@ -13,7 +15,8 @@ export type MenuToggleCallback = () => void;
export type CardHTMLElement = LitElement &
ReactiveControllerHost &
ActionEventTarget &
ActionExecutionRequestEventTarget;
ActionExecutionRequestEventTarget &
CardMediaReviewEventTarget;
export class CardElementManager {
protected _api: CardElementAPI;
@@ -120,6 +123,10 @@ export class CardElementManager {
'advanced-camera-card:action:execution-request',
this._api.getActionsManager().handleActionExecutionRequestEvent,
);
this._element.addEventListener(
'advanced-camera-card:media:reviewed',
this._handleMediaReviewed,
);
// Listen for HA `navigate` actions.
// See: https://github.com/home-assistant/frontend/blob/273992c8e9c3062c6e49481b6d7d688a07067232/src/common/navigate.ts#L43
@@ -198,6 +205,10 @@ export class CardElementManager {
'advanced-camera-card:action:execution-request',
this._api.getActionsManager().handleActionExecutionRequestEvent,
);
this._element.removeEventListener(
'advanced-camera-card:media:reviewed',
this._handleMediaReviewed,
);
window.removeEventListener(
'location-changed',
@@ -208,4 +219,15 @@ export class CardElementManager {
this._api.getQueryStringManager().requestExecution,
);
}
protected _handleMediaReviewed = (ev: CustomEvent<ViewItem>): void => {
// If the selected media item has a change of review status, update the card
// (e.g. for the menu).
if (
this._api.getViewManager().getView()?.queryResults?.getSelectedResult() ===
ev.detail
) {
this.update();
}
};
}
+10
View File
@@ -28,9 +28,11 @@ import { InitializationManager } from './initialization-manager';
import { InteractionManager } from './interaction-manager';
import { KeyboardStateManager } from './keyboard-state-manager';
import { MediaLoadedInfoManager } from './media-info-manager';
import { MediaPlayerManager } from './media-player-manager';
import { MessageManager } from './message-manager';
import { MicrophoneManager } from './microphone-manager';
import { OverlayMessageManager } from './overlay-message-manager';
import { QueryStringManager } from './query-string-manager';
import { StatusBarItemManager } from './status-bar-item-manager';
import { StyleManager } from './style-manager';
@@ -56,6 +58,7 @@ import {
CardMediaPlayerAPI,
CardMessageAPI,
CardMicrophoneAPI,
CardOverlayMessageAPI,
CardQueryStringAPI,
CardStyleAPI,
CardTriggersAPI,
@@ -87,6 +90,7 @@ export class CardController
CardMediaPlayerAPI,
CardMessageAPI,
CardMicrophoneAPI,
CardOverlayMessageAPI,
CardQueryStringAPI,
CardStyleAPI,
CardTriggersAPI,
@@ -118,9 +122,11 @@ export class CardController
protected _interactionManager = new InteractionManager(this);
protected _keyboardStateManager = new KeyboardStateManager(this);
protected _mediaLoadedInfoManager = new MediaLoadedInfoManager(this);
protected _mediaPlayerManager = new MediaPlayerManager(this);
protected _messageManager = new MessageManager(this);
protected _microphoneManager = new MicrophoneManager(this);
protected _overlayMessageManager = new OverlayMessageManager(this);
protected _queryStringManager = new QueryStringManager(this);
protected _statusBarItemManager = new StatusBarItemManager(this);
protected _styleManager = new StyleManager(this);
@@ -248,6 +254,10 @@ export class CardController
this._microphoneManager = new MicrophoneManager(this);
}
public getOverlayMessageManager(): OverlayMessageManager {
return this._overlayMessageManager;
}
public getQueryStringManager(): QueryStringManager {
return this._queryStringManager;
}
+11 -4
View File
@@ -15,10 +15,8 @@ export class FoldersExecutor {
this._ha = engines?.ha ?? new HAFoldersEngine();
}
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
return (
this._getFolderEngine(folder.type)?.generateDefaultFolderQuery(folder) ?? null
);
public getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null {
return this._getFolderEngine(folder.type)?.getDefaultQueryParameters(folder) ?? null;
}
public generateChildFolderQuery(
@@ -79,6 +77,15 @@ export class FoldersExecutor {
);
}
public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean {
return (
this._getFolderEngine(query.folder.type)?.areResultsFresh(
resultsTimestamp,
query,
) ?? true
);
}
private _getFolderEngine(type?: FolderType): FoldersEngine | null {
switch (type) {
case folderTypeSchema.enum.ha:
+30 -10
View File
@@ -1,16 +1,17 @@
import { sub } from 'date-fns';
import { NonEmptyTuple } from 'type-fest';
import { ConditionState } from '../../../conditions/types';
import {
FolderConfig,
folderTypeSchema,
HA_MEDIA_SOURCE_ROOT,
HAFolderPathComponent,
HAFolderConfig,
HAFolderPathComponent,
} from '../../../config/schema/folders';
import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media';
import { BrowseMediaViewFolder } from '../../../ha/browse-media/item';
import {
BrowseMedia,
BROWSE_MEDIA_CACHE_SECONDS,
BrowseMediaCache,
BrowseMediaMetadata,
RichBrowseMedia,
@@ -22,16 +23,18 @@ import {
} from '../../../ha/browse-media/walker';
import { getMediaDownloadPath } from '../../../ha/download';
import { HomeAssistant } from '../../../ha/types';
import { QuerySource } from '../../../query-source.js';
import { Endpoint } from '../../../types';
import { ViewFolder, ViewItem } from '../../../view/item';
import { ViewItemClassifier } from '../../../view/item-classifier';
import { ViewItemCapabilities } from '../../../view/types';
import {
DownloadHelpers,
EngineOptions,
FolderPathComponent,
FolderQuery,
FoldersEngine,
FolderPathComponent,
} from '../types';
import { MediaMatcher } from './media-matcher';
import { MetadataGenerator } from './metadata-generator.js';
@@ -83,17 +86,18 @@ export class HAFoldersEngine implements FoldersEngine {
return;
}
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
public getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null {
if (folder.type !== folderTypeSchema.enum.ha) {
return null;
}
return {
source: QuerySource.Folder,
folder,
path: this.getDefaultFolderPathComponents(folder.ha),
path: this._getDefaultPathComponents(folder.ha),
};
}
private getDefaultFolderPathComponents(
private _getDefaultPathComponents(
haFolderConfig?: HAFolderConfig,
): NonEmptyTuple<FolderPathComponent> {
const shouldAddDefaultRoot =
@@ -158,14 +162,21 @@ export class HAFoldersEngine implements FoldersEngine {
targets: BrowseMediaTarget<BrowseMediaMetadata>[],
): BrowseMediaStep<BrowseMediaMetadata>[] => {
const nextComponent = pathComponents.shift();
const limit = query.limit ?? null;
return [
{
targets,
metadataGenerator: (media: BrowseMedia, parent?: BrowseMedia) =>
metadataGenerator: (media, parent) =>
this._metadataGenerator.generate(media, parent, nextComponent?.ha?.parsers),
// At the final step (no nextComponent), apply limit via earlyExit.
...(limit && {
earlyExit: (media) => media.length >= limit,
}),
...(nextComponent && {
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
matcher: (media) =>
this._mediaMatcher.match(hass, media, {
matchers: nextComponent.ha?.matchers,
// Set foldersOnly to true if there are more stages in the path,
@@ -187,9 +198,11 @@ export class HAFoldersEngine implements FoldersEngine {
},
);
return getViewItemsFromBrowseMediaArray(browseMedia, {
const results = getViewItemsFromBrowseMediaArray(browseMedia, {
folder: query.folder,
path: query.path,
});
return query.limit ? results.slice(0, query.limit) : results;
}
public generateChildFolderQuery(
@@ -202,7 +215,7 @@ export class HAFoldersEngine implements FoldersEngine {
}
// Get the full configured path to find parsers/matchers for this depth.
const fullPath = this.getDefaultFolderPathComponents(query.folder.ha);
const fullPath = this._getDefaultPathComponents(query.folder.ha);
const nextConfiguredComponent = fullPath[query.path.length];
// Use the configured component's parsers/matchers if available, otherwise
@@ -214,4 +227,11 @@ export class HAFoldersEngine implements FoldersEngine {
path: [...query.path, { folder, ha }],
};
}
public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean {
return (
!!query &&
resultsTimestamp >= sub(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS })
);
}
}
+13 -3
View File
@@ -2,7 +2,9 @@ import { cloneDeep } from 'lodash-es';
import { ConditionState } from '../../conditions/types';
import { FolderConfig, FolderConfigWithoutID } from '../../config/schema/folders';
import { localize } from '../../localize/localize';
import { hasUnsupportedFilters } from '../../query-source.js';
import { Endpoint } from '../../types';
import { getFolderID } from '../../utils/folder';
import { ViewFolder, ViewItem } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
import { CardFoldersAPI } from '../types';
@@ -26,7 +28,7 @@ export class FoldersManager {
public addFolders(folders: FolderConfigWithoutID[]): void {
for (const folder of folders) {
const folderNumber = this._folders.size;
const id = folder.id ?? `folder/${folderNumber.toString()}`;
const id = getFolderID(folder, folderNumber);
if (this._folders.has(id)) {
throw new FolderInitializationError(
localize('error.duplicate_folder_id'),
@@ -57,9 +59,9 @@ export class FoldersManager {
: this._folders.values().next().value ?? null;
}
public generateDefaultFolderQuery(folder?: FolderConfig): FolderQuery | null {
public getDefaultQueryParameters(folder?: FolderConfig): FolderQuery | null {
const _folder = folder ?? this.getFolder();
return _folder ? this._executor.generateDefaultFolderQuery(_folder) : null;
return _folder ? this._executor.getDefaultQueryParameters(_folder) : null;
}
public generateChildFolderQuery(
@@ -74,12 +76,20 @@ export class FoldersManager {
conditionState?: ConditionState,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
if (hasUnsupportedFilters(query)) {
return null;
}
const hass = this._api.getHASSManager().getHASS();
return hass
? this._executor.expandFolder(hass, query, conditionState, engineOptions)
: null;
}
public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean {
return this._executor.areResultsFresh(resultsTimestamp, query);
}
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
return this._executor.getItemCapabilities(item);
}
+9 -2
View File
@@ -3,6 +3,7 @@ import { ConditionState } from '../../conditions/types';
import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types';
import { BaseQuery, QueryFilters, QuerySource } from '../../query-source';
import { Endpoint } from '../../types';
import { AdvancedCameraCardError } from '../../types.js';
import { ViewFolder, ViewItem } from '../../view/item';
@@ -30,12 +31,14 @@ export interface FolderPathComponent extends FolderPathComponentMetadata {
folder?: ViewFolder;
}
export interface FolderQuery {
export interface FolderQuery extends BaseQuery, QueryFilters {
source: QuerySource.Folder;
folder: FolderConfig;
// A trail of paths to navigate back to the "root", with the last path being
// the path that this query directly refers to.
path: NonEmptyTuple<FolderPathComponent>;
limit?: number;
}
// ===============
@@ -47,7 +50,8 @@ export interface DownloadHelpers {
}
export interface FoldersEngine {
generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null;
getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null;
generateChildFolderQuery(query: FolderQuery, folder: ViewFolder): FolderQuery | null;
expandFolder(
@@ -63,5 +67,8 @@ export interface FoldersEngine {
item: ViewItem,
options?: DownloadHelpers,
): Promise<Endpoint | null>;
favorite(hass: HomeAssistant | null, item: ViewItem, favorite: boolean): Promise<void>;
areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean;
}
@@ -0,0 +1,31 @@
import { OverlayMessage } from '../types';
import { CardOverlayMessageAPI } from './types';
export class OverlayMessageManager {
protected _message: OverlayMessage | null = null;
protected _api: CardOverlayMessageAPI;
constructor(api: CardOverlayMessageAPI) {
this._api = api;
}
public getMessage(): OverlayMessage | null {
return this._message;
}
public hasMessage(): boolean {
return this._message !== null;
}
public setMessage(message: OverlayMessage): void {
this._message = message;
this._api.getCardElementManager().update();
}
public reset(): void {
if (this._message) {
this._message = null;
this._api.getCardElementManager().update();
}
}
}
+14 -1
View File
@@ -47,10 +47,12 @@ export class StatusBarItemManager {
? options?.cameraManager?.getCameraMetadata(options?.view?.camera)
: null;
const engineIcon = cameraMetadata?.engineIcon ?? null;
const selectedResult = options?.view?.queryResults?.getSelectedResult();
const severity = selectedResult?.getSeverity() ?? null;
const title = options?.view?.is('live')
? cameraMetadata?.title ?? null
: options?.view?.isViewerView()
? options?.view.queryResults?.getSelectedResult()?.getTitle() ?? null
? selectedResult?.getTitle() ?? null
: null;
const resolution = options?.mediaLoadedInfo
? this._calculateResolution(options?.mediaLoadedInfo)
@@ -60,6 +62,17 @@ export class StatusBarItemManager {
: null;
return [
...(severity
? [
{
type: 'custom:advanced-camera-card-status-bar-icon' as const,
icon: 'mdi:circle-medium',
severity,
...options?.statusConfig?.items.severity,
},
]
: []),
...(title
? [
{
+26 -10
View File
@@ -104,14 +104,16 @@ export class TriggersManager {
.actions.trigger;
const defaultView = this._api.getConfigManager().getConfig()?.view.default;
// If this is a high-fidelity event where we are certain about new media,
// don't take action unless it's to change to live (Frigate engine may pump
// out events where there's no new media to show). Other trigger actions
// (e.g. media, update) do not make sense without having some new media.
// Early exit guard: If this is a high-fidelity event where we are certain
// about new media, don't take action unless it's to change to live (Frigate
// engine may pump out events where there's no new media to show). Other
// trigger actions (e.g. media, update) do not make sense without having
// some new media.
if (
ev.fidelity === 'high' &&
!ev.snapshot &&
!ev.clip &&
!ev.review &&
!(
triggerAction === 'live' ||
(triggerAction === 'default' && defaultView === 'live')
@@ -139,12 +141,26 @@ export class TriggersManager {
},
});
} else if (ev.fidelity === 'high' && triggerAction === 'media') {
await this._api.getViewManager().setViewByParametersWithNewQuery({
params: {
view: ev.clip ? 'clip' : 'snapshot',
camera: ev.cameraID,
},
});
// Choose the most appropriate media view based on what's available.
// Priority: review > clip > snapshot
const view = ev.review
? 'review'
: ev.clip
? 'clip'
: ev.snapshot
? 'snapshot'
: /* istanbul ignore next: unreachable due to early exit guard above -- @preserve */
null;
/* istanbul ignore next: unreachable due to early exit guard above -- @preserve */
if (view) {
await this._api.getViewManager().setViewByParametersWithNewQuery({
params: {
view,
camera: ev.cameraID,
},
});
}
}
}
+7
View File
@@ -22,6 +22,7 @@ import type { MediaLoadedInfoManager } from './media-info-manager';
import type { MediaPlayerManager } from './media-player-manager';
import type { MessageManager } from './message-manager';
import type { MicrophoneManager } from './microphone-manager';
import type { OverlayMessageManager } from './overlay-message-manager';
import type { QueryStringManager } from './query-string-manager';
import type { StatusBarItemManager } from './status-bar-item-manager';
import type { StyleManager } from './style-manager';
@@ -52,6 +53,7 @@ export interface CardActionsAPI {
getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager;
getMicrophoneManager(): MicrophoneManager;
getOverlayMessageManager(): OverlayMessageManager;
getStatusBarItemManager(): StatusBarItemManager;
getTriggersManager(): TriggersManager;
getViewItemManager(): ViewItemManager;
@@ -149,6 +151,7 @@ export interface CardElementAPI {
getMediaPlayerManager(): MediaPlayerManager;
getMicrophoneManager(): MicrophoneManager;
getQueryStringManager(): QueryStringManager;
getViewManager(): ViewManager;
}
export interface CardExpandAPI {
@@ -245,6 +248,10 @@ export interface CardMessageAPI {
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
}
export interface CardOverlayMessageAPI {
getCardElementManager(): CardElementManager;
}
export interface CardMicrophoneAPI {
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
+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 {
if (ViewItemClassifier.isMedia(item) && item.getCameraID()) {
return ViewMediaSource.Camera;
@@ -1,13 +1,16 @@
import { Query } from '../../../view/query';
import { QueryResults } from '../../../view/query-results';
import { UnifiedQuery } from '../../../view/unified-query';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class SetQueryViewModifier implements ViewModifier {
protected _query?: Query | null;
protected _query?: UnifiedQuery | null;
protected _queryResults?: QueryResults | null;
constructor(options?: { query?: Query | null; queryResults?: QueryResults | null }) {
constructor(options?: {
query?: UnifiedQuery | null;
queryResults?: QueryResults | null;
}) {
this._query = options?.query;
this._queryResults = options?.queryResults;
}
-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 { ViewItem } from '../../view/item.js';
import { QueryResults } from '../../view/query-results.js';
import { Query } from '../../view/query.js';
import { View, ViewParameters } from '../../view/view.js';
export interface ViewModifier {
@@ -26,11 +26,6 @@ export interface QueryExecutorOptions {
useCache?: boolean;
}
export interface QueryExecutorResult {
query: Query;
queryResults: QueryResults;
}
export interface ViewFactoryOptions {
// An existing view to evolve from.
baseView?: View | null;
+10 -11
View File
@@ -1,7 +1,6 @@
import { ViewContext } from 'view';
import { log } from '../../utils/debug';
import { getStreamCameraID } from '../../utils/substream';
import { QueryClassifier } from '../../view/query-classifier';
import { View } from '../../view/view';
import { InitializationAspect } from '../initialization-manager';
import { CardViewAPI } from '../types';
@@ -234,19 +233,19 @@ export class ViewManager implements ViewManagerInterface {
// If the user is currently using the viewer, and then switches to the
// gallery we make an attempt to keep the query/queryResults the same so
// the gallery can be used to click back and forth to the viewer, and the
// selected media can be centered in the gallery. See the matching code in
// `updated()` in `gallery.ts`. We specifically must ensure that the new
// target media of the gallery (e.g. clips, snapshots or recordings) is
// equal to the queries that are currently used in the viewer.
// selected media can be centered in the gallery.
//
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
const switchingFromViewerToGallery =
this._view?.isViewerView() && newView?.isMediaGalleryView();
const newMediaType = newView?.getDefaultMediaType();
const alreadyHasMatchingQuery =
QueryClassifier.getMediaType(this._view?.query) === newMediaType;
return !!switchingFromViewerToGallery && alreadyHasMatchingQuery;
if (!this._view?.isViewerView() || !newView?.isGalleryView()) {
return false;
}
// Check if the viewer came from this gallery type. If they did, we preserve
// the query/results to ensure consistency in navigation between media &
// gallery.
const originView = this._view?.context?.gallery?.originView;
return originView === newView.view;
}
public setViewWithMergedContext(context: ViewContext | null): void {
+149 -55
View File
@@ -1,11 +1,15 @@
import { sub } from 'date-fns';
import { ClipsOrSnapshotsOrAll } from '../../types';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
import { QueryResults } from '../../view/query-results';
import { UnifiedQuery } from '../../view/unified-query';
import { MediaTypeSpec, UnifiedQueryBuilder } from '../../view/unified-query-builder';
import { UnifiedQueryRunner } from '../../view/unified-query-runner';
import { View } from '../../view/view';
import { CardViewAPI } from '../types';
import { MergeContextViewModifier } from './modifiers/merge-context';
import { RemoveContextPropertyViewModifier } from './modifiers/remove-context-property';
import { SetQueryViewModifier } from './modifiers/set-query';
import { QueryExecutor } from './query-executor';
import { QueryExecutorOptions, ViewModifier } from './types';
/**
@@ -14,27 +18,42 @@ import { QueryExecutorOptions, ViewModifier } from './types';
* and if a query is made as part of this view the result can be applied later.
*/
export class ViewQueryExecutor {
protected _api: CardViewAPI;
protected _executor: QueryExecutor;
private _api: CardViewAPI;
constructor(api: CardViewAPI, executor?: QueryExecutor) {
constructor(api: CardViewAPI) {
this._api = api;
this._executor = executor ?? new QueryExecutor(api);
}
public async getExistingQueryModifiers(
view: View,
queryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> {
return view.query
if (!view.query) {
return null;
}
const runner = new UnifiedQueryRunner(
this._api.getCameraManager(),
this._api.getFoldersManager(),
this._api.getConditionStateManager(),
);
const items = await runner.execute(view.query, {
useCache: queryExecutorOptions?.useCache,
});
const queryResults = this._applyResultSelection(
new QueryResults({ results: items }),
queryExecutorOptions,
);
return queryResults
? [
new SetQueryViewModifier({
queryResults: (
await this._executor.executeQuery(view.query, queryExecutorOptions)
)?.queryResults,
queryResults,
}),
]
: [];
: null;
}
public async getNewQueryModifiers(
@@ -47,7 +66,7 @@ export class ViewQueryExecutor {
});
}
protected async _executeNewQuery(
private async _executeNewQuery(
view: View,
queryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> {
@@ -56,71 +75,111 @@ export class ViewQueryExecutor {
return null;
}
const mediaType = view?.getDefaultMediaType();
const viewModifiers: ViewModifier[] = [];
const builder = new UnifiedQueryBuilder(
this._api.getCameraManager(),
this._api.getFoldersManager(),
);
const runner = new UnifiedQueryRunner(
this._api.getCameraManager(),
this._api.getFoldersManager(),
this._api.getConditionStateManager(),
);
const executeMediaQuery = async (
mediaType: ClipsOrSnapshotsOrAll | 'recordings' | null,
): Promise<ViewModifier[]> => {
/* istanbul ignore if: this path cannot be reached -- @preserve */
if (!mediaType) {
const executeQuery = async (query: UnifiedQuery | null): Promise<ViewModifier[]> => {
if (!query) {
return [];
}
const results =
mediaType === 'recordings'
? await this._executor.executeDefaultRecordingQuery({
...(!view.isGrid() && { cameraID: view.camera }),
executorOptions: queryExecutorOptions,
})
: mediaType === 'clips' || mediaType === 'snapshots' || mediaType === 'all'
? await this._executor.executeDefaultEventQuery({
...(!view.isGrid() && { cameraID: view.camera }),
eventsMediaType: mediaType,
executorOptions: queryExecutorOptions,
})
: /* istanbul ignore next -- @preserve */
null;
const items = await runner.execute(query, {
useCache: queryExecutorOptions?.useCache,
});
return results ? [new SetQueryViewModifier(results)] : [];
return [
new SetQueryViewModifier({
query,
queryResults: new QueryResults({ results: items }),
}),
];
};
const executeFolderQuery = async (): Promise<ViewModifier[]> => {
const results = await this._executor.executeFolderQuery(queryExecutorOptions);
return results ? [new SetQueryViewModifier(results)] : [];
};
const cameraForQuery = view.isGrid() ? undefined : view.camera;
switch (view.view) {
case 'live':
if (config.live.controls.thumbnails.mode !== 'none') {
viewModifiers.push(
...(await executeMediaQuery(
config.live.controls.thumbnails.media_type === 'recordings'
? 'recordings'
: config.live.controls.thumbnails.events_media_type,
)),
);
const defaultQuery = builder.buildDefaultCameraQuery(cameraForQuery);
if (defaultQuery) {
viewModifiers.push(...(await executeQuery(defaultQuery)));
}
}
break;
case 'media':
// If the user is looking at media in the `media` view and then
// changes camera (via the menu) it should default to showing clips
// for the new camera.
viewModifiers.push(...(await executeMediaQuery('clips')));
case 'timeline':
// Timeline view always queries all cameras with media capabilities.
viewModifiers.push(...(await executeQuery(builder.buildDefaultCameraQuery())));
break;
case 'media':
// If the user is looking at media in the `media` view and then
// changes camera (via the menu) it should default to showing clips
// for the new camera.
case 'clip':
case 'clips':
viewModifiers.push(
...(await executeQuery(
builder.buildCameraMediaQuery(MediaTypeSpec.clips(), {
cameraID: cameraForQuery,
limit: this._getLimit(),
}),
)),
);
break;
case 'snapshot':
case 'snapshots':
viewModifiers.push(
...(await executeQuery(
builder.buildCameraMediaQuery(MediaTypeSpec.snapshots(), {
cameraID: cameraForQuery,
limit: this._getLimit(),
}),
)),
);
break;
case 'recording':
case 'recordings':
viewModifiers.push(...(await executeMediaQuery(mediaType)));
viewModifiers.push(
...(await executeQuery(
builder.buildCameraMediaQuery(MediaTypeSpec.recordings(), {
cameraID: cameraForQuery,
limit: this._getLimit(),
}),
)),
);
break;
case 'review':
case 'reviews':
viewModifiers.push(
...(await executeQuery(
builder.buildCameraMediaQuery(MediaTypeSpec.reviews(), {
cameraID: cameraForQuery,
limit: this._getLimit(),
}),
)),
);
break;
case 'folder':
case 'folders':
viewModifiers.push(...(await executeFolderQuery()));
viewModifiers.push(
...(await executeQuery(
builder.buildDefaultFolderQuery(queryExecutorOptions?.folder, {
limit: this._getLimit(),
}),
)),
);
break;
}
@@ -131,7 +190,7 @@ export class ViewQueryExecutor {
return viewModifiers;
}
protected _getTimelineWindowViewModifier(view: View): ViewModifier[] {
private _getTimelineWindowViewModifier(view: View): ViewModifier[] {
if (view.is('live')) {
// For live views, always force the timeline to now, regardless of
// presence or not of events.
@@ -166,7 +225,7 @@ export class ViewQueryExecutor {
}
}
protected _getSeekTimeModifier(time?: Date): ViewModifier[] {
private _getSeekTimeModifier(time?: Date): ViewModifier[] {
if (time) {
return [
new MergeContextViewModifier({
@@ -179,4 +238,39 @@ export class ViewQueryExecutor {
return [new RemoveContextPropertyViewModifier('mediaViewer', 'seek')];
}
}
private _applyResultSelection(
queryResults: QueryResults,
options?: QueryExecutorOptions,
): QueryResults | null {
if (options?.rejectResults?.(queryResults)) {
return null;
}
const timeSelection = options?.selectResult?.time;
if (options?.selectResult?.id) {
queryResults.selectBestResult((media) =>
media.findIndex((m) => m.getID() === options.selectResult?.id),
);
} else if (options?.selectResult?.func) {
queryResults.selectResultIfFound(options.selectResult.func);
} else if (timeSelection) {
queryResults.selectBestResult((itemArray) =>
findBestMediaTimeIndex(
itemArray,
timeSelection.time,
timeSelection.favorCameraID,
),
);
}
return queryResults;
}
private _getLimit(): number {
return (
this._api.getConfigManager().getConfig()?.performance?.features
?.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT
);
}
}
+18 -6
View File
@@ -17,6 +17,7 @@ import './components/menu.js';
import { AdvancedCameraCardMenu } from './components/menu.js';
import './components/message.js';
import { renderMessage } from './components/message.js';
import './components/overlay-message.js';
import './components/overlay.js';
import { AdvancedCameraCardOverlay } from './components/overlay.js';
import './components/status-bar';
@@ -32,7 +33,7 @@ import { REPO_URL } from './const.js';
import { HomeAssistant, LovelaceCardEditor } from './ha/types.js';
import { localize } from './localize/localize.js';
import cardStyle from './scss/card.scss';
import { MediaLoadedInfo, Message } from './types.js';
import { MediaLoadedInfo, Message, OverlayMessage } from './types.js';
import { hasAction } from './utils/action.js';
import { getReleaseVersion } from './utils/diagnostics';
@@ -235,9 +236,9 @@ class AdvancedCameraCard extends LitElement {
return html`
${position === 'overlay'
? html`<advanced-camera-card-overlay
>${getContents('overlay')}</advanced-camera-card-overlay
>`
? html`<advanced-camera-card-overlay>
${getContents('overlay')}
</advanced-camera-card-overlay>`
: html`<div class="outerlay" data-position="${position}">
${getContents('outerlay')}
</div>`}
@@ -360,8 +361,9 @@ class AdvancedCameraCard extends LitElement {
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
@advanced-camera-card:message=${(ev: CustomEvent<Message>) =>
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
this._controller.getMediaLoadedInfoManager().set(ev.detail);
}}
@advanced-camera-card:media:unloaded=${() =>
this._controller.getMediaLoadedInfoManager().clear()}
@advanced-camera-card:media:volumechange=${
@@ -374,6 +376,11 @@ class AdvancedCameraCard extends LitElement {
() => this.requestUpdate() /* Refresh play/pause menu button */
}
@advanced-camera-card:focus=${() => this.focus()}
@advanced-camera-card:overlay-message:show=${(
ev: CustomEvent<OverlayMessage>,
) => this._controller.getOverlayMessageManager().setMessage(ev.detail)}
@advanced-camera-card:overlay-message:dismiss=${() =>
this._controller.getOverlayMessageManager().reset()}
>
${showLoading
? html`<advanced-camera-card-loading
@@ -454,6 +461,11 @@ class AdvancedCameraCard extends LitElement {
>
</advanced-camera-card-elements>`
: ``}
${this._controller.getOverlayMessageManager().getMessage()
? html`<advanced-camera-card-overlay-message
.message=${this._controller.getOverlayMessageManager().getMessage()}
></advanced-camera-card-overlay-message>`
: ''}
</ha-card>`,
);
}
-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';
import { LitElement } from 'lit';
import { isEqual, orderBy, uniqWith } from 'lodash-es';
import { CameraManager } from '../camera-manager/manager';
import { CameraManager, CameraQueryClassifier } from '../camera-manager/manager';
import { DateRange, PartialDateRange } from '../camera-manager/range';
import { CameraQuery, MediaMetadata, QueryType } from '../camera-manager/types';
import {
EventQuery,
MediaMetadata,
QueryType,
ReviewQuery,
} from '../camera-manager/types';
import { FoldersManager } from '../card-controller/folders/manager';
import { ViewManagerInterface } from '../card-controller/view/types';
import { SelectOption, SelectValues } from '../components/select';
import { CardWideConfig } from '../config/schema/types';
import { localize } from '../localize/localize';
import { ViewMediaType } from '../types';
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
import { EventMediaQuery, RecordingMediaQuery } from '../view/query';
import { QueryClassifier } from '../view/query-classifier';
interface MediaFilterControls {
events: boolean;
recordings: boolean;
favorites: boolean;
}
import { UnifiedQueryBuilder } from '../view/unified-query-builder';
export interface MediaFilterCoreDefaults {
cameraIDs?: string[];
favorite?: MediaFilterCoreFavoriteSelection;
mediaType?: MediaFilterMediaType;
reviewed?: MediaFilterCoreReviewedSelection;
mediaTypes?: MediaFilterMediaType[];
what?: string[];
when?: string;
where?: string[];
@@ -44,6 +45,11 @@ export enum MediaFilterCoreFavoriteSelection {
NotFavorite = 'not-favorite',
}
export enum MediaFilterCoreReviewedSelection {
Reviewed = 'reviewed',
NotReviewed = 'not-reviewed',
}
export enum MediaFilterCoreWhen {
Today = 'today',
Yesterday = 'yesterday',
@@ -56,6 +62,7 @@ export enum MediaFilterMediaType {
Clips = 'clips',
Snapshots = 'snapshots',
Recordings = 'recordings',
Reviews = 'reviews',
}
export class MediaFilterController {
@@ -72,6 +79,7 @@ export class MediaFilterController {
protected _whereOptions: SelectOption[] = [];
protected _tagsOptions: SelectOption[] = [];
protected _favoriteOptions: SelectOption[];
protected _reviewedOptions: SelectOption[];
protected _defaults: MediaFilterCoreDefaults | null = null;
protected _viewManager: ViewManagerInterface | null = null;
@@ -102,6 +110,20 @@ export class MediaFilterController {
value: MediaFilterMediaType.Recordings,
label: localize('media_filter.media_types.recordings'),
},
{
value: MediaFilterMediaType.Reviews,
label: localize('media_filter.media_types.reviews'),
},
];
this._reviewedOptions = [
{
value: MediaFilterCoreReviewedSelection.Reviewed,
label: localize('media_filter.reviewed'),
},
{
value: MediaFilterCoreReviewedSelection.NotReviewed,
label: localize('media_filter.not_reviewed'),
},
];
this._staticWhenOptions = [
{
@@ -149,6 +171,9 @@ export class MediaFilterController {
public getFavoriteOptions(): SelectOption[] {
return this._favoriteOptions;
}
public getReviewedOptions(): SelectOption[] {
return this._reviewedOptions;
}
public getDefaults(): MediaFilterCoreDefaults | null {
return this._defaults;
}
@@ -158,229 +183,204 @@ export class MediaFilterController {
public async valueChangeHandler(
cameraManager: CameraManager,
foldersManager: FoldersManager,
cardWideConfig: CardWideConfig,
values: {
camera?: string | string[];
mediaType?: MediaFilterMediaType;
camera?: SelectValues;
mediaTypes?: SelectValues;
when: {
selected?: string | string[];
selected?: SelectValues;
from?: Date | null;
to?: Date | null;
};
favorite?: MediaFilterCoreFavoriteSelection;
where?: string | string[];
what?: string | string[];
tags?: string | string[];
favorite?: SelectValues;
reviewed?: SelectValues;
where?: SelectValues;
what?: SelectValues;
tags?: SelectValues;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_ev?: unknown,
): Promise<void> {
const getArrayValueAsSet = (val?: SelectValues): Set<string> | null => {
const getArrayValueAsSet = <T extends string>(val?: SelectValues): Set<T> | null => {
// The reported value may be '' if the field is clearable (i.e. the user
// can click 'x').
if (val && Array.isArray(val) && val.length && !val.includes('')) {
return new Set([...val]);
return new Set([...val]) as Set<T>;
}
return null;
};
const cameraIDs =
getArrayValueAsSet(values.camera) ?? this._getAllCameraIDs(cameraManager);
if (!cameraIDs.size || !values.mediaType) {
return;
}
const when = this._getWhen(values.when);
const favorite = values.favorite
? values.favorite === MediaFilterCoreFavoriteSelection.Favorite
: null;
// A note on views:
// - In the below, if the user selects a camera to view media for, the main
// view camera is also set to that value (e.g. a user browsing the
// gallery, chooses a different camera in the media filter, then
// subsequently chooses the live button -- they would expect the live view
// for that filtered camera not the prior camera).
// - Similarly, if the user chooses clips or snapshots, set the actual view
// to 'clips' or 'snapshots' in order to ensure the right icon is shown as
// selected in the menu.
const reviewed = values.reviewed
? values.reviewed === MediaFilterCoreReviewedSelection.Reviewed
: null;
const where = getArrayValueAsSet(values.where);
const what = getArrayValueAsSet(values.what);
const tags = getArrayValueAsSet(values.tags);
const limit = cardWideConfig.performance?.features.media_chunk_size;
if (
values.mediaType === MediaFilterMediaType.Clips ||
values.mediaType === MediaFilterMediaType.Snapshots
) {
const where = getArrayValueAsSet(values.where);
const what = getArrayValueAsSet(values.what);
const tags = getArrayValueAsSet(values.tags);
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const query = builder.buildFilterQuery(
getArrayValueAsSet(values.camera),
getArrayValueAsSet<ViewMediaType>(values.mediaTypes),
{
...(when?.start && { start: when.start }),
...(when?.end && { end: when.end }),
...(limit && { limit }),
...(favorite !== null && { favorite }),
...(reviewed !== null && { reviewed }),
...(tags && { tags }),
...(what && { what }),
...(where && { where }),
},
);
const queries = new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: cameraIDs,
...(tags && { tags: tags }),
...(what && { what: what }),
...(where && { where: where }),
...(favorite !== null && { favorite: favorite }),
...(when && {
...(when.start && { start: when.start }),
...(when.end && { end: when.end }),
}),
...(limit && { limit: limit }),
...(values.mediaType === MediaFilterMediaType.Clips && { hasClip: true }),
...(values.mediaType === MediaFilterMediaType.Snapshots && {
hasSnapshot: true,
}),
},
]);
this._viewManager?.setViewByParametersWithExistingQuery({
params: {
query: queries,
// See 'A note on views' above for these two arguments
...(cameraIDs.size === 1 && { camera: [...cameraIDs][0] }),
view: values.mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots',
},
});
} else {
const queries = new RecordingMediaQuery([
{
type: QueryType.Recording,
cameraIDs: cameraIDs,
...(limit && { limit: limit }),
...(when && {
...(when.start && { start: when.start }),
...(when.end && { end: when.end }),
}),
...(favorite !== null && { favorite: favorite }),
},
]);
this._viewManager?.setViewByParametersWithExistingQuery({
params: {
query: queries,
// See 'A note on views' above for these two arguments
...(cameraIDs.size === 1 && { camera: [...cameraIDs][0] }),
view: 'recordings',
},
});
if (!query) {
return;
}
// Get camera IDs from the query (builder may have defaulted these)
const queryCameraIDs = query.getAllCameraIDs();
const cameraID = queryCameraIDs.size === 1 ? [...queryCameraIDs][0] : undefined;
this._viewManager?.setViewByParametersWithExistingQuery({
params: {
query,
// If single camera, set it as the active camera for menu navigation
...(cameraID && { camera: cameraID }),
},
});
// Need to ensure we update the element as the date-picker selections may
// have changed, and we need to un/set the selected class.
this._host.requestUpdate();
}
protected _getAllCameraIDs(cameraManager: CameraManager): Set<string> {
return cameraManager.getStore().getCameraIDsWithCapability({
anyCapabilities: ['clips', 'snapshots', 'recordings'],
});
}
public computeInitialDefaultsFromView(cameraManager: CameraManager): void {
public computeInitialDefaultsFromView(
cameraManager: CameraManager,
foldersManager: FoldersManager,
): void {
const view = this._viewManager?.getView();
const query = view?.query;
const allCameraIDs = this._getAllCameraIDs(cameraManager);
if (!view || !QueryClassifier.isMediaQuery(query) || !allCameraIDs.size) {
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const allCameraIDs = builder.getAllMediaCapableCameraIDs();
if (!view || !query?.hasNodes() || !allCameraIDs.size) {
return;
}
const queries = query.getQuery();
if (!queries) {
return;
}
let mediaType: MediaFilterMediaType | undefined;
const mediaQueries = query.getMediaQueries();
const mediaTypes: MediaFilterMediaType[] = [];
let cameraIDs: string[] | undefined;
let what: string[] | undefined;
let where: string[] | undefined;
let favorite: MediaFilterCoreFavoriteSelection | undefined;
let reviewed: MediaFilterCoreReviewedSelection | undefined;
let tags: string[] | undefined;
const cameraIDSets = uniqWith(
queries.map((query: CameraQuery) => query.cameraIDs),
isEqual,
);
// Special note: If all visible cameras are selected, this is the same as no
// selector at all.
if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, allCameraIDs)) {
cameraIDs = [...queries[0].cameraIDs];
const cameraIDsFromQuery = query.getAllCameraIDs();
// Note: With folder filtering, selecting all cameras is NOT the same as
// selecting none. "All cameras" means just camera media, while "none"
// would include folder media too.
if (cameraIDsFromQuery.size > 0) {
cameraIDs = [...cameraIDsFromQuery];
}
const favoriteValues = uniqWith(
queries.map((query) => query.favorite),
isEqual,
// Extract favorite from all media queries (only if explicitly set to true/false)
const favoriteValues = new Set(
mediaQueries.map((mediaQuery) =>
CameraQueryClassifier.isEventQuery(mediaQuery) ? mediaQuery.favorite : undefined,
),
);
if (favoriteValues.length === 1 && queries[0].favorite !== undefined) {
favorite = queries[0].favorite
? MediaFilterCoreFavoriteSelection.Favorite
: MediaFilterCoreFavoriteSelection.NotFavorite;
if (favoriteValues.size === 1) {
const fav = [...favoriteValues][0];
if (fav !== undefined) {
favorite = fav
? MediaFilterCoreFavoriteSelection.Favorite
: MediaFilterCoreFavoriteSelection.NotFavorite;
}
}
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (QueryClassifier.isEventQuery(view.query)) {
const queries = view.query.getQuery();
// Detect media types from queries
const eventQueries = query.getMediaQueries<EventQuery>({ type: QueryType.Event });
if (eventQueries.length > 0) {
const hasClips = eventQueries.some((q) => q.hasClip);
const hasSnapshots = eventQueries.some((q) => q.hasSnapshot);
const hasNeither = !hasClips && !hasSnapshots;
/* istanbul ignore if: the if path cannot be reached -- @preserve */
if (!queries) {
return;
if (hasClips || hasNeither) {
mediaTypes.push(MediaFilterMediaType.Clips);
}
const hasClips = uniqWith(
queries.map((query) => query.hasClip),
isEqual,
);
const hasSnapshots = uniqWith(
queries.map((query) => query.hasSnapshot),
isEqual,
);
if (hasClips.length === 1 && hasSnapshots.length === 1) {
mediaType = !!hasClips[0]
? MediaFilterMediaType.Clips
: !!hasSnapshots[0]
? MediaFilterMediaType.Snapshots
: undefined;
if (hasSnapshots || hasNeither) {
mediaTypes.push(MediaFilterMediaType.Snapshots);
}
}
if (query.hasMediaQueriesOfType(QueryType.Recording)) {
mediaTypes.push(MediaFilterMediaType.Recordings);
}
if (query.hasMediaQueriesOfType(QueryType.Review)) {
mediaTypes.push(MediaFilterMediaType.Reviews);
}
if (eventQueries.length > 0) {
const whatSets = uniqWith(
queries.map((query) => query.what),
eventQueries.map((q) => q.what),
isEqual,
);
if (whatSets.length === 1 && queries[0].what?.size) {
what = [...queries[0].what];
if (whatSets.length === 1 && eventQueries[0].what?.size) {
what = [...eventQueries[0].what];
}
const whereSets = uniqWith(
queries.map((query) => query.where),
eventQueries.map((q) => q.where),
isEqual,
);
if (whereSets.length === 1 && queries[0].where?.size) {
where = [...queries[0].where];
if (whereSets.length === 1 && eventQueries[0].where?.size) {
where = [...eventQueries[0].where];
}
const tagsSets = uniqWith(
queries.map((query) => query.tags),
eventQueries.map((q) => q.tags),
isEqual,
);
if (tagsSets.length === 1 && queries[0].tags?.size) {
tags = [...queries[0].tags];
if (tagsSets.length === 1 && eventQueries[0].tags?.size) {
tags = [...eventQueries[0].tags];
}
}
// Extract reviewed from review queries (only if explicitly set to true/false)
const reviewQueries = query.getMediaQueries<ReviewQuery>({ type: QueryType.Review });
if (reviewQueries.length > 0) {
const reviewedValues = new Set(reviewQueries.map((q) => q.reviewed));
if (reviewedValues.size === 1) {
const rev = [...reviewedValues][0];
if (rev !== undefined) {
reviewed = rev
? MediaFilterCoreReviewedSelection.Reviewed
: MediaFilterCoreReviewedSelection.NotReviewed;
}
}
} else if (QueryClassifier.isRecordingQuery(view.query)) {
mediaType = MediaFilterMediaType.Recordings;
}
this._defaults = {
...(mediaType && { mediaType: mediaType }),
...(cameraIDs && { cameraIDs: cameraIDs }),
...(what && { what: what }),
...(where && { where: where }),
...(favorite !== undefined && { favorite: favorite }),
...(tags && { tags: tags }),
...(mediaTypes.length && { mediaTypes }),
...(cameraIDs && { cameraIDs }),
...(what && { what }),
...(where && { where }),
...(favorite !== undefined && { favorite }),
...(reviewed !== undefined && { reviewed }),
...(tags && { tags }),
};
}
public computeCameraOptions(cameraManager: CameraManager): void {
this._cameraOptions = [...this._getAllCameraIDs(cameraManager)].map((cameraID) => ({
public computeCameraOptions(
cameraManager: CameraManager,
foldersManager: FoldersManager,
): void {
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
this._cameraOptions = [...builder.getAllMediaCapableCameraIDs()].map((cameraID) => ({
value: cameraID,
label: cameraManager.getCameraMetadata(cameraID)?.title ?? cameraID,
}));
@@ -441,23 +441,6 @@ export class MediaFilterController {
this._host.requestUpdate();
}
public getControlsToShow(cameraManager: CameraManager): MediaFilterControls {
const view = this._viewManager?.getView();
const events = QueryClassifier.isEventQuery(view?.query);
const recordings = QueryClassifier.isRecordingQuery(view?.query);
const managerCapabilities = cameraManager.getAggregateCameraCapabilities();
return {
events: events,
recordings: recordings,
favorites: events
? managerCapabilities?.has('favorite-events')
: recordings
? managerCapabilities?.has('favorite-recordings')
: false,
};
}
protected _computeWhenOptions(): void {
this._whenOptions = [...this._staticWhenOptions, ...this._metaDataWhenOptions];
}
@@ -1,27 +1,40 @@
import { format } from 'date-fns';
import { CameraManager } from '../../camera-manager/manager';
import { CameraManagerCameraMetadata } from '../../camera-manager/types';
import { ViewItemManager } from '../../card-controller/view/item-manager';
import { ViewManagerEpoch } from '../../card-controller/view/types';
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { Icon } from '../../types';
import { MetadataField, OverlayMessage, OverlayMessageControl } from '../../types';
import { getDurationString, prettifyTitle } from '../../utils/basic';
import {
downloadMedia,
navigateToTimeline,
toggleFavorite,
toggleReviewed,
} from '../../utils/media-actions';
import { ViewItem } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier';
import { ViewItemCapabilities } from '../../view/types';
interface Detail {
icon?: Icon;
hint?: string;
title: string;
export interface OverlayControlsContext {
hass?: HomeAssistant;
viewItemManager?: ViewItemManager;
viewManagerEpoch?: ViewManagerEpoch;
capabilities?: ViewItemCapabilities | null;
}
export class ThumbnailDetailsController {
private _details: Detail[] = [];
private _heading: string | null = null;
export class MediaDetailsController {
private _details: MetadataField[] = [];
private _heading: MetadataField | null = null;
private _item: ViewItem | null = null;
public calculate(
cameraManager?: CameraManager | null,
item?: ViewItem,
seek?: Date,
): void {
this._item = item ?? null;
const cameraID = ViewItemClassifier.isMedia(item) ? item.getCameraID() : null;
const cameraMetadata = cameraID
? cameraManager?.getCameraMetadata(cameraID) ?? null
@@ -43,12 +56,34 @@ export class ThumbnailDetailsController {
const rawScore = item.getScore();
const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null;
this._heading = whatWithTags ? `${whatWithTags}${score ? ` ${score}` : ''}` : null;
this._heading = whatWithTags
? { title: `${whatWithTags}${score ? ` ${score}` : ''}` }
: null;
return;
}
if (ViewItemClassifier.isReview(item)) {
const title = item.getTitle();
const severity = item.getSeverity();
this._heading = title
? {
title: title,
emphasis: severity ?? undefined,
hint:
localize('common.severity') +
': ' +
localize('common.severities.' + severity),
icon: { icon: 'mdi:circle-medium' },
}
: null;
return;
}
if (cameraMetadata?.title) {
this._heading = cameraMetadata.title;
this._heading = {
title: cameraMetadata.title,
};
return;
}
@@ -137,7 +172,9 @@ export class ThumbnailDetailsController {
// To avoid duplication, if the event has a starttime, the title is omitted
// from the details.
const includeTitle = !ViewItemClassifier.isEvent(item) || !startTime;
const includeTitle =
(!ViewItemClassifier.isEvent(item) && !ViewItemClassifier.isReview(item)) ||
!startTime;
this._details = [
...(includeTitle && itemTitle
? [
@@ -154,11 +191,91 @@ export class ThumbnailDetailsController {
];
}
public getHeading(): string | null {
public getHeading(): MetadataField | null {
return this._heading;
}
public getDetails(): Detail[] {
public getDetails(): MetadataField[] {
return this._details;
}
/**
* Get an overlay message for the item.
* @param context Optional context to include controls.
* @returns An OverlayMessage.
*/
public getMessage(context?: OverlayControlsContext): OverlayMessage {
return {
heading: this._heading ?? undefined,
controls: context ? this._getControls(context) : undefined,
details: this._details,
text: ViewItemClassifier.isMedia(this._item)
? this._item.getDescription() ?? undefined
: undefined,
};
}
protected _getControls(context: OverlayControlsContext): OverlayMessageControl[] {
const controls: OverlayMessageControl[] = [];
const item = this._item;
if (!item) {
return controls;
}
if (ViewItemClassifier.isReview(item)) {
const isReviewed = item.isReviewed();
controls.push({
title: isReviewed
? localize('common.set_reviews.unreviewed')
: localize('common.set_reviews.reviewed'),
icon: { icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline' },
callback: async () => {
const success = await toggleReviewed(item, context);
return success ? this.getMessage(context) : null;
},
});
}
if (context.capabilities?.canFavorite && ViewItemClassifier.isMedia(item)) {
const isFavorite = item.isFavorite();
controls.push({
title: localize('thumbnail.retain_indefinitely'),
icon: { icon: isFavorite ? 'mdi:star' : 'mdi:star-outline' },
emphasis: isFavorite ? 'medium' : undefined,
callback: async () => {
const success = await toggleFavorite(item, context);
return success ? this.getMessage(context) : null;
},
});
}
if (context.capabilities?.canDownload && item.getID()) {
controls.push({
title: localize('thumbnail.download'),
icon: { icon: 'mdi:download' },
callback: async () => {
await downloadMedia(item, context);
// Close overlay message after download.
return null;
},
});
}
if (ViewItemClassifier.supportsTimeline(item) && context.viewManagerEpoch) {
controls.push({
title: localize('thumbnail.timeline'),
icon: { icon: 'mdi:target' },
callback: () => {
navigateToTimeline(item, context);
// Close overlay after timeline navigation
return null;
},
});
}
return controls;
}
}
+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 { MicrophoneManager } from '../card-controller/microphone-manager';
import { ViewManager } from '../card-controller/view/view-manager';
import { VIEWS_USER_SPECIFIED } from '../config/schema/common/const';
import {
AdvancedCameraCardView,
VIEWS_USER_SPECIFIED,
} from '../config/schema/common/const';
import { MenuItemBase } from '../config/schema/elements/custom/menu/base';
import { MenuItem } from '../config/schema/elements/custom/menu/types';
import { AdvancedCameraCardConfig } from '../config/schema/types';
import { getEntityTitle } from '../ha/get-entity-title';
@@ -19,6 +23,7 @@ import {
createMediaPlayerAction,
createPTZControlsAction,
createPTZMultiAction,
createSetReviewAction,
createViewAction,
isAdvancedCameraCardCustomAction,
} from '../utils/action';
@@ -27,7 +32,6 @@ import { isBeingCasted } from '../utils/casting';
import { getPTZTarget } from '../utils/ptz';
import { getStreamCameraID, hasSubstream } from '../utils/substream';
import { ViewItemClassifier } from '../view/item-classifier';
import { QueryClassifier } from '../view/query-classifier';
import { View } from '../view/view';
import { getCameraIDsForViewName, isViewSupportedByCamera } from '../view/view-support';
@@ -77,9 +81,12 @@ export class MenuButtonController {
this._getClipsButton(config, cameraManager, foldersManager, options?.view),
this._getSnapshotsButton(config, cameraManager, foldersManager, options?.view),
this._getRecordingsButton(config, cameraManager, foldersManager, options?.view),
this._getReviewsButton(config, cameraManager, foldersManager, options?.view),
this._getImageButton(config, cameraManager, foldersManager, options?.view),
this._getTimelineButton(config, cameraManager, foldersManager, options?.view),
this._getDownloadButton(config, cameraManager, options?.view),
this._getInfoButton(config, cameraManager, options?.view),
this._getSetReviewButton(config, options?.view),
this._getCameraUIButton(config, options?.showCameraUIButton),
this._getMicrophoneButton(
config,
@@ -245,17 +252,45 @@ export class MenuButtonController {
: null;
}
/**
* Determine if a media button (clips/snapshots) should be shown. These are
* hidden by default if reviews are supported.
*/
protected _shouldShowEventMediaButton(
viewName: 'clips' | 'snapshots',
buttonConfig: MenuItemBase,
cameraManager: CameraManager,
foldersManager: FoldersManager,
view?: View | null,
): boolean {
const supportsEventView =
view &&
isViewSupportedByCamera(viewName, cameraManager, foldersManager, view.camera);
const supportsReviewsView =
view &&
isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera);
// Show if: explicitly enabled OR (supports view AND reviews not supported)
return !!supportsEventView && (buttonConfig.enabled || !supportsReviewsView);
}
protected _getClipsButton(
config: AdvancedCameraCardConfig,
cameraManager: CameraManager,
foldersManager: FoldersManager,
view?: View | null,
): MenuItem | null {
return view &&
isViewSupportedByCamera('clips', cameraManager, foldersManager, view.camera)
return this._shouldShowEventMediaButton(
'clips',
config.menu.buttons.clips,
cameraManager,
foldersManager,
view,
)
? {
icon: 'mdi:filmstrip',
...config.menu.buttons.clips,
enabled: true,
type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.clips'),
style: view?.is('clips') ? this._getEmphasizedStyle() : {},
@@ -271,11 +306,17 @@ export class MenuButtonController {
foldersManager: FoldersManager,
view?: View | null,
): MenuItem | null {
return view &&
isViewSupportedByCamera('snapshots', cameraManager, foldersManager, view.camera)
return this._shouldShowEventMediaButton(
'snapshots',
config.menu.buttons.snapshots,
cameraManager,
foldersManager,
view,
)
? {
icon: 'mdi:camera',
...config.menu.buttons.snapshots,
enabled: true,
type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.snapshots'),
style: view?.is('snapshots') ? this._getEmphasizedStyle() : {},
@@ -305,6 +346,26 @@ export class MenuButtonController {
: null;
}
protected _getReviewsButton(
config: AdvancedCameraCardConfig,
cameraManager: CameraManager,
foldersManager: FoldersManager,
view?: View | null,
): MenuItem | null {
return view &&
isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera)
? {
icon: 'mdi:play-box-multiple',
...config.menu.buttons.reviews,
type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.reviews'),
style: view.is('reviews') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('reviews'),
hold_action: createViewAction('review'),
}
: null;
}
protected _getImageButton(
config: AdvancedCameraCardConfig,
cameraManager: CameraManager,
@@ -365,6 +426,49 @@ export class MenuButtonController {
return null;
}
protected _getInfoButton(
config: AdvancedCameraCardConfig,
_cameraManager: CameraManager,
view?: View | null,
): MenuItem | null {
const selectedItem = view?.queryResults?.getSelectedResult();
if (!ViewItemClassifier.isMedia(selectedItem)) {
return null;
}
return {
icon: 'mdi:information-outline',
...config.menu.buttons.info,
type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.menu.buttons.info'),
tap_action: createGeneralAction('info'),
};
}
protected _getSetReviewButton(
config: AdvancedCameraCardConfig,
view?: View | null,
): MenuItem | null {
const selectedItem = view?.queryResults?.getSelectedResult();
if (!view?.isViewerView() || !ViewItemClassifier.isReview(selectedItem)) {
return null;
}
const isReviewed = selectedItem.isReviewed();
if (isReviewed === null) {
return null;
}
return {
icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline',
...config.menu.buttons.set_review,
type: 'custom:advanced-camera-card-menu-icon',
title: isReviewed
? localize('common.set_reviews.unreviewed')
: localize('common.set_reviews.reviewed'),
tap_action: createSetReviewAction(),
style: isReviewed ? this._getEmphasizedStyle() : {},
};
}
protected _getCameraUIButton(
config: AdvancedCameraCardConfig,
showCameraUIButton?: boolean,
@@ -667,9 +771,8 @@ export class MenuButtonController {
}
if (folders.length === 1) {
const isSelected =
QueryClassifier.isFolderQuery(view?.query) &&
view.query.getQuery()?.folder.id === folders[0][0];
const folderID = folders[0][0];
const isSelected = !!view?.query?.getFolderQueries(folderID).length;
const folder = folders[0][1];
return {
@@ -684,9 +787,7 @@ export class MenuButtonController {
}
const submenuItems = folders.map(([id, folder]) => {
const isSelected =
QueryClassifier.isFolderQuery(view?.query) &&
view.query.getQuery()?.folder.id === id;
const isSelected = !!view?.query?.getFolderQueries(id).length;
return {
enabled: true,
@@ -736,6 +837,7 @@ export class MenuButtonController {
button: MenuItem,
options?: MenuButtonControllerOptions,
): StyleInfo {
// Review
for (const actionSet of [
button.tap_action,
button.double_tap_action,
@@ -752,7 +854,9 @@ export class MenuButtonController {
VIEWS_USER_SPECIFIED.some(
(viewName) =>
viewName === action.advanced_camera_card_action &&
options?.view?.is(action.advanced_camera_card_action),
options?.view?.is(
action.advanced_camera_card_action as AdvancedCameraCardView,
),
) ||
(action.advanced_camera_card_action === 'default' &&
options?.view?.is(config.view.default)) ||
+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) {
this._thumbnail = thumbnail;
this._icon = null;
this._thumbnailClass = isBrandUrl(thumbnail) ? 'brand' : null;
this._thumbnailClass = isBrandUrl(thumbnail) ? 'placeholder' : null;
} else {
this._thumbnail = null;
this._thumbnailClass = null;
+135 -139
View File
@@ -14,7 +14,6 @@ import {
} from 'vis-timeline';
import { CameraManager } from '../../camera-manager/manager';
import { rangesOverlap } from '../../camera-manager/range';
import { MediaQuery } from '../../camera-manager/types';
import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly';
import { FoldersManager } from '../../card-controller/folders/manager';
import { ViewItemManager } from '../../card-controller/view/item-manager';
@@ -41,22 +40,12 @@ import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
import { ViewMedia } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier';
import {
EventMediaQuery,
FolderViewQuery,
Query,
RecordingMediaQuery,
} from '../../view/query';
import { QueryClassifier, QueryType } from '../../view/query-classifier';
import { QueryResults } from '../../view/query-results';
import { UnifiedQuery } from '../../view/unified-query';
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer';
import { mergeViewContext } from '../../view/view';
import { AdvancedCameraCardTimelineItem, TimelineDataSource } from './source';
import {
ExtendedTimeline,
TimelineItemClickAction,
TimelineKeys,
TimelineRangeChange,
} from './types';
import { ExtendedTimeline, TimelineItemClickAction, TimelineRangeChange } from './types';
// An event used to fetch data required for thumbnail rendering. See special
// note below on why this is necessary.
@@ -81,7 +70,7 @@ interface TimelineControllerOptions {
timelineConfig?: TimelineCoreConfig;
mini?: boolean;
thumbnailConfig?: ThumbnailsControlBaseConfig;
keys?: TimelineKeys;
query?: UnifiedQuery;
}
const TIMELINE_TARGET_BAR_ID = 'target_bar';
@@ -94,10 +83,14 @@ export class TimelineController {
private _timeline: ExtendedTimeline | null = null;
private _hass: HomeAssistant | null = null;
private _cameraManager: CameraManager | null = null;
private _foldersManager: FoldersManager | null = null;
private _viewItemManager: ViewItemManager | null = null;
private _viewManagerEpoch: ViewManagerEpoch | null = null;
private _timelineConfig: TimelineCoreConfig | null = null;
private _mini = false;
private _panMode: TimelinePanMode | null = null;
@@ -136,26 +129,56 @@ export class TimelineController {
this._pointerHeld = null;
}
public setOptions(options: TimelineControllerOptions): void {
this.destroyTimeline();
/**
* Extract the "shape" of a query - a clone without time ranges.
* Shape determines timeline structure (groups).
*/
private _getQueryShape(query: UnifiedQuery): UnifiedQuery {
return UnifiedQueryTransformer.stripTimeRange(query);
}
if (
options.keys &&
options.cameraManager &&
options.foldersManager &&
options.conditionStateManager &&
options.timelineConfig
) {
this._source = new TimelineDataSource(
options.cameraManager,
options.foldersManager,
options.conditionStateManager,
options.keys,
options.timelineConfig.events_media_type,
options.timelineConfig.show_recordings,
);
} else {
this._source = null;
private _hasSameShape(a?: UnifiedQuery | null, b?: UnifiedQuery | null): boolean {
if (!a && !b) {
return true;
}
if (!a || !b) {
return false;
}
return a.isEqual(b);
}
public setOptions(options: TimelineControllerOptions): void {
// Extract the shape (query without time ranges) for comparison.
const newShape = options.query ? this._getQueryShape(options.query) : null;
// Rebuild source if config, dependencies, or shape changed.
const needsRebuild =
!this._source ||
this._cameraManager !== (options.cameraManager ?? null) ||
this._foldersManager !== (options.foldersManager ?? null) ||
!isEqual(this._timelineConfig, options.timelineConfig ?? null) ||
!this._hasSameShape(this._source?.shape, newShape);
if (needsRebuild) {
this.destroyTimeline();
if (
newShape &&
options.cameraManager &&
options.foldersManager &&
options.conditionStateManager &&
options.timelineConfig
) {
this._source = new TimelineDataSource(
options.cameraManager,
options.foldersManager,
options.conditionStateManager,
newShape,
options.timelineConfig.show_recordings,
);
} else {
this._source = null;
}
}
if (this._thumbnailConfig !== (options.thumbnailConfig ?? null)) {
@@ -187,6 +210,7 @@ export class TimelineController {
this._thumbnailConfig = options?.thumbnailConfig ?? null;
this._cameraManager = options?.cameraManager ?? null;
this._foldersManager = options?.foldersManager ?? null;
this._viewItemManager = options?.viewItemManager ?? null;
this._timelineConfig = options?.timelineConfig ?? null;
this._mini = options?.mini ?? false;
@@ -268,13 +292,13 @@ export class TimelineController {
this._timeline = new Timeline(
this._timelineElement,
this._source.dataset,
this._source.groups,
options,
);
} else {
this._timeline = new Timeline(
this._timelineElement,
this._source.dataset,
this._source.groups,
options,
);
}
@@ -500,16 +524,15 @@ export class TimelineController {
}
const view = this._viewManagerEpoch?.manager.getView();
const id = String(properties.item);
const item = this._source?.dataset.get(id) ?? null;
const id = properties.item ? String(properties.item) : null;
const item = id ? this._source?.dataset.get(id) ?? null : null;
if (
this._ignoreClick ||
!view ||
!this._viewManagerEpoch ||
!this._source ||
!properties.what ||
!item
!properties.what
) {
return;
}
@@ -519,9 +542,15 @@ export class TimelineController {
if (
this._timelineConfig?.show_recordings &&
properties.time &&
['background', 'axis'].includes(properties.what)
['background', 'axis'].includes(properties.what) &&
this._source &&
this._timeline
) {
const query = this._createQuery('recording');
const query = this._source.buildRecordingsWindowedQuery(
convertRangeToCacheFriendlyTimes(
this._getPrefetchWindow(this._timeline.getWindow()),
),
);
if (query) {
await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
baseView: view,
@@ -533,9 +562,14 @@ export class TimelineController {
},
},
},
modifiers: [
new MergeContextViewModifier({
mediaViewer: { seek: properties.time },
}),
],
});
}
} else if (properties.item && properties.what === 'item') {
} else if (item && properties.what === 'item') {
const cameraID = String(properties.group);
const criteria = {
@@ -566,49 +600,16 @@ export class TimelineController {
// - If a folder media was loaded into the timeline from a prior folder
// query other than the one stored in the view (e.g. user navigated to
// a different folder in the thumbnails carousel).
if (item.query) {
// Item has a reference query (e.g. folders), use that.
const media = this._source.dataset
.get({
filter: (timelineItem) => item.query === timelineItem.query,
})
.map((timelineItem) => timelineItem.media)
.filter(isTruthy);
const selectedIndex = media.findIndex((m) => m.getID() === id);
if (selectedIndex >= 0) {
const queryResults = new QueryResults({ results: media, selectedIndex });
this._viewManagerEpoch?.manager.setViewByParameters({
params: {
view: 'media',
query: item.query,
queryResults,
},
modifiers: [new MergeContextViewModifier(context)],
});
}
} else {
const currentQueryType =
QueryClassifier.getQueryType(view.query) ??
this._source.getKeyType() === 'camera'
? 'event'
: this._source.getKeyType() === 'folder'
? 'folder'
: null;
const query = currentQueryType ? this._createQuery(currentQueryType) : null;
if (query) {
await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: { view: 'media', query: query },
queryExecutorOptions: {
selectResult: {
id,
},
rejectResults: (results) => !results.hasResults(),
},
modifiers: [new MergeContextViewModifier(context)],
});
}
const queryResults = this._buildQueryResultsFromExistingItem(item);
if (item && item.query && queryResults) {
this._viewManagerEpoch?.manager.setViewByParameters({
params: {
view: 'media',
query: item.query,
queryResults,
},
modifiers: [new MergeContextViewModifier(context)],
});
}
} else {
this._viewManagerEpoch.manager.setViewByParameters({
@@ -630,6 +631,25 @@ export class TimelineController {
this._ignoreClick = false;
}
private _buildQueryResultsFromExistingItem(
item: AdvancedCameraCardTimelineItem,
): QueryResults | null {
const query = item.query;
if (!query || !this._source) {
return null;
}
const media = this._source.dataset
.get({
filter: (timelineItem) => !!timelineItem.query && query === timelineItem.query,
})
.map((timelineItem) => timelineItem.media)
.filter(isTruthy);
const selectedIndex = media.findIndex((m) => m.getID() === item.id);
return selectedIndex >= 0
? new QueryResults({ results: media, selectedIndex })
: null;
}
/**
* Get a broader prefetch window from a start and end basis.
* @param window The window to broaden.
@@ -643,31 +663,19 @@ export class TimelineController {
};
}
private _createQuery(
type: QueryType,
options?: {
window?: TimelineWindow;
},
): Query | null {
if (!this._timeline || !this._source) {
return null;
}
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(
this._getPrefetchWindow(options?.window ?? this._timeline.getWindow()),
);
if (type === 'event') {
const queries = this._source.getTimelineEventQueries(cacheFriendlyWindow);
return queries ? new EventMediaQuery(queries) : null;
} else if (type === 'recording') {
const queries = this._source.getTimelineRecordingQueries(cacheFriendlyWindow);
return queries ? new RecordingMediaQuery(queries) : null;
} else if (type === 'folder') {
const queries = this._source.getTimelineFolderQuery();
return queries ? new FolderViewQuery(queries) : null;
}
return null;
/**
* Apply a cache-friendly prefetch window to all media queries.
*/
private _applyWindowToQuery(
query: UnifiedQuery,
window: TimelineWindow,
): UnifiedQuery {
const prefetchWindow = this._getPrefetchWindow(window);
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(prefetchWindow);
return UnifiedQueryTransformer.rebuildQuery(query, {
start: cacheFriendlyWindow.start,
end: cacheFriendlyWindow.end,
});
}
private _timelineRangeChangedHandler = async (properties: {
@@ -690,16 +698,14 @@ export class TimelineController {
return;
}
await this._source?.refresh(this._getPrefetchWindow(properties), {
view,
});
await this._source?.refresh(this._getPrefetchWindow(properties));
const queryType = QueryClassifier.getQueryType(view.query);
if (!queryType) {
if (!view.query) {
return;
}
const query = this._createQuery(queryType);
if (!query || this._alreadyHasAcceptableMediaQuery(query)) {
const query = this._applyWindowToQuery(view.query, properties);
if (this._alreadyHasAcceptableMediaQuery(query)) {
return;
}
@@ -720,27 +726,22 @@ export class TimelineController {
});
};
private _alreadyHasAcceptableMediaQuery(freshQuery: Query): boolean {
private _alreadyHasAcceptableMediaQuery(freshQuery: UnifiedQuery): boolean {
const view = this._viewManagerEpoch?.manager.getView();
const query = view?.query;
if (!this._cameraManager || !query) {
if (!this._source || !query) {
return false;
}
const currentResultTimestamp = view?.queryResults?.getResultsTimestamp();
if (!currentResultTimestamp) {
return false;
}
return (
!!query?.getQuery() &&
!!currentResultTimestamp &&
((QueryClassifier.isFolderQuery(query) && query.isEqual(freshQuery)) ||
(QueryClassifier.isMediaQuery(query) &&
QueryClassifier.isMediaQuery(freshQuery) &&
query.isSupersetOf(freshQuery) &&
this._cameraManager.areMediaQueriesResultsFresh<MediaQuery>(
currentResultTimestamp,
query.getQuery(),
)))
query.isSupersetOf(freshQuery) &&
this._source.areResultsFresh(currentResultTimestamp, query)
);
}
@@ -787,16 +788,14 @@ export class TimelineController {
}
const prefetchedWindow = this._getPrefetchWindow(desiredWindow);
if (!this._pointerHeld) {
if (!this._pointerHeld && view.query) {
// Don't fetch any data or touch the timeline in any way if the user is
// currently interacting with it. Without this the subsequent data fetches
// (via fetchIfNecessary) may update the timeline contents which causes
// the visjs timeline to stop dragging/panning operations which is very
// disruptive to the user.
await this._source?.refresh(prefetchedWindow, {
view,
});
this._source.addEventMediaToDataset(view.queryResults?.getResults(), view.query);
await this._source?.refresh(prefetchedWindow);
this._source.addMediaToDataset(view.query, view.queryResults?.getResults());
}
const currentSelection = this._timeline.getSelection();
@@ -845,14 +844,11 @@ export class TimelineController {
//
// Also don't generate thumbnails in mini-timelines (they will already have
// been generated).
const queryType = QueryClassifier.getQueryType(view.query);
if (!queryType) {
if (!view.query) {
return;
}
const freshMediaQuery = this._createQuery(queryType, {
window: desiredWindow,
});
const freshMediaQuery = this._applyWindowToQuery(view.query, desiredWindow);
if (
!this._mini &&
+94 -161
View File
@@ -1,31 +1,25 @@
import { add, sub } from 'date-fns';
import { DataSet } from 'vis-data';
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
import { EqualityCache } from '../../cache/equality-cache';
import { CameraManager } from '../../camera-manager/manager';
import {
compressRanges,
ExpiringMemoryRangeSet,
MemoryRangeSet,
} from '../../camera-manager/range';
import {
EventQuery,
RecordingQuery,
RecordingSegment,
} from '../../camera-manager/types';
import { RecordingSegment } from '../../camera-manager/types';
import { capEndDate } from '../../camera-manager/utils/cap-end-date';
import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly';
import { FoldersManager } from '../../card-controller/folders/manager';
import { FolderQuery } from '../../card-controller/folders/types';
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
import { FolderConfig } from '../../config/schema/folders';
import { ClipsOrSnapshotsOrAll } from '../../types';
import { errorToConsole, ModifyInterface } from '../../utils/basic.js';
import { errorToConsole } from '../../utils/basic.js';
import { ViewItem, ViewMedia } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier';
import { FolderViewQuery, Query } from '../../view/query';
import { View } from '../../view/view';
import { TimelineKeys } from './types';
import { UnifiedQuery } from '../../view/unified-query';
import { UnifiedQueryBuilder } from '../../view/unified-query-builder';
import { UnifiedQueryRunner } from '../../view/unified-query-runner';
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer';
// Allow timeline freshness to be at least this number of seconds out of date
// (caching times in the data-engine may increase the effective delay).
@@ -37,9 +31,7 @@ const TIMELINE_FRESHNESS_TOLERANCE_SECONDS = 30;
// instead of clean recording blocks.
const TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS = 60;
type TimelineViewQuery = Query;
export interface AdvancedCameraCardTimelineItem extends TimelineItem {
export type AdvancedCameraCardTimelineItem = TimelineItem & {
// Use numbers to avoid significant volumes of Date object construction (for
// high-quantity recording segments).
start: number;
@@ -48,25 +40,33 @@ export interface AdvancedCameraCardTimelineItem extends TimelineItem {
// DataSet requires string (not HTMLElement) content.
content: string;
media?: ViewMedia;
// View query object from which this timeline item is associated with.
query?: TimelineViewQuery;
}
// Severity is duplicated here (also available via media.getSeverity())
// because vis-timeline's dataAttributes option requires properties to exist
// directly on the item object to render them as data-* HTML attributes for
// CSS styling.
severity?: string;
} & ( // Ensure that if there's a media item there is a query it is associated with.
| {
media: ViewMedia;
query: UnifiedQuery;
}
| {
media?: never;
query?: never;
}
);
interface AdvancedCameraCardGroup {
id: string;
content: string;
}
interface RefreshOptions {
view?: View;
}
export class TimelineDataSource {
private _cameraManager: CameraManager;
private _foldersManager: FoldersManager;
private _conditionStateManager: ConditionStateManagerReadonlyInterface;
private _builder: UnifiedQueryBuilder;
private _runner: UnifiedQueryRunner;
private _dataset: DataSet<AdvancedCameraCardTimelineItem> = new DataSet();
private _groups: DataSet<AdvancedCameraCardGroup>;
@@ -76,33 +76,34 @@ export class TimelineDataSource {
// high-N segments into a smaller number of consecutive recording blocks).
private _recordingRanges = new MemoryRangeSet();
// Cache event ranges since re-adding the same events is a timeline
// performance killer (even if the request results are cached).
private _eventRanges = new ExpiringMemoryRangeSet();
private _folderCache = new EqualityCache<FolderQuery, Date>();
// Cache for all query results in this source instance.
// Uses a single range set since shape determines content type.
private _cache = new ExpiringMemoryRangeSet();
private _eventsMediaType: ClipsOrSnapshotsOrAll;
private _showRecordings: boolean;
private _keys: TimelineKeys;
// The "shape" of the query, a UnifiedQuery without time ranges. Determines
// the groups/structure of the timeline.
private _shape: UnifiedQuery;
constructor(
cameraManager: CameraManager,
foldersManager: FoldersManager,
conditionStateManager: ConditionStateManagerReadonlyInterface,
keys: TimelineKeys,
eventsMediaType: ClipsOrSnapshotsOrAll,
shape: UnifiedQuery,
showRecordings: boolean,
) {
this._cameraManager = cameraManager;
this._foldersManager = foldersManager;
this._conditionStateManager = conditionStateManager;
this._keys = keys;
this._groups = this._generateGroups(keys);
this._eventsMediaType = eventsMediaType;
this._builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
this._runner = new UnifiedQueryRunner(
cameraManager,
foldersManager,
conditionStateManager,
);
this._shape = shape;
this._showRecordings = showRecordings;
this._groups = this._generateGroups();
}
get dataset(): DataSet<AdvancedCameraCardTimelineItem> {
@@ -113,8 +114,12 @@ export class TimelineDataSource {
return this._groups;
}
public getKeyType(): 'camera' | 'folder' {
return this._keys.type;
get shape(): UnifiedQuery {
return this._shape;
}
public areResultsFresh(resultsTimestamp: Date, query: UnifiedQuery): boolean {
return this._runner.areResultsFresh(resultsTimestamp, query);
}
private _getGroupIDForCamera(cameraID: string): string {
@@ -122,30 +127,32 @@ export class TimelineDataSource {
}
private _getGroupIDForFolder(folderConfig: FolderConfig): string {
return folderConfig.id;
return `folder/${folderConfig.id}`;
}
private _generateGroups(keys: TimelineKeys): DataSet<AdvancedCameraCardGroup> {
private _generateGroups(): DataSet<AdvancedCameraCardGroup> {
const groups: AdvancedCameraCardGroup[] = [];
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (keys.type === 'camera') {
keys.cameraIDs?.forEach((cameraID) => {
const cameraMetadata = this._cameraManager.getCameraMetadata(cameraID);
groups.push({
id: this._getGroupIDForCamera(cameraID),
content: cameraMetadata?.title ?? cameraID,
});
});
} else if (keys.type === 'folder') {
const folderID = this._getGroupIDForFolder(keys.folder);
// Add folder-based groups
const folderQueries = this._shape.getFolderQueries();
for (const folderQuery of folderQueries) {
const folderID = this._getGroupIDForFolder(folderQuery.folder);
groups.push({
id: folderID,
content: keys.folder?.title ?? folderID,
content: folderQuery.folder.title ?? folderID,
});
}
// Add camera-based groups
const cameraIDs = this._shape.getAllCameraIDs();
cameraIDs.forEach((cameraID) => {
const cameraMetadata = this._cameraManager.getCameraMetadata(cameraID);
groups.push({
id: this._getGroupIDForCamera(cameraID),
content: cameraMetadata?.title ?? cameraID,
});
});
return new DataSet(groups);
}
@@ -164,14 +171,11 @@ export class TimelineDataSource {
}
}
public addEventMediaToDataset(
mediaArray?: ViewItem[] | null,
query?: TimelineViewQuery | null,
): void {
public addMediaToDataset(query: UnifiedQuery, mediaArray?: ViewItem[] | null): void {
const data: AdvancedCameraCardTimelineItem[] = [];
for (const media of mediaArray ?? []) {
if (!ViewItemClassifier.isEvent(media)) {
if (!ViewItemClassifier.isEvent(media) && !ViewItemClassifier.isReview(media)) {
continue;
}
@@ -193,7 +197,10 @@ export class TimelineDataSource {
start: startTime.getTime(),
type: 'range',
end: media.getUsableEndTime()?.getTime(),
...(query && { query }),
...(ViewItemClassifier.isReview(media) && {
severity: media.getSeverity() ?? undefined,
}),
query,
});
}
}
@@ -201,134 +208,60 @@ export class TimelineDataSource {
this._dataset.update(data);
}
private async _refreshEvents(
window: TimelineWindow,
options?: RefreshOptions,
): Promise<void> {
await this._refreshEventsFromCamera(window, options);
await this._refreshEventsFromFolder();
public buildRecordingsWindowedQuery(window: TimelineWindow): UnifiedQuery | null {
return this._builder.buildRecordingsQuery(this._shape.getAllCameraIDs(), {
start: window.start,
end: window.end,
});
}
private async _refreshEventsFromCamera(
window: TimelineWindow,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_options?: RefreshOptions,
): Promise<void> {
if (this._keys.type !== 'camera') {
return;
}
private async _refreshQuery(window: TimelineWindow): Promise<void> {
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
if (
this._eventRanges.hasCoverage({
start: window.start,
end: sub(capEndDate(window.end), {
this._cache.hasCoverage({
start: cacheFriendlyWindow.start,
end: sub(capEndDate(cacheFriendlyWindow.end), {
seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS,
}),
})
) {
return;
}
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow);
if (!eventQueries) {
return;
}
this.addEventMediaToDataset(
await this._cameraManager.executeMediaQueries(eventQueries),
);
const query = UnifiedQueryTransformer.rebuildQuery(this._shape, {
start: cacheFriendlyWindow.start,
end: cacheFriendlyWindow.end,
});
this._eventRanges.add({
this.addMediaToDataset(query, await this._runner.execute(query));
this._cache.add({
...cacheFriendlyWindow,
expires: add(new Date(), { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS }),
});
}
private async _refreshEventsFromFolder(): Promise<void> {
if (this._keys.type !== 'folder') {
return;
}
const folderQuery = this.getTimelineFolderQuery();
if (!folderQuery) {
return;
}
const lastDate = this._folderCache.get(folderQuery);
const now = new Date();
if (
lastDate &&
lastDate >= sub(now, { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS })
) {
return;
}
this.addEventMediaToDataset(
await this._foldersManager.expandFolder(
folderQuery,
this._conditionStateManager.getState(),
),
new FolderViewQuery(folderQuery),
);
this._folderCache.set(folderQuery, now);
}
public async refresh(window: TimelineWindow, options?: RefreshOptions): Promise<void> {
public async refresh(window: TimelineWindow): Promise<void> {
try {
await Promise.all([
this._refreshEvents(window, options),
this._refreshQuery(window),
...(this._showRecordings ? [this._refreshRecordings(window)] : []),
]);
} catch (e) {
errorToConsole(e as Error);
// Intentionally ignore errors here, since it is likely the user will
// change the range again and a subsequent call may work. To do otherwise
// would be jarring to the timeline experience in the case of transient
// errors from the backend.
}
}
public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null {
if (this._keys.type !== 'camera' || !this._keys.cameraIDs.size) {
return null;
}
return this._cameraManager.generateDefaultEventQueries(this._keys.cameraIDs, {
start: window.start,
end: window.end,
...(this._eventsMediaType === 'clips' && { hasClip: true }),
...(this._eventsMediaType === 'snapshots' && { hasSnapshot: true }),
});
}
public getTimelineRecordingQueries(window: TimelineWindow): RecordingQuery[] | null {
if (this._keys.type !== 'camera' || !this._keys.cameraIDs.size) {
return null;
}
return this._cameraManager.generateDefaultRecordingQueries(this._keys.cameraIDs, {
start: window.start,
end: window.end,
});
}
public getTimelineFolderQuery(): FolderQuery | null {
if (this._keys.type !== 'folder') {
return null;
}
return this._foldersManager.generateDefaultFolderQuery(this._keys.folder);
}
private async _refreshRecordings(window: TimelineWindow): Promise<void> {
const cameraIDs = this._keys.type === 'camera' ? this._keys.cameraIDs : null;
// Recordings only apply to camera-based shapes
const cameraIDs = this._shape.getAllCameraIDs();
if (!cameraIDs?.size) {
return;
}
type AdvancedCameraCardTimelineItemWithEnd = ModifyInterface<
AdvancedCameraCardTimelineItem,
{ end: number }
>;
type AdvancedCameraCardTimelineItemWithEnd = AdvancedCameraCardTimelineItem & {
end: number;
};
const convertSegmentToRecording = (
cameraID: string,
-11
View File
@@ -3,7 +3,6 @@ import { CameraManager } from '../../camera-manager/manager';
import { ViewItemManager } from '../../card-controller/view/item-manager';
import { ViewManagerEpoch } from '../../card-controller/view/types';
import { CameraConfig } from '../../config/schema/cameras';
import { FolderConfig } from '../../config/schema/folders';
import { HomeAssistant } from '../../ha/types';
import { ViewMedia } from '../../view/item';
@@ -21,16 +20,6 @@ export interface ThumbnailDataRequest {
export class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
export type TimelineKeys =
| {
type: 'camera';
cameraIDs: Set<string>;
}
| {
type: 'folder';
folder: FolderConfig;
};
export interface ExtendedTimeline extends Timeline {
// setCustomTimeMarker currently missing from Timeline types.
setCustomTimeMarker?(time: DateType, id?: IdType): void;
+19 -2
View File
@@ -52,6 +52,9 @@ export class AdvancedCameraCardCarousel extends LitElement {
protected _refRoot: Ref<HTMLElement> = createRef();
protected _carousel: CarouselController | null = null;
// Track slide count to distinguish user-navigation from content changes.
protected _previousSlideCount: number | null = null;
connectedCallback(): void {
super.connectedCallback();
@@ -100,6 +103,8 @@ export class AdvancedCameraCardCarousel extends LitElement {
}
protected updated(changedProps: PropertyValues): void {
const currentSlideCount = this._refParent.value?.assignedElements().length ?? 0;
if (
!this._carousel &&
this._refRoot.value &&
@@ -127,9 +132,21 @@ export class AdvancedCameraCardCarousel extends LitElement {
wheelScrolling: this.wheelScrolling,
},
);
} else if (changedProps.has('selected')) {
this._carousel?.selectSlide(this.selected);
this._previousSlideCount = currentSlideCount;
} else if (changedProps.has('selected') && this._carousel) {
// Only scroll to selection if the slide count hasn't changed.
// This distinguishes between:
// - Navigation (next/prev): count same → scroll to selection
// - Content change (item removed): count changed → preserve position
const contentChanged =
this._previousSlideCount !== null &&
this._previousSlideCount !== currentSlideCount;
if (!contentChanged && this.selected !== this._carousel.getSelectedIndex()) {
this._carousel.selectSlide(this.selected);
}
}
this._previousSlideCount = currentSlideCount;
}
static get styles(): CSSResultGroup {
-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 { createRef, ref, Ref } from 'lit/directives/ref.js';
import { CameraManager } from '../camera-manager/manager';
import { FoldersManager } from '../card-controller/folders/manager';
import { ViewManagerEpoch } from '../card-controller/view/types';
import {
MediaFilterController,
MediaFilterCoreFavoriteSelection,
MediaFilterCoreWhen,
MediaFilterMediaType,
} from '../components-lib/media-filter-controller';
import { CardWideConfig } from '../config/schema/types';
import { HomeAssistant } from '../ha/types';
@@ -38,6 +37,9 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public foldersManager?: FoldersManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@@ -56,6 +58,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
protected _refWhat: Ref<AdvancedCameraCardSelect> = createRef();
protected _refWhere: Ref<AdvancedCameraCardSelect> = createRef();
protected _refFavorite: Ref<AdvancedCameraCardSelect> = createRef();
protected _refReviewed: Ref<AdvancedCameraCardSelect> = createRef();
protected _refTags: Ref<AdvancedCameraCardSelect> = createRef();
protected willUpdate(changedProps: PropertyValues): void {
@@ -63,42 +66,56 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
this._mediaFilterController.setViewManager(this.viewManagerEpoch?.manager ?? null);
}
if (changedProps.has('cameraManager') && this.cameraManager) {
this._mediaFilterController.computeCameraOptions(this.cameraManager);
if (
(changedProps.has('cameraManager') || changedProps.has('foldersManager')) &&
this.cameraManager &&
this.foldersManager
) {
this._mediaFilterController.computeCameraOptions(
this.cameraManager,
this.foldersManager,
);
this._mediaFilterController.computeMetadataOptions(this.cameraManager);
}
// The first time the viewManager is set, compute the initial default selections.
if (
!changedProps.get('viewManager') &&
(!changedProps.get('viewManager') || changedProps.has('foldersManager')) &&
this.viewManagerEpoch &&
this.cameraManager
this.cameraManager &&
this.foldersManager
) {
this._mediaFilterController.computeInitialDefaultsFromView(this.cameraManager);
this._mediaFilterController.computeInitialDefaultsFromView(
this.cameraManager,
this.foldersManager,
);
}
}
protected render(): TemplateResult | void {
const valueChange = async () => {
if (!this.cameraManager || !this.viewManagerEpoch || !this.cardWideConfig) {
if (
!this.cameraManager ||
!this.foldersManager ||
!this.viewManagerEpoch ||
!this.cardWideConfig
) {
return;
}
await this._mediaFilterController.valueChangeHandler(
this.cameraManager,
this.foldersManager,
this.cardWideConfig,
{
camera: this._refCamera.value?.value ?? undefined,
mediaType: (this._refMediaType.value?.value ?? undefined) as
| MediaFilterMediaType
| undefined,
mediaTypes: this._refMediaType.value?.value ?? undefined,
when: {
selected: this._refWhen.value?.value ?? undefined,
from: this._refWhenFrom.value?.value,
to: this._refWhenTo.value?.value,
},
favorite: (this._refFavorite.value?.value ?? undefined) as
| MediaFilterCoreFavoriteSelection
| undefined,
favorite: this._refFavorite.value?.value ?? undefined,
reviewed: this._refReviewed.value?.value ?? undefined,
where: this._refWhere.value?.value ?? undefined,
what: this._refWhat.value?.value ?? undefined,
tags: this._refTags.value?.value ?? undefined,
@@ -126,7 +143,6 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
return;
}
const controls = this._mediaFilterController.getControlsToShow(this.cameraManager);
const defaults = this._mediaFilterController.getDefaults();
const whatOptions = this._mediaFilterController.getWhatOptions();
const tagsOptions = this._mediaFilterController.getTagsOptions();
@@ -137,7 +153,9 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
label=${localize('media_filter.media_type')}
placeholder=${localize('media_filter.select_media_type')}
.options=${this._mediaFilterController.getMediaTypeOptions()}
.initialValue=${defaults?.mediaType}
.initialValue=${defaults?.mediaTypes}
multiple
clearable
@advanced-camera-card:select:change=${() => valueChange()}
>
</advanced-camera-card-select>
@@ -184,7 +202,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
@advanced-camera-card:select:change=${() => valueChange()}
>
</advanced-camera-card-select>
${controls.events && whatOptions.length
${whatOptions.length
? html` <advanced-camera-card-select
${ref(this._refWhat)}
label=${localize('media_filter.what')}
@@ -197,7 +215,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
>
</advanced-camera-card-select>`
: ''}
${controls.events && tagsOptions.length
${tagsOptions.length
? html` <advanced-camera-card-select
${ref(this._refTags)}
label=${localize('media_filter.tag')}
@@ -210,7 +228,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
>
</advanced-camera-card-select>`
: ''}
${controls.events && whereOptions.length
${whereOptions.length
? html` <advanced-camera-card-select
${ref(this._refWhere)}
label=${localize('media_filter.where')}
@@ -223,20 +241,26 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
>
</advanced-camera-card-select>`
: ''}
${controls.favorites
? html`
<advanced-camera-card-select
${ref(this._refFavorite)}
label=${localize('media_filter.favorite')}
placeholder=${localize('media_filter.select_favorite')}
.options=${this._mediaFilterController.getFavoriteOptions()}
.initialValue=${defaults?.favorite}
clearable
@advanced-camera-card:select:change=${() => valueChange()}
>
</advanced-camera-card-select>
`
: ''}`;
<advanced-camera-card-select
${ref(this._refFavorite)}
label=${localize('media_filter.favorite')}
placeholder=${localize('media_filter.select_favorite')}
.options=${this._mediaFilterController.getFavoriteOptions()}
.initialValue=${defaults?.favorite}
clearable
@advanced-camera-card:select:change=${() => valueChange()}
>
</advanced-camera-card-select>
<advanced-camera-card-select
${ref(this._refReviewed)}
label=${localize('media_filter.reviewed')}
placeholder=${localize('media_filter.select_reviewed')}
.options=${this._mediaFilterController.getReviewedOptions()}
.initialValue=${defaults?.reviewed}
clearable
@advanced-camera-card:select:change=${() => valueChange()}
>
</advanced-camera-card-select>`;
}
static get styles(): CSSResultGroup {
+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;
this.value = value;
// The underlying gr-select element will call on the first first value set
// The underlying gr-select element will call on the first value set
// (even when the user has not interacted with the control). Do not
// dispatch events for this.
if (!initialValueSet) {
// dispatch events for this UNLESS the value is actually non-empty
// (indicating a user selection).
if (!initialValueSet || (Array.isArray(value) ? value.length : !!value)) {
fireAdvancedCameraCardEvent(this, 'select:change', value);
}
}
+3
View File
@@ -100,6 +100,7 @@ export class AdvancedCameraCardStatusBar extends LitElement {
return html`<div
.actionHandler=${handler}
class="${classes}"
data-severity=${item.severity ?? ''}
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
>
${item.string}
@@ -109,6 +110,7 @@ export class AdvancedCameraCardStatusBar extends LitElement {
.actionHandler=${handler}
.icon=${{ icon: item.icon }}
class="${classes}"
data-severity=${item.severity ?? ''}
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
></advanced-camera-card-icon>`;
} else if (item.type === 'custom:advanced-camera-card-status-bar-image') {
@@ -116,6 +118,7 @@ export class AdvancedCameraCardStatusBar extends LitElement {
.actionHandler=${handler}
class="${classes}"
src="${item.image}"
data-severity=${item.severity ?? ''}
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
/>`;
}
+4 -79
View File
@@ -7,22 +7,18 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { isEqual } from 'lodash-es';
import { CameraManager } from '../camera-manager/manager.js';
import { FoldersManager } from '../card-controller/folders/manager.js';
import { ViewItemManager } from '../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import { TimelineKeys } from '../components-lib/timeline/types.js';
import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js';
import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js';
import { MiniTimelineControlConfig } from '../config/schema/common/controls/timeline.js';
import { FolderConfig } from '../config/schema/folders.js';
import { CardWideConfig } from '../config/schema/types.js';
import { HomeAssistant } from '../ha/types.js';
import basicBlockStyle from '../scss/basic-block.scss';
import { contentsChanged } from '../utils/basic.js';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
import { QueryClassifier } from '../view/query-classifier.js';
import './surround-basic.js';
import './thumbnail-carousel';
@@ -55,11 +51,8 @@ export class AdvancedCameraCardSurround extends LitElement {
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
protected _timelineKeys?: TimelineKeys | null = null;
/**
* Determine if a drawer is being used.
* @returns `true` if a drawer is used, `false` otherwise.
*/
protected _hasDrawer(): boolean {
return (
@@ -67,80 +60,11 @@ export class AdvancedCameraCardSurround extends LitElement {
);
}
protected willUpdate(changedProperties: PropertyValues): void {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected willUpdate(_changedProperties: PropertyValues): void {
if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') {
import('./timeline-core.js');
}
// Only reset the timeline cameraIDs when the media or display mode
// materially changes (and not on every view change, since the view will
// change frequently when the user is scrubbing video).
const view = this.viewManagerEpoch?.manager.getView();
if (
changedProperties.has('viewManagerEpoch') &&
(this.viewManagerEpoch?.manager.hasMajorMediaChange(
this.viewManagerEpoch?.oldView,
) ||
this.viewManagerEpoch?.oldView?.displayMode !== view?.displayMode)
) {
const newKeys = this._getTimelineKeys();
// Update only if changed, to avoid unnecessary timeline destructions.
if (!isEqual(newKeys, this._timelineKeys)) {
this._timelineKeys = newKeys ?? undefined;
}
}
}
protected _getTimelineKeys(): TimelineKeys | null {
const cameraIDsToKeys = (cameraIDs: Set<string> | null): TimelineKeys | null => {
return cameraIDs?.size
? {
type: 'camera',
cameraIDs,
}
: null;
};
const folderToKeys = (folderConfig: FolderConfig): TimelineKeys => {
return {
type: 'folder',
folder: folderConfig,
};
};
const view = this.viewManagerEpoch?.manager.getView();
if (!view || !this.cameraManager) {
return null;
}
if (view.is('live')) {
const capabilitySearch = {
anyCapabilities: ['clips' as const, 'snapshots' as const, 'recordings' as const],
};
if (view.supportsMultipleDisplayModes() && view.isGrid()) {
return cameraIDsToKeys(
this.cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch),
);
} else {
return cameraIDsToKeys(
this.cameraManager
.getStore()
.getAllDependentCameras(view.camera, capabilitySearch),
);
}
}
const queries = view.query;
if (view.isViewerView()) {
if (QueryClassifier.isMediaQuery(queries)) {
return cameraIDsToKeys(queries.getQueryCameraIDs());
} else if (QueryClassifier.isFolderQuery(queries)) {
const folderConfig = queries.getQuery()?.folder;
return folderConfig ? folderToKeys(folderConfig) : null;
}
}
return null;
}
protected render(): TemplateResult | void {
@@ -175,7 +99,9 @@ export class AdvancedCameraCardSurround extends LitElement {
.viewItemManager=${this.viewItemManager}
.fadeThumbnails=${view.isViewerView()}
.viewManagerEpoch=${this.viewManagerEpoch}
.foldersManager=${this.foldersManager}
.selected=${view.queryResults?.getSelectedIndex() ?? undefined}
.cardWideConfig=${this.cardWideConfig}
>
</advanced-camera-card-thumbnail-carousel>`
: ''}
@@ -189,7 +115,6 @@ export class AdvancedCameraCardSurround extends LitElement {
this.thumbnailConfig?.mode === 'none'
? 'play'
: 'select'}
.keys=${this._timelineKeys}
.mini=${true}
.timelineConfig=${this.timelineConfig}
.thumbnailConfig=${this.thumbnailConfig}
+78 -65
View File
@@ -9,14 +9,20 @@ import {
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
import { FoldersManager } from '../card-controller/folders/manager.js';
import { ViewItemManager } from '../card-controller/view/item-manager.js';
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import {
getUpFolderMediaItem,
upFolderClickHandler,
} from '../components-lib/folder/up-folder.js';
FolderNavigationParamaters,
getUpFolderItem,
navigateToFolder,
navigateToMedia,
navigateUp,
} from '../components-lib/navigation.js';
import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js';
import { CardWideConfig } from '../config/schema/types.js';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
import { HomeAssistant } from '../ha/types.js';
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
@@ -24,7 +30,7 @@ import { CarouselDirection } from '../utils/embla/carousel-controller.js';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
import { ViewItemClassifier } from '../view/item-classifier.js';
import { ViewItem, ViewMedia } from '../view/item.js';
import { QueryClassifier } from '../view/query-classifier.js';
import { UnifiedQueryBuilder } from '../view/unified-query-builder.js';
import './carousel.js';
import './thumbnail/thumbnail.js';
@@ -43,18 +49,50 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public foldersManager?: FoldersManager;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: false })
public config?: ThumbnailsControlConfig;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public fadeThumbnails = false;
protected _thumbnails: TemplateResult[] = [];
protected _builder: UnifiedQueryBuilder | null = null;
protected _getLimit(): number {
return (
this.cardWideConfig?.performance?.features?.media_chunk_size ??
MEDIA_CHUNK_SIZE_DEFAULT
);
}
protected _getFolderNavOptions(): FolderNavigationParamaters | undefined {
return this._builder && this.viewManagerEpoch
? {
builder: this._builder,
viewManagerEpoch: this.viewManagerEpoch,
limit: this._getLimit(),
}
: undefined;
}
protected willUpdate(changedProps: PropertyValues): void {
if (
(changedProps.has('cameraManager') || changedProps.has('foldersManager')) &&
this.cameraManager &&
this.foldersManager
) {
this._builder = new UnifiedQueryBuilder(this.cameraManager, this.foldersManager);
}
if (changedProps.has('config')) {
if (this.config?.size) {
this.style.setProperty(
@@ -94,53 +132,17 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
);
}
protected _itemClickCallback(item: ViewItem, ev: Event): void {
stopEventFromActivatingCardWideActions(ev);
const view = this.viewManagerEpoch?.manager.getView();
const query = view?.query;
const results = view?.queryResults;
if (!view || !query || !results) {
return;
}
if (ViewItemClassifier.isMedia(item)) {
const newResults = results
.clone()
.selectResultIfFound((result) => result === item);
const cameraID = item.getCameraID();
fireAdvancedCameraCardEvent<ThumbnailMediaSelect>(
this,
'thumbnails-carousel:media-select',
{ media: item },
);
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
view: 'media',
queryResults: newResults,
...(cameraID && { camera: cameraID }),
},
protected _handleMediaClick(item: ViewMedia): void {
fireAdvancedCameraCardEvent<ThumbnailMediaSelect>(
this,
'thumbnails-carousel:media-select',
{ media: item },
);
if (this.viewManagerEpoch) {
navigateToMedia(item, {
viewManagerEpoch: this.viewManagerEpoch,
modifiers: [new RemoveContextViewModifier(['timeline', 'mediaViewer'])],
});
} else if (
QueryClassifier.isFolderQuery(query) &&
ViewItemClassifier.isFolder(item)
) {
const rawQuery = query.getQuery();
if (!rawQuery) {
return;
}
this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: {
query: query.clone().setQuery({
folder: rawQuery.folder,
path: [...(rawQuery.path ?? []), { folder: item }],
}),
},
});
}
}
@@ -171,31 +173,42 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
?show_favorite_control=${this.config?.show_favorite_control}
?show_timeline_control=${this.config?.show_timeline_control}
?show_download_control=${this.config?.show_download_control}
?show_review_control=${this.config?.show_review_control}
?show_info_control=${this.config?.show_info_control}
@click=${(ev: Event) => clickCallback(item, ev)}
>
</advanced-camera-card-thumbnail>`;
}
protected _renderThumbnails(): TemplateResult[] {
const upThumbnail = getUpFolderMediaItem(this.viewManagerEpoch?.manager.getView());
const thumbnails: TemplateResult[] = [
...(upThumbnail
? [
this._renderThumbnail(upThumbnail, false, (item: ViewItem, ev: Event) =>
upFolderClickHandler(item, ev, this.viewManagerEpoch),
),
]
: []),
];
const upFolderItem = getUpFolderItem(
this.viewManagerEpoch?.manager.getView()?.query,
);
const thumbnails: TemplateResult[] = upFolderItem
? [
this._renderThumbnail(upFolderItem, false, (_item: ViewItem, ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
navigateUp(this._getFolderNavOptions());
}),
]
: [];
const view = this.viewManagerEpoch?.manager.getView();
const selectedIndex = this._getSelectedSlide();
for (const item of view?.queryResults?.getResults() ?? []) {
const clickHandler = (item: ViewItem, ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (ViewItemClassifier.isMedia(item)) {
this._handleMediaClick(item);
} else if (ViewItemClassifier.isFolder(item)) {
navigateToFolder(item, this._getFolderNavOptions());
}
};
thumbnails.push(
this._renderThumbnail(
item,
selectedIndex === thumbnails.length,
(item: ViewItem, ev: Event) => this._itemClickCallback(item, ev),
clickHandler,
view?.context?.mediaViewer?.seek,
),
);
@@ -214,18 +227,18 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
}
protected render(): TemplateResult | void {
const direction = this._getDirection();
if (!this._thumbnails.length || !this.config || !direction) {
if (!this._thumbnails.length || !this.config?.mode || this.config.mode === 'none') {
return;
}
return html`<advanced-camera-card-carousel
direction=${direction}
.selected=${this._getSelectedSlide() ?? 0}
class="${classMap({ fade: this.fadeThumbnails })}"
direction=${this._getDirection() ?? 'horizontal'}
.selected=${this._getSelectedSlide()}
.dragFree=${true}
>
${this._thumbnails}
</advanced-camera-card-carousel>`;
</advanced-camera-card-carousel> `;
}
static get styles(): CSSResultGroup {
+23 -23
View File
@@ -7,10 +7,12 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../../camera-manager/manager';
import { ThumbnailDetailsController } from '../../components-lib/thumbnail/details-controller';
import { MediaDetailsController } from '../../components-lib/media/details-controller';
import { HomeAssistant } from '../../ha/types';
import thumbnailDetailsStyle from '../../scss/thumbnail-details.scss';
import { MetadataField } from '../../types';
import { ViewItem } from '../../view/item';
import '../icon';
@@ -28,7 +30,7 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
@property({ attribute: false })
public seek?: Date;
private _controller = new ThumbnailDetailsController();
private _controller = new MediaDetailsController();
protected willUpdate(changedProperties: PropertyValues): void {
if (['item', 'seek', 'cameraManager'].some((prop) => changedProperties.has(prop))) {
@@ -40,28 +42,26 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
const heading = this._controller.getHeading();
const details = this._controller.getDetails();
const renderDetail = (detail: MetadataField, heading = false): TemplateResult => {
return html`<div
class=${classMap({
heading,
})}
>
${detail.icon
? html` <advanced-camera-card-icon
severity=${detail.emphasis}
title=${detail.hint ?? ''}
.icon=${detail.icon}
></advanced-camera-card-icon>`
: ''}
<span title=${detail.title}>${detail.title}</span>
</div>`;
};
return html`
${heading
? html` <div class="heading">
<span title=${heading}>${heading}</span>
</div>`
: ``}
${details
? html` <div class="details">
${details.map(
(detail) =>
html`<div>
${detail.icon
? html` <advanced-camera-card-icon
title=${detail.hint ?? ''}
.icon=${detail.icon}
></advanced-camera-card-icon>`
: ''}
<span>${detail.title}</span>
</div>`,
)}
</div>`
: ''}
${heading ? renderDetail(heading, true) : ``}
${details.length ? details.map((detail) => renderDetail(detail)) : ``}
`;
}
+178 -17
View File
@@ -9,10 +9,26 @@ import {
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../../../camera-manager/manager';
import { ViewItemManager } from '../../../card-controller/view/item-manager';
import { ViewManagerEpoch } from '../../../card-controller/view/types';
import {
MediaDetailsController,
OverlayControlsContext,
} from '../../../components-lib/media/details-controller';
import { ThumbnailFeatureController } from '../../../components-lib/thumbnail/feature/controller';
import { HomeAssistant } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import thumbnailFeatureStyle from '../../../scss/thumbnail-feature.scss';
import { stopEventFromActivatingCardWideActions } from '../../../utils/action';
import {
downloadMedia,
navigateToTimeline,
toggleFavorite,
toggleReviewed,
} from '../../../utils/media-actions';
import { dispatchShowOverlayMessageEvent } from '../../../utils/overlay-message';
import { ViewItem } from '../../../view/item';
import { ViewItemClassifier } from '../../../view/item-classifier';
import '../../icon.js';
import './thumbnail.js';
@@ -24,12 +40,33 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public item?: ViewItem;
@property({ attribute: false })
public hasDetails?: boolean;
@property({ attribute: true, type: Boolean })
public show_favorite_control = false;
@property({ attribute: true, type: Boolean })
public show_timeline_control = false;
@property({ attribute: true, type: Boolean })
public show_download_control = false;
@property({ attribute: true, type: Boolean })
public show_review_control = false;
@property({ attribute: true, type: Boolean })
public show_info_control = false;
private _controller = new ThumbnailFeatureController();
protected willUpdate(changedProperties: PropertyValues): void {
@@ -40,34 +77,158 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
}
}
private _getControlContext(): OverlayControlsContext {
return {
hass: this.hass,
viewItemManager: this.viewItemManager,
viewManagerEpoch: this.viewManagerEpoch,
capabilities: this.item ? this.viewItemManager?.getCapabilities(this.item) : null,
};
}
protected render(): TemplateResult | void {
if (!this.item) {
return;
}
const starClasses = {
star: true,
starred: ViewItemClassifier.isMedia(this.item) && !!this.item?.isFavorite(),
};
const shouldShowTimelineControl =
this.show_timeline_control && ViewItemClassifier.supportsTimeline(this.item);
const mediaCapabilities = this.viewItemManager?.getCapabilities(this.item) ?? null;
const shouldShowFavoriteControl =
this.show_favorite_control &&
this.item &&
this.hass &&
mediaCapabilities?.canFavorite;
const shouldShowDownloadControl =
this.show_download_control &&
this.hass &&
this.item.getID() &&
mediaCapabilities?.canDownload;
const isReviewed = ViewItemClassifier.isReview(this.item)
? this.item.isReviewed()
: null;
const shouldShowReviewControl = this.show_review_control && isReviewed !== null;
const shouldShowInfoControl =
this.show_info_control && ViewItemClassifier.isMedia(this.item);
const title = this._controller.getTitle();
const subtitles = this._controller.getSubtitles();
const iconClasses = classMap({
background: title || subtitles.length,
});
const hasText = !!title || !!subtitles.length;
const mainIconClasses = {
placeholder: true,
};
const thumbnailClass = this._controller.getThumbnailClass();
const thumbnailClasses = classMap({
...(thumbnailClass && { [thumbnailClass]: true }),
'has-text': hasText,
});
return html`
${this._controller.getThumbnail()
? html` <advanced-camera-card-thumbnail-feature-thumbnail
class="${thumbnailClasses}"
.hass=${this.hass}
.thumbnail=${this._controller.getThumbnail()}
aria-label=${this.item?.getTitle() ?? ''}
title=${this.item?.getTitle() ?? ''}
></advanced-camera-card-thumbnail-feature-thumbnail>`
: this._controller.getIcon()
? html`<advanced-camera-card-icon
class="${iconClasses}"
.icon=${{ icon: this._controller.getIcon() }}
<div class=${classMap({ media: true, 'has-text': hasText })}>
${this._controller.getThumbnail()
? html` <advanced-camera-card-thumbnail-feature-thumbnail
class="${thumbnailClasses}"
.hass=${this.hass}
.thumbnail=${this._controller.getThumbnail()}
aria-label=${this.item?.getTitle() ?? ''}
title=${this.item?.getTitle() ?? ''}
></advanced-camera-card-thumbnail-feature-thumbnail>`
: this._controller.getIcon()
? html`<advanced-camera-card-icon
class=${classMap(mainIconClasses)}
.icon=${{ icon: this._controller.getIcon() }}
></advanced-camera-card-icon>`
: ''}
</div>
${shouldShowReviewControl
? html`<advanced-camera-card-icon
class="review"
title=${isReviewed
? localize('common.set_reviews.unreviewed')
: localize('common.set_reviews.reviewed')}
.icon=${{
icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline',
}}
@click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (this.item) {
await toggleReviewed(this.item, this._getControlContext());
}
}}
></advanced-camera-card-icon>`
: shouldShowFavoriteControl
? html` <advanced-camera-card-icon
class="${classMap(starClasses)}"
title=${localize('thumbnail.retain_indefinitely')}
.icon=${{
icon: this.item.isFavorite() ? 'mdi:star' : 'mdi:star-outline',
}}
@click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (
this.item &&
(await toggleFavorite(this.item, this._getControlContext()))
) {
this.requestUpdate();
}
}}
></advanced-camera-card-icon>`
: ''}
${title || subtitles.length
: ``}
${shouldShowInfoControl
? html`<advanced-camera-card-icon
class="info"
.icon=${{ icon: 'mdi:information-outline' }}
title=${this.item?.getDescription() ?? ''}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
const detailsController = new MediaDetailsController();
detailsController.calculate(this.cameraManager, this.item);
dispatchShowOverlayMessageEvent(
this,
detailsController.getMessage(this._getControlContext()),
);
}}
></advanced-camera-card-icon>`
: ''}
${shouldShowTimelineControl
? html`<advanced-camera-card-icon
class="timeline"
.icon=${{ icon: 'mdi:target' }}
title=${localize('thumbnail.timeline')}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (this.item) {
navigateToTimeline(this.item, this._getControlContext());
}
}}
></advanced-camera-card-icon>`
: ''}
${shouldShowDownloadControl
? html` <advanced-camera-card-icon
class="download"
.icon=${{ icon: 'mdi:download' }}
title=${localize('thumbnail.download')}
@click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (this.item) {
await downloadMedia(this.item, this._getControlContext());
}
}}
></advanced-camera-card-icon>`
: ``}
${hasText
? html`
${title ? html`<div class="title">${title}</div>` : ''}
${subtitles.length
+13 -95
View File
@@ -1,18 +1,11 @@
import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { FoldersManager } from '../../card-controller/folders/manager.js';
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
import { RemoveContextViewModifier } from '../../card-controller/view/modifiers/remove-context.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { dispatchAdvancedCameraCardErrorEvent } from '../../components-lib/message/dispatch.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import thumbnailStyle from '../../scss/thumbnail.scss';
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
import { errorToConsole } from '../../utils/basic.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { ViewItem } from '../../view/item.js';
import './details.js';
import './feature/feature.js';
@@ -56,6 +49,12 @@ export class AdvancedCameraCardThumbnail extends LitElement {
@property({ attribute: true, type: Boolean })
public show_download_control = false;
@property({ attribute: true, type: Boolean })
public show_review_control = false;
@property({ attribute: true, type: Boolean })
public show_info_control = false;
@property({ attribute: false })
public seek?: Date;
@@ -68,62 +67,21 @@ export class AdvancedCameraCardThumbnail extends LitElement {
return;
}
const starClasses = {
star: true,
starred: ViewItemClassifier.isMedia(this.item) && !!this.item?.isFavorite(),
};
const shouldShowTimelineControl =
this.show_timeline_control &&
((ViewItemClassifier.isEvent(this.item) && this.item.getStartTime()) ||
(ViewItemClassifier.isRecording(this.item) &&
this.item.getStartTime() &&
this.item.getEndTime()));
const mediaCapabilities = this.viewItemManager?.getCapabilities(this.item) ?? null;
const shouldShowFavoriteControl =
this.show_favorite_control &&
this.item &&
this.hass &&
mediaCapabilities?.canFavorite;
const shouldShowDownloadControl =
this.show_download_control &&
this.hass &&
this.item.getID() &&
mediaCapabilities?.canDownload;
return html`
<advanced-camera-card-thumbnail-feature
.cameraManager=${this.cameraManager}
.hasDetails=${this.details}
.hass=${this.hass}
.item=${this.item}
.viewItemManager=${this.viewItemManager}
.viewManagerEpoch=${this.viewManagerEpoch}
.show_favorite_control=${this.show_favorite_control}
.show_timeline_control=${this.show_timeline_control}
.show_download_control=${this.show_download_control}
.show_review_control=${this.show_review_control}
.show_info_control=${this.show_info_control}
>
</advanced-camera-card-thumbnail-feature>
${shouldShowFavoriteControl
? html` <advanced-camera-card-icon
class="${classMap(starClasses)}"
title=${localize('thumbnail.retain_indefinitely')}
.icon=${{ icon: this.item.isFavorite() ? 'mdi:star' : 'mdi:star-outline' }}
@click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (this.hass && this.item) {
try {
await this.viewItemManager?.favorite(
this.item,
!this.item.isFavorite(),
);
} catch (e) {
errorToConsole(e as Error);
return;
}
this.requestUpdate();
}
}}
/></advanced-camera-card-icon>`
: ``}
${this.details
? html`<advanced-camera-card-thumbnail-details
.hass=${this.hass}
@@ -132,46 +90,6 @@ export class AdvancedCameraCardThumbnail extends LitElement {
.seek=${this.seek}
></advanced-camera-card-thumbnail-details>`
: ''}
${shouldShowTimelineControl
? html`<advanced-camera-card-icon
class="timeline"
.icon=${{ icon: 'mdi:target' }}
title=${localize('thumbnail.timeline')}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (!this.viewManagerEpoch || !this.item) {
return;
}
this.viewManagerEpoch.manager.setViewByParameters({
params: {
view: 'timeline',
queryResults: this.viewManagerEpoch?.manager
.getView()
?.queryResults?.clone()
.selectResultIfFound((media) => media === this.item),
},
modifiers: [new RemoveContextViewModifier(['timeline'])],
});
}}
></advanced-camera-card-icon>`
: ''}
${shouldShowDownloadControl
? html` <advanced-camera-card-icon
class="download"
.icon=${{ icon: 'mdi:download' }}
title=${localize('thumbnail.download')}
@click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (this.hass && this.item) {
try {
this.viewItemManager?.download(this.item);
} catch (error: unknown) {
dispatchAdvancedCameraCardErrorEvent(this, error);
}
}
}}
></advanced-camera-card-icon>`
: ``}
`;
}
+19 -9
View File
@@ -18,7 +18,6 @@ import {
ThumbnailDataRequest,
ThumbnailDataRequestEvent,
TimelineItemClickAction,
TimelineKeys,
} from '../components-lib/timeline/types';
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
import { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails';
@@ -120,11 +119,6 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
@property({ attribute: true, type: Boolean, reflect: true })
public mini = false;
// Which cameraIDs to include in the timeline. If not specified, all cameraIDs
// are shown.
@property({ attribute: false, hasChanged: contentsChanged })
public keys?: TimelineKeys;
@property({ attribute: false })
public cameraManager?: CameraManager;
@@ -152,7 +146,22 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
return;
}
if (!this.keys) {
const view = this.viewManagerEpoch?.manager.getView();
const isLoading = !!view?.context?.loading?.query;
if (isLoading) {
if (!this.mini) {
return renderMessage({
message: localize('error.awaiting_media'),
icon: 'mdi:chart-gantt',
type: 'info',
dotdotdot: true,
});
}
return;
}
if (!view?.query || !view.query.hasNodes()) {
if (!this.mini) {
return renderMessage({
message: localize('error.no_camera_or_media_for_timeline'),
@@ -234,13 +243,14 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
if (
[
'viewManagerEpoch',
'cameraManager',
'viewItemManager',
'timelineConfig',
'mini',
'thumbnailConfig',
'keys',
'conditionStateManager',
'foldersManager',
].some((prop) => changedProps.has(prop))
) {
this._controller.setOptions({
@@ -251,7 +261,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
timelineConfig: this.timelineConfig,
mini: this.mini,
thumbnailConfig: this.thumbnailConfig,
keys: this.keys,
query: this.viewManagerEpoch?.manager.getView()?.query ?? undefined,
});
}
}
-44
View File
@@ -4,13 +4,11 @@ import { CameraManager } from '../camera-manager/manager';
import { FoldersManager } from '../card-controller/folders/manager';
import { ViewItemManager } from '../card-controller/view/item-manager';
import { ViewManagerEpoch } from '../card-controller/view/types';
import { TimelineKeys } from '../components-lib/timeline/types';
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
import { TimelineConfig } from '../config/schema/timeline';
import { CardWideConfig } from '../config/schema/types';
import { HomeAssistant } from '../ha/types';
import basicBlockStyle from '../scss/basic-block.scss';
import { QueryClassifier } from '../view/query-classifier';
import './surround.js';
import './timeline-core.js';
@@ -40,47 +38,6 @@ export class AdvancedCameraCardTimeline extends LitElement {
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
protected _getKeys(): TimelineKeys | undefined {
const query = this.viewManagerEpoch?.manager.getView()?.query;
// If there's a query, try to extract camera IDs or folder info from it.
if (QueryClassifier.isMediaQuery(query)) {
const cameraIDs = query.getQueryCameraIDs();
if (cameraIDs && cameraIDs.size) {
return {
type: 'camera',
cameraIDs,
};
}
} else if (QueryClassifier.isFolderQuery(query)) {
const folderConfig = query.getQuery()?.folder;
if (folderConfig) {
return {
type: 'folder',
folder: folderConfig,
};
}
}
// Otherwise fall back to all cameras that support media queries.
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability({
anyCapabilities: ['clips', 'snapshots', 'recordings'],
});
const folder = this.foldersManager?.getFolder() ?? null;
return cameraIDs?.size
? {
type: 'camera',
cameraIDs,
}
: folder
? {
type: 'folder',
folder,
}
: undefined;
}
protected render(): TemplateResult | void {
if (!this.timelineConfig) {
return html``;
@@ -96,7 +53,6 @@ export class AdvancedCameraCardTimeline extends LitElement {
.foldersManager=${this.foldersManager}
.conditionStateManager=${this.conditionStateManager}
.viewItemManager=${this.viewItemManager}
.keys=${this._getKeys()}
.cardWideConfig=${this.cardWideConfig}
.itemClickAction=${this.timelineConfig.controls.thumbnails.mode === 'none'
? 'play'
+2 -2
View File
@@ -53,7 +53,7 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
);
}
}}
@loadeddata=${(ev: Event) => {
@loadeddata="${(ev: Event) => {
dispatchMediaLoadedEvent(this, ev, {
...(this._mediaPlayerController && {
mediaPlayerController: this._mediaPlayerController,
@@ -64,7 +64,7 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
},
technology: ['mp4'],
});
}}
}}"
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
@play=${() => dispatchMediaPlayEvent(this)}
@pause=${() => dispatchMediaPauseEvent(this)}
+5
View File
@@ -9,6 +9,7 @@ import {
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
import { CardWideConfig } from '../../config/schema/types.js';
@@ -39,6 +40,9 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
protected _renderCarousel(filterCamera?: string): TemplateResult {
const selectedCameraID = this.viewManagerEpoch?.manager.getView()?.camera;
@@ -60,6 +64,7 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.showControls=${!filterCamera || selectedCameraID === filterCamera}
.viewItemManager=${this.viewItemManager}
>
</advanced-camera-card-viewer-carousel>
`;
+5
View File
@@ -8,6 +8,7 @@ import {
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { ViewerConfig } from '../../config/schema/viewer.js';
@@ -50,6 +51,9 @@ export class AdvancedCameraCardViewer extends LitElement {
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: 'empty', reflect: true, type: Boolean })
public isEmpty = false;
@@ -96,6 +100,7 @@ export class AdvancedCameraCardViewer extends LitElement {
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.viewItemManager=${this.viewItemManager}
>
</advanced-camera-card-viewer-grid>`;
}
+4 -9
View File
@@ -10,6 +10,7 @@ import { customElement, property, state } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { QueryType } from '../../camera-manager/types.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
@@ -29,7 +30,7 @@ import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../ty
import { errorToConsole } from '../../utils/basic.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { VideoContentType, ViewMedia } from '../../view/item.js';
import { QueryClassifier } from '../../view/query-classifier.js';
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js';
import '../image-player.js';
import { renderProgressIndicator } from '../progress-indicator.js';
import '../video-player.js';
@@ -85,19 +86,13 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
// If this specific media item has no clip, then do nothing (even if all
// the other media items do).
!ViewItemClassifier.isEvent(this.media) ||
!QueryClassifier.isEventQuery(view.query)
!view.query?.hasMediaQueriesOfType(QueryType.Event)
) {
return;
}
// Convert the query to a clips equivalent.
const clipQuery = view.query.clone();
clipQuery.convertToClipsQueries();
const queries = clipQuery.getQuery();
if (!queries) {
return;
}
const clipQuery = UnifiedQueryTransformer.convertToClips(view.query);
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: {
+8 -16
View File
@@ -76,16 +76,14 @@ export class AdvancedCameraCardViews extends LitElement {
if (view?.is('live') || this._shouldLivePreload()) {
import('./live/index.js');
}
if (view?.isMediaGalleryView() && !view.is('folders')) {
import('./gallery/media-gallery.js');
if (view?.isGalleryView()) {
import('./gallery/gallery.js');
} else if (view?.isViewerView()) {
import('./viewer/index.js');
} else if (view?.is('image')) {
import('./image.js');
} else if (view?.is('timeline')) {
import('./timeline.js');
} else if (view?.is('folders')) {
import('./gallery/folder-gallery.js');
}
}
@@ -167,16 +165,18 @@ export class AdvancedCameraCardViews extends LitElement {
>
</advanced-camera-card-image>`
: ``}
${!this.hide && view?.isMediaGalleryView() && !view.is('folders')
? html` <advanced-camera-card-media-gallery
${!this.hide && view?.isGalleryView()
? html` <advanced-camera-card-gallery
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.galleryConfig=${this.config.media_gallery}
.cameraManager=${this.cameraManager}
.foldersManager=${this.foldersManager}
.viewItemManager=${this.viewItemManager}
.cardWideConfig=${this.cardWideConfig}
.conditionStateManager=${this.conditionStateManager}
>
</advanced-camera-card-media-gallery>`
</advanced-camera-card-gallery>`
: ``}
${!this.hide && view?.isViewerView()
? html`
@@ -187,6 +187,7 @@ export class AdvancedCameraCardViews extends LitElement {
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.viewItemManager=${this.viewItemManager}
>
</advanced-camera-card-viewer>
`
@@ -212,15 +213,6 @@ export class AdvancedCameraCardViews extends LitElement {
>
</advanced-camera-card-diagnostics>`
: ``}
${!this.hide && view?.is('folders')
? html` <advanced-camera-card-folder-gallery
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewItemManager=${this.viewItemManager}
.galleryConfig=${this.config.media_gallery}
.foldersManager=${this.foldersManager}
></advanced-camera-card-folder-gallery>`
: ``}
${
// Note: Subtle difference in condition below vs the other views in
// order to always render the live view for live.preload mode.
+24 -8
View File
@@ -6,13 +6,9 @@ import {
CONF_CAMERAS_GLOBAL_PTZ,
CONF_DIMENSIONS_HEIGHT,
CONF_ELEMENTS,
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_OVERRIDES,
CONF_PROFILES,
CONF_STATUS_BAR,
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
CONF_VIEW_DEFAULT_RESET_ENTITIES,
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
@@ -825,18 +821,19 @@ const UPGRADES = [
upgradeWithOverrides('media_viewer.auto_unmute', (data) =>
data === 'all' ? ['selected', 'visible'] : data === 'never' ? null : arrayify(data),
),
upgradeMoveToWithOverrides(
'live.controls.thumbnails.media',
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
'live.controls.thumbnails.events_media_type',
),
upgradeMoveToWithOverrides('timeline.media', CONF_TIMELINE_EVENTS_MEDIA_TYPE),
upgradeMoveToWithOverrides('timeline.media', 'timeline.events_media_type'),
upgradeMoveToWithOverrides(
'live.controls.timeline.media',
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
'live.controls.timeline.events_media_type',
),
upgradeMoveToWithOverrides(
'media_viewer.controls.timeline.media',
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
'media_viewer.controls.timeline.events_media_type',
),
upgradeMoveToWithOverrides('view.scan', CONF_VIEW_TRIGGERS),
upgradeMoveToWithOverrides(
@@ -944,4 +941,23 @@ const UPGRADES = [
frigateCardToAdvancedCameraCardStyleTransform,
),
upgradeMoveToWithOverrides('menu.buttons.frigate', 'menu.buttons.iris'),
// v8.0.0+
upgradeMoveToWithOverrides(
'live.controls.thumbnails.media_type',
'cameras_global.media.type',
),
upgradeMoveToWithOverrides(
'live.controls.thumbnails.events_media_type',
'cameras_global.media.events_type',
{
transform: (val) => {
// 'all' is the default, delete it
if (val === 'all') {
return null;
}
return val;
},
},
),
];
@@ -7,6 +7,7 @@ const GENERAL_ACTIONS = [
'download',
'expand',
'fullscreen',
'info',
'live_substream_off',
'live_substream_on',
'menu_toggle',
@@ -0,0 +1,14 @@
import { z } from 'zod';
import { advancedCameraCardCustomActionsBaseSchema } from './base';
export const setReviewActionConfigSchema =
advancedCameraCardCustomActionsBaseSchema.extend({
advanced_camera_card_action: z.literal('set_review'),
// Mark a review as reviewed or unreviewed.
// - true = mark reviewed
// - false = mark unreviewed
// - undefined = toggle reviewed status
reviewed: z.boolean().optional(),
});
export type SetReviewActionConfig = z.infer<typeof setReviewActionConfigSchema>;
+4
View File
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { statusBarItemBaseSchema } from '../common/status-bar';
import { severitySchema } from '../common/severity';
import { advancedCameraCardCustomActionsBaseSchema } from './custom/base';
import { cameraSelectActionConfigSchema } from './custom/camera-select';
import { viewDisplayModeActionConfigSchema } from './custom/display-mode';
@@ -12,6 +13,7 @@ import { ptzActionConfigSchema } from './custom/ptz';
import { ptzControlsActionConfigSchema } from './custom/ptz-controls';
import { ptzDigitalActionConfigSchema } from './custom/ptz-digital';
import { ptzMultiActionSchema } from './custom/ptz-multi';
import { setReviewActionConfigSchema } from './custom/set-review';
import { sleepActionConfigSchema } from './custom/sleep';
import { substreamSelectActionConfigSchema } from './custom/substream-select';
import { viewActionConfigSchema } from './custom/view';
@@ -51,6 +53,7 @@ const advancedCameraCardCustomActionSchema = z.union([
ptzControlsActionConfigSchema,
ptzDigitalActionConfigSchema,
ptzMultiActionSchema,
setReviewActionConfigSchema,
sleepActionConfigSchema,
statusBarActionConfigSchema,
substreamSelectActionConfigSchema,
@@ -106,6 +109,7 @@ const statusBarItemElementsBaseSchema = statusBarItemBaseSchema.extend({
sufficient: z.boolean().default(false).optional(),
exclusive: z.boolean().default(false).optional(),
expand: z.boolean().default(false).optional(),
severity: severitySchema.optional(),
actions: actionsBaseSchema.optional(),
});
+39 -1
View File
@@ -3,6 +3,8 @@ import { capabilityKeys } from '../../types';
import { mediaLayoutConfigSchema } from './camera/media-layout';
import { ptzCameraConfigDefaults, ptzCameraConfigSchema } from './camera/ptz';
import { aspectRatioSchema } from './common/aspect-ratio';
import { eventsMediaTypeSchema } from './common/events-media';
import { severitySchema } from './common/severity';
import { imageBaseConfigSchema, imageConfigDefault } from './common/image';
const CAMERA_TRIGGER_EVENT_TYPES = [
@@ -124,8 +126,12 @@ export const cameraConfigDefault = {
triggers: {
motion: false,
occupancy: false,
events: [...CAMERA_TRIGGER_EVENT_TYPES],
events: [],
entities: [],
reviews: {
severities: ['high' as const],
description: true,
},
},
proxy: {
dynamic: true,
@@ -173,6 +179,26 @@ const cameraDimensionsSchema = z.object({
});
export type CameraDimensionsConfig = z.infer<typeof cameraDimensionsSchema>;
// Camera media configuration for default media type in live/timeline views.
const CAMERA_MEDIA_TYPES = [
'auto',
'reviews',
'events',
'recordings',
'folder',
] as const;
export type CameraMediaType = (typeof CAMERA_MEDIA_TYPES)[number];
const cameraMediaConfigDefault = {
type: 'auto' as CameraMediaType,
};
const cameraMediaConfigSchema = z.object({
type: z.enum(CAMERA_MEDIA_TYPES).default(cameraMediaConfigDefault.type),
events_type: eventsMediaTypeSchema.optional(),
folders: z.array(z.string()).optional(),
});
export const cameraConfigSchema = z
.object({
camera_entity: z.string().optional(),
@@ -209,6 +235,16 @@ export const cameraConfigSchema = z
.enum(CAMERA_TRIGGER_EVENT_TYPES)
.array()
.default(cameraConfigDefault.triggers.events),
reviews: z
.object({
severities: severitySchema
.array()
.default([...cameraConfigDefault.triggers.reviews.severities]),
description: z
.boolean()
.default(cameraConfigDefault.triggers.reviews.description),
})
.default(cameraConfigDefault.triggers.reviews),
})
.default(cameraConfigDefault.triggers),
@@ -274,6 +310,8 @@ export const cameraConfigSchema = z
dimensions: cameraDimensionsSchema.optional(),
media: cameraMediaConfigSchema.optional(),
proxy: proxyConfigSchema.default(cameraConfigDefault.proxy),
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1650
+2
View File
@@ -22,6 +22,8 @@ export const VIEWS_USER_SPECIFIED = [
'snapshots',
'recording',
'recordings',
'review',
'reviews',
'image',
'timeline',
] as const;
@@ -9,8 +9,10 @@ const thumbnailControlsBaseDefaults = {
size: THUMBNAIL_WIDTH_DEFAULT,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
show_timeline_control: false,
show_download_control: false,
show_review_control: true,
show_info_control: true,
};
// Configuration for the actual rendered thumbnail.
@@ -30,6 +32,12 @@ export const thumbnailsControlBaseSchema = z.object({
show_download_control: z
.boolean()
.default(thumbnailControlsBaseDefaults.show_download_control),
show_review_control: z
.boolean()
.default(thumbnailControlsBaseDefaults.show_review_control),
show_info_control: z
.boolean()
.default(thumbnailControlsBaseDefaults.show_info_control),
});
export type ThumbnailsControlBaseConfig = z.infer<typeof thumbnailsControlBaseSchema>;
+1 -14
View File
@@ -1,10 +1,7 @@
import { z } from 'zod';
import { eventsMediaTypeSchema } from '../events-media';
import { thumbnailControlsDefaults } from './thumbnails';
const timelineCoreConfigDefault = {
export const timelineCoreConfigDefault = {
clustering_threshold: 3,
events_media_type: 'all' as const,
window_seconds: 60 * 60,
show_recordings: true,
style: 'stack' as const,
@@ -26,9 +23,6 @@ export const timelineCoreConfigSchema = z.object({
.number()
.optional()
.default(timelineCoreConfigDefault.clustering_threshold),
events_media_type: eventsMediaTypeSchema
.optional()
.default(timelineCoreConfigDefault.events_media_type),
window_seconds: z
.number()
.min(1 * 60)
@@ -58,10 +52,3 @@ export const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({
style: timelineCoreConfigSchema.shape.style.default(miniTimelineConfigDefault.style),
});
export type MiniTimelineControlConfig = z.infer<typeof miniTimelineConfigSchema>;
export const timelineConfigDefault = {
...timelineCoreConfigDefault,
controls: {
thumbnails: thumbnailControlsDefaults,
},
};
+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(),
permanent: z.boolean().default(false).optional(),
});
export type MenuItemBase = z.infer<typeof menuBaseSchema>;
+2 -18
View File
@@ -11,7 +11,6 @@ import {
miniTimelineConfigSchema,
} from './common/controls/timeline';
import { viewDisplaySchema } from './common/display';
import { eventsMediaTypeSchema } from './common/events-media';
import {
MEDIA_ACTION_NEGATIVE_CONDITIONS,
MEDIA_ACTION_POSITIVE_CONDITIONS,
@@ -41,12 +40,6 @@ const microphoneConfigSchema = z
.default(microphoneConfigDefault);
export type MicrophoneConfig = z.infer<typeof microphoneConfigSchema>;
const liveThumbnailControlsDefaults = {
...thumbnailControlsDefaults,
media_type: 'events' as const,
events_media_type: 'all' as const,
};
export const liveConfigDefault = {
auto_play: [...MEDIA_ACTION_POSITIVE_CONDITIONS],
auto_pause: [],
@@ -67,7 +60,7 @@ export const liveConfigDefault = {
style: 'chevrons' as const,
},
ptz: ptzControlsDefaults,
thumbnails: liveThumbnailControlsDefaults,
thumbnails: thumbnailControlsDefaults,
timeline: miniTimelineConfigDefault,
wheel: true,
},
@@ -76,15 +69,6 @@ export const liveConfigDefault = {
},
};
const livethumbnailsControlSchema = thumbnailsControlSchema.extend({
media_type: z
.enum(['events', 'recordings'])
.default(liveConfigDefault.controls.thumbnails.media_type),
events_media_type: eventsMediaTypeSchema.default(
liveConfigDefault.controls.thumbnails.events_media_type,
),
});
export const liveConfigSchema = z
.object({
auto_pause: z
@@ -118,7 +102,7 @@ export const liveConfigSchema = z
})
.default(liveConfigDefault.controls.next_previous),
ptz: ptzControlsConfigSchema.default(liveConfigDefault.controls.ptz),
thumbnails: livethumbnailsControlSchema.default(
thumbnails: thumbnailsControlSchema.default(
liveConfigDefault.controls.thumbnails,
),
timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline),
+10 -4
View File
@@ -29,7 +29,7 @@ export const menuConfigDefault = {
buttons: {
camera_ui: visibleButtonDefault,
cameras: visibleButtonDefault,
clips: visibleButtonDefault,
clips: hiddenButtonDefault,
ptz_home: hiddenButtonDefault,
display_mode: visibleButtonDefault,
download: visibleButtonDefault,
@@ -38,6 +38,7 @@ export const menuConfigDefault = {
iris: visibleButtonDefault,
fullscreen: visibleButtonDefault,
image: hiddenButtonDefault,
info: visibleButtonDefault,
live: visibleButtonDefault,
media_player: visibleButtonDefault,
microphone: {
@@ -48,8 +49,10 @@ export const menuConfigDefault = {
play: hiddenButtonDefault,
ptz_controls: hiddenButtonDefault,
recordings: hiddenButtonDefault,
reviews: visibleButtonDefault,
set_review: visibleButtonDefault,
screenshot: hiddenButtonDefault,
snapshots: visibleButtonDefault,
snapshots: hiddenButtonDefault,
substreams: visibleButtonDefault,
timeline: visibleButtonDefault,
},
@@ -76,7 +79,7 @@ export const menuConfigSchema = z
.object({
camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui),
cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras),
clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips),
clips: hiddenButtonSchema.default(menuConfigDefault.buttons.clips),
ptz_home: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_home),
display_mode: visibleButtonSchema.default(
menuConfigDefault.buttons.display_mode,
@@ -87,6 +90,7 @@ export const menuConfigSchema = z
iris: visibleButtonSchema.default(menuConfigDefault.buttons.iris),
fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen),
image: hiddenButtonSchema.default(menuConfigDefault.buttons.image),
info: visibleButtonSchema.default(menuConfigDefault.buttons.info),
live: visibleButtonSchema.default(menuConfigDefault.buttons.live),
media_player: visibleButtonSchema.default(
menuConfigDefault.buttons.media_player,
@@ -102,8 +106,10 @@ export const menuConfigSchema = z
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
ptz_controls: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_controls),
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
reviews: visibleButtonSchema.default(menuConfigDefault.buttons.reviews),
set_review: visibleButtonSchema.default(menuConfigDefault.buttons.set_review),
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots),
snapshots: hiddenButtonSchema.default(menuConfigDefault.buttons.snapshots),
substreams: visibleButtonSchema.default(menuConfigDefault.buttons.substreams),
timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline),
})
+2
View File
@@ -23,6 +23,7 @@ export const statusBarConfigDefault = {
items: {
engine: statusBarItemDefault,
resolution: statusBarItemDefault,
severity: statusBarItemDefault,
technology: statusBarItemDefault,
title: statusBarItemDefault,
},
@@ -50,6 +51,7 @@ export const statusBarConfigSchema = z
resolution: statusBarItemBaseSchema.default(
statusBarConfigDefault.items.resolution,
),
severity: statusBarItemBaseSchema.default(statusBarConfigDefault.items.severity),
title: statusBarItemBaseSchema.default(statusBarConfigDefault.items.title),
})
.default(statusBarConfigDefault.items),
+13 -5
View File
@@ -1,17 +1,25 @@
import { z } from 'zod';
import { thumbnailsControlSchema } from './common/controls/thumbnails';
import {
timelineConfigDefault,
thumbnailControlsDefaults,
thumbnailsControlSchema,
} from './common/controls/thumbnails';
import {
timelineCoreConfigDefault,
timelineCoreConfigSchema,
} from './common/controls/timeline';
export const timelineConfigDefault = {
...timelineCoreConfigDefault,
controls: {
thumbnails: thumbnailControlsDefaults,
},
};
export const timelineConfigSchema = timelineCoreConfigSchema
.extend({
controls: z
.object({
thumbnails: thumbnailsControlSchema.default(
timelineConfigDefault.controls.thumbnails,
),
thumbnails: thumbnailsControlSchema.default(thumbnailControlsDefaults),
})
.default(timelineConfigDefault.controls),
})
+1 -2
View File
@@ -3,7 +3,6 @@ import { deepRemoveDefaults } from '../../utils/zod';
import { automationsSchema } from './automations';
import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './cameras';
import { cardIDRegex } from './common/const';
import { timelineConfigDefault } from './common/controls/timeline';
import { imageConfigDefault } from './common/image';
import { DebugConfig, debugConfigDefault, debugConfigSchema } from './debug';
import { dimensionsConfigSchema } from './dimensions';
@@ -22,7 +21,7 @@ import {
import { profilesSchema } from './profiles';
import { remoteControlConfigDefault, remoteControlConfigSchema } from './remote-control';
import { statusBarConfigDefault, statusBarConfigSchema } from './status-bar';
import { timelineConfigSchema } from './timeline';
import { timelineConfigDefault, timelineConfigSchema } from './timeline';
import { viewConfigDefault, viewConfigSchema } from './view';
import { viewerConfigDefault, viewerConfigSchema } from './viewer';
+27 -10
View File
@@ -109,6 +109,15 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES =
`${CONF_CAMERAS}.#.triggers.entities` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS =
`${CONF_CAMERAS}.#.triggers.events` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_SEVERITIES =
`${CONF_CAMERAS}.#.triggers.reviews.severities` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION =
`${CONF_CAMERAS}.#.triggers.reviews.description` as const;
export const CONF_CAMERAS_ARRAY_MEDIA_TYPE = `${CONF_CAMERAS}.#.media.type` as const;
export const CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE =
`${CONF_CAMERAS}.#.media.events_type` as const;
export const CONF_CAMERAS_ARRAY_MEDIA_FOLDERS =
`${CONF_CAMERAS}.#.media.folders` as const;
const CONF_CAMERAS_GLOBAL = 'cameras_global' as const;
export const CONF_CAMERAS_GLOBAL_LIVE_PROVIDER =
@@ -190,6 +199,10 @@ export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_favorite_control` as const;
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_timeline_control` as const;
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_review_control` as const;
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_info_control` as const;
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.size` as const;
@@ -230,12 +243,15 @@ export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_favorite_control` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_timeline_control` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_review_control` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_info_control` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD =
`${CONF_MEDIA_VIEWER}.controls.timeline.clustering_threshold` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE =
`${CONF_MEDIA_VIEWER}.controls.timeline.events_media_type` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H =
`${CONF_MEDIA_VIEWER}.controls.timeline.format.24h` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE =
@@ -274,10 +290,7 @@ export const CONF_LIVE_CONTROLS_PTZ_ORIENTATION =
export const CONF_LIVE_CONTROLS_PTZ_POSITION =
`${CONF_LIVE}.controls.ptz.position` as const;
export const CONF_LIVE_CONTROLS_WHEEL = `${CONF_LIVE}.controls.wheel` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE =
`${CONF_LIVE}.controls.thumbnails.media_type` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE =
`${CONF_LIVE}.controls.thumbnails.events_media_type` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE =
`${CONF_LIVE}.controls.thumbnails.mode` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE =
@@ -290,10 +303,12 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
`${CONF_LIVE}.controls.thumbnails.show_timeline_control` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL =
`${CONF_LIVE}.controls.thumbnails.show_review_control` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL =
`${CONF_LIVE}.controls.thumbnails.show_info_control` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD =
`${CONF_LIVE}.controls.timeline.clustering_threshold` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE =
`${CONF_LIVE}.controls.timeline.events_media_type` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H =
`${CONF_LIVE}.controls.timeline.format.24h` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_MODE =
@@ -341,8 +356,6 @@ const CONF_TIMELINE = 'timeline' as const;
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
`${CONF_TIMELINE}.clustering_threshold` as const;
export const CONF_TIMELINE_EVENTS_MEDIA_TYPE =
`${CONF_TIMELINE}.events_media_type` as const;
export const CONF_TIMELINE_FORMAT_24H = `${CONF_TIMELINE}.format.24h` as const;
export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
export const CONF_TIMELINE_STYLE = `${CONF_TIMELINE}.style` as const;
@@ -358,6 +371,10 @@ export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_TIMELINE}.controls.thumbnails.show_favorite_control` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
`${CONF_TIMELINE}.controls.thumbnails.show_timeline_control` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL =
`${CONF_TIMELINE}.controls.thumbnails.show_review_control` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL =
`${CONF_TIMELINE}.controls.thumbnails.show_info_control` as const;
const CONF_MENU = 'menu' as const;
export const CONF_MENU_ALIGNMENT = `${CONF_MENU}.alignment` as const;
+169 -52
View File
@@ -71,6 +71,9 @@ import {
CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS,
CONF_CAMERAS_ARRAY_IMAGE_URL,
CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE,
CONF_CAMERAS_ARRAY_MEDIA_FOLDERS,
CONF_CAMERAS_ARRAY_MEDIA_TYPE,
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN,
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN,
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
@@ -88,6 +91,8 @@ import {
CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS,
CONF_CAMERAS_ARRAY_TRIGGERS_MOTION,
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION,
CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_SEVERITIES,
CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
CONF_DIMENSIONS_ASPECT_RATIO,
@@ -117,16 +122,15 @@ import {
CONF_LIVE_CONTROLS_PTZ_MODE,
CONF_LIVE_CONTROLS_PTZ_ORIENTATION,
CONF_LIVE_CONTROLS_PTZ_POSITION,
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE,
CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H,
CONF_LIVE_CONTROLS_TIMELINE_MODE,
CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE,
@@ -153,6 +157,8 @@ import {
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS,
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE,
CONF_MEDIA_VIEWER_AUTO_MUTE,
@@ -166,10 +172,11 @@ import {
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE,
@@ -212,9 +219,10 @@ import {
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_TIMELINE_FORMAT_24H,
CONF_TIMELINE_SHOW_RECORDINGS,
CONF_TIMELINE_STYLE,
@@ -259,6 +267,7 @@ import { localize } from './localize/localize.js';
import editorStyle from './scss/editor.scss';
import { arrayMove, prettifyTitle } from './utils/basic.js';
import { getCameraID } from './utils/camera.js';
import { getFolderID } from './utils/folder.js';
const MENU_CAMERAS = 'cameras';
const MENU_CAMERAS_CAPABILITIES = 'cameras.capabilities';
@@ -275,7 +284,9 @@ const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye';
const MENU_CAMERAS_PROXY = 'cameras.proxy';
const MENU_CAMERAS_REOLINK = 'cameras.reolink';
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
const MENU_CAMERAS_TRIGGERS_REVIEWS = 'cameras.triggers.reviews';
const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
const MENU_CAMERAS_MEDIA = 'cameras.media';
const MENU_FOLDERS = 'folders';
const MENU_FOLDERS_HA = 'folders.ha';
const MENU_LIVE_CONTROLS = 'live.controls';
@@ -424,6 +435,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
{ value: 'live', label: localize('config.view.views.live') },
{ value: 'recording', label: localize('config.view.views.recording') },
{ value: 'recordings', label: localize('config.view.views.recordings') },
{ value: 'review', label: localize('config.view.views.review') },
{ value: 'reviews', label: localize('config.view.views.reviews') },
{ value: 'snapshot', label: localize('config.view.views.snapshot') },
{ value: 'snapshots', label: localize('config.view.views.snapshots') },
{ value: 'timeline', label: localize('config.view.views.timeline') },
@@ -535,25 +548,53 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
protected _thumbnailMediaTypes: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'auto',
label: localize('config.common.media_types.auto'),
},
{
value: 'events',
label: localize('config.common.controls.thumbnails.media_types.events'),
label: localize('config.common.media_types.events'),
},
{
value: 'recordings',
label: localize('config.common.controls.thumbnails.media_types.recordings'),
label: localize('config.common.media_types.recordings'),
},
{
value: 'reviews',
label: localize('config.common.media_types.reviews'),
},
];
protected _timelineThumbnailMediaTypes: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'auto',
label: localize('config.common.media_types.auto'),
},
{
value: 'events',
label: localize('config.common.media_types.events'),
},
{
value: 'reviews',
label: localize('config.common.media_types.reviews'),
},
];
protected _thumbnailEventsMediaTypes: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'all',
label: localize('config.common.events_media_types.all'),
},
{
value: 'clips',
label: localize('config.common.controls.thumbnails.events_media_types.clips'),
label: localize('config.common.events_media_types.clips'),
},
{
value: 'snapshots',
label: localize('config.common.controls.thumbnails.events_media_types.snapshots'),
label: localize('config.common.events_media_types.snapshots'),
},
];
@@ -573,14 +614,14 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
protected _timelineEventsMediaTypes: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'all', label: localize('config.common.timeline.events_media_types.all') },
{ value: 'all', label: localize('config.common.events_media_types.all') },
{
value: 'clips',
label: localize('config.common.timeline.events_media_types.clips'),
label: localize('config.common.events_media_types.clips'),
},
{
value: 'snapshots',
label: localize('config.common.timeline.events_media_types.snapshots'),
label: localize('config.common.events_media_types.snapshots'),
},
];
@@ -693,6 +734,15 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
{ value: 'dashboard', label: localize('config.cameras.cast.methods.dashboard') },
];
protected _cameraMediaTypes: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'auto', label: localize('config.common.media_types.auto') },
{ value: 'events', label: localize('config.common.media_types.events') },
{ value: 'recordings', label: localize('config.common.media_types.recordings') },
{ value: 'reviews', label: localize('config.common.media_types.reviews') },
{ value: 'folder', label: localize('config.common.media_types.folder') },
];
protected _ptzModes: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'on', label: localize('config.live.controls.ptz.modes.on') },
@@ -795,6 +845,22 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
},
];
protected _severities: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'high',
label: localize('common.severities.high'),
},
{
value: 'medium',
label: localize('common.severities.medium'),
},
{
value: 'low',
label: localize('common.severities.low'),
},
];
protected _timelinePanModes: EditorSelectOption[] = [
{ value: '', label: '' },
{
@@ -1607,7 +1673,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
* @param configPathStyle Timeline style config path.
* @param configPathWindowSeconds Timeline window config path.
* @param configPathClusteringThreshold Clustering threshold config path.
* @param configPathTimelineEventsMediaType Timeline media config path.
* @param configPathShowRecordings Show recordings config path.
* @param defaultShowRecordings Default value of show_recordings.
* @returns A rendered template.
@@ -1617,7 +1682,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
configPathStyle: string,
configPathWindowSeconds: string,
configPathClusteringThreshold: string,
configPathTimelineEventsMediaType: string,
configPathShowRecordings: string,
configPathFormat24h: string,
defaultShowRecordings: boolean,
@@ -1639,13 +1703,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
${this._renderNumberInput(configPathClusteringThreshold, {
label: localize(`config.common.${CONF_TIMELINE_CLUSTERING_THRESHOLD}`),
})}
${this._renderOptionSelector(
configPathTimelineEventsMediaType,
this._timelineEventsMediaTypes,
{
label: localize(`config.common.${CONF_TIMELINE_EVENTS_MEDIA_TYPE}`),
},
)}
${this._renderSwitch(configPathShowRecordings, defaultShowRecordings, {
label: localize(`config.common.${CONF_TIMELINE_SHOW_RECORDINGS}`),
})}
@@ -1668,7 +1725,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
* @param domain The submenu domain.
* @param configPathWindowSeconds Timeline window config path.
* @param configPathClusteringThreshold Clustering threshold config path.
* @param configPathTimelineEventsMediaType Timeline media config path.
* @param configPathShowRecordings Show recordings config path.
* @returns A rendered template.
*/
@@ -1678,7 +1735,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
configPathStyle: string,
configPathWindowSeconds: string,
configPathClusteringThreshold: string,
configPathTimelineEventsMediaType: string,
configPathShowRecordings: string,
configPathFormat24h: string,
defaultShowRecordings: boolean,
@@ -1698,7 +1755,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
configPathStyle,
configPathWindowSeconds,
configPathClusteringThreshold,
configPathTimelineEventsMediaType,
configPathShowRecordings,
configPathFormat24h,
defaultShowRecordings,
@@ -1814,15 +1870,17 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
configPathShowFavoriteControl: string,
configPathShowTimelineControl: string,
configPathShowDownloadControl: string,
configPathShowReviewControl: string,
configPathShowInfoControl: string,
defaults: {
show_details: boolean;
show_favorite_control: boolean;
show_timeline_control: boolean;
show_download_control: boolean;
show_review_control: boolean;
show_info_control: boolean;
},
options?: {
configPathMediaType?: string;
configPathEventsMediaType?: string;
configPathMode?: string;
},
): TemplateResult | void {
@@ -1841,24 +1899,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
},
)}`
: html``}
${options?.configPathMediaType
? html`${this._renderOptionSelector(
options.configPathMediaType,
this._thumbnailMediaTypes,
{
label: localize('config.common.controls.thumbnails.media_type'),
},
)}`
: html``}
${options?.configPathEventsMediaType
? html`${this._renderOptionSelector(
options.configPathEventsMediaType,
this._thumbnailEventsMediaTypes,
{
label: localize('config.common.controls.thumbnails.events_media_type'),
},
)}`
: html``}
${this._renderNumberInput(configPathSize, {
min: THUMBNAIL_WIDTH_MIN,
max: THUMBNAIL_WIDTH_MAX,
@@ -1888,6 +1928,12 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
label: localize('config.common.controls.thumbnails.show_download_control'),
},
)}
${this._renderSwitch(configPathShowReviewControl, defaults.show_review_control, {
label: localize('config.common.controls.thumbnails.show_review_control'),
})}
${this._renderSwitch(configPathShowInfoControl, defaults.show_info_control, {
label: localize('config.common.controls.thumbnails.show_info_control'),
})}
`,
);
}
@@ -2125,6 +2171,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
cameras: RawAdvancedCameraCardConfigArray,
cameraIndex: number,
entities: string[],
folders: RawAdvancedCameraCardConfigArray,
addNewCamera?: boolean,
): TemplateResult | void {
const liveProviders: EditorSelectOption[] = [
@@ -2159,6 +2206,14 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
}
});
const folderOptions: EditorSelectOption[] = [];
folders.forEach((folder, index) => {
folderOptions.push({
value: getFolderID(folder, index),
label: this._getEditorFolderTitle(index, folder),
});
});
const submenuClasses = {
submenu: true,
selected: this._expandedMenus[MENU_CAMERAS] === cameraIndex,
@@ -2441,6 +2496,65 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
label: localize('config.cameras.triggers.events.editor_label'),
},
)}
${this._putInSubmenu(
MENU_CAMERAS_TRIGGERS_REVIEWS,
cameraIndex,
'config.cameras.triggers.reviews.editor_label',
'mdi:check-circle',
html`
${this._renderOptionSelector(
getArrayConfigPath(
CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_SEVERITIES,
cameraIndex,
),
this._severities,
{
multiple: true,
label: localize('common.severity'),
},
)}
${this._renderSwitch(
getArrayConfigPath(
CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION,
cameraIndex,
),
this._defaults.cameras.triggers.reviews.description,
)}
`,
)}
`,
)}
${this._putInSubmenu(
MENU_CAMERAS_MEDIA,
cameraIndex,
'config.cameras.media.editor_label',
'mdi:play-box-outline',
html`
${this._renderOptionSelector(
getArrayConfigPath(CONF_CAMERAS_ARRAY_MEDIA_TYPE, cameraIndex),
this._cameraMediaTypes,
{
label: localize('config.cameras.media.type'),
},
)}
${this._renderOptionSelector(
getArrayConfigPath(
CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE,
cameraIndex,
),
this._thumbnailEventsMediaTypes,
{
label: localize('config.cameras.media.events_type'),
},
)}
${this._renderOptionSelector(
getArrayConfigPath(CONF_CAMERAS_ARRAY_MEDIA_FOLDERS, cameraIndex),
folderOptions,
{
multiple: true,
label: localize('config.cameras.media.folders'),
},
)}
`,
)}
${this._putInSubmenu(
@@ -2731,9 +2845,9 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
? html`
<div class="values">
${cameras.map((_, index) =>
this._renderCamera(cameras, index, entities),
this._renderCamera(cameras, index, entities, folders),
)}
${this._renderCamera(cameras, cameras.length, entities, true)}
${this._renderCamera(cameras, cameras.length, entities, folders, true)}
</div>
`
: ''}
@@ -2784,6 +2898,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
${this._renderMenuButton('cameras') /* */}
${this._renderMenuButton('substreams') /* */}
${this._renderMenuButton('live') /* */}
${this._renderMenuButton('reviews') /* */}
${this._renderMenuButton('clips') /* */}
${this._renderMenuButton('snapshots')}
${this._renderMenuButton('recordings')}
@@ -2935,11 +3050,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
this._defaults.live.controls.thumbnails,
{
configPathMediaType: CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE,
configPathEventsMediaType:
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
configPathMode: CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
},
)}
@@ -2949,7 +3063,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
CONF_LIVE_CONTROLS_TIMELINE_STYLE,
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H,
this._defaults.live.controls.timeline.show_recordings,
@@ -3035,6 +3148,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
this._defaults.media_gallery.controls.thumbnails,
)}
${this._renderFilterControls(
@@ -3137,6 +3252,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
this._defaults.media_viewer.controls.thumbnails,
{
configPathMode: CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
@@ -3148,7 +3265,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H,
this._defaults.media_viewer.controls.timeline.show_recordings,
@@ -3179,7 +3295,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
CONF_TIMELINE_STYLE,
CONF_TIMELINE_WINDOW_SECONDS,
CONF_TIMELINE_CLUSTERING_THRESHOLD,
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_TIMELINE_SHOW_RECORDINGS,
CONF_TIMELINE_FORMAT_24H,
this._defaults.timeline.show_recordings,
@@ -3192,6 +3307,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
this._defaults.timeline.controls.thumbnails,
{
configPathMode: CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,

Some files were not shown because too many files have changed in this diff Show More