@@ -6,6 +6,7 @@
|
|||||||
- [`conditions`](conditions.md)
|
- [`conditions`](conditions.md)
|
||||||
- [`dimensions`](dimensions.md)
|
- [`dimensions`](dimensions.md)
|
||||||
- [`elements`](elements/README.md)
|
- [`elements`](elements/README.md)
|
||||||
|
- [`folders`](folders.md)
|
||||||
- [`image`](image.md)
|
- [`image`](image.md)
|
||||||
- [`live`](live.md)
|
- [`live`](live.md)
|
||||||
- [`media_gallery`](media-gallery.md)
|
- [`media_gallery`](media-gallery.md)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
- [`conditions`](../conditions.md)
|
- [`conditions`](../conditions.md)
|
||||||
- [`dimensions`](../dimensions.md)
|
- [`dimensions`](../dimensions.md)
|
||||||
- [`elements`](../elements/README.md)
|
- [`elements`](../elements/README.md)
|
||||||
|
- [`folders`](../folders.md)
|
||||||
- [`image`](../image.md)
|
- [`image`](../image.md)
|
||||||
- [`live`](../live.md)
|
- [`live`](../live.md)
|
||||||
- [`media_gallery`](../media-gallery.md)
|
- [`media_gallery`](../media-gallery.md)
|
||||||
|
|||||||
@@ -112,6 +112,20 @@ action: custom:advanced-camera-card-action
|
|||||||
advanced_camera_card_action: expand
|
advanced_camera_card_action: expand
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## `folder`
|
||||||
|
|
||||||
|
Show a given folder in the folder gallery.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
action: custom:advanced-camera-card-action
|
||||||
|
advanced_camera_card_action: folder
|
||||||
|
# [...]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Default | Description |
|
||||||
|
| --------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `folder` | The first configured folder (under [`folders`](../../folders.md)). | An optional id of the folder to show, see the `id` parameter under [`folders` configuration](../../folders.md). |
|
||||||
|
|
||||||
## `fullscreen`
|
## `fullscreen`
|
||||||
|
|
||||||
Toggle fullscreen.
|
Toggle fullscreen.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
- [`conditions`](../../conditions.md)
|
- [`conditions`](../../conditions.md)
|
||||||
- [`dimensions`](../../dimensions.md)
|
- [`dimensions`](../../dimensions.md)
|
||||||
- [`elements`](../../elements/README.md)
|
- [`elements`](../../elements/README.md)
|
||||||
|
- [`folders`](../../folders.md)
|
||||||
- [`image`](../../image.md)
|
- [`image`](../../image.md)
|
||||||
- [`live`](../../live.md)
|
- [`live`](../../live.md)
|
||||||
- [`media_gallery`](../../media-gallery.md)
|
- [`media_gallery`](../../media-gallery.md)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
- [`conditions`](../../conditions.md)
|
- [`conditions`](../../conditions.md)
|
||||||
- [`dimensions`](../../dimensions.md)
|
- [`dimensions`](../../dimensions.md)
|
||||||
- [`elements`](../../elements/README.md)
|
- [`elements`](../../elements/README.md)
|
||||||
|
- [`folders`](../../folders.md)
|
||||||
- [`image`](../../image.md)
|
- [`image`](../../image.md)
|
||||||
- [`live`](../../live.md)
|
- [`live`](../../live.md)
|
||||||
- [`media_gallery`](../../media-gallery.md)
|
- [`media_gallery`](../../media-gallery.md)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
- [`engine`](engine.md)
|
- [`engine`](engine.md)
|
||||||
- [`conditions`](../conditions.md)
|
- [`conditions`](../conditions.md)
|
||||||
- [`dimensions`](../dimensions.md)
|
- [`dimensions`](../dimensions.md)
|
||||||
|
- [`folders`](../folders.md)
|
||||||
- [`elements`](../elements/README.md)
|
- [`elements`](../elements/README.md)
|
||||||
- [`image`](../image.md)
|
- [`image`](../image.md)
|
||||||
- [`live`](../live.md)
|
- [`live`](../live.md)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
!> This functionality is experimental. It may be broken, slow or change without warning.
|
!> This functionality is experimental. It may be broken, slow or change without warning or major version number change.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
- [`elements`](README.md)
|
- [`elements`](README.md)
|
||||||
- [Custom Elements](./custom/README.md)
|
- [Custom Elements](./custom/README.md)
|
||||||
- [Stock Elements](./stock/README.md)
|
- [Stock Elements](./stock/README.md)
|
||||||
|
- [`folders`](../folders.md)
|
||||||
- [`image`](../image.md)
|
- [`image`](../image.md)
|
||||||
- [`live`](../live.md)
|
- [`live`](../live.md)
|
||||||
- [`media_gallery`](../media-gallery.md)
|
- [`media_gallery`](../media-gallery.md)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
- [`elements`](../../elements/README.md)
|
- [`elements`](../../elements/README.md)
|
||||||
- [Custom Actions](README.md)
|
- [Custom Actions](README.md)
|
||||||
- [Stock Actions](../stock/README.md)
|
- [Stock Actions](../stock/README.md)
|
||||||
|
- [`folders`](../../folders.md)
|
||||||
- [`image`](../../image.md)
|
- [`image`](../../image.md)
|
||||||
- [`live`](../../live.md)
|
- [`live`](../../live.md)
|
||||||
- [`media_gallery`](../../media-gallery.md)
|
- [`media_gallery`](../../media-gallery.md)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
- [`elements`](../../elements/README.md)
|
- [`elements`](../../elements/README.md)
|
||||||
- [Custom Actions](../custom/README.md)
|
- [Custom Actions](../custom/README.md)
|
||||||
- [Stock Actions](README.md)
|
- [Stock Actions](README.md)
|
||||||
|
- [`folders`](../../folders.md)
|
||||||
- [`image`](../../image.md)
|
- [`image`](../../image.md)
|
||||||
- [`live`](../../live.md)
|
- [`live`](../../live.md)
|
||||||
- [`media_gallery`](../../media-gallery.md)
|
- [`media_gallery`](../../media-gallery.md)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# `folders`
|
||||||
|
|
||||||
|
[](./common/experimental-warning.md ':include')
|
||||||
|
|
||||||
|
The `folders` stanza is used for configuring folders from which media/subfolders may be viewed.
|
||||||
|
|
||||||
|
?> To configure the behavior of the gallery in which folders are displayed, see the [`media_gallery` configuration](./media-gallery.md).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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). |
|
||||||
|
|
||||||
|
## `ha`
|
||||||
|
|
||||||
|
Used to specify a Home Assistant media folder.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
folders:
|
||||||
|
- type: ha
|
||||||
|
ha:
|
||||||
|
# [...]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
| ------ | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `url` | | An optional Home Assistant `Media` browser URL to use as the query base. If `path` is also specified, those matchers are applied against folders "below" the folder specified in `url`. |
|
||||||
|
| `path` | [`{ id: media-source:// }`] | An optional array of matchers to dynamically compare against the Home Assistant media folder hierarchy. See below. |
|
||||||
|
|
||||||
|
?> `url` is never fetched, nor sent over the network. It is only processed
|
||||||
|
locally in your browser. The host part of the URL can optionally be removed.
|
||||||
|
|
||||||
|
### `path`
|
||||||
|
|
||||||
|
An array of matchers to navigate "down" a folder hierarchy. If `url` is also
|
||||||
|
specified, matchers are applied starting at that folder, otherwise they are
|
||||||
|
applied at the media source root (i.e. `media-source://`).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
folders:
|
||||||
|
- type: ha
|
||||||
|
ha:
|
||||||
|
path:
|
||||||
|
# [...]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
| ---------- | ------- | ------------------------------------------------------ |
|
||||||
|
| `id` | | An optional media source `id` to match against. |
|
||||||
|
| `title` | | An optional title name to match against. |
|
||||||
|
| `title_re` | | An optional title regular expression to match against. |
|
||||||
|
|
||||||
|
?> Specifying multiple `path` matchers (other than `id`) requires a query at
|
||||||
|
each level of the folder hierarchy and is slower than directly specifying the
|
||||||
|
media source `id` (if known) or the `url` of the folder.
|
||||||
|
|
||||||
|
#### Examples
|
||||||
|
|
||||||
|
See [Folder Examples](../examples.md?id=folders).
|
||||||
|
|
||||||
|
#### Understanding Media Source IDs and "parent folders"
|
||||||
|
|
||||||
|
Home Assistant Media Source IDs are typically long integration-specific non-user
|
||||||
|
friendly strings that refer to a media item, or folder of media items. Media
|
||||||
|
source "folders" do not have an intrinsic parent as with filesystem folders,
|
||||||
|
rather a trail is built as the user navigates "downwards" -- but anything could
|
||||||
|
theoretically be the parent of anything.
|
||||||
|
|
||||||
|
## Fully expanded reference
|
||||||
|
|
||||||
|
[](common/expanded-warning.md ':include')
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
folders:
|
||||||
|
- type: ha
|
||||||
|
ha:
|
||||||
|
url: https://my-ha-instance.local/media-browser/browser/app%2Cmedia-source%3A%2F%2Ffrigate
|
||||||
|
path:
|
||||||
|
- id: 'media-source://'
|
||||||
|
- title: 'Frigate'
|
||||||
|
- title_re: 'Clips.*'
|
||||||
|
- title_re: 'Person.*'
|
||||||
|
```
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# `media_gallery`
|
# `media_gallery`
|
||||||
|
|
||||||
The `media_gallery` is used for providing an overview of all `clips`, `snapshots` and `recordings` in a thumbnail gallery.
|
The `media_gallery` is used for providing an overview of all `clips`,
|
||||||
|
`snapshots`, `recordings` and `folder` contents in a thumbnail gallery.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
media_gallery:
|
media_gallery:
|
||||||
@@ -8,9 +9,9 @@ media_gallery:
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
|
| ---------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `actions` | | [Actions](actions/README.md) to use for all views that use the `media_gallery` (e.g. `clips`, `snapshots`, `recordings`). |
|
| `actions` | | [Actions](actions/README.md) to use for all views that use the `media_gallery` (e.g. `clips`, `folder`, `snapshots`, `recordings`). |
|
||||||
| `controls` | | Configuration for the Media viewer controls. See below. |
|
| `controls` | | Configuration for the Media Gallery controls. See below. |
|
||||||
|
|
||||||
## `controls`
|
## `controls`
|
||||||
|
|
||||||
@@ -26,8 +27,8 @@ media_gallery:
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ------ | ------- | ----------------------------------------------------------------------------------------------------- |
|
| ------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `mode` | `right` | Whether to show the gallery media filter to the `left`, to the `right` or `none` for no media filter. |
|
| `mode` | `right` | Whether to show the gallery media filter to the `left`, to the `right` or `none` for no media filter. The `folder` view does not support media filtering. |
|
||||||
|
|
||||||
### `thumbnails`
|
### `thumbnails`
|
||||||
|
|
||||||
|
|||||||
+53
-47
@@ -36,6 +36,7 @@ menu:
|
|||||||
| `display_mode` | The `display_mode` button allows changing between single and grid views. |
|
| `display_mode` | The `display_mode` button allows changing between single and grid views. |
|
||||||
| `download` | The `download` menu button: allow direct download of the media being displayed. |
|
| `download` | The `download` menu button: allow direct download of the media being displayed. |
|
||||||
| `expand` | The `expand` menu button: expand the card into a popup/dialog. |
|
| `expand` | The `expand` menu button: expand the card into a popup/dialog. |
|
||||||
|
| `folders` | The `folders` menu button to select a folder of media to view in the [`media_gallery`](./media-gallery.md). Will only appear if [`folders`](./folders.md) are configured. |
|
||||||
| `fullscreen` | The `fullscreen` menu button: expand the card to consume the fullscreen. Please note that fullscreen behavior on iPhone is limited, see [troubleshooting](../troubleshooting.md?id=fullscreen-doesn39t-work-on-iphone). |
|
| `fullscreen` | The `fullscreen` menu button: expand the card to consume the fullscreen. Please note that fullscreen behavior on iPhone is limited, see [troubleshooting](../troubleshooting.md?id=fullscreen-doesn39t-work-on-iphone). |
|
||||||
| `image` | The `image` view menu button: brings the user to the static `image` view. |
|
| `image` | The `image` view menu button: brings the user to the static `image` view. |
|
||||||
| `iris` | The main Advanced Camera Card `iris` menu button: brings the user to the default configured view (`view.default`), or collapses/expands the menu if the `menu.style` is `hidden` . |
|
| `iris` | The main Advanced Camera Card `iris` menu button: brings the user to the default configured view (`view.default`), or collapses/expands the menu if the `menu.style` is `hidden` . |
|
||||||
@@ -80,66 +81,56 @@ This card supports several menu styles.
|
|||||||
menu:
|
menu:
|
||||||
alignment: left
|
alignment: left
|
||||||
buttons:
|
buttons:
|
||||||
iris:
|
|
||||||
priority: 50
|
|
||||||
enabled: true
|
|
||||||
alignment: matching
|
|
||||||
icon: iris
|
|
||||||
cameras:
|
|
||||||
priority: 50
|
|
||||||
enabled: true
|
|
||||||
alignment: matching
|
|
||||||
icon: mdi:video-switch
|
|
||||||
substreams:
|
|
||||||
priority: 50
|
|
||||||
enabled: true
|
|
||||||
alignment: matching
|
|
||||||
icon: mdi:video-input-component
|
|
||||||
live:
|
|
||||||
priority: 50
|
|
||||||
enabled: true
|
|
||||||
alignment: matching
|
|
||||||
icon: mdi:cctv
|
|
||||||
clips:
|
|
||||||
priority: 50
|
|
||||||
enabled: true
|
|
||||||
alignment: matching
|
|
||||||
icon: mdi:filmstrip
|
|
||||||
snapshots:
|
|
||||||
priority: 50
|
|
||||||
enabled: true
|
|
||||||
alignment: matching
|
|
||||||
icon: mdi:camera
|
|
||||||
image:
|
|
||||||
priority: 50
|
|
||||||
enabled: false
|
|
||||||
alignment: matching
|
|
||||||
icon: mdi:image
|
|
||||||
timeline:
|
|
||||||
priority: 50
|
|
||||||
enabled: true
|
|
||||||
alignment: matching
|
|
||||||
icon: mdi:chart-gantt
|
|
||||||
download:
|
|
||||||
priority: 50
|
|
||||||
enabled: true
|
|
||||||
alignment: matching
|
|
||||||
icon: mdi:download
|
|
||||||
camera_ui:
|
camera_ui:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:web
|
icon: mdi:web
|
||||||
fullscreen:
|
cameras:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:fullscreen
|
icon: mdi:video-switch
|
||||||
|
clips:
|
||||||
|
priority: 50
|
||||||
|
enabled: true
|
||||||
|
alignment: matching
|
||||||
|
icon: mdi:filmstrip
|
||||||
|
download:
|
||||||
|
priority: 50
|
||||||
|
enabled: true
|
||||||
|
alignment: matching
|
||||||
|
icon: mdi:download
|
||||||
expand:
|
expand:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:arrow-expand-all
|
icon: mdi:arrow-expand-all
|
||||||
|
folders:
|
||||||
|
priority: 50
|
||||||
|
enabled: true
|
||||||
|
alignment: matching
|
||||||
|
icon: mdi:folder-multiple
|
||||||
|
fullscreen:
|
||||||
|
priority: 50
|
||||||
|
enabled: true
|
||||||
|
alignment: matching
|
||||||
|
icon: mdi:fullscreen
|
||||||
|
image:
|
||||||
|
priority: 50
|
||||||
|
enabled: false
|
||||||
|
alignment: matching
|
||||||
|
icon: mdi:image
|
||||||
|
iris:
|
||||||
|
priority: 50
|
||||||
|
enabled: true
|
||||||
|
alignment: matching
|
||||||
|
icon: iris
|
||||||
|
live:
|
||||||
|
priority: 50
|
||||||
|
enabled: true
|
||||||
|
alignment: matching
|
||||||
|
icon: mdi:cctv
|
||||||
media_player:
|
media_player:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: false
|
enabled: false
|
||||||
@@ -171,6 +162,21 @@ menu:
|
|||||||
enabled: true
|
enabled: true
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:home
|
icon: mdi:home
|
||||||
|
snapshots:
|
||||||
|
priority: 50
|
||||||
|
enabled: true
|
||||||
|
alignment: matching
|
||||||
|
icon: mdi:camera
|
||||||
|
substreams:
|
||||||
|
priority: 50
|
||||||
|
enabled: true
|
||||||
|
alignment: matching
|
||||||
|
icon: mdi:video-input-component
|
||||||
|
timeline:
|
||||||
|
priority: 50
|
||||||
|
enabled: true
|
||||||
|
alignment: matching
|
||||||
|
icon: mdi:chart-gantt
|
||||||
button_size: 40
|
button_size: 40
|
||||||
position: top
|
position: top
|
||||||
style: hidden
|
style: hidden
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ This card supports several different views.
|
|||||||
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `clip` | Shows a viewer for the most recent clip for this camera. Can also be accessed by holding down the `clips` menu icon. |
|
| `clip` | Shows a viewer for the most recent clip for this camera. Can also be accessed by holding down the `clips` menu icon. |
|
||||||
| `clips` | Shows a gallery of clips for this camera. |
|
| `clips` | Shows a gallery of clips for this camera. |
|
||||||
|
| `folder` | Shows a gallery of media from a [`folder`](./folders.md). |
|
||||||
| `image` | Shows a static image specified by the `image` parameter, can be used as a discrete default view or a screensaver (via `view.interaction_seconds`). |
|
| `image` | Shows a static image specified by the `image` parameter, can be used as a discrete default view or a screensaver (via `view.interaction_seconds`). |
|
||||||
| `live` | Shows the live camera view with the configured [live provider](./cameras/live-provider.md). |
|
| `live` | Shows the live camera view with the configured [live provider](./cameras/live-provider.md). |
|
||||||
| `recording` | Shows a viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` menu icon. |
|
| `recording` | Shows a viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` menu icon. |
|
||||||
|
|||||||
+1
-3
@@ -31,7 +31,7 @@ Post `v6.0.0`, all releases are automated with ([semantic-release](https://githu
|
|||||||
|
|
||||||
Releases follow [Semantic Versioning](https://semver.org/) with the following definitions:
|
Releases follow [Semantic Versioning](https://semver.org/) with the following definitions:
|
||||||
|
|
||||||
- **MAJOR** version changes for any backwards incompatible changes. This means any change that would _require_ users to update their card config, regardless of whether that update is automated or manual.
|
- **MAJOR** version changes for any backwards incompatible changes (excluding functionality marked as experimental). This means any change that would _require_ users to update their card config, regardless of whether that update is automated or manual.
|
||||||
- **MINOR** version changes for any functionality added in a backwards compatible manner. This may mean new features or behavioral changes that do not require a card update.
|
- **MINOR** version changes for any functionality added in a backwards compatible manner. This may mean new features or behavioral changes that do not require a card update.
|
||||||
- **PATCH** version changes for backward compatible bug fixes
|
- **PATCH** version changes for backward compatible bug fixes
|
||||||
|
|
||||||
@@ -46,8 +46,6 @@ Releases follow [Semantic Versioning](https://semver.org/) with the following de
|
|||||||
|
|
||||||
## Translations
|
## Translations
|
||||||
|
|
||||||
[](https://fink.inlang.com/github.com/dermotduffy/advanced-camera-card?ref=badge)
|
|
||||||
|
|
||||||
To add translations, you can manually edit the JSON translation files in
|
To add translations, you can manually edit the JSON translation files in
|
||||||
`src/localize/languages` or use the [inlang](https://inlang.com/) online editor.
|
`src/localize/languages` or use the [inlang](https://inlang.com/) online editor.
|
||||||
|
|
||||||
|
|||||||
@@ -336,6 +336,79 @@ cameras:
|
|||||||
all_cameras: true
|
all_cameras: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Folders
|
||||||
|
|
||||||
|
These examples create folders that can be viewed in the
|
||||||
|
[`media_gallery`](./configuration/media-gallery.md).
|
||||||
|
|
||||||
|
### Home Assistant default root
|
||||||
|
|
||||||
|
This example creates a folder at the Home Assistant media root.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: custom:advanced-camera-card
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.office
|
||||||
|
folders:
|
||||||
|
- type: ha
|
||||||
|
```
|
||||||
|
|
||||||
|
### Folder within the Home Assistant default root
|
||||||
|
|
||||||
|
This example applies a title match against the Home Assistant media root folder
|
||||||
|
looking for a folder entitled `Frigate`. The resulting media will be the
|
||||||
|
contents of that folder (if found).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: custom:advanced-camera-card
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.office
|
||||||
|
folders:
|
||||||
|
- type: ha
|
||||||
|
ha:
|
||||||
|
path:
|
||||||
|
- title: 'Frigate'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Folder URLs
|
||||||
|
|
||||||
|
This example uses the `url` parameter to establish the root of the query. Within
|
||||||
|
that folder, it looks for a sub-folder that matches the regular expression
|
||||||
|
`Clips.*`, and within that looks for a folder that matches the regular
|
||||||
|
expression `Person.*`. The resulting media will be the contents of that folder
|
||||||
|
(if found).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: custom:advanced-camera-card
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.office
|
||||||
|
folders:
|
||||||
|
- type: ha
|
||||||
|
ha:
|
||||||
|
url: https://my-ha-instance.local/media-browser/browser/app%2Cmedia-source%3A%2F%2Ffrigate
|
||||||
|
path:
|
||||||
|
- title_re: 'Clips.*'
|
||||||
|
- title_re: 'Person.*'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Folder Paths
|
||||||
|
|
||||||
|
This example starts with the `media-source://frigate` folder, and looks for a
|
||||||
|
precisely titled `Clips [my-instance]` folder within that. The resulting media
|
||||||
|
will be the contents of that folder (if found).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: custom:advanced-camera-card
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.office
|
||||||
|
folders:
|
||||||
|
- type: ha
|
||||||
|
ha:
|
||||||
|
path:
|
||||||
|
- id: 'media-source://frigate'
|
||||||
|
- title: 'Clips [my-instance]'
|
||||||
|
```
|
||||||
|
|
||||||
## Human interaction
|
## Human interaction
|
||||||
|
|
||||||
This example will automatically use a HD live substream when
|
This example will automatically use a HD live substream when
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig } from 'eslint/config';
|
||||||
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import { FlatCompat } from '@eslint/eslintrc';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
recommendedConfig: js.configs.recommended,
|
||||||
|
allConfig: js.configs.all,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
{
|
||||||
|
extends: compat.extends('plugin:@typescript-eslint/recommended', 'prettier'),
|
||||||
|
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
+5
-5
@@ -69,12 +69,12 @@
|
|||||||
"@types/js-yaml": "^4",
|
"@types/js-yaml": "^4",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/masonry-layout": "^4.2.8",
|
"@types/masonry-layout": "^4.2.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.13.0",
|
"@typescript-eslint/eslint-plugin": "^8.30.1",
|
||||||
"@typescript-eslint/parser": "^7.13.0",
|
"@typescript-eslint/parser": "^8.30.1",
|
||||||
"@vitest/coverage-istanbul": "^1.6.0",
|
"@vitest/coverage-istanbul": "^1.6.0",
|
||||||
"conventional-changelog-conventionalcommits": "^8.0.0",
|
"conventional-changelog-conventionalcommits": "^8.0.0",
|
||||||
"docsify-cli": "^4.4.4",
|
"docsify-cli": "^4.4.4",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^9.24.0",
|
||||||
"eslint-config-airbnb-base": "^15.0.0",
|
"eslint-config-airbnb-base": "^15.0.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-import": "^2.29.1",
|
"eslint-plugin-import": "^2.29.1",
|
||||||
@@ -91,8 +91,8 @@
|
|||||||
"semantic-release": "^24.1.1",
|
"semantic-release": "^24.1.1",
|
||||||
"semantic-release-export-data": "^1.1.0",
|
"semantic-release-export-data": "^1.1.0",
|
||||||
"ts-prune": "^0.10.3",
|
"ts-prune": "^0.10.3",
|
||||||
"type-fest": "^4.20.0",
|
"type-fest": "^4.41.0",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.8.3",
|
||||||
"vitest": "^1.6.0",
|
"vitest": "^1.6.0",
|
||||||
"vitest-mock-extended": "^1.3.1"
|
"vitest-mock-extended": "^1.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export const actionHandler = directive(
|
|||||||
return noChange;
|
return noChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
render(_options?: AdvancedCameraCardActionHandlerOptions) {}
|
render(_options?: AdvancedCameraCardActionHandlerOptions) {}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { CacheInterface } from './types.js';
|
||||||
|
|
||||||
|
export class CacheBase<Key, Value> implements CacheInterface<Key, Value> {
|
||||||
|
private _cache: Map<Key, Value>;
|
||||||
|
|
||||||
|
constructor(cache: Map<Key, Value>) {
|
||||||
|
this._cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the cache has a given id.
|
||||||
|
* @param key
|
||||||
|
* @returns `true` if the id is in the cache, `false` otherwise.
|
||||||
|
*/
|
||||||
|
public has(key: Key): boolean {
|
||||||
|
return this._cache.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public entries(): MapIterator<[Key, Value]> {
|
||||||
|
return this._cache.entries();
|
||||||
|
}
|
||||||
|
|
||||||
|
public delete(key: Key): boolean {
|
||||||
|
return this._cache.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public clear(): void {
|
||||||
|
this._cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get resolved media information given an id.
|
||||||
|
* @param key The id.
|
||||||
|
* @returns The `ResolvedMedia` for this id.
|
||||||
|
*/
|
||||||
|
public get(key: Key): Value | null {
|
||||||
|
return this._cache.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMatches(predicate: (arg: Value) => boolean): Value[] {
|
||||||
|
return [...this._cache.values()].filter(predicate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a given ResolvedMedia to the cache.
|
||||||
|
* @param key The id for the object.
|
||||||
|
* @param resolvedMedia The `ResolvedMedia` object.
|
||||||
|
*/
|
||||||
|
public set(key: Key, val: Value): void {
|
||||||
|
this._cache.set(key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import { CacheBase } from './base';
|
||||||
|
|
||||||
|
export class Cache<Key, Value> extends CacheBase<Key, Value> {
|
||||||
|
constructor() {
|
||||||
|
super(new Map());
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import { CacheBase } from './base';
|
||||||
|
import { EqualityMap } from './equality-map';
|
||||||
|
|
||||||
|
export class EqualityCache<Key, Value> extends CacheBase<Key, Value> {
|
||||||
|
constructor() {
|
||||||
|
super(new EqualityMap());
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+80
@@ -0,0 +1,80 @@
|
|||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
|
||||||
|
interface EqualityMapItem<Key, Value> {
|
||||||
|
key: Key;
|
||||||
|
value: Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A simple equality based map. This is not performant and should be used for
|
||||||
|
* small datasets only.
|
||||||
|
*/
|
||||||
|
export class EqualityMap<Key, Value> implements Map<Key, Value> {
|
||||||
|
private _data: EqualityMapItem<Key, Value>[] = [];
|
||||||
|
|
||||||
|
get [Symbol.toStringTag](): string {
|
||||||
|
return 'EqualityMap';
|
||||||
|
}
|
||||||
|
|
||||||
|
public has(key: Key): boolean {
|
||||||
|
return !!this.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(searchKey: Key): Value | undefined {
|
||||||
|
for (const pair of this._data) {
|
||||||
|
if (isEqual(pair.key, searchKey)) {
|
||||||
|
return pair.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set(key: Key, value: Value): this {
|
||||||
|
this.delete(key);
|
||||||
|
this._data.push({ key, value });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public delete(searchKey: Key): boolean {
|
||||||
|
for (let i = 0; i < this._data.length; i++) {
|
||||||
|
if (isEqual(this._data[i].key, searchKey)) {
|
||||||
|
this._data.splice(i, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clear(): void {
|
||||||
|
this._data = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public *entries(): MapIterator<[Key, Value]> {
|
||||||
|
for (const pair of this._data) {
|
||||||
|
yield [pair.key, pair.value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public forEach(
|
||||||
|
callbackfn: (value: Value, key: Key, map: Map<Key, Value>) => void,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
thisArg?: any,
|
||||||
|
): void {
|
||||||
|
for (const pair of this._data) {
|
||||||
|
callbackfn.call(thisArg, pair.value, pair.key, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public get size(): number {
|
||||||
|
return this._data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public [Symbol.iterator](): IterableIterator<[Key, Value]> {
|
||||||
|
return this.entries();
|
||||||
|
}
|
||||||
|
public keys(): IterableIterator<Key> {
|
||||||
|
return this._data.map((pair) => pair.key).values();
|
||||||
|
}
|
||||||
|
public values(): IterableIterator<Value> {
|
||||||
|
return this._data.map((pair) => pair.value).values();
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+70
@@ -0,0 +1,70 @@
|
|||||||
|
import { CacheInterface } from './types';
|
||||||
|
import { EqualityCache } from './equality-cache';
|
||||||
|
|
||||||
|
interface ExpiringValue<Value> {
|
||||||
|
value: Value;
|
||||||
|
expires?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExpiringEqualityCache<Key, Value> implements CacheInterface<Key, Value> {
|
||||||
|
protected _data: EqualityCache<Key, ExpiringValue<Value>> = new EqualityCache();
|
||||||
|
|
||||||
|
public get(key: Key): Value | null {
|
||||||
|
const value = this._data.get(key);
|
||||||
|
const now = new Date();
|
||||||
|
return value && (!value.expires || now <= value.expires) ? value.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public has(key: Key): boolean {
|
||||||
|
return !!this.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public set(key: Key, value: Value, expiry?: Date): void {
|
||||||
|
this._data.set(key, {
|
||||||
|
value: value,
|
||||||
|
expires: expiry,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up old requests on set.
|
||||||
|
this._expireOldValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
public delete(key: Key): boolean {
|
||||||
|
return this._data.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public clear(): void {
|
||||||
|
this._data.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public *entries(): MapIterator<[Key, Value]> {
|
||||||
|
const now = new Date();
|
||||||
|
for (const [key, value] of this._data.entries()) {
|
||||||
|
if (!value.expires || now <= value.expires) {
|
||||||
|
yield [key, value.value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMatches(predicate: (value: Value) => boolean): Value[] {
|
||||||
|
const out: Value[] = [];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
for (const [_key, value] of this.entries()) {
|
||||||
|
if (predicate(value)) {
|
||||||
|
out.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _expireOldValues(): void {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const [key, value] of this._data.entries()) {
|
||||||
|
if (value.expires && now > value.expires) {
|
||||||
|
this._data.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import QuickLRU from 'quick-lru';
|
||||||
|
import { CacheBase } from './base';
|
||||||
|
|
||||||
|
export class LRUCache<Key, Value> extends CacheBase<Key, Value> {
|
||||||
|
constructor(maxSize: number) {
|
||||||
|
super(new QuickLRU({ maxSize }));
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
export interface CacheInterface<K, V> {
|
||||||
|
has(k: K): boolean;
|
||||||
|
get(k: K): V | null;
|
||||||
|
set(k: K, v: V): void;
|
||||||
|
delete(k: K): boolean;
|
||||||
|
clear(): void;
|
||||||
|
entries(): MapIterator<[K, V]>;
|
||||||
|
getMatches(predicate: (arg: V) => boolean): V[];
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { localize } from '../../localize/localize';
|
import { localize } from '../../localize/localize';
|
||||||
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
|
|
||||||
import { Camera, CameraInitializationOptions } from '../camera';
|
import { Camera, CameraInitializationOptions } from '../camera';
|
||||||
import { CameraInitializationError } from '../error';
|
import { CameraInitializationError } from '../error';
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
||||||
import { CameraConfig } from '../../config/schema/cameras';
|
import { CameraConfig } from '../../config/schema/cameras';
|
||||||
|
import { BROWSE_MEDIA_CACHE_SECONDS } from '../../ha/browse-media/types';
|
||||||
|
import { BrowseMediaWalker } from '../../ha/browse-media/walker';
|
||||||
|
import { getMediaDownloadPath } from '../../ha/download';
|
||||||
|
import { EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||||
|
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { canonicalizeHAURL } from '../../utils/ha';
|
import { Endpoint } from '../../types';
|
||||||
import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager';
|
import { ViewMedia } from '../../view/item';
|
||||||
import { BROWSE_MEDIA_CACHE_SECONDS } from '../../utils/ha/browse-media/types';
|
import { ViewItemCapabilities } from '../../view/types';
|
||||||
import { EntityRegistryManager } from '../../utils/ha/registry/entity/types';
|
|
||||||
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
|
|
||||||
import { ViewMedia } from '../../view/media';
|
|
||||||
import { RequestCache } from '../cache';
|
|
||||||
import { CameraManagerEngine } from '../engine';
|
import { CameraManagerEngine } from '../engine';
|
||||||
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||||
import {
|
import {
|
||||||
CameraEndpoint,
|
|
||||||
CameraEventCallback,
|
CameraEventCallback,
|
||||||
CameraManagerMediaCapabilities,
|
CameraManagerRequestCache,
|
||||||
DataQuery,
|
CameraQuery,
|
||||||
EventQuery,
|
EventQuery,
|
||||||
PartialEventQuery,
|
PartialEventQuery,
|
||||||
QueryType,
|
QueryType,
|
||||||
@@ -28,22 +28,22 @@ export class BrowseMediaCameraManagerEngine
|
|||||||
extends GenericCameraManagerEngine
|
extends GenericCameraManagerEngine
|
||||||
implements CameraManagerEngine
|
implements CameraManagerEngine
|
||||||
{
|
{
|
||||||
protected _browseMediaManager: BrowseMediaManager;
|
protected _browseMediaWalker: BrowseMediaWalker;
|
||||||
protected _entityRegistryManager: EntityRegistryManager;
|
protected _entityRegistryManager: EntityRegistryManager;
|
||||||
protected _resolvedMediaCache: ResolvedMediaCache;
|
protected _resolvedMediaCache: ResolvedMediaCache;
|
||||||
protected _requestCache: RequestCache;
|
protected _requestCache: CameraManagerRequestCache;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
entityRegistryManager: EntityRegistryManager,
|
entityRegistryManager: EntityRegistryManager,
|
||||||
stateWatcher: StateWatcherSubscriptionInterface,
|
stateWatcher: StateWatcherSubscriptionInterface,
|
||||||
browseMediaManager: BrowseMediaManager,
|
browseMediaManager: BrowseMediaWalker,
|
||||||
resolvedMediaCache: ResolvedMediaCache,
|
resolvedMediaCache: ResolvedMediaCache,
|
||||||
requestCache: RequestCache,
|
requestCache: CameraManagerRequestCache,
|
||||||
eventCallback?: CameraEventCallback,
|
eventCallback?: CameraEventCallback,
|
||||||
) {
|
) {
|
||||||
super(stateWatcher, eventCallback);
|
super(stateWatcher, eventCallback);
|
||||||
this._entityRegistryManager = entityRegistryManager;
|
this._entityRegistryManager = entityRegistryManager;
|
||||||
this._browseMediaManager = browseMediaManager;
|
this._browseMediaWalker = browseMediaManager;
|
||||||
this._resolvedMediaCache = resolvedMediaCache;
|
this._resolvedMediaCache = resolvedMediaCache;
|
||||||
this._requestCache = requestCache;
|
this._requestCache = requestCache;
|
||||||
}
|
}
|
||||||
@@ -66,18 +66,11 @@ export class BrowseMediaCameraManagerEngine
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
_cameraConfig: CameraConfig,
|
_cameraConfig: CameraConfig,
|
||||||
media: ViewMedia,
|
media: ViewMedia,
|
||||||
): Promise<CameraEndpoint | null> {
|
): Promise<Endpoint | null> {
|
||||||
const contentID = media.getContentID();
|
return getMediaDownloadPath(hass, media.getContentID(), this._resolvedMediaCache);
|
||||||
if (!contentID) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const resolvedMedia = await resolveMedia(hass, contentID, this._resolvedMediaCache);
|
|
||||||
return resolvedMedia
|
|
||||||
? { endpoint: canonicalizeHAURL(hass, resolvedMedia.url) }
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getQueryResultMaxAge(query: DataQuery): number | null {
|
public getQueryResultMaxAge(query: CameraQuery): number | null {
|
||||||
if (query.type === QueryType.Event) {
|
if (query.type === QueryType.Event) {
|
||||||
return BROWSE_MEDIA_CACHE_SECONDS;
|
return BROWSE_MEDIA_CACHE_SECONDS;
|
||||||
}
|
}
|
||||||
@@ -85,7 +78,7 @@ export class BrowseMediaCameraManagerEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities {
|
public getMediaCapabilities(_media: ViewMedia): ViewItemCapabilities {
|
||||||
return {
|
return {
|
||||||
canFavorite: false,
|
canFavorite: false,
|
||||||
canDownload: true,
|
canDownload: true,
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { format } from 'date-fns';
|
|
||||||
import isEqual from 'lodash-es/isEqual';
|
|
||||||
import { formatDateAndTime } from '../../utils/basic';
|
|
||||||
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
|
|
||||||
import {
|
|
||||||
ViewMedia,
|
|
||||||
EventViewMedia,
|
|
||||||
ViewMediaType,
|
|
||||||
VideoContentType,
|
|
||||||
} from '../../view/media';
|
|
||||||
import { BrowseMediaMetadata } from '../browse-media/types';
|
|
||||||
|
|
||||||
class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMedia {
|
|
||||||
protected _browseMedia: RichBrowseMedia<BrowseMediaMetadata>;
|
|
||||||
protected _id: string;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
mediaType: ViewMediaType,
|
|
||||||
cameraID: string,
|
|
||||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
|
|
||||||
) {
|
|
||||||
super(mediaType, cameraID);
|
|
||||||
this._browseMedia = browseMedia;
|
|
||||||
|
|
||||||
// Generate a custom ID that uses the start date (to allow multiple
|
|
||||||
// BrowseMedia objects (e.g. images and movies) to be de-duplicated).
|
|
||||||
if (browseMedia._metadata?.startDate) {
|
|
||||||
this._id = `${cameraID}/${format(
|
|
||||||
browseMedia._metadata.startDate,
|
|
||||||
'yyyy-MM-dd HH:mm:ss',
|
|
||||||
)}`;
|
|
||||||
} else {
|
|
||||||
this._id = browseMedia.media_content_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getStartTime(): Date | null {
|
|
||||||
return this._browseMedia._metadata?.startDate ?? null;
|
|
||||||
}
|
|
||||||
public getEndTime(): Date | null {
|
|
||||||
return this._browseMedia._metadata?.endDate ?? null;
|
|
||||||
}
|
|
||||||
public getVideoContentType(): VideoContentType | null {
|
|
||||||
return VideoContentType.MP4;
|
|
||||||
}
|
|
||||||
public getID(): string {
|
|
||||||
return this._id;
|
|
||||||
}
|
|
||||||
public getContentID(): string {
|
|
||||||
return this._browseMedia.media_content_id;
|
|
||||||
}
|
|
||||||
public getTitle(): string | null {
|
|
||||||
const startTime = this.getStartTime();
|
|
||||||
return startTime ? formatDateAndTime(startTime) : this._browseMedia.title;
|
|
||||||
}
|
|
||||||
public getThumbnail(): string | null {
|
|
||||||
return this._browseMedia.thumbnail;
|
|
||||||
}
|
|
||||||
public getWhat(): string[] | null {
|
|
||||||
return this._browseMedia._metadata?.what ?? null;
|
|
||||||
}
|
|
||||||
public getScore(): number | null {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
public getTags(): string[] | null {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
public isGroupableWith(that: EventViewMedia): boolean {
|
|
||||||
return (
|
|
||||||
this.getMediaType() === that.getMediaType() &&
|
|
||||||
isEqual(this.getWhere(), that.getWhere()) &&
|
|
||||||
isEqual(this.getWhat(), that.getWhat())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BrowseMediaViewMediaFactory {
|
|
||||||
static createEventViewMedia(
|
|
||||||
mediaType: 'clip' | 'snapshot',
|
|
||||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
|
|
||||||
cameraID: string,
|
|
||||||
): BrowseMediaEventViewMedia {
|
|
||||||
return new BrowseMediaEventViewMedia(mediaType, cameraID, browseMedia);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export interface BrowseMediaMetadata {
|
|
||||||
cameraID: string;
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
what?: string[];
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import {
|
|
||||||
RichBrowseMedia,
|
|
||||||
MEDIA_CLASS_VIDEO,
|
|
||||||
MEDIA_CLASS_IMAGE,
|
|
||||||
} from '../../../utils/ha/browse-media/types';
|
|
||||||
import { ViewMedia } from '../../../view/media';
|
|
||||||
import { BrowseMediaViewMediaFactory } from '../media';
|
|
||||||
import { BrowseMediaMetadata } from '../types';
|
|
||||||
|
|
||||||
export const getViewMediaFromBrowseMediaArray = (
|
|
||||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[],
|
|
||||||
): ViewMedia[] | null => {
|
|
||||||
const lookup: Map<string, ViewMedia> = new Map();
|
|
||||||
for (const browseMediaItem of browseMedia) {
|
|
||||||
const cameraID = browseMediaItem._metadata?.cameraID;
|
|
||||||
if (!cameraID) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaType =
|
|
||||||
browseMediaItem.media_class === MEDIA_CLASS_VIDEO
|
|
||||||
? 'clip'
|
|
||||||
: browseMediaItem.media_class === MEDIA_CLASS_IMAGE
|
|
||||||
? 'snapshot'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!mediaType) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const media = BrowseMediaViewMediaFactory.createEventViewMedia(
|
|
||||||
mediaType,
|
|
||||||
browseMediaItem,
|
|
||||||
cameraID,
|
|
||||||
);
|
|
||||||
|
|
||||||
const id = media.getID();
|
|
||||||
const existing = lookup.get(id);
|
|
||||||
|
|
||||||
// De-duplicate events with precisely the same ID (same
|
|
||||||
// hour/minute/second) choosing clip > snapshot.
|
|
||||||
if (
|
|
||||||
!existing ||
|
|
||||||
(existing.getMediaType() === 'snapshot' && media.getMediaType() === 'clip')
|
|
||||||
) {
|
|
||||||
lookup.set(id, media);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...lookup.values()];
|
|
||||||
};
|
|
||||||
@@ -1,69 +1,6 @@
|
|||||||
import isEqual from 'lodash-es/isEqual';
|
import { orderBy, sortedUniqBy } from 'lodash-es';
|
||||||
import orderBy from 'lodash-es/orderBy';
|
|
||||||
import sortedUniqBy from 'lodash-es/sortedUniqBy';
|
|
||||||
import { DateRange, MemoryRangeSet } from './range';
|
import { DateRange, MemoryRangeSet } from './range';
|
||||||
import { DataQuery, QueryResults, RecordingSegment } from './types';
|
import { RecordingSegment } from './types';
|
||||||
|
|
||||||
interface RequestCacheItem<Request, Response> {
|
|
||||||
request: Request;
|
|
||||||
response: Response;
|
|
||||||
expires?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CameraManagerCache<Request, Response> {
|
|
||||||
get(request: Request): Response | null;
|
|
||||||
has(request: Request): boolean;
|
|
||||||
set(request: Request, response: Response, expiry?: Date): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MemoryRequestCache<Request, Response>
|
|
||||||
implements CameraManagerCache<Request, Response>
|
|
||||||
{
|
|
||||||
protected _data: RequestCacheItem<Request, Response>[] = [];
|
|
||||||
|
|
||||||
public get(request: Request): Response | null {
|
|
||||||
const now = new Date();
|
|
||||||
for (const item of this._data) {
|
|
||||||
if (
|
|
||||||
(!item.expires || now <= item.expires) &&
|
|
||||||
this._contains(request, item.request)
|
|
||||||
) {
|
|
||||||
return item.response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public clear(): void {
|
|
||||||
this._data = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public has(request: Request): boolean {
|
|
||||||
return !!this.get(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
public set(request: Request, response: Response, expiry?: Date): void {
|
|
||||||
this._data.push({
|
|
||||||
request: request,
|
|
||||||
response: response,
|
|
||||||
expires: expiry,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up old requests on set.
|
|
||||||
this._expireOldRequests();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _contains(a: Request, b: Request): boolean {
|
|
||||||
return isEqual(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _expireOldRequests(): void {
|
|
||||||
const now = new Date();
|
|
||||||
this._data = this._data.filter((item) => !item.expires || now < item.expires);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RequestCache extends MemoryRequestCache<DataQuery, QueryResults> {}
|
|
||||||
|
|
||||||
class MemoryRangedCache<Data> {
|
class MemoryRangedCache<Data> {
|
||||||
protected _ranges: MemoryRangeSet = new MemoryRangeSet();
|
protected _ranges: MemoryRangeSet = new MemoryRangeSet();
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { ActionsExecutor } from '../card-controller/actions/types';
|
|||||||
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
|
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
|
||||||
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
|
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
|
||||||
import { CameraConfig } from '../config/schema/cameras';
|
import { CameraConfig } from '../config/schema/cameras';
|
||||||
|
import { isTriggeredState } from '../ha/is-triggered-state';
|
||||||
|
import { HassStateDifference } from '../ha/types';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import { HassStateDifference, isTriggeredState } from '../utils/ha';
|
|
||||||
import { Capabilities } from './capabilities';
|
import { Capabilities } from './capabilities';
|
||||||
import { CameraManagerEngine } from './engine';
|
import { CameraManagerEngine } from './engine';
|
||||||
import { CameraNoIDError } from './error';
|
import { CameraNoIDError } from './error';
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
|
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
|
||||||
import { CameraConfig } from '../config/schema/cameras';
|
import { CameraConfig } from '../config/schema/cameras';
|
||||||
|
import { BrowseMediaWalker } from '../ha/browse-media/walker';
|
||||||
|
import { EntityRegistryManager } from '../ha/registry/entity/types';
|
||||||
|
import { ResolvedMediaCache } from '../ha/resolved-media';
|
||||||
import { HomeAssistant } from '../ha/types';
|
import { HomeAssistant } from '../ha/types';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
|
import { RecordingSegmentsCache } from './cache';
|
||||||
import { EntityRegistryManager } from '../utils/ha/registry/entity/types';
|
|
||||||
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
|
||||||
import { RecordingSegmentsCache, RequestCache } from './cache';
|
|
||||||
import { CameraManagerEngine } from './engine';
|
import { CameraManagerEngine } from './engine';
|
||||||
import { CameraInitializationError } from './error';
|
import { CameraInitializationError } from './error';
|
||||||
import { CameraEventCallback, Engine } from './types';
|
import { CameraEventCallback, CameraManagerRequestCache, Engine } from './types';
|
||||||
import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
|
import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
|
||||||
|
|
||||||
interface CameraManagerEngineFactoryOptions {
|
interface CameraManagerEngineFactoryOptions {
|
||||||
@@ -44,7 +44,7 @@ export class CameraManagerEngineFactory {
|
|||||||
this._entityRegistryManager,
|
this._entityRegistryManager,
|
||||||
options.stateWatcher,
|
options.stateWatcher,
|
||||||
new RecordingSegmentsCache(),
|
new RecordingSegmentsCache(),
|
||||||
new RequestCache(),
|
new CameraManagerRequestCache(),
|
||||||
options.eventCallback,
|
options.eventCallback,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
@@ -55,9 +55,9 @@ export class CameraManagerEngineFactory {
|
|||||||
cameraManagerEngine = new MotionEyeCameraManagerEngine(
|
cameraManagerEngine = new MotionEyeCameraManagerEngine(
|
||||||
this._entityRegistryManager,
|
this._entityRegistryManager,
|
||||||
options.stateWatcher,
|
options.stateWatcher,
|
||||||
new BrowseMediaManager(),
|
new BrowseMediaWalker(),
|
||||||
options.resolvedMediaCache,
|
options.resolvedMediaCache,
|
||||||
new RequestCache(),
|
new CameraManagerRequestCache(),
|
||||||
options.eventCallback,
|
options.eventCallback,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
@@ -66,9 +66,9 @@ export class CameraManagerEngineFactory {
|
|||||||
cameraManagerEngine = new ReolinkCameraManagerEngine(
|
cameraManagerEngine = new ReolinkCameraManagerEngine(
|
||||||
this._entityRegistryManager,
|
this._entityRegistryManager,
|
||||||
options.stateWatcher,
|
options.stateWatcher,
|
||||||
new BrowseMediaManager(),
|
new BrowseMediaWalker(),
|
||||||
options.resolvedMediaCache,
|
options.resolvedMediaCache,
|
||||||
new RequestCache(),
|
new CameraManagerRequestCache(),
|
||||||
options.eventCallback,
|
options.eventCallback,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { CameraConfig } from '../config/schema/cameras';
|
import { CameraConfig } from '../config/schema/cameras';
|
||||||
import { HomeAssistant } from '../ha/types';
|
import { HomeAssistant } from '../ha/types';
|
||||||
import { ViewMedia } from '../view/media';
|
import { Endpoint } from '../types';
|
||||||
|
import { ViewMedia } from '../view/item';
|
||||||
|
import { ViewItemCapabilities } from '../view/types';
|
||||||
import { Camera } from './camera';
|
import { Camera } from './camera';
|
||||||
import { CameraManagerReadOnlyConfigStore } from './store';
|
import { CameraManagerReadOnlyConfigStore } from './store';
|
||||||
import {
|
import {
|
||||||
CameraEndpoint,
|
|
||||||
CameraEndpoints,
|
CameraEndpoints,
|
||||||
CameraEndpointsContext,
|
CameraEndpointsContext,
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
CameraManagerMediaCapabilities,
|
CameraQuery,
|
||||||
DataQuery,
|
|
||||||
Engine,
|
Engine,
|
||||||
EngineOptions,
|
EngineOptions,
|
||||||
EventQuery,
|
EventQuery,
|
||||||
@@ -90,7 +90,7 @@ export interface CameraManagerEngine {
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
media: ViewMedia,
|
media: ViewMedia,
|
||||||
): Promise<CameraEndpoint | null>;
|
): Promise<Endpoint | null>;
|
||||||
|
|
||||||
favoriteMedia(
|
favoriteMedia(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@@ -99,7 +99,7 @@ export interface CameraManagerEngine {
|
|||||||
favorite: boolean,
|
favorite: boolean,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
getQueryResultMaxAge(query: DataQuery): number | null;
|
getQueryResultMaxAge(query: CameraQuery): number | null;
|
||||||
|
|
||||||
getMediaSeekTime(
|
getMediaSeekTime(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@@ -121,7 +121,7 @@ export interface CameraManagerEngine {
|
|||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
): CameraManagerCameraMetadata;
|
): CameraManagerCameraMetadata;
|
||||||
|
|
||||||
getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null;
|
getMediaCapabilities(media: ViewMedia): ViewItemCapabilities | null;
|
||||||
|
|
||||||
getCameraEndpoints(
|
getCameraEndpoints(
|
||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import uniq from 'lodash-es/uniq';
|
import { uniq } from 'lodash-es';
|
||||||
import { ActionsExecutor } from '../../card-controller/actions/types';
|
import { ActionsExecutor } from '../../card-controller/actions/types';
|
||||||
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
||||||
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
|
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
|
||||||
import { CameraConfig } from '../../config/schema/cameras';
|
import { CameraConfig } from '../../config/schema/cameras';
|
||||||
|
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { localize } from '../../localize/localize';
|
import { localize } from '../../localize/localize';
|
||||||
import { PTZCapabilities, PTZMovementType } from '../../types';
|
import { PTZCapabilities, PTZMovementType } from '../../types';
|
||||||
import { errorToConsole } from '../../utils/basic';
|
import { errorToConsole } from '../../utils/basic';
|
||||||
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
|
|
||||||
import { Camera, CameraInitializationOptions } from '../camera';
|
import { Camera, CameraInitializationOptions } from '../camera';
|
||||||
import { Capabilities } from '../capabilities';
|
import { Capabilities } from '../capabilities';
|
||||||
import { CameraManagerEngine } from '../engine';
|
import { CameraManagerEngine } from '../engine';
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
import { add, endOfHour, format, fromUnixTime, startOfHour } from 'date-fns';
|
import { add, endOfHour, format, fromUnixTime, startOfHour } from 'date-fns';
|
||||||
import isEqual from 'lodash-es/isEqual';
|
import { isEqual, orderBy, throttle, uniqWith } from 'lodash-es';
|
||||||
import orderBy from 'lodash-es/orderBy';
|
|
||||||
import throttle from 'lodash-es/throttle';
|
|
||||||
import uniqWith from 'lodash-es/uniqWith';
|
|
||||||
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
||||||
import { CameraConfig } from '../../config/schema/cameras';
|
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 { HomeAssistant } from '../../ha/types';
|
||||||
|
import { Endpoint } from '../../types';
|
||||||
import {
|
import {
|
||||||
allPromises,
|
allPromises,
|
||||||
formatDate,
|
formatDate,
|
||||||
prettifyTitle,
|
prettifyTitle,
|
||||||
runWhenIdleIfSupported,
|
runWhenIdleIfSupported,
|
||||||
} from '../../utils/basic';
|
} from '../../utils/basic';
|
||||||
import { getEntityTitle } from '../../utils/ha';
|
import { ViewMedia, ViewMediaType } from '../../view/item';
|
||||||
import { EntityRegistryManager } from '../../utils/ha/registry/entity/types';
|
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||||
import { ViewMedia } from '../../view/media';
|
import { ViewItemCapabilities } from '../../view/types';
|
||||||
import { ViewMediaClassifier } from '../../view/media-classifier';
|
import { RecordingSegmentsCache } from '../cache';
|
||||||
import { RecordingSegmentsCache, RequestCache } from '../cache';
|
|
||||||
import { Camera } from '../camera';
|
import { Camera } from '../camera';
|
||||||
import {
|
import {
|
||||||
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
|
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
|
||||||
@@ -26,13 +25,12 @@ import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
|||||||
import { DateRange } from '../range';
|
import { DateRange } from '../range';
|
||||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||||
import {
|
import {
|
||||||
CameraEndpoint,
|
|
||||||
CameraEndpoints,
|
CameraEndpoints,
|
||||||
CameraEndpointsContext,
|
CameraEndpointsContext,
|
||||||
CameraEventCallback,
|
CameraEventCallback,
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
CameraManagerMediaCapabilities,
|
CameraManagerRequestCache,
|
||||||
DataQuery,
|
CameraQuery,
|
||||||
Engine,
|
Engine,
|
||||||
EngineOptions,
|
EngineOptions,
|
||||||
EventQuery,
|
EventQuery,
|
||||||
@@ -112,7 +110,7 @@ export class FrigateCameraManagerEngine
|
|||||||
protected _entityRegistryManager: EntityRegistryManager;
|
protected _entityRegistryManager: EntityRegistryManager;
|
||||||
protected _frigateEventWatcher: FrigateEventWatcher;
|
protected _frigateEventWatcher: FrigateEventWatcher;
|
||||||
protected _recordingSegmentsCache: RecordingSegmentsCache;
|
protected _recordingSegmentsCache: RecordingSegmentsCache;
|
||||||
protected _requestCache: RequestCache;
|
protected _requestCache: CameraManagerRequestCache;
|
||||||
|
|
||||||
// Garbage collect segments at most once an hour.
|
// Garbage collect segments at most once an hour.
|
||||||
protected _throttledSegmentGarbageCollector = throttle(
|
protected _throttledSegmentGarbageCollector = throttle(
|
||||||
@@ -125,7 +123,7 @@ export class FrigateCameraManagerEngine
|
|||||||
entityRegistryManager: EntityRegistryManager,
|
entityRegistryManager: EntityRegistryManager,
|
||||||
stateWatcher: StateWatcherSubscriptionInterface,
|
stateWatcher: StateWatcherSubscriptionInterface,
|
||||||
recordingSegmentsCache: RecordingSegmentsCache,
|
recordingSegmentsCache: RecordingSegmentsCache,
|
||||||
requestCache: RequestCache,
|
requestCache: CameraManagerRequestCache,
|
||||||
eventCallback?: CameraEventCallback,
|
eventCallback?: CameraEventCallback,
|
||||||
) {
|
) {
|
||||||
super(stateWatcher, eventCallback);
|
super(stateWatcher, eventCallback);
|
||||||
@@ -158,13 +156,13 @@ export class FrigateCameraManagerEngine
|
|||||||
_hass: HomeAssistant,
|
_hass: HomeAssistant,
|
||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
media: ViewMedia,
|
media: ViewMedia,
|
||||||
): Promise<CameraEndpoint | null> {
|
): Promise<Endpoint | null> {
|
||||||
if (FrigateViewMediaClassifier.isFrigateEvent(media)) {
|
if (FrigateViewMediaClassifier.isFrigateEvent(media)) {
|
||||||
return {
|
return {
|
||||||
endpoint:
|
endpoint:
|
||||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||||
`/notifications/${media.getID()}/` +
|
`/notifications/${media.getID()}/` +
|
||||||
`${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
|
`${ViewItemClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
|
||||||
`?download=true`,
|
`?download=true`,
|
||||||
sign: true,
|
sign: true,
|
||||||
};
|
};
|
||||||
@@ -541,7 +539,7 @@ export class FrigateCameraManagerEngine
|
|||||||
|
|
||||||
protected _getCameraIDMatch(
|
protected _getCameraIDMatch(
|
||||||
store: CameraManagerReadOnlyConfigStore,
|
store: CameraManagerReadOnlyConfigStore,
|
||||||
query: DataQuery,
|
query: CameraQuery,
|
||||||
instanceID: string,
|
instanceID: string,
|
||||||
cameraName: string,
|
cameraName: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
@@ -588,17 +586,17 @@ export class FrigateCameraManagerEngine
|
|||||||
if (!cameraConfig) {
|
if (!cameraConfig) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mediaType: 'clip' | 'snapshot' | null = null;
|
let mediaType: ViewMediaType | null = null;
|
||||||
if (
|
if (
|
||||||
!query.hasClip &&
|
!query.hasClip &&
|
||||||
!query.hasSnapshot &&
|
!query.hasSnapshot &&
|
||||||
(event.has_clip || event.has_snapshot)
|
(event.has_clip || event.has_snapshot)
|
||||||
) {
|
) {
|
||||||
mediaType = event.has_clip ? 'clip' : 'snapshot';
|
mediaType = event.has_clip ? ViewMediaType.Clip : ViewMediaType.Snapshot;
|
||||||
} else if (query.hasSnapshot && event.has_snapshot) {
|
} else if (query.hasSnapshot && event.has_snapshot) {
|
||||||
mediaType = 'snapshot';
|
mediaType = ViewMediaType.Snapshot;
|
||||||
} else if (query.hasClip && event.has_clip) {
|
} else if (query.hasClip && event.has_clip) {
|
||||||
mediaType = 'clip';
|
mediaType = ViewMediaType.Clip;
|
||||||
}
|
}
|
||||||
if (!mediaType) {
|
if (!mediaType) {
|
||||||
continue;
|
continue;
|
||||||
@@ -646,7 +644,7 @@ export class FrigateCameraManagerEngine
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getQueryResultMaxAge(query: DataQuery): number | null {
|
public getQueryResultMaxAge(query: CameraQuery): number | null {
|
||||||
if (query.type === QueryType.Event) {
|
if (query.type === QueryType.Event) {
|
||||||
return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
|
return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
|
||||||
} else if (query.type === QueryType.Recording) {
|
} else if (query.type === QueryType.Recording) {
|
||||||
@@ -664,11 +662,12 @@ export class FrigateCameraManagerEngine
|
|||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
const start = media.getStartTime();
|
const start = media.getStartTime();
|
||||||
const end = media.getEndTime();
|
const end = media.getEndTime();
|
||||||
if (!start || !end || target < start || target > end) {
|
const cameraID = media.getCameraID();
|
||||||
|
|
||||||
|
if (!start || !end || target < start || target > end || !cameraID) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cameraID = media.getCameraID();
|
|
||||||
const query: RecordingSegmentsQuery = {
|
const query: RecordingSegmentsQuery = {
|
||||||
cameraIDs: new Set([cameraID]),
|
cameraIDs: new Set([cameraID]),
|
||||||
start: start,
|
start: start,
|
||||||
@@ -898,9 +897,9 @@ export class FrigateCameraManagerEngine
|
|||||||
return seekMilliseconds / 1000;
|
return seekMilliseconds / 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities {
|
public getMediaCapabilities(media: ViewMedia): ViewItemCapabilities {
|
||||||
return {
|
return {
|
||||||
canFavorite: ViewMediaClassifier.isEvent(media),
|
canFavorite: ViewItemClassifier.isEvent(media),
|
||||||
canDownload: true,
|
canDownload: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -926,7 +925,7 @@ export class FrigateCameraManagerEngine
|
|||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
context?: CameraEndpointsContext,
|
context?: CameraEndpointsContext,
|
||||||
): CameraEndpoints | null {
|
): CameraEndpoints | null {
|
||||||
const getUIEndpoint = (): CameraEndpoint | null => {
|
const getUIEndpoint = (): Endpoint | null => {
|
||||||
if (!cameraConfig.frigate.url) {
|
if (!cameraConfig.frigate.url) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -976,7 +975,7 @@ export class FrigateCameraManagerEngine
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getJSMPEG = (): CameraEndpoint | null => {
|
const getJSMPEG = (): Endpoint | null => {
|
||||||
return {
|
return {
|
||||||
endpoint:
|
endpoint:
|
||||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export class FrigateEventWatcher implements FrigateEventWatcherSubscriptionInter
|
|||||||
let json: unknown;
|
let json: unknown;
|
||||||
try {
|
try {
|
||||||
json = JSON.parse(data);
|
json = JSON.parse(data);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Received non-JSON payload as Frigate event', data);
|
console.warn('Received non-JSON payload as Frigate event', data);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ViewMedia } from '../../view/media';
|
import { ViewMedia } from '../../view/item';
|
||||||
import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media';
|
import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media';
|
||||||
|
|
||||||
export class FrigateViewMediaClassifier {
|
export class FrigateViewMediaClassifier {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fromUnixTime } from 'date-fns';
|
import { fromUnixTime } from 'date-fns';
|
||||||
import isEqual from 'lodash-es/isEqual';
|
import { isEqual } from 'lodash-es';
|
||||||
import { CameraConfig } from '../../config/schema/cameras';
|
import { CameraConfig } from '../../config/schema/cameras';
|
||||||
import {
|
import {
|
||||||
EventViewMedia,
|
EventViewMedia,
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
VideoContentType,
|
VideoContentType,
|
||||||
ViewMedia,
|
ViewMedia,
|
||||||
ViewMediaType,
|
ViewMediaType,
|
||||||
} from '../../view/media';
|
} from '../../view/item';
|
||||||
import { FrigateEvent, FrigateRecording } from './types';
|
import { FrigateEvent, FrigateRecording } from './types';
|
||||||
import {
|
import {
|
||||||
getEventMediaContentID,
|
getEventMediaContentID,
|
||||||
@@ -36,7 +36,7 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
|
|||||||
// sublabels (`_splitSubLabels` in engine-frigate.ts).
|
// sublabels (`_splitSubLabels` in engine-frigate.ts).
|
||||||
subLabels?: string[],
|
subLabels?: string[],
|
||||||
) {
|
) {
|
||||||
super(mediaType, cameraID);
|
super(mediaType, { cameraID });
|
||||||
this._event = event;
|
this._event = event;
|
||||||
this._contentID = contentID;
|
this._contentID = contentID;
|
||||||
this._thumbnail = thumbnail;
|
this._thumbnail = thumbnail;
|
||||||
@@ -88,8 +88,6 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
|
|||||||
public getTags(): string[] | null {
|
public getTags(): string[] | null {
|
||||||
return this._subLabels;
|
return this._subLabels;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
public isGroupableWith(that: EventViewMedia): boolean {
|
public isGroupableWith(that: EventViewMedia): boolean {
|
||||||
return (
|
return (
|
||||||
this.getMediaType() === that.getMediaType() &&
|
this.getMediaType() === that.getMediaType() &&
|
||||||
@@ -113,7 +111,7 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
|
|||||||
contentID: string,
|
contentID: string,
|
||||||
title: string,
|
title: string,
|
||||||
) {
|
) {
|
||||||
super(mediaType, cameraID);
|
super(mediaType, { cameraID });
|
||||||
this._recording = recording;
|
this._recording = recording;
|
||||||
this._id = id;
|
this._id = id;
|
||||||
this._contentID = contentID;
|
this._contentID = contentID;
|
||||||
@@ -150,7 +148,7 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
|
|||||||
|
|
||||||
export class FrigateViewMediaFactory {
|
export class FrigateViewMediaFactory {
|
||||||
static createEventViewMedia(
|
static createEventViewMedia(
|
||||||
mediaType: 'clip' | 'snapshot',
|
mediaType: ViewMediaType,
|
||||||
cameraID: string,
|
cameraID: string,
|
||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
event: FrigateEvent,
|
event: FrigateEvent,
|
||||||
@@ -191,7 +189,7 @@ export class FrigateViewMediaFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new FrigateRecordingViewMedia(
|
return new FrigateRecordingViewMedia(
|
||||||
'recording',
|
ViewMediaType.Recording,
|
||||||
cameraID,
|
cameraID,
|
||||||
recording,
|
recording,
|
||||||
getRecordingID(cameraConfig, recording),
|
getRecordingID(cameraConfig, recording),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
|
import { homeAssistantWSRequest } from '../../ha/ws-request';
|
||||||
import { localize } from '../../localize/localize';
|
import { localize } from '../../localize/localize';
|
||||||
import { AdvancedCameraCardError } from '../../types';
|
import { AdvancedCameraCardError } from '../../types';
|
||||||
import { homeAssistantWSRequest } from '../../utils/ha/ws-request';
|
|
||||||
import { RecordingSegment } from '../types';
|
import { RecordingSegment } from '../types';
|
||||||
import {
|
import {
|
||||||
EventSummary,
|
EventSummary,
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
|
||||||
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
||||||
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
|
|
||||||
import { CameraConfig } from '../../config/schema/cameras';
|
import { CameraConfig } from '../../config/schema/cameras';
|
||||||
|
import { getEntityTitle } from '../../ha/get-entity-title';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { getEntityTitle } from '../../utils/ha';
|
import { Endpoint } from '../../types';
|
||||||
import { ViewMedia } from '../../view/media';
|
import { ViewMedia } from '../../view/item';
|
||||||
|
import { ViewItemCapabilities } from '../../view/types';
|
||||||
import { Camera } from '../camera';
|
import { Camera } from '../camera';
|
||||||
import { Capabilities } from '../capabilities';
|
import { Capabilities } from '../capabilities';
|
||||||
import { CameraManagerEngine } from '../engine';
|
import { CameraManagerEngine } from '../engine';
|
||||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||||
import {
|
import {
|
||||||
CameraEndpoint,
|
|
||||||
CameraEndpoints,
|
CameraEndpoints,
|
||||||
CameraEndpointsContext,
|
CameraEndpointsContext,
|
||||||
CameraEventCallback,
|
CameraEventCallback,
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
CameraManagerMediaCapabilities,
|
CameraQuery,
|
||||||
DataQuery,
|
|
||||||
Engine,
|
Engine,
|
||||||
EngineOptions,
|
EngineOptions,
|
||||||
EventQuery,
|
EventQuery,
|
||||||
@@ -155,7 +154,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
|||||||
_hass: HomeAssistant,
|
_hass: HomeAssistant,
|
||||||
_cameraConfig: CameraConfig,
|
_cameraConfig: CameraConfig,
|
||||||
_media: ViewMedia,
|
_media: ViewMedia,
|
||||||
): Promise<CameraEndpoint | null> {
|
): Promise<Endpoint | null> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +167,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getQueryResultMaxAge(_query: DataQuery): number | null {
|
public getQueryResultMaxAge(_query: CameraQuery): number | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +210,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null {
|
public getMediaCapabilities(_media: ViewMedia): ViewItemCapabilities | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +218,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
|||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
_context?: CameraEndpointsContext,
|
_context?: CameraEndpointsContext,
|
||||||
): CameraEndpoints | null {
|
): CameraEndpoints | null {
|
||||||
const getWebRTCCard = (): CameraEndpoint | null => {
|
const getWebRTCCard = (): Endpoint | null => {
|
||||||
// The user may override this in their webrtc_card configuration.
|
// The user may override this in their webrtc_card configuration.
|
||||||
const endpoint = cameraConfig.camera_entity ? cameraConfig.camera_entity : null;
|
const endpoint = cameraConfig.camera_entity ? cameraConfig.camera_entity : null;
|
||||||
return endpoint ? { endpoint: endpoint } : null;
|
return endpoint ? { endpoint: endpoint } : null;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { add } from 'date-fns';
|
import { add } from 'date-fns';
|
||||||
import cloneDeep from 'lodash-es/cloneDeep';
|
import { cloneDeep, sum } from 'lodash-es';
|
||||||
import sum from 'lodash-es/sum';
|
|
||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
import { CardCameraAPI } from '../card-controller/types.js';
|
import { CardCameraAPI } from '../card-controller/types.js';
|
||||||
|
import { sortItems } from '../card-controller/view/sort.js';
|
||||||
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz.js';
|
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz.js';
|
||||||
import { CameraConfig, CamerasConfig } from '../config/schema/cameras.js';
|
import { CameraConfig, CamerasConfig } from '../config/schema/cameras.js';
|
||||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
|
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
|
||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
|
import { Endpoint } from '../types.js';
|
||||||
import {
|
import {
|
||||||
allPromises,
|
allPromises,
|
||||||
arrayify,
|
arrayify,
|
||||||
@@ -16,19 +17,19 @@ import {
|
|||||||
} from '../utils/basic.js';
|
} from '../utils/basic.js';
|
||||||
import { getCameraID } from '../utils/camera.js';
|
import { getCameraID } from '../utils/camera.js';
|
||||||
import { log } from '../utils/debug.js';
|
import { log } from '../utils/debug.js';
|
||||||
import { ViewMedia } from '../view/media.js';
|
import { ViewItemClassifier } from '../view/item-classifier.js';
|
||||||
|
import { ViewItem, ViewMedia } from '../view/item.js';
|
||||||
|
import { ViewItemCapabilities } from '../view/types.js';
|
||||||
import { Capabilities } from './capabilities.js';
|
import { Capabilities } from './capabilities.js';
|
||||||
import { CameraManagerEngineFactory } from './engine-factory.js';
|
import { CameraManagerEngineFactory } from './engine-factory.js';
|
||||||
import { CameraManagerEngine } from './engine.js';
|
import { CameraManagerEngine } from './engine.js';
|
||||||
import { CameraInitializationError } from './error.js';
|
import { CameraInitializationError } from './error.js';
|
||||||
import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js';
|
import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js';
|
||||||
import {
|
import {
|
||||||
CameraEndpoint,
|
|
||||||
CameraEndpoints,
|
CameraEndpoints,
|
||||||
CameraEndpointsContext,
|
CameraEndpointsContext,
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
CameraManagerMediaCapabilities,
|
CameraQuery,
|
||||||
DataQuery,
|
|
||||||
Engine,
|
Engine,
|
||||||
EngineOptions,
|
EngineOptions,
|
||||||
EventQuery,
|
EventQuery,
|
||||||
@@ -38,7 +39,7 @@ import {
|
|||||||
MediaMetadataQuery,
|
MediaMetadataQuery,
|
||||||
MediaMetadataQueryResults,
|
MediaMetadataQueryResults,
|
||||||
MediaQuery,
|
MediaQuery,
|
||||||
PartialDataQuery,
|
PartialCameraQuery,
|
||||||
PartialEventQuery,
|
PartialEventQuery,
|
||||||
PartialQueryConcreteType,
|
PartialQueryConcreteType,
|
||||||
PartialRecordingQuery,
|
PartialRecordingQuery,
|
||||||
@@ -55,24 +56,25 @@ import {
|
|||||||
RecordingSegmentsQueryResultsMap,
|
RecordingSegmentsQueryResultsMap,
|
||||||
ResultsMap,
|
ResultsMap,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { sortMedia } from './utils/sort-media.js';
|
|
||||||
|
|
||||||
export class QueryClassifier {
|
export class CameraQueryClassifier {
|
||||||
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
|
public static isEventQuery(
|
||||||
|
query: CameraQuery | PartialCameraQuery,
|
||||||
|
): query is EventQuery {
|
||||||
return query.type === QueryType.Event;
|
return query.type === QueryType.Event;
|
||||||
}
|
}
|
||||||
public static isRecordingQuery(
|
public static isRecordingQuery(
|
||||||
query: DataQuery | PartialDataQuery,
|
query: CameraQuery | PartialCameraQuery,
|
||||||
): query is RecordingQuery {
|
): query is RecordingQuery {
|
||||||
return query.type === QueryType.Recording;
|
return query.type === QueryType.Recording;
|
||||||
}
|
}
|
||||||
public static isRecordingSegmentsQuery(
|
public static isRecordingSegmentsQuery(
|
||||||
query: DataQuery | PartialDataQuery,
|
query: CameraQuery | PartialCameraQuery,
|
||||||
): query is RecordingSegmentsQuery {
|
): query is RecordingSegmentsQuery {
|
||||||
return query.type === QueryType.RecordingSegments;
|
return query.type === QueryType.RecordingSegments;
|
||||||
}
|
}
|
||||||
public static isMediaMetadataQuery(
|
public static isMediaMetadataQuery(
|
||||||
query: DataQuery | PartialDataQuery,
|
query: CameraQuery | PartialCameraQuery,
|
||||||
): query is MediaMetadataQuery {
|
): query is MediaMetadataQuery {
|
||||||
return query.type === QueryType.MediaMetadata;
|
return query.type === QueryType.MediaMetadata;
|
||||||
}
|
}
|
||||||
@@ -103,7 +105,7 @@ export class QueryResultClassifier {
|
|||||||
|
|
||||||
export interface ExtendedMediaQueryResult<T extends MediaQuery> {
|
export interface ExtendedMediaQueryResult<T extends MediaQuery> {
|
||||||
queries: T[];
|
queries: T[];
|
||||||
results: ViewMedia[];
|
results: ViewItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CameraManager {
|
export class CameraManager {
|
||||||
@@ -318,7 +320,7 @@ export class CameraManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _generateDefaultQueries<PQT extends PartialDataQuery>(
|
protected _generateDefaultQueries<PQT extends PartialCameraQuery>(
|
||||||
cameraIDs: string | Set<string>,
|
cameraIDs: string | Set<string>,
|
||||||
partialQuery: PQT,
|
partialQuery: PQT,
|
||||||
): PartialQueryConcreteType<PQT>[] | null {
|
): PartialQueryConcreteType<PQT>[] | null {
|
||||||
@@ -330,17 +332,17 @@ export class CameraManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [engine, cameraIDs] of engines) {
|
for (const [engine, cameraIDs] of engines) {
|
||||||
let queries: DataQuery[] | null = null;
|
let queries: CameraQuery[] | null = null;
|
||||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||||
if (QueryClassifier.isEventQuery(partialQuery)) {
|
if (CameraQueryClassifier.isEventQuery(partialQuery)) {
|
||||||
queries = engine.generateDefaultEventQuery(this._store, cameraIDs, partialQuery);
|
queries = engine.generateDefaultEventQuery(this._store, cameraIDs, partialQuery);
|
||||||
} else if (QueryClassifier.isRecordingQuery(partialQuery)) {
|
} else if (CameraQueryClassifier.isRecordingQuery(partialQuery)) {
|
||||||
queries = engine.generateDefaultRecordingQuery(
|
queries = engine.generateDefaultRecordingQuery(
|
||||||
this._store,
|
this._store,
|
||||||
cameraIDs,
|
cameraIDs,
|
||||||
partialQuery,
|
partialQuery,
|
||||||
);
|
);
|
||||||
} else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
|
} else if (CameraQueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
|
||||||
queries = engine.generateDefaultRecordingSegmentsQuery(
|
queries = engine.generateDefaultRecordingSegmentsQuery(
|
||||||
this._store,
|
this._store,
|
||||||
cameraIDs,
|
cameraIDs,
|
||||||
@@ -426,7 +428,7 @@ export class CameraManager {
|
|||||||
|
|
||||||
public async extendMediaQueries<T extends MediaQuery>(
|
public async extendMediaQueries<T extends MediaQuery>(
|
||||||
queries: T[],
|
queries: T[],
|
||||||
results: ViewMedia[],
|
results: ViewItem[],
|
||||||
direction: 'earlier' | 'later',
|
direction: 'earlier' | 'later',
|
||||||
engineOptions?: EngineOptions,
|
engineOptions?: EngineOptions,
|
||||||
): Promise<ExtendedMediaQueryResult<T> | null> {
|
): Promise<ExtendedMediaQueryResult<T> | null> {
|
||||||
@@ -438,6 +440,9 @@ export class CameraManager {
|
|||||||
const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => {
|
const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => {
|
||||||
let output: Date | null = null;
|
let output: Date | null = null;
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
|
if (!ViewItemClassifier.isMedia(result)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const startTime = result.getStartTime();
|
const startTime = result.getStartTime();
|
||||||
if (
|
if (
|
||||||
startTime &&
|
startTime &&
|
||||||
@@ -495,7 +500,7 @@ export class CameraManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputMedia = sortMedia(results.concat(newChunkMedia));
|
const outputMedia = sortItems(results.concat(newChunkMedia));
|
||||||
|
|
||||||
// If the media did not _ACTUALLY_ get longer, there is no new media despite
|
// If the media did not _ACTUALLY_ get longer, there is no new media despite
|
||||||
// the increased limit, so just return null.
|
// the increased limit, so just return null.
|
||||||
@@ -509,7 +514,7 @@ export class CameraManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMediaDownloadPath(media: ViewMedia): Promise<CameraEndpoint | null> {
|
public async getMediaDownloadPath(media: ViewMedia): Promise<Endpoint | null> {
|
||||||
const cameraConfig = this._store.getCameraConfigForMedia(media);
|
const cameraConfig = this._store.getCameraConfigForMedia(media);
|
||||||
const engine = this._store.getEngineForMedia(media);
|
const engine = this._store.getEngineForMedia(media);
|
||||||
const hass = this._api.getHASSManager().getHASS();
|
const hass = this._api.getHASSManager().getHASS();
|
||||||
@@ -520,7 +525,7 @@ export class CameraManager {
|
|||||||
return await engine.getMediaDownloadPath(hass, cameraConfig, media);
|
return await engine.getMediaDownloadPath(hass, cameraConfig, media);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null {
|
public getMediaCapabilities(media: ViewMedia): ViewItemCapabilities | null {
|
||||||
const engine = this._store.getEngineForMedia(media);
|
const engine = this._store.getEngineForMedia(media);
|
||||||
if (!engine) {
|
if (!engine) {
|
||||||
return null;
|
return null;
|
||||||
@@ -603,7 +608,7 @@ export class CameraManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _handleQuery<QT extends DataQuery>(
|
protected async _handleQuery<QT extends CameraQuery>(
|
||||||
query: QT | QT[],
|
query: QT | QT[],
|
||||||
engineOptions?: EngineOptions,
|
engineOptions?: EngineOptions,
|
||||||
): Promise<Map<QT, QueryReturnType<QT>>> {
|
): Promise<Map<QT, QueryReturnType<QT>>> {
|
||||||
@@ -623,28 +628,28 @@ export class CameraManager {
|
|||||||
let engineResult: Map<QT, QueryReturnType<QT>> | null = null;
|
let engineResult: Map<QT, QueryReturnType<QT>> | null = null;
|
||||||
|
|
||||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||||
if (QueryClassifier.isEventQuery(query)) {
|
if (CameraQueryClassifier.isEventQuery(query)) {
|
||||||
engineResult = (await engine.getEvents(
|
engineResult = (await engine.getEvents(
|
||||||
hass,
|
hass,
|
||||||
this._store,
|
this._store,
|
||||||
query,
|
query,
|
||||||
engineOptions,
|
engineOptions,
|
||||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||||
} else if (QueryClassifier.isRecordingQuery(query)) {
|
} else if (CameraQueryClassifier.isRecordingQuery(query)) {
|
||||||
engineResult = (await engine.getRecordings(
|
engineResult = (await engine.getRecordings(
|
||||||
hass,
|
hass,
|
||||||
this._store,
|
this._store,
|
||||||
query,
|
query,
|
||||||
engineOptions,
|
engineOptions,
|
||||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||||
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
|
} else if (CameraQueryClassifier.isRecordingSegmentsQuery(query)) {
|
||||||
engineResult = (await engine.getRecordingSegments(
|
engineResult = (await engine.getRecordingSegments(
|
||||||
hass,
|
hass,
|
||||||
this._store,
|
this._store,
|
||||||
query,
|
query,
|
||||||
engineOptions,
|
engineOptions,
|
||||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||||
} else if (QueryClassifier.isMediaMetadataQuery(query)) {
|
} else if (CameraQueryClassifier.isMediaMetadataQuery(query)) {
|
||||||
engineResult = (await engine.getMediaMetadata(
|
engineResult = (await engine.getMediaMetadata(
|
||||||
hass,
|
hass,
|
||||||
this._store,
|
this._store,
|
||||||
@@ -697,7 +702,7 @@ export class CameraManager {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _convertQueryResultsToMedia<QT extends DataQuery>(
|
protected _convertQueryResultsToMedia<QT extends CameraQuery>(
|
||||||
results: ResultsMap<QT>,
|
results: ResultsMap<QT>,
|
||||||
): ViewMedia[] {
|
): ViewMedia[] {
|
||||||
const mediaArray: ViewMedia[] = [];
|
const mediaArray: ViewMedia[] = [];
|
||||||
@@ -714,12 +719,12 @@ export class CameraManager {
|
|||||||
let media: ViewMedia[] | null = null;
|
let media: ViewMedia[] | null = null;
|
||||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||||
if (
|
if (
|
||||||
QueryClassifier.isEventQuery(query) &&
|
CameraQueryClassifier.isEventQuery(query) &&
|
||||||
QueryResultClassifier.isEventQueryResult(result)
|
QueryResultClassifier.isEventQueryResult(result)
|
||||||
) {
|
) {
|
||||||
media = engine.generateMediaFromEvents(hass, this._store, query, result);
|
media = engine.generateMediaFromEvents(hass, this._store, query, result);
|
||||||
} else if (
|
} else if (
|
||||||
QueryClassifier.isRecordingQuery(query) &&
|
CameraQueryClassifier.isRecordingQuery(query) &&
|
||||||
QueryResultClassifier.isRecordingQueryResult(result)
|
QueryResultClassifier.isRecordingQueryResult(result)
|
||||||
) {
|
) {
|
||||||
media = engine.generateMediaFromRecordings(hass, this._store, query, result);
|
media = engine.generateMediaFromRecordings(hass, this._store, query, result);
|
||||||
@@ -729,7 +734,7 @@ export class CameraManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sortMedia(mediaArray);
|
return sortItems(mediaArray);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCameraEndpoints(
|
public getCameraEndpoints(
|
||||||
|
|||||||
@@ -1,32 +1,29 @@
|
|||||||
import { add, endOfDay, parse, startOfDay } from 'date-fns';
|
import { add, endOfDay, parse, startOfDay } from 'date-fns';
|
||||||
import orderBy from 'lodash-es/orderBy';
|
import { orderBy } from 'lodash-es';
|
||||||
import { CameraConfig } from '../../config/schema/cameras';
|
import { CameraConfig } from '../../config/schema/cameras';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media';
|
||||||
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
|
||||||
import {
|
|
||||||
BrowseMediaStep,
|
|
||||||
BrowseMediaTarget,
|
|
||||||
} from '../../utils/ha/browse-media/browse-media-manager';
|
|
||||||
import {
|
import {
|
||||||
BROWSE_MEDIA_CACHE_SECONDS,
|
BROWSE_MEDIA_CACHE_SECONDS,
|
||||||
BrowseMedia,
|
BrowseMedia,
|
||||||
|
BrowseMediaCache,
|
||||||
|
BrowseMediaMetadata,
|
||||||
MEDIA_CLASS_IMAGE,
|
MEDIA_CLASS_IMAGE,
|
||||||
MEDIA_CLASS_VIDEO,
|
MEDIA_CLASS_VIDEO,
|
||||||
RichBrowseMedia,
|
RichBrowseMedia,
|
||||||
} from '../../utils/ha/browse-media/types';
|
} from '../../ha/browse-media/types';
|
||||||
import { ViewMedia } from '../../view/media';
|
import { BrowseMediaStep, BrowseMediaTarget } from '../../ha/browse-media/walker';
|
||||||
|
import { isMediaWithinDates } from '../../ha/browse-media/within-dates';
|
||||||
|
import { HomeAssistant } from '../../ha/types';
|
||||||
|
import { Endpoint } from '../../types';
|
||||||
|
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||||
|
import { ViewMedia } from '../../view/item';
|
||||||
import { BrowseMediaCamera } from '../browse-media/camera';
|
import { BrowseMediaCamera } from '../browse-media/camera';
|
||||||
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
||||||
import { BrowseMediaMetadata } from '../browse-media/types';
|
|
||||||
import { getViewMediaFromBrowseMediaArray } from '../browse-media/utils/browse-media-to-view-media';
|
|
||||||
import { isMediaWithinDates } from '../browse-media/utils/within-dates';
|
|
||||||
import { MemoryRequestCache } from '../cache';
|
|
||||||
import { Camera } from '../camera';
|
import { Camera } from '../camera';
|
||||||
import { Capabilities } from '../capabilities';
|
import { Capabilities } from '../capabilities';
|
||||||
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
||||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||||
import {
|
import {
|
||||||
CameraEndpoint,
|
|
||||||
CameraEndpoints,
|
CameraEndpoints,
|
||||||
CameraEndpointsContext,
|
CameraEndpointsContext,
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
@@ -67,8 +64,8 @@ const MOTIONEYE_REPL_SUBSTITUTIONS: Record<string, string> = {
|
|||||||
const MOTIONEYE_REPL_REGEXP = new RegExp(/(%Y|%m|%d|%H|%M|%S)/g);
|
const MOTIONEYE_REPL_REGEXP = new RegExp(/(%Y|%m|%d|%H|%M|%S)/g);
|
||||||
|
|
||||||
export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||||
protected _directoryCache = new MemoryRequestCache<string, BrowseMedia>();
|
protected _directoryCache = new BrowseMediaCache<BrowseMediaMetadata>();
|
||||||
protected _fileCache = new MemoryRequestCache<string, BrowseMedia>();
|
protected _fileCache = new BrowseMediaCache<BrowseMediaMetadata>();
|
||||||
|
|
||||||
public getEngineType(): Engine {
|
public getEngineType(): Engine {
|
||||||
return Engine.MotionEye;
|
return Engine.MotionEye;
|
||||||
@@ -224,7 +221,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
|||||||
};
|
};
|
||||||
|
|
||||||
// For motionEye snapshots and clips are mutually exclusive.
|
// For motionEye snapshots and clips are mutually exclusive.
|
||||||
return await this._browseMediaManager.walkBrowseMedias(
|
return await this._browseMediaWalker.walk(
|
||||||
hass,
|
hass,
|
||||||
[
|
[
|
||||||
...(matchOptions?.hasClip !== false && !matchOptions?.hasSnapshot
|
...(matchOptions?.hasClip !== false && !matchOptions?.hasSnapshot
|
||||||
@@ -291,7 +288,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
|||||||
);
|
);
|
||||||
|
|
||||||
const limit = perCameraQuery.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT;
|
const limit = perCameraQuery.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT;
|
||||||
const media = await this._browseMediaManager.walkBrowseMedias(
|
const media = await this._browseMediaWalker.walk(
|
||||||
hass,
|
hass,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -431,7 +428,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
|||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
context?: CameraEndpointsContext,
|
context?: CameraEndpointsContext,
|
||||||
): CameraEndpoints | null {
|
): CameraEndpoints | null {
|
||||||
const getUIEndpoint = (): CameraEndpoint | null => {
|
const getUIEndpoint = (): Endpoint | null => {
|
||||||
return cameraConfig.motioneye?.url
|
return cameraConfig.motioneye?.url
|
||||||
? {
|
? {
|
||||||
endpoint: cameraConfig.motioneye.url,
|
endpoint: cameraConfig.motioneye.url,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
|
import { BrowseMediaMetadata, RichBrowseMedia } from '../../ha/browse-media/types';
|
||||||
import { BrowseMediaMetadata } from '../browse-media/types';
|
|
||||||
import { Engine, EventQueryResults } from '../types';
|
import { Engine, EventQueryResults } from '../types';
|
||||||
|
|
||||||
// ================================
|
// ================================
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import orderBy from 'lodash-es/orderBy';
|
import { orderBy } from 'lodash-es';
|
||||||
|
|
||||||
interface Range<T extends Date | number> {
|
interface Range<T extends Date | number> {
|
||||||
start: T;
|
start: T;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { ActionsExecutor } from '../../card-controller/actions/types';
|
import { ActionsExecutor } from '../../card-controller/actions/types';
|
||||||
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
|
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
|
||||||
|
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { localize } from '../../localize/localize';
|
import { localize } from '../../localize/localize';
|
||||||
import { PTZCapabilities, PTZMovementType } from '../../types';
|
import { PTZCapabilities, PTZMovementType } from '../../types';
|
||||||
import { createSelectOptionAction } from '../../utils/action.js';
|
import { createSelectOptionAction } from '../../utils/action.js';
|
||||||
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
|
|
||||||
import { BrowseMediaCamera } from '../browse-media/camera';
|
import { BrowseMediaCamera } from '../browse-media/camera';
|
||||||
import { Camera, CameraInitializationOptions } from '../camera';
|
import { Camera, CameraInitializationOptions } from '../camera';
|
||||||
import { Capabilities } from '../capabilities';
|
import { Capabilities } from '../capabilities';
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import { add, endOfDay, parse, startOfDay } from 'date-fns';
|
import { add, endOfDay, parse, startOfDay } from 'date-fns';
|
||||||
import { orderBy } from 'lodash-es';
|
import { orderBy } from 'lodash-es';
|
||||||
import { CameraConfig } from '../../config/schema/cameras';
|
import { CameraConfig } from '../../config/schema/cameras';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media';
|
||||||
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
import { sortMediaByStartDate } from '../../ha/browse-media/sort-browse-media-by-start-date';
|
||||||
import { sortMediaByStartDate } from '../../utils/ha/browse-media/browse-media-manager';
|
|
||||||
import {
|
import {
|
||||||
BROWSE_MEDIA_CACHE_SECONDS,
|
BROWSE_MEDIA_CACHE_SECONDS,
|
||||||
BrowseMedia,
|
BrowseMedia,
|
||||||
|
BrowseMediaCache,
|
||||||
|
BrowseMediaMetadata,
|
||||||
MEDIA_CLASS_VIDEO,
|
MEDIA_CLASS_VIDEO,
|
||||||
RichBrowseMedia,
|
RichBrowseMedia,
|
||||||
} from '../../utils/ha/browse-media/types';
|
} from '../../ha/browse-media/types';
|
||||||
import { ViewMedia } from '../../view/media';
|
import { isMediaWithinDates } from '../../ha/browse-media/within-dates';
|
||||||
|
import { HomeAssistant } from '../../ha/types';
|
||||||
|
import { Endpoint } from '../../types';
|
||||||
|
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||||
|
import { ViewMedia } from '../../view/item';
|
||||||
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
||||||
import { BrowseMediaMetadata } from '../browse-media/types';
|
|
||||||
import { getViewMediaFromBrowseMediaArray } from '../browse-media/utils/browse-media-to-view-media';
|
|
||||||
import { isMediaWithinDates } from '../browse-media/utils/within-dates';
|
|
||||||
import { MemoryRequestCache } from '../cache';
|
|
||||||
import { Camera } from '../camera';
|
import { Camera } from '../camera';
|
||||||
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
||||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||||
import {
|
import {
|
||||||
CameraEndpoint,
|
|
||||||
CameraEndpoints,
|
CameraEndpoints,
|
||||||
CameraEndpointsContext,
|
CameraEndpointsContext,
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
@@ -48,7 +48,8 @@ export class ReolinkQueryResultsClassifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||||
protected _cache = new MemoryRequestCache<string, BrowseMedia>();
|
protected _camerasCache = new BrowseMediaCache<BrowseMediaReolinkCameraMetadata>();
|
||||||
|
protected _cache = new BrowseMediaCache<BrowseMediaMetadata>();
|
||||||
|
|
||||||
public getEngineType(): Engine {
|
public getEngineType(): Engine {
|
||||||
return Engine.Reolink;
|
return Engine.Reolink;
|
||||||
@@ -172,7 +173,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
|||||||
// that match the expected camera. Some Reolink cameras will not show up
|
// that match the expected camera. Some Reolink cameras will not show up
|
||||||
// here causing errors.
|
// here causing errors.
|
||||||
// https://github.com/dermotduffy/advanced-camera-card/issues/1723
|
// https://github.com/dermotduffy/advanced-camera-card/issues/1723
|
||||||
const camerasWithMedia = await this._browseMediaManager.walkBrowseMedias(
|
const camerasWithMedia = await this._browseMediaWalker.walk(
|
||||||
hass,
|
hass,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -188,7 +189,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
...(engineOptions?.useCache !== false && { cache: this._cache }),
|
...(engineOptions?.useCache !== false && { cache: this._camerasCache }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this._browseMediaManager.walkBrowseMedias(
|
return await this._browseMediaWalker.walk(
|
||||||
hass,
|
hass,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -263,7 +264,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
|||||||
let media: RichBrowseMedia<BrowseMediaMetadata>[] = [];
|
let media: RichBrowseMedia<BrowseMediaMetadata>[] = [];
|
||||||
|
|
||||||
if (directories?.length) {
|
if (directories?.length) {
|
||||||
media = await this._browseMediaManager.walkBrowseMedias(
|
media = await this._browseMediaWalker.walk(
|
||||||
hass,
|
hass,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -395,7 +396,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
|||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
context?: CameraEndpointsContext,
|
context?: CameraEndpointsContext,
|
||||||
): CameraEndpoints | null {
|
): CameraEndpoints | null {
|
||||||
const getUIEndpoint = (): CameraEndpoint | null => {
|
const getUIEndpoint = (): Endpoint | null => {
|
||||||
return cameraConfig.reolink?.url
|
return cameraConfig.reolink?.url
|
||||||
? {
|
? {
|
||||||
endpoint: cameraConfig.reolink.url,
|
endpoint: cameraConfig.reolink.url,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
|
import { BrowseMediaMetadata, RichBrowseMedia } from '../../ha/browse-media/types';
|
||||||
import { BrowseMediaMetadata } from '../browse-media/types';
|
|
||||||
import { Engine, EventQueryResults } from '../types';
|
import { Engine, EventQueryResults } from '../types';
|
||||||
|
|
||||||
export interface BrowseMediaReolinkCameraMetadata {
|
export interface BrowseMediaReolinkCameraMetadata {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CameraConfig } from '../config/schema/cameras';
|
import { CameraConfig } from '../config/schema/cameras';
|
||||||
import { CapabilityKey } from '../types';
|
import { CapabilityKey } from '../types';
|
||||||
import { allPromises } from '../utils/basic';
|
import { allPromises } from '../utils/basic';
|
||||||
import { ViewMedia } from '../view/media';
|
import { ViewMedia } from '../view/item';
|
||||||
import { Camera } from './camera';
|
import { Camera } from './camera';
|
||||||
import { CameraManagerEngine } from './engine';
|
import { CameraManagerEngine } from './engine';
|
||||||
import { CapabilitySearchOptions, Engine } from './types';
|
import { CapabilitySearchOptions, Engine } from './types';
|
||||||
@@ -133,7 +133,8 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null {
|
public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null {
|
||||||
return this.getCameraConfig(media.getCameraID());
|
const cameraID = media.getCameraID();
|
||||||
|
return cameraID ? this.getCameraConfig(cameraID) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getEngineOfType(engine: Engine): CameraManagerEngine | null {
|
public getEngineOfType(engine: Engine): CameraManagerEngine | null {
|
||||||
@@ -163,7 +164,8 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getEngineForMedia(media: ViewMedia): CameraManagerEngine | null {
|
public getEngineForMedia(media: ViewMedia): CameraManagerEngine | null {
|
||||||
return this.getEngineForCameraID(media.getCameraID());
|
const cameraID = media.getCameraID();
|
||||||
|
return cameraID ? this.getEngineForCameraID(cameraID) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+17
-21
@@ -1,7 +1,8 @@
|
|||||||
|
import { ExpiringEqualityCache } from '../cache/expiring-cache';
|
||||||
import { SSLCiphers } from '../config/schema/cameras';
|
import { SSLCiphers } from '../config/schema/cameras';
|
||||||
import { AdvancedCameraCardView } from '../config/schema/common/const';
|
import { AdvancedCameraCardView } from '../config/schema/common/const';
|
||||||
import { CapabilityKey, Icon } from '../types';
|
import { CapabilityKey, Endpoint, Icon } from '../types';
|
||||||
import { ViewMedia } from '../view/media';
|
import { ViewMedia } from '../view/item';
|
||||||
|
|
||||||
// ====
|
// ====
|
||||||
// Base
|
// Base
|
||||||
@@ -28,11 +29,11 @@ export enum Engine {
|
|||||||
Reolink = 'reolink',
|
Reolink = 'reolink',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DataQuery {
|
export interface CameraQuery {
|
||||||
type: QueryType;
|
type: QueryType;
|
||||||
cameraIDs: Set<string>;
|
cameraIDs: Set<string>;
|
||||||
}
|
}
|
||||||
export type PartialDataQuery = Partial<DataQuery>;
|
export type PartialCameraQuery = Partial<CameraQuery>;
|
||||||
|
|
||||||
interface TimeBasedDataQuery {
|
interface TimeBasedDataQuery {
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -44,7 +45,7 @@ interface LimitedDataQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaQuery
|
export interface MediaQuery
|
||||||
extends DataQuery,
|
extends CameraQuery,
|
||||||
Partial<TimeBasedDataQuery>,
|
Partial<TimeBasedDataQuery>,
|
||||||
Partial<LimitedDataQuery> {
|
Partial<LimitedDataQuery> {
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
@@ -100,11 +101,6 @@ interface CapabilitySearchAllAny {
|
|||||||
}
|
}
|
||||||
export type CapabilitySearchOptions = CapabilityKey | CapabilitySearchAllAny;
|
export type CapabilitySearchOptions = CapabilityKey | CapabilitySearchAllAny;
|
||||||
|
|
||||||
export interface CameraManagerMediaCapabilities {
|
|
||||||
canFavorite: boolean;
|
|
||||||
canDownload: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CameraManagerCameraMetadata {
|
export interface CameraManagerCameraMetadata {
|
||||||
title: string;
|
title: string;
|
||||||
icon: Icon;
|
icon: Icon;
|
||||||
@@ -118,16 +114,11 @@ export interface CameraEndpointsContext {
|
|||||||
view?: AdvancedCameraCardView;
|
view?: AdvancedCameraCardView;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CameraEndpoint {
|
|
||||||
endpoint: string;
|
|
||||||
sign?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CameraEndpoints {
|
export interface CameraEndpoints {
|
||||||
ui?: CameraEndpoint;
|
ui?: Endpoint;
|
||||||
go2rtc?: CameraEndpoint;
|
go2rtc?: Endpoint;
|
||||||
jsmpeg?: CameraEndpoint;
|
jsmpeg?: Endpoint;
|
||||||
webrtcCard?: CameraEndpoint;
|
webrtcCard?: Endpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CameraProxyConfig {
|
export interface CameraProxyConfig {
|
||||||
@@ -157,6 +148,11 @@ export interface CameraEvent {
|
|||||||
}
|
}
|
||||||
export type CameraEventCallback = (ev: CameraEvent) => void;
|
export type CameraEventCallback = (ev: CameraEvent) => void;
|
||||||
|
|
||||||
|
export class CameraManagerRequestCache extends ExpiringEqualityCache<
|
||||||
|
CameraQuery,
|
||||||
|
QueryResults
|
||||||
|
> {}
|
||||||
|
|
||||||
// ===========
|
// ===========
|
||||||
// Event Query
|
// Event Query
|
||||||
// ===========
|
// ===========
|
||||||
@@ -202,7 +198,7 @@ export interface RecordingQueryResults extends QueryResults {
|
|||||||
// Recording Segments Query
|
// Recording Segments Query
|
||||||
// ========================
|
// ========================
|
||||||
|
|
||||||
export interface RecordingSegmentsQuery extends DataQuery, TimeBasedDataQuery {
|
export interface RecordingSegmentsQuery extends CameraQuery, TimeBasedDataQuery {
|
||||||
type: QueryType.RecordingSegments;
|
type: QueryType.RecordingSegments;
|
||||||
}
|
}
|
||||||
export type PartialRecordingSegmentsQuery = Partial<RecordingSegmentsQuery>;
|
export type PartialRecordingSegmentsQuery = Partial<RecordingSegmentsQuery>;
|
||||||
@@ -216,7 +212,7 @@ export interface RecordingSegmentsQueryResults extends QueryResults {
|
|||||||
// Media metadata Query
|
// Media metadata Query
|
||||||
// ====================
|
// ====================
|
||||||
|
|
||||||
export interface MediaMetadataQuery extends DataQuery {
|
export interface MediaMetadataQuery extends CameraQuery {
|
||||||
type: QueryType.MediaMetadata;
|
type: QueryType.MediaMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CameraConfig } from '../../config/schema/cameras';
|
import { CameraConfig } from '../../config/schema/cameras';
|
||||||
import { CameraEndpoint } from '../types';
|
import { Endpoint } from '../../types';
|
||||||
|
|
||||||
export const getDefaultGo2RTCEndpoint = (
|
export const getDefaultGo2RTCEndpoint = (
|
||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
@@ -7,7 +7,7 @@ export const getDefaultGo2RTCEndpoint = (
|
|||||||
url?: string;
|
url?: string;
|
||||||
stream?: string;
|
stream?: string;
|
||||||
},
|
},
|
||||||
): CameraEndpoint | null => {
|
): Endpoint | null => {
|
||||||
const url = options?.url ?? cameraConfig.go2rtc?.url;
|
const url = options?.url ?? cameraConfig.go2rtc?.url;
|
||||||
const stream = options?.stream ?? cameraConfig.go2rtc?.stream;
|
const stream = options?.stream ?? cameraConfig.go2rtc?.stream;
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import orderBy from 'lodash-es/orderBy';
|
|
||||||
import uniqBy from 'lodash-es/uniqBy';
|
|
||||||
import { ViewMedia } from '../../view/media';
|
|
||||||
|
|
||||||
export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => {
|
|
||||||
return orderBy(
|
|
||||||
// Ensure uniqueness by the ID (if specified), otherwise all elements
|
|
||||||
// are assumed to be unique.
|
|
||||||
uniqBy(mediaArray, (media) => media.getID() ?? media),
|
|
||||||
|
|
||||||
// Sort all items leading oldest -> youngest (so media is loaded in this
|
|
||||||
// order in the viewer which matches the left-to-right timeline order).
|
|
||||||
(media) => media.getStartTime() ?? media.getID(),
|
|
||||||
'asc',
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
getActionConfigGivenAction,
|
getActionConfigGivenAction,
|
||||||
isAdvancedCameraCardCustomAction,
|
isAdvancedCameraCardCustomAction,
|
||||||
} from '../../utils/action.js';
|
} from '../../utils/action.js';
|
||||||
import { allPromises } from '../../utils/basic.js';
|
import { allPromises, errorToConsole } from '../../utils/basic.js';
|
||||||
import { TemplateRenderer } from '../templates/index.js';
|
import { TemplateRenderer } from '../templates/index.js';
|
||||||
import { CardActionsManagerAPI } from '../types.js';
|
import { CardActionsManagerAPI } from '../types.js';
|
||||||
import { ActionSet } from './actions/set.js';
|
import { ActionSet } from './actions/set.js';
|
||||||
@@ -53,7 +53,7 @@ export class ActionsManager implements ActionsExecutor {
|
|||||||
let specificActions: Actions | undefined = undefined;
|
let specificActions: Actions | undefined = undefined;
|
||||||
if (view?.is('live')) {
|
if (view?.is('live')) {
|
||||||
specificActions = config?.live.actions;
|
specificActions = config?.live.actions;
|
||||||
} else if (view?.isGalleryView()) {
|
} else if (view?.isMediaGalleryView()) {
|
||||||
specificActions = config?.media_gallery?.actions;
|
specificActions = config?.media_gallery?.actions;
|
||||||
} else if (view?.isViewerView()) {
|
} else if (view?.isViewerView()) {
|
||||||
specificActions = config?.media_viewer.actions;
|
specificActions = config?.media_viewer.actions;
|
||||||
@@ -150,6 +150,7 @@ export class ActionsManager implements ActionsExecutor {
|
|||||||
await actionSet.execute(this._api);
|
await actionSet.execute(this._api);
|
||||||
forwardHaptic('success');
|
forwardHaptic('success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
errorToConsole(e as Error);
|
||||||
forwardHaptic('warning');
|
forwardHaptic('warning');
|
||||||
}
|
}
|
||||||
this._actionsInFlight = this._actionsInFlight.filter((a) => a !== actionSet);
|
this._actionsInFlight = this._actionsInFlight.filter((a) => a !== actionSet);
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export class DownloadAction extends AdvancedCameraCardAction<GeneralActionConfig
|
|||||||
public async execute(api: CardActionsAPI): Promise<void> {
|
public async execute(api: CardActionsAPI): Promise<void> {
|
||||||
await super.execute(api);
|
await super.execute(api);
|
||||||
|
|
||||||
await api.getDownloadManager().downloadViewerMedia();
|
const item = api.getViewManager().getView()?.queryResults?.getSelectedResult();
|
||||||
|
if (item) {
|
||||||
|
await api.getViewItemManager().download(item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { FolderActionConfig } from '../../../config/schema/actions/custom/folder';
|
||||||
|
import { FolderViewQuery } from '../../../view/query';
|
||||||
|
import { CardActionsAPI } from '../../types';
|
||||||
|
import { AdvancedCameraCardAction } from './base';
|
||||||
|
|
||||||
|
export class FolderAction extends AdvancedCameraCardAction<FolderActionConfig> {
|
||||||
|
public async execute(api: CardActionsAPI): Promise<void> {
|
||||||
|
await super.execute(api);
|
||||||
|
|
||||||
|
const folder = api.getFoldersManager().getFolder(this._action.folder);
|
||||||
|
if (!folder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = api.getFoldersManager().generateDefaultFolderQuery(folder);
|
||||||
|
if (!query) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.getViewManager().setViewByParametersWithExistingQuery({
|
||||||
|
params: {
|
||||||
|
view: 'folder',
|
||||||
|
query: new FolderViewQuery(query),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { MediaPlayerActionConfig } from '../../../config/schema/actions/custom/media-player';
|
import { MediaPlayerActionConfig } from '../../../config/schema/actions/custom/media-player';
|
||||||
import { getStreamCameraID } from '../../../utils/substream';
|
import { getStreamCameraID } from '../../../utils/substream';
|
||||||
|
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||||
import { CardActionsAPI } from '../../types';
|
import { CardActionsAPI } from '../../types';
|
||||||
import { AdvancedCameraCardAction } from './base';
|
import { AdvancedCameraCardAction } from './base';
|
||||||
|
|
||||||
@@ -9,15 +10,19 @@ export class MediaPlayerAction extends AdvancedCameraCardAction<MediaPlayerActio
|
|||||||
|
|
||||||
const mediaPlayer = this._action.media_player;
|
const mediaPlayer = this._action.media_player;
|
||||||
const mediaPlayerController = api.getMediaPlayerManager();
|
const mediaPlayerController = api.getMediaPlayerManager();
|
||||||
const view = api.getViewManager().getView();
|
|
||||||
const media = view?.queryResults?.getSelectedResult() ?? null;
|
|
||||||
|
|
||||||
if (this._action.media_player_action === 'stop') {
|
if (this._action.media_player_action === 'stop') {
|
||||||
await mediaPlayerController.stop(mediaPlayer);
|
await mediaPlayerController.stop(mediaPlayer);
|
||||||
} else if (view?.is('live')) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = api.getViewManager().getView();
|
||||||
|
const item = view?.queryResults?.getSelectedResult() ?? null;
|
||||||
|
|
||||||
|
if (view?.is('live')) {
|
||||||
await mediaPlayerController.playLive(mediaPlayer, getStreamCameraID(view));
|
await mediaPlayerController.playLive(mediaPlayer, getStreamCameraID(view));
|
||||||
} else if (view?.isViewerView() && media) {
|
} else if (view?.isViewerView() && item && ViewItemClassifier.isMedia(item)) {
|
||||||
await mediaPlayerController.playMedia(mediaPlayer, media);
|
await mediaPlayerController.playMedia(mediaPlayer, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import clamp from 'lodash-es/clamp';
|
import { clamp } from 'lodash-es';
|
||||||
import {
|
import {
|
||||||
PartialZoomSettings,
|
PartialZoomSettings,
|
||||||
ZOOM_DEFAULT_PAN_X,
|
ZOOM_DEFAULT_PAN_X,
|
||||||
|
|||||||
@@ -86,12 +86,15 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
|
|||||||
setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this);
|
setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this);
|
||||||
|
|
||||||
const singleStep = async (): Promise<void> => {
|
const singleStep = async (): Promise<void> => {
|
||||||
this._action.ptz_action &&
|
/* istanbul ignore else: the else path cannot be reached as ptz_action
|
||||||
(await api
|
being present is checked above -- @preserve */
|
||||||
|
if (this._action.ptz_action) {
|
||||||
|
await api
|
||||||
.getCameraManager()
|
.getCameraManager()
|
||||||
.executePTZAction(ptzCameraID, this._action.ptz_action, {
|
.executePTZAction(ptzCameraID, this._action.ptz_action, {
|
||||||
preset: this._action.ptz_preset,
|
preset: this._action.ptz_preset,
|
||||||
}));
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!this._stopped) {
|
if (!this._stopped) {
|
||||||
// Only start the timer for the next step after this step returns, and
|
// Only start the timer for the next step after this step returns, and
|
||||||
@@ -121,13 +124,16 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this._timer.start(ptzConfiguration.c2r_delay_between_calls_seconds, async () => {
|
this._timer.start(ptzConfiguration.c2r_delay_between_calls_seconds, async () => {
|
||||||
this._action.ptz_action &&
|
/* istanbul ignore else: the else path cannot be reached as ptz_action
|
||||||
(await api
|
being present is checked above -- @preserve */
|
||||||
|
if (this._action.ptz_action) {
|
||||||
|
await api
|
||||||
.getCameraManager()
|
.getCameraManager()
|
||||||
.executePTZAction(ptzCameraID, this._action.ptz_action, {
|
.executePTZAction(ptzCameraID, this._action.ptz_action, {
|
||||||
preset: this._action.ptz_preset,
|
preset: this._action.ptz_preset,
|
||||||
phase: 'stop',
|
phase: 'stop',
|
||||||
}));
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
|
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
|
||||||
|
import { downloadURL } from '../../../utils/download';
|
||||||
|
import { generateScreenshotFilename } from '../../../utils/screenshot';
|
||||||
import { CardActionsAPI } from '../../types';
|
import { CardActionsAPI } from '../../types';
|
||||||
import { AdvancedCameraCardAction } from './base';
|
import { AdvancedCameraCardAction } from './base';
|
||||||
|
|
||||||
@@ -6,6 +8,13 @@ export class ScreenshotAction extends AdvancedCameraCardAction<GeneralActionConf
|
|||||||
public async execute(api: CardActionsAPI): Promise<void> {
|
public async execute(api: CardActionsAPI): Promise<void> {
|
||||||
await super.execute(api);
|
await super.execute(api);
|
||||||
|
|
||||||
await api.getDownloadManager().downloadScreenshot();
|
const url = await api
|
||||||
|
.getMediaLoadedInfoManager()
|
||||||
|
.get()
|
||||||
|
?.mediaPlayerController?.getScreenshotURL();
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
downloadURL(url, generateScreenshotFilename(api.getViewManager().getView()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SleepActionConfig } from '../../../config/schema/actions/custom/sleep';
|
import { SleepActionConfig } from '../../../config/schema/actions/custom/sleep';
|
||||||
import { sleep } from '../../../utils/basic';
|
import { sleep } from '../../../utils/sleep';
|
||||||
import { CardActionsAPI } from '../../types';
|
import { CardActionsAPI } from '../../types';
|
||||||
import { timeDeltaToSeconds } from '../utils/time-delta';
|
import { timeDeltaToSeconds } from '../utils/time-delta';
|
||||||
import { AdvancedCameraCardAction } from './base';
|
import { AdvancedCameraCardAction } from './base';
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { CardActionsAPI } from '../../types';
|
|||||||
import { AdvancedCameraCardAction } from './base';
|
import { AdvancedCameraCardAction } from './base';
|
||||||
|
|
||||||
export class StatusBarAction extends AdvancedCameraCardAction<StatusBarActionConfig> {
|
export class StatusBarAction extends AdvancedCameraCardAction<StatusBarActionConfig> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
public async execute(api: CardActionsAPI): Promise<void> {
|
public async execute(api: CardActionsAPI): Promise<void> {
|
||||||
await super.execute(api);
|
await super.execute(api);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DefaultAction } from './actions/default';
|
|||||||
import { DisplayModeSelectAction } from './actions/display-mode-select';
|
import { DisplayModeSelectAction } from './actions/display-mode-select';
|
||||||
import { DownloadAction } from './actions/download';
|
import { DownloadAction } from './actions/download';
|
||||||
import { ExpandAction } from './actions/expand';
|
import { ExpandAction } from './actions/expand';
|
||||||
|
import { FolderAction } from './actions/folder';
|
||||||
import { FullscreenAction } from './actions/fullscreen';
|
import { FullscreenAction } from './actions/fullscreen';
|
||||||
import { InternalCallbackAction } from './actions/internal-callback';
|
import { InternalCallbackAction } from './actions/internal-callback';
|
||||||
import { LogAction } from './actions/log';
|
import { LogAction } from './actions/log';
|
||||||
@@ -150,6 +151,8 @@ export class ActionFactory {
|
|||||||
return new StatusBarAction(context, action, options?.config);
|
return new StatusBarAction(context, action, options?.config);
|
||||||
case INTERNAL_CALLBACK_ACTION:
|
case INTERNAL_CALLBACK_ACTION:
|
||||||
return new InternalCallbackAction(context, action, options?.config);
|
return new InternalCallbackAction(context, action, options?.config);
|
||||||
|
case 'folder':
|
||||||
|
return new FolderAction(context, action, options?.config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* istanbul ignore next: this path cannot be reached -- @preserve */
|
/* istanbul ignore next: this path cannot be reached -- @preserve */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import merge from 'lodash-es/merge';
|
import { merge } from 'lodash-es';
|
||||||
import { Action, TargetedActionContext } from '../types';
|
import { Action, TargetedActionContext } from '../types';
|
||||||
import { ActionContext } from 'action';
|
import { ActionContext } from 'action';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ViewItemClassifier } from '../view/item-classifier';
|
||||||
import { CardCameraURLAPI } from './types';
|
import { CardCameraURLAPI } from './types';
|
||||||
|
|
||||||
export class CameraURLManager {
|
export class CameraURLManager {
|
||||||
@@ -20,11 +21,11 @@ export class CameraURLManager {
|
|||||||
|
|
||||||
public getCameraURL(): string | null {
|
public getCameraURL(): string | null {
|
||||||
const view = this._api.getViewManager().getView();
|
const view = this._api.getViewManager().getView();
|
||||||
const media = view?.queryResults?.getSelectedResult() ?? null;
|
const item = view?.queryResults?.getSelectedResult() ?? null;
|
||||||
const endpoints = view?.camera
|
const endpoints = view?.camera
|
||||||
? this._api.getCameraManager().getCameraEndpoints(view.camera, {
|
? this._api.getCameraManager().getCameraEndpoints(view.camera, {
|
||||||
view: view.view,
|
view: view.view,
|
||||||
...(media && { media: media }),
|
...(item && ViewItemClassifier.isMedia(item) && { media: item }),
|
||||||
}) ?? null
|
}) ?? null
|
||||||
: null;
|
: null;
|
||||||
return endpoints?.ui?.endpoint ?? null;
|
return endpoints?.ui?.endpoint ?? null;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { LitElement, ReactiveControllerHost } from 'lit';
|
import { LitElement, ReactiveControllerHost } from 'lit';
|
||||||
import { ActionEventTarget } from '../action-handler-directive';
|
import { ActionEventTarget } from '../action-handler-directive';
|
||||||
|
import { isCardInPanel } from '../ha/panel';
|
||||||
import { setOrRemoveAttribute } from '../utils/basic';
|
import { setOrRemoveAttribute } from '../utils/basic';
|
||||||
import { isBeingCasted } from '../utils/casting';
|
import { isBeingCasted } from '../utils/casting';
|
||||||
import { isCardInPanel } from '../utils/ha';
|
|
||||||
import { ActionExecutionRequestEventTarget } from './actions/utils/execution-request';
|
import { ActionExecutionRequestEventTarget } from './actions/utils/execution-request';
|
||||||
import { InitializationAspect } from './initialization-manager';
|
import { InitializationAspect } from './initialization-manager';
|
||||||
import { CardElementAPI } from './types';
|
import { CardElementAPI } from './types';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import isEqual from 'lodash-es/isEqual';
|
import { isEqual } from 'lodash-es';
|
||||||
import { ConditionsManager } from '../../conditions/conditions-manager.js';
|
import { ConditionsManager } from '../../conditions/conditions-manager.js';
|
||||||
import { isConfigUpgradeable } from '../../config/management.js';
|
import { isConfigUpgradeable } from '../../config/management.js';
|
||||||
import { setProfiles } from '../../config/profiles/set-profiles.js';
|
import { setProfiles } from '../../config/profiles/set-profiles.js';
|
||||||
@@ -15,6 +15,7 @@ import { CardConfigAPI } from '../types.js';
|
|||||||
import { getOverriddenConfig } from './get-overridden-config.js';
|
import { getOverriddenConfig } from './get-overridden-config.js';
|
||||||
import { setAutomationsFromConfig } from './load-automations.js';
|
import { setAutomationsFromConfig } from './load-automations.js';
|
||||||
import { setRemoteControlEntityFromConfig } from './load-control-entities.js';
|
import { setRemoteControlEntityFromConfig } from './load-control-entities.js';
|
||||||
|
import { setFoldersFromConfig } from './load-folders.js';
|
||||||
import { setKeyboardShortcutsFromConfig } from './load-keyboard-shortcuts.js';
|
import { setKeyboardShortcutsFromConfig } from './load-keyboard-shortcuts.js';
|
||||||
|
|
||||||
export class ConfigManager {
|
export class ConfigManager {
|
||||||
@@ -136,6 +137,7 @@ export class ConfigManager {
|
|||||||
const previousConfig = this._overriddenConfig;
|
const previousConfig = this._overriddenConfig;
|
||||||
this._overriddenConfig = overriddenConfig;
|
this._overriddenConfig = overriddenConfig;
|
||||||
|
|
||||||
|
setFoldersFromConfig(this._api);
|
||||||
this._api.getStyleManager().updateFromConfig();
|
this._api.getStyleManager().updateFromConfig();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CardConfigLoaderAPI } from '../types';
|
import { CardConfigLoaderAPI } from '../types';
|
||||||
|
|
||||||
export const setAutomationsFromConfig = (api: CardConfigLoaderAPI) => {
|
export const setAutomationsFromConfig = (api: CardConfigLoaderAPI): void => {
|
||||||
api.getAutomationsManager().deleteAutomations();
|
api.getAutomationsManager().deleteAutomations();
|
||||||
api
|
api
|
||||||
.getAutomationsManager()
|
.getAutomationsManager()
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { CardConfigLoaderAPI } from '../types';
|
||||||
|
|
||||||
|
export const setFoldersFromConfig = (api: CardConfigLoaderAPI): void => {
|
||||||
|
api.getFoldersManager().deleteFolders();
|
||||||
|
try {
|
||||||
|
api
|
||||||
|
.getFoldersManager()
|
||||||
|
.addFolders(api.getConfigManager().getConfig()?.folders ?? []);
|
||||||
|
} catch (ev) {
|
||||||
|
api.getMessageManager().setErrorIfHigherPriority(ev);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,17 +2,12 @@ import { ReactiveController } from 'lit';
|
|||||||
import { CameraManager } from '../camera-manager/manager';
|
import { CameraManager } from '../camera-manager/manager';
|
||||||
import { ConditionStateManager } from '../conditions/state-manager';
|
import { ConditionStateManager } from '../conditions/state-manager';
|
||||||
import { AdvancedCameraCardConfig } from '../config/schema/types';
|
import { AdvancedCameraCardConfig } from '../config/schema/types';
|
||||||
|
import { DeviceRegistryManager } from '../ha/registry/device';
|
||||||
|
import { DeviceCache } from '../ha/registry/device/types';
|
||||||
|
import { EntityRegistryManagerLive } from '../ha/registry/entity';
|
||||||
|
import { EntityCache, EntityRegistryManager } from '../ha/registry/entity/types';
|
||||||
|
import { ResolvedMediaCache } from '../ha/resolved-media';
|
||||||
import { LovelaceCardEditor } from '../ha/types';
|
import { LovelaceCardEditor } from '../ha/types';
|
||||||
import {
|
|
||||||
createDeviceRegistryCache,
|
|
||||||
DeviceRegistryManager,
|
|
||||||
} from '../utils/ha/registry/device';
|
|
||||||
import {
|
|
||||||
createEntityRegistryCache,
|
|
||||||
EntityRegistryManagerLive,
|
|
||||||
} from '../utils/ha/registry/entity';
|
|
||||||
import { EntityRegistryManager } from '../utils/ha/registry/entity/types';
|
|
||||||
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
|
||||||
import { ActionsManager } from './actions/actions-manager';
|
import { ActionsManager } from './actions/actions-manager';
|
||||||
import { AutomationsManager } from './automations-manager';
|
import { AutomationsManager } from './automations-manager';
|
||||||
import { CameraURLManager } from './camera-url-manager';
|
import { CameraURLManager } from './camera-url-manager';
|
||||||
@@ -24,8 +19,8 @@ import {
|
|||||||
} from './card-element-manager';
|
} from './card-element-manager';
|
||||||
import { ConfigManager } from './config/config-manager';
|
import { ConfigManager } from './config/config-manager';
|
||||||
import { DefaultManager } from './default-manager';
|
import { DefaultManager } from './default-manager';
|
||||||
import { DownloadManager } from './download-manager';
|
|
||||||
import { ExpandManager } from './expand-manager';
|
import { ExpandManager } from './expand-manager';
|
||||||
|
import { FoldersManager } from './folders/manager';
|
||||||
import { FullscreenManager } from './fullscreen/fullscreen-manager';
|
import { FullscreenManager } from './fullscreen/fullscreen-manager';
|
||||||
import { HASSManager } from './hass/hass-manager';
|
import { HASSManager } from './hass/hass-manager';
|
||||||
import { InitializationManager } from './initialization-manager';
|
import { InitializationManager } from './initialization-manager';
|
||||||
@@ -65,6 +60,7 @@ import {
|
|||||||
CardTriggersAPI,
|
CardTriggersAPI,
|
||||||
CardViewAPI,
|
CardViewAPI,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { ViewItemManager } from './view/item-manager';
|
||||||
import { ViewManager } from './view/view-manager';
|
import { ViewManager } from './view/view-manager';
|
||||||
|
|
||||||
export class CardController
|
export class CardController
|
||||||
@@ -98,12 +94,8 @@ export class CardController
|
|||||||
|
|
||||||
// These properties may be used in the construction of 'managers' (and should
|
// These properties may be used in the construction of 'managers' (and should
|
||||||
// be created first).
|
// be created first).
|
||||||
protected _deviceRegistryManager = new DeviceRegistryManager(
|
protected _deviceRegistryManager = new DeviceRegistryManager(new DeviceCache());
|
||||||
createDeviceRegistryCache(),
|
protected _entityRegistryManager = new EntityRegistryManagerLive(new EntityCache());
|
||||||
);
|
|
||||||
protected _entityRegistryManager = new EntityRegistryManagerLive(
|
|
||||||
createEntityRegistryCache(),
|
|
||||||
);
|
|
||||||
protected _resolvedMediaCache = new ResolvedMediaCache();
|
protected _resolvedMediaCache = new ResolvedMediaCache();
|
||||||
|
|
||||||
protected _actionsManager = new ActionsManager(this, new TemplateRenderer());
|
protected _actionsManager = new ActionsManager(this, new TemplateRenderer());
|
||||||
@@ -113,8 +105,8 @@ export class CardController
|
|||||||
protected _cardElementManager: CardElementManager;
|
protected _cardElementManager: CardElementManager;
|
||||||
protected _configManager = new ConfigManager(this);
|
protected _configManager = new ConfigManager(this);
|
||||||
protected _defaultManager = new DefaultManager(this);
|
protected _defaultManager = new DefaultManager(this);
|
||||||
protected _downloadManager = new DownloadManager(this);
|
|
||||||
protected _expandManager = new ExpandManager(this);
|
protected _expandManager = new ExpandManager(this);
|
||||||
|
protected _foldersManager = new FoldersManager(this);
|
||||||
protected _fullscreenManager = new FullscreenManager(this);
|
protected _fullscreenManager = new FullscreenManager(this);
|
||||||
protected _hassManager = new HASSManager(this);
|
protected _hassManager = new HASSManager(this);
|
||||||
protected _initializationManager = new InitializationManager(this);
|
protected _initializationManager = new InitializationManager(this);
|
||||||
@@ -129,6 +121,7 @@ export class CardController
|
|||||||
protected _styleManager = new StyleManager(this);
|
protected _styleManager = new StyleManager(this);
|
||||||
protected _triggersManager = new TriggersManager(this);
|
protected _triggersManager = new TriggersManager(this);
|
||||||
protected _viewManager = new ViewManager(this);
|
protected _viewManager = new ViewManager(this);
|
||||||
|
protected _viewItemManager = new ViewItemManager(this);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
host: CardHTMLElement,
|
host: CardHTMLElement,
|
||||||
@@ -193,10 +186,6 @@ export class CardController
|
|||||||
return this._deviceRegistryManager;
|
return this._deviceRegistryManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDownloadManager(): DownloadManager {
|
|
||||||
return this._downloadManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getEntityRegistryManager(): EntityRegistryManager {
|
public getEntityRegistryManager(): EntityRegistryManager {
|
||||||
return this._entityRegistryManager;
|
return this._entityRegistryManager;
|
||||||
}
|
}
|
||||||
@@ -205,6 +194,10 @@ export class CardController
|
|||||||
return this._expandManager;
|
return this._expandManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getFoldersManager(): FoldersManager {
|
||||||
|
return this._foldersManager;
|
||||||
|
}
|
||||||
|
|
||||||
public getFullscreenManager(): FullscreenManager {
|
public getFullscreenManager(): FullscreenManager {
|
||||||
return this._fullscreenManager;
|
return this._fullscreenManager;
|
||||||
}
|
}
|
||||||
@@ -282,6 +275,10 @@ export class CardController
|
|||||||
return this._viewManager;
|
return this._viewManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getViewItemManager(): ViewItemManager {
|
||||||
|
return this._viewItemManager;
|
||||||
|
}
|
||||||
|
|
||||||
// *************************************************************************
|
// *************************************************************************
|
||||||
// Handlers
|
// Handlers
|
||||||
// *************************************************************************
|
// *************************************************************************
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import isEqual from 'lodash-es/isEqual';
|
import { isEqual } from 'lodash-es';
|
||||||
import { AdvancedCameraCardConfig } from '../config/schema/types';
|
import { AdvancedCameraCardConfig } from '../config/schema/types';
|
||||||
import { createGeneralAction } from '../utils/action';
|
import { createGeneralAction } from '../utils/action';
|
||||||
import { isActionAllowedBasedOnInteractionState } from '../utils/interaction-mode';
|
import { isActionAllowedBasedOnInteractionState } from '../utils/interaction-mode';
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { downloadMedia, downloadURL } from '../utils/download';
|
|
||||||
import { generateScreenshotFilename } from '../utils/screenshot';
|
|
||||||
import { CardDownloadAPI } from './types';
|
|
||||||
|
|
||||||
export class DownloadManager {
|
|
||||||
protected _api: CardDownloadAPI;
|
|
||||||
|
|
||||||
constructor(api: CardDownloadAPI) {
|
|
||||||
this._api = api;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async downloadViewerMedia(): Promise<boolean> {
|
|
||||||
const media = this._api
|
|
||||||
.getViewManager()
|
|
||||||
.getView()
|
|
||||||
?.queryResults?.getSelectedResult();
|
|
||||||
const hass = this._api.getHASSManager().getHASS();
|
|
||||||
|
|
||||||
if (!media || !hass) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await downloadMedia(hass, this._api.getCameraManager(), media);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
this._api.getMessageManager().setErrorIfHigherPriority(error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async downloadScreenshot(): Promise<void> {
|
|
||||||
const url = await this._api
|
|
||||||
.getMediaLoadedInfoManager()
|
|
||||||
.get()
|
|
||||||
?.mediaPlayerController?.getScreenshotURL();
|
|
||||||
if (url) {
|
|
||||||
downloadURL(url, generateScreenshotFilename(this._api.getViewManager().getView()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { FolderConfig, FolderType, folderTypeSchema } from '../../config/schema/folders';
|
||||||
|
import { HomeAssistant } from '../../ha/types';
|
||||||
|
import { Endpoint } from '../../types';
|
||||||
|
import { ViewItem } from '../../view/item';
|
||||||
|
import { ViewItemCapabilities } from '../../view/types';
|
||||||
|
import { sortItems } from '../view/sort';
|
||||||
|
import { HAFoldersEngine } from './ha/engine';
|
||||||
|
import { DownloadHelpers, EngineOptions, FolderQuery, FoldersEngine } from './types';
|
||||||
|
|
||||||
|
export class FoldersExecutor {
|
||||||
|
private _ha: FoldersEngine;
|
||||||
|
|
||||||
|
constructor(engines?: { ha?: HAFoldersEngine }) {
|
||||||
|
this._ha = engines?.ha ?? new HAFoldersEngine();
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
|
||||||
|
return (
|
||||||
|
this._getFolderEngine(folder.type)?.generateDefaultFolderQuery(folder) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async expandFolder(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
query: FolderQuery,
|
||||||
|
engineOptions?: EngineOptions,
|
||||||
|
): Promise<ViewItem[] | null> {
|
||||||
|
const results =
|
||||||
|
(await this._getFolderEngine(query.folder.type)?.expandFolder(
|
||||||
|
hass,
|
||||||
|
query,
|
||||||
|
engineOptions,
|
||||||
|
)) ?? null;
|
||||||
|
return results ? sortItems(results) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
|
||||||
|
return (
|
||||||
|
this._getFolderEngine(item.getFolder()?.type)?.getItemCapabilities(item) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDownloadPath(
|
||||||
|
hass: HomeAssistant | null,
|
||||||
|
item: ViewItem,
|
||||||
|
helpers?: DownloadHelpers,
|
||||||
|
): Promise<Endpoint | null> {
|
||||||
|
return await (this._getFolderEngine(item.getFolder()?.type)?.getDownloadPath(
|
||||||
|
hass,
|
||||||
|
item,
|
||||||
|
helpers,
|
||||||
|
) ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async favorite(
|
||||||
|
hass: HomeAssistant | null,
|
||||||
|
item: ViewItem,
|
||||||
|
favorite: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
return await this._getFolderEngine(item.getFolder()?.type)?.favorite(
|
||||||
|
hass,
|
||||||
|
item,
|
||||||
|
favorite,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getFolderEngine(type?: FolderType): FoldersEngine | null {
|
||||||
|
switch (type) {
|
||||||
|
case folderTypeSchema.enum.ha:
|
||||||
|
return this._ha;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { NonEmptyTuple } from 'type-fest';
|
||||||
|
import {
|
||||||
|
FolderConfig,
|
||||||
|
folderTypeSchema,
|
||||||
|
HA_MEDIA_SOURCE_ROOT,
|
||||||
|
HAFolderConfig,
|
||||||
|
HAFolderPathComponent,
|
||||||
|
} from '../../../config/schema/folders';
|
||||||
|
import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media';
|
||||||
|
import { BrowseMedia, BrowseMediaCache } from '../../../ha/browse-media/types';
|
||||||
|
import {
|
||||||
|
BrowseMediaStep,
|
||||||
|
BrowseMediaTarget,
|
||||||
|
BrowseMediaWalker,
|
||||||
|
} from '../../../ha/browse-media/walker';
|
||||||
|
import { getMediaDownloadPath } from '../../../ha/download';
|
||||||
|
import { HomeAssistant } from '../../../ha/types';
|
||||||
|
import { Endpoint } from '../../../types';
|
||||||
|
import { ViewItem } from '../../../view/item';
|
||||||
|
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||||
|
import { ViewItemCapabilities } from '../../../view/types';
|
||||||
|
import {
|
||||||
|
DownloadHelpers,
|
||||||
|
EngineOptions,
|
||||||
|
FolderPathComponent,
|
||||||
|
FolderQuery,
|
||||||
|
FoldersEngine,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export class HAFoldersEngine implements FoldersEngine {
|
||||||
|
private _browseMediaManager: BrowseMediaWalker;
|
||||||
|
private _cache = new BrowseMediaCache();
|
||||||
|
|
||||||
|
public constructor(browseMediaManager?: BrowseMediaWalker) {
|
||||||
|
this._browseMediaManager = browseMediaManager ?? new BrowseMediaWalker();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
|
||||||
|
return {
|
||||||
|
canFavorite: false,
|
||||||
|
canDownload: !ViewItemClassifier.isFolder(item),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDownloadPath(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
item: ViewItem,
|
||||||
|
helpers?: DownloadHelpers,
|
||||||
|
): Promise<Endpoint | null> {
|
||||||
|
if (!ViewItemClassifier.isMedia(item)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getMediaDownloadPath(hass, item.getContentID(), helpers?.resolvedMediaCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async favorite(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
_hass: HomeAssistant,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
_item: ViewItem,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
_favorite: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
|
||||||
|
if (folder.type !== folderTypeSchema.enum.ha) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
folder,
|
||||||
|
path: this.getDefaultFolderPathComponents(folder.ha),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async expandFolder(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
query: FolderQuery,
|
||||||
|
engineOptions?: EngineOptions,
|
||||||
|
): Promise<ViewItem[] | null> {
|
||||||
|
if (query.folder.type !== folderTypeSchema.enum.ha) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathComponents = [...query.path];
|
||||||
|
|
||||||
|
// Search through the path components from the start to find the last
|
||||||
|
// component with a precise media source id, which is where the queries
|
||||||
|
// start (and may drill down from).
|
||||||
|
let start: string | null = null;
|
||||||
|
while (pathComponents.length > 0) {
|
||||||
|
const id = pathComponents[0]?.id;
|
||||||
|
if (id) {
|
||||||
|
start = id;
|
||||||
|
pathComponents.shift();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no media source id is found, return null, as there is no "starting
|
||||||
|
// query".
|
||||||
|
if (start === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This matcher matches a browse media against a given path component.
|
||||||
|
const componentMatcher = (
|
||||||
|
media: BrowseMedia,
|
||||||
|
component?: FolderPathComponent,
|
||||||
|
): boolean => {
|
||||||
|
return (
|
||||||
|
!component ||
|
||||||
|
(media.can_expand &&
|
||||||
|
(component.ha?.title === media.title ||
|
||||||
|
(component.ha?.title_re &&
|
||||||
|
new RegExp(component.ha.title_re).test(media.title)) ||
|
||||||
|
component.id === media.media_content_id))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate a walk step, optionally matching against the next path component
|
||||||
|
// (if any), otherwise just returning all the media at this level.
|
||||||
|
const generateStep = (targets: BrowseMediaTarget[]): BrowseMediaStep[] => {
|
||||||
|
const nextComponent = pathComponents.shift();
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
targets,
|
||||||
|
...(nextComponent && {
|
||||||
|
matcher: (media: BrowseMedia) => componentMatcher(media, nextComponent),
|
||||||
|
advance: (targets) => generateStep(targets),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const browseMedia = await this._browseMediaManager.walk(
|
||||||
|
hass,
|
||||||
|
generateStep([start]),
|
||||||
|
{
|
||||||
|
...((engineOptions?.useCache ?? true) && { cache: this._cache }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return getViewItemsFromBrowseMediaArray(browseMedia, {
|
||||||
|
folder: query.folder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultFolderPathComponents(
|
||||||
|
haFolderConfig?: HAFolderConfig,
|
||||||
|
): NonEmptyTuple<FolderPathComponent> {
|
||||||
|
const shouldAddDefaultRoot = !haFolderConfig?.url && !haFolderConfig?.path?.[0]?.id;
|
||||||
|
|
||||||
|
const defaultPath = [
|
||||||
|
...(shouldAddDefaultRoot ? [{ id: HA_MEDIA_SOURCE_ROOT }] : []),
|
||||||
|
...(haFolderConfig?.url ?? []),
|
||||||
|
...(haFolderConfig?.path ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return defaultPath.map((component) =>
|
||||||
|
this._convertHAPathComponentToFolderPathComponent(component),
|
||||||
|
) as [FolderPathComponent, ...FolderPathComponent[]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert from the HA folder path component config schema to the general,
|
||||||
|
// which pulls `path` to the top level.
|
||||||
|
private _convertHAPathComponentToFolderPathComponent(
|
||||||
|
component: HAFolderPathComponent,
|
||||||
|
): FolderPathComponent {
|
||||||
|
return {
|
||||||
|
id: component.id,
|
||||||
|
ha: {
|
||||||
|
...component,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { cloneDeep } from 'lodash-es';
|
||||||
|
import { FolderConfig } from '../../config/schema/folders';
|
||||||
|
import { localize } from '../../localize/localize';
|
||||||
|
import { Endpoint } from '../../types';
|
||||||
|
import { ViewItem } from '../../view/item';
|
||||||
|
import { ViewItemCapabilities } from '../../view/types';
|
||||||
|
import { CardFoldersAPI } from '../types';
|
||||||
|
import { FoldersExecutor } from './executor';
|
||||||
|
import { EngineOptions, FolderInitializationError, FolderQuery } from './types';
|
||||||
|
|
||||||
|
export class FoldersManager {
|
||||||
|
private _api: CardFoldersAPI;
|
||||||
|
private _executor: FoldersExecutor;
|
||||||
|
private _folders: Map<string, FolderConfig> = new Map();
|
||||||
|
|
||||||
|
constructor(api: CardFoldersAPI, executor?: FoldersExecutor) {
|
||||||
|
this._api = api;
|
||||||
|
this._executor = executor ?? new FoldersExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
public deleteFolders(): void {
|
||||||
|
this._folders.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public addFolders(folders: FolderConfig[]): void {
|
||||||
|
for (const folder of folders) {
|
||||||
|
const folderNumber = this._folders.size;
|
||||||
|
const id = folder.id ?? `folder/${folderNumber.toString()}`;
|
||||||
|
if (this._folders.has(id)) {
|
||||||
|
throw new FolderInitializationError(
|
||||||
|
localize('error.duplicate_folder_id'),
|
||||||
|
folder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._folders.set(id, {
|
||||||
|
title: `${localize('common.folder')} ${folderNumber}`,
|
||||||
|
...cloneDeep(folder),
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getFolderCount(): number {
|
||||||
|
return this._folders.size;
|
||||||
|
}
|
||||||
|
public getFolders(): MapIterator<[string, FolderConfig]> {
|
||||||
|
return this._folders.entries();
|
||||||
|
}
|
||||||
|
public getFolder(id?: string): FolderConfig | null {
|
||||||
|
return id
|
||||||
|
? this._folders.get(id) ?? null
|
||||||
|
: this._folders.values().next().value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateDefaultFolderQuery(folder?: FolderConfig): FolderQuery | null {
|
||||||
|
const _folder = folder ?? this.getFolder();
|
||||||
|
return _folder ? this._executor.generateDefaultFolderQuery(_folder) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async expandFolder(
|
||||||
|
query: FolderQuery,
|
||||||
|
engineOptions?: EngineOptions,
|
||||||
|
): Promise<ViewItem[] | null> {
|
||||||
|
const hass = this._api.getHASSManager().getHASS();
|
||||||
|
return hass ? this._executor.expandFolder(hass, query, engineOptions) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
|
||||||
|
return this._executor.getItemCapabilities(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDownloadPath(item: ViewItem): Promise<Endpoint | null> {
|
||||||
|
return await this._executor.getDownloadPath(
|
||||||
|
this._api.getHASSManager().getHASS(),
|
||||||
|
item,
|
||||||
|
{
|
||||||
|
resolvedMediaCache: this._api.getResolvedMediaCache(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async favorite(item: ViewItem, favorite: boolean): Promise<void> {
|
||||||
|
return await this._executor.favorite(
|
||||||
|
this._api.getHASSManager().getHASS(),
|
||||||
|
item,
|
||||||
|
favorite,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { NonEmptyTuple } from 'type-fest';
|
||||||
|
import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
|
||||||
|
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
||||||
|
import { HomeAssistant } from '../../ha/types';
|
||||||
|
import { Endpoint } from '../../types';
|
||||||
|
import { AdvancedCameraCardError } from '../../types.js';
|
||||||
|
import { ViewItem } from '../../view/item';
|
||||||
|
import { ViewItemCapabilities } from '../../view/types';
|
||||||
|
|
||||||
|
// ====
|
||||||
|
// Base
|
||||||
|
// ====
|
||||||
|
|
||||||
|
export interface EngineOptions {
|
||||||
|
useCache?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FolderInitializationError extends AdvancedCameraCardError {}
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Folder Query
|
||||||
|
// ============
|
||||||
|
|
||||||
|
export type FolderPathComponent = {
|
||||||
|
id?: string;
|
||||||
|
ha?: Omit<HAFolderPathComponent, 'id'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FolderQuery {
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============
|
||||||
|
// Folders Engines
|
||||||
|
// ===============
|
||||||
|
|
||||||
|
export interface DownloadHelpers {
|
||||||
|
resolvedMediaCache?: ResolvedMediaCache | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FoldersEngine {
|
||||||
|
generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null;
|
||||||
|
expandFolder(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
query: FolderQuery,
|
||||||
|
engineOptions?: EngineOptions,
|
||||||
|
): Promise<ViewItem[] | null>;
|
||||||
|
|
||||||
|
getItemCapabilities(item: ViewItem): ViewItemCapabilities | null;
|
||||||
|
getDownloadPath(
|
||||||
|
hass: HomeAssistant | null,
|
||||||
|
item: ViewItem,
|
||||||
|
options?: DownloadHelpers,
|
||||||
|
): Promise<Endpoint | null>;
|
||||||
|
favorite(hass: HomeAssistant | null, item: ViewItem, favorite: boolean): Promise<void>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { hasHAConnectionStateChanged } from '../../ha/has-hass-connection-changed';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { localize } from '../../localize/localize';
|
import { localize } from '../../localize/localize';
|
||||||
import { hasHAConnectionStateChanged } from '../../utils/ha';
|
|
||||||
import { CardHASSAPI } from '../types';
|
import { CardHASSAPI } from '../types';
|
||||||
import { StateWatcher, StateWatcherSubscriptionInterface } from './state-watcher';
|
import { StateWatcher, StateWatcherSubscriptionInterface } from './state-watcher';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { HomeAssistant } from '../../ha/types';
|
import { getHassDifferences } from '../../ha/get-hass-differences';
|
||||||
import { getHassDifferences, HassStateDifference } from '../../utils/ha';
|
import { HassStateDifference, HomeAssistant } from '../../ha/types';
|
||||||
|
|
||||||
type StateWatcherCallback = (difference: HassStateDifference) => void;
|
type StateWatcherCallback = (difference: HassStateDifference) => void;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
import { loadLanguages } from '../localize/localize';
|
import { loadLanguages } from '../localize/localize';
|
||||||
import { sideLoadHomeAssistantElements } from '../utils/ha';
|
import { sideLoadHomeAssistantElements } from '../ha/side-load-ha-elements';
|
||||||
import { Initializer } from '../utils/initializer/initializer';
|
import { Initializer } from '../utils/initializer/initializer';
|
||||||
import { CardInitializerAPI } from './types';
|
import { CardInitializerAPI } from './types';
|
||||||
|
|
||||||
@@ -42,6 +42,10 @@ export class InitializationManager {
|
|||||||
return this._everInitialized;
|
return this._everInitialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isInitialized(aspect: InitializationAspect): boolean {
|
||||||
|
return this._initializer.isInitialized(aspect);
|
||||||
|
}
|
||||||
|
|
||||||
public isInitializedMandatory(): boolean {
|
public isInitializedMandatory(): boolean {
|
||||||
const config = this._api.getConfigManager().getConfig();
|
const config = this._api.getConfigManager().getConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import throttle from 'lodash-es/throttle';
|
import { throttle } from 'lodash-es';
|
||||||
import { setOrRemoveAttribute } from '../utils/basic';
|
import { setOrRemoveAttribute } from '../utils/basic';
|
||||||
import { Timer } from '../utils/timer';
|
import { Timer } from '../utils/timer';
|
||||||
import { CardInteractionAPI } from './types';
|
import { CardInteractionAPI } from './types';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CardKeyboardStateAPI, KeysState } from './types';
|
import { CardKeyboardStateAPI, KeysState } from './types';
|
||||||
import isEqual from 'lodash-es/isEqual';
|
import { isEqual } from 'lodash-es';
|
||||||
|
|
||||||
export class KeyboardStateManager {
|
export class KeyboardStateManager {
|
||||||
protected _api: CardKeyboardStateAPI;
|
protected _api: CardKeyboardStateAPI;
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import {
|
|||||||
MEDIA_PLAYER_SUPPORT_STOP,
|
MEDIA_PLAYER_SUPPORT_STOP,
|
||||||
MEDIA_PLAYER_SUPPORT_TURN_OFF,
|
MEDIA_PLAYER_SUPPORT_TURN_OFF,
|
||||||
} from '../const';
|
} from '../const';
|
||||||
|
import { Entity } from '../ha/registry/entity/types';
|
||||||
|
import { supportsFeature } from '../ha/supports-feature';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import { errorToConsole } from '../utils/basic';
|
import { errorToConsole } from '../utils/basic';
|
||||||
import { supportsFeature } from '../utils/ha';
|
import { ViewMedia } from '../view/item';
|
||||||
import { Entity } from '../utils/ha/registry/entity/types';
|
import { ViewItemClassifier } from '../view/item-classifier';
|
||||||
import { ViewMedia } from '../view/media';
|
|
||||||
import { ViewMediaClassifier } from '../view/media-classifier';
|
|
||||||
import { CardMediaPlayerAPI } from './types';
|
import { CardMediaPlayerAPI } from './types';
|
||||||
|
|
||||||
export class MediaPlayerManager {
|
export class MediaPlayerManager {
|
||||||
@@ -196,7 +196,7 @@ export class MediaPlayerManager {
|
|||||||
await hass.callService('media_player', 'play_media', {
|
await hass.callService('media_player', 'play_media', {
|
||||||
entity_id: mediaPlayer,
|
entity_id: mediaPlayer,
|
||||||
media_content_id: media.getContentID(),
|
media_content_id: media.getContentID(),
|
||||||
media_content_type: ViewMediaClassifier.isVideo(media) ? 'video' : 'image',
|
media_content_type: ViewItemClassifier.isVideo(media) ? 'video' : 'image',
|
||||||
extra: {
|
extra: {
|
||||||
...(title && { title: title }),
|
...(title && { title: title }),
|
||||||
...(thumbnail && { thumb: thumbnail }),
|
...(thumbnail && { thumb: thumbnail }),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import isEqual from 'lodash-es/isEqual';
|
import { isEqual } from 'lodash-es';
|
||||||
import { CameraManager } from '../camera-manager/manager';
|
import { CameraManager } from '../camera-manager/manager';
|
||||||
import { StatusBarItem } from '../config/schema/actions/types';
|
import { StatusBarItem } from '../config/schema/actions/types';
|
||||||
import { StatusBarConfig } from '../config/schema/status-bar';
|
import { StatusBarConfig } from '../config/schema/status-bar';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import orderBy from 'lodash-es/orderBy';
|
import { orderBy, throttle } from 'lodash-es';
|
||||||
import throttle from 'lodash-es/throttle';
|
|
||||||
import { CameraEvent } from '../camera-manager/types';
|
import { CameraEvent } from '../camera-manager/types';
|
||||||
import { Timer } from '../utils/timer';
|
import { Timer } from '../utils/timer';
|
||||||
import { CardTriggersAPI } from './types';
|
import { CardTriggersAPI } from './types';
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { CameraManager } from '../camera-manager/manager';
|
import type { CameraManager } from '../camera-manager/manager';
|
||||||
import type { ConditionStateManager } from '../conditions/state-manager';
|
import type { ConditionStateManager } from '../conditions/state-manager';
|
||||||
import type { Automation } from '../config/schema/automations';
|
import type { Automation } from '../config/schema/automations';
|
||||||
import type { EntityRegistryManager } from '../utils/ha/registry/entity/types';
|
import type { EntityRegistryManager } from '../ha/registry/entity/types';
|
||||||
import type { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
import type { ResolvedMediaCache } from '../ha/resolved-media';
|
||||||
import type { ActionsManager } from './actions/actions-manager';
|
import type { ActionsManager } from './actions/actions-manager';
|
||||||
import type { AutomationsManager } from './automations-manager';
|
import type { AutomationsManager } from './automations-manager';
|
||||||
import type { CameraURLManager } from './camera-url-manager';
|
import type { CameraURLManager } from './camera-url-manager';
|
||||||
import type { CardElementManager } from './card-element-manager';
|
import type { CardElementManager } from './card-element-manager';
|
||||||
import type { ConfigManager } from './config/config-manager';
|
import type { ConfigManager } from './config/config-manager';
|
||||||
import type { DefaultManager } from './default-manager';
|
import type { DefaultManager } from './default-manager';
|
||||||
import type { DownloadManager } from './download-manager';
|
|
||||||
import type { ExpandManager } from './expand-manager';
|
import type { ExpandManager } from './expand-manager';
|
||||||
|
import type { FoldersManager } from './folders/manager';
|
||||||
import type { FullscreenManager } from './fullscreen/fullscreen-manager';
|
import type { FullscreenManager } from './fullscreen/fullscreen-manager';
|
||||||
import type { HASSManager } from './hass/hass-manager';
|
import type { HASSManager } from './hass/hass-manager';
|
||||||
import type { InitializationManager } from './initialization-manager';
|
import type { InitializationManager } from './initialization-manager';
|
||||||
@@ -24,6 +24,7 @@ import type { QueryStringManager } from './query-string-manager';
|
|||||||
import type { StatusBarItemManager } from './status-bar-item-manager';
|
import type { StatusBarItemManager } from './status-bar-item-manager';
|
||||||
import type { StyleManager } from './style-manager';
|
import type { StyleManager } from './style-manager';
|
||||||
import type { TriggersManager } from './triggers-manager';
|
import type { TriggersManager } from './triggers-manager';
|
||||||
|
import type { ViewItemManager } from './view/item-manager';
|
||||||
import type { ViewManager } from './view/view-manager';
|
import type { ViewManager } from './view/view-manager';
|
||||||
|
|
||||||
// *************************************************************************
|
// *************************************************************************
|
||||||
@@ -40,8 +41,8 @@ export interface CardActionsAPI {
|
|||||||
getCardElementManager(): CardElementManager;
|
getCardElementManager(): CardElementManager;
|
||||||
getConditionStateManager(): ConditionStateManager;
|
getConditionStateManager(): ConditionStateManager;
|
||||||
getConfigManager(): ConfigManager;
|
getConfigManager(): ConfigManager;
|
||||||
getDownloadManager(): DownloadManager;
|
|
||||||
getExpandManager(): ExpandManager;
|
getExpandManager(): ExpandManager;
|
||||||
|
getFoldersManager(): FoldersManager;
|
||||||
getFullscreenManager(): FullscreenManager;
|
getFullscreenManager(): FullscreenManager;
|
||||||
getHASSManager(): HASSManager;
|
getHASSManager(): HASSManager;
|
||||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||||
@@ -50,6 +51,7 @@ export interface CardActionsAPI {
|
|||||||
getMicrophoneManager(): MicrophoneManager;
|
getMicrophoneManager(): MicrophoneManager;
|
||||||
getStatusBarItemManager(): StatusBarItemManager;
|
getStatusBarItemManager(): StatusBarItemManager;
|
||||||
getTriggersManager(): TriggersManager;
|
getTriggersManager(): TriggersManager;
|
||||||
|
getViewItemManager(): ViewItemManager;
|
||||||
getViewManager(): ViewManager;
|
getViewManager(): ViewManager;
|
||||||
}
|
}
|
||||||
export type CardActionsManagerAPI = CardActionsAPI;
|
export type CardActionsManagerAPI = CardActionsAPI;
|
||||||
@@ -90,6 +92,7 @@ export interface CardConfigAPI {
|
|||||||
getConditionStateManager(): ConditionStateManager;
|
getConditionStateManager(): ConditionStateManager;
|
||||||
getConfigManager(): ConfigManager;
|
getConfigManager(): ConfigManager;
|
||||||
getDefaultManager(): DefaultManager;
|
getDefaultManager(): DefaultManager;
|
||||||
|
getFoldersManager(): FoldersManager;
|
||||||
getHASSManager(): HASSManager;
|
getHASSManager(): HASSManager;
|
||||||
getInitializationManager(): InitializationManager;
|
getInitializationManager(): InitializationManager;
|
||||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||||
@@ -104,6 +107,8 @@ export interface CardConfigAPI {
|
|||||||
export interface CardConfigLoaderAPI {
|
export interface CardConfigLoaderAPI {
|
||||||
getAutomationsManager(): AutomationsManager;
|
getAutomationsManager(): AutomationsManager;
|
||||||
getConfigManager(): ConfigManager;
|
getConfigManager(): ConfigManager;
|
||||||
|
getFoldersManager(): FoldersManager;
|
||||||
|
getMessageManager(): MessageManager;
|
||||||
getHASSManager(): HASSManager;
|
getHASSManager(): HASSManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +153,12 @@ export interface CardExpandAPI {
|
|||||||
getFullscreenManager(): FullscreenManager;
|
getFullscreenManager(): FullscreenManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CardFoldersAPI {
|
||||||
|
getConfigManager(): ConfigManager;
|
||||||
|
getHASSManager(): HASSManager;
|
||||||
|
getResolvedMediaCache(): ResolvedMediaCache;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CardFullscreenAPI {
|
export interface CardFullscreenAPI {
|
||||||
getCardElementManager(): CardElementManager;
|
getCardElementManager(): CardElementManager;
|
||||||
getConditionStateManager(): ConditionStateManager;
|
getConditionStateManager(): ConditionStateManager;
|
||||||
@@ -270,7 +281,9 @@ export interface CardViewAPI {
|
|||||||
getCardElementManager(): CardElementManager;
|
getCardElementManager(): CardElementManager;
|
||||||
getConditionStateManager(): ConditionStateManager;
|
getConditionStateManager(): ConditionStateManager;
|
||||||
getConfigManager(): ConfigManager;
|
getConfigManager(): ConfigManager;
|
||||||
|
getFoldersManager(): FoldersManager;
|
||||||
getHASSManager(): HASSManager;
|
getHASSManager(): HASSManager;
|
||||||
|
getInitializationManager(): InitializationManager;
|
||||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||||
getMessageManager(): MessageManager;
|
getMessageManager(): MessageManager;
|
||||||
getQueryStringManager(): QueryStringManager;
|
getQueryStringManager(): QueryStringManager;
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { format } from 'date-fns';
|
||||||
|
import { localize } from '../../localize/localize';
|
||||||
|
import { AdvancedCameraCardError } from '../../types';
|
||||||
|
import { errorToConsole } from '../../utils/basic';
|
||||||
|
import { downloadURL } from '../../utils/download';
|
||||||
|
import { homeAssistantSignPath } from '../../ha/sign-path';
|
||||||
|
import { ViewItem } from '../../view/item';
|
||||||
|
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||||
|
import { ViewItemCapabilities } from '../../view/types';
|
||||||
|
import { CardViewAPI } from '../types';
|
||||||
|
|
||||||
|
enum ViewMediaSource {
|
||||||
|
Camera = 'camera',
|
||||||
|
Folder = 'folder',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ViewItemManager {
|
||||||
|
private _api: CardViewAPI;
|
||||||
|
|
||||||
|
constructor(api: CardViewAPI) {
|
||||||
|
this._api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCapabilities(item: ViewItem): ViewItemCapabilities | null {
|
||||||
|
const source = this._getMediaSource(item);
|
||||||
|
if (source === ViewMediaSource.Camera && ViewItemClassifier.isMedia(item)) {
|
||||||
|
return this._api.getCameraManager().getMediaCapabilities(item);
|
||||||
|
}
|
||||||
|
if (source === ViewMediaSource.Folder) {
|
||||||
|
return this._api.getFoldersManager().getItemCapabilities(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async download(item: ViewItem): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this._download(item);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this._api.getMessageManager().setErrorIfHigherPriority(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async favorite(item: ViewItem, favorite: boolean): Promise<void> {
|
||||||
|
const source = this._getMediaSource(item);
|
||||||
|
if (source === ViewMediaSource.Camera && ViewItemClassifier.isMedia(item)) {
|
||||||
|
return await this._api.getCameraManager().favoriteMedia(item, favorite);
|
||||||
|
}
|
||||||
|
/* istanbul ignore else: this path cannot be reached -- @preserve */
|
||||||
|
if (source === ViewMediaSource.Folder) {
|
||||||
|
return this._api.getFoldersManager().favorite(item, favorite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getMediaSource(item: ViewItem): ViewMediaSource | null {
|
||||||
|
if (ViewItemClassifier.isMedia(item) && item.getCameraID()) {
|
||||||
|
return ViewMediaSource.Camera;
|
||||||
|
}
|
||||||
|
if (ViewItemClassifier.isFolder(item) || item.getFolder()) {
|
||||||
|
return ViewMediaSource.Folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* istanbul ignore next: this path cannot be reached -- @preserve */
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _download(item: ViewItem): Promise<void> {
|
||||||
|
const hass = this._api.getHASSManager().getHASS();
|
||||||
|
if (!hass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = this._getMediaSource(item);
|
||||||
|
const endpoint =
|
||||||
|
source === ViewMediaSource.Camera && ViewItemClassifier.isMedia(item)
|
||||||
|
? await this._api.getCameraManager().getMediaDownloadPath(item)
|
||||||
|
: source === ViewMediaSource.Folder
|
||||||
|
? await this._api.getFoldersManager().getDownloadPath(item)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!endpoint) {
|
||||||
|
throw new AdvancedCameraCardError(localize('error.download_no_media'));
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalURL = endpoint.endpoint;
|
||||||
|
if (endpoint.sign) {
|
||||||
|
let response: string | null | undefined;
|
||||||
|
try {
|
||||||
|
response = await homeAssistantSignPath(hass, endpoint.endpoint);
|
||||||
|
} catch (e) {
|
||||||
|
errorToConsole(e as Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
throw new AdvancedCameraCardError(localize('error.download_sign_failed'));
|
||||||
|
}
|
||||||
|
finalURL = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL(finalURL, this._generateDownloadFilename(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
private _generateDownloadFilename(item: ViewItem): string {
|
||||||
|
const toFilename = (input: string): string => {
|
||||||
|
return input.toLowerCase().replaceAll(/[^a-z0-9]/gi, '-');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ViewItemClassifier.isMedia(item)) {
|
||||||
|
const cameraID = item.getCameraID();
|
||||||
|
const id = item.getID();
|
||||||
|
const startTime = item.getStartTime();
|
||||||
|
|
||||||
|
return (
|
||||||
|
(cameraID ? toFilename(cameraID) : 'media') +
|
||||||
|
(id ? `_${toFilename(id)}` : '') +
|
||||||
|
(startTime ? `_${format(startTime, `yyyy-MM-dd-HH-mm-ss`)}` : '') +
|
||||||
|
('.' + (item.getMediaType() === 'clip' ? 'mp4' : 'jpg'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* istanbul ignore else: this path cannot be reached -- @preserve */
|
||||||
|
if (ViewItemClassifier.isFolder(item)) {
|
||||||
|
return toFilename(item.getTitle() ?? 'media');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* istanbul ignore next: this path cannot be reached -- @preserve */
|
||||||
|
return 'download';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
import { MediaQueries } from '../../../view/media-queries';
|
import { Query } from '../../../view/query';
|
||||||
import { MediaQueriesResults } from '../../../view/media-queries-results';
|
import { QueryResults } from '../../../view/query-results';
|
||||||
import { View } from '../../../view/view';
|
import { View } from '../../../view/view';
|
||||||
import { ViewModifier } from '../types';
|
import { ViewModifier } from '../types';
|
||||||
|
|
||||||
export class SetQueryViewModifier implements ViewModifier {
|
export class SetQueryViewModifier implements ViewModifier {
|
||||||
protected _query?: MediaQueries | null;
|
protected _query?: Query | null;
|
||||||
protected _queryResults?: MediaQueriesResults | null;
|
protected _queryResults?: QueryResults | null;
|
||||||
|
|
||||||
constructor(options?: {
|
constructor(options?: { query?: Query | null; queryResults?: QueryResults | null }) {
|
||||||
query?: MediaQueries | null;
|
|
||||||
queryResults?: MediaQueriesResults | null;
|
|
||||||
}) {
|
|
||||||
this._query = options?.query;
|
this._query = options?.query;
|
||||||
this._queryResults = options?.queryResults;
|
this._queryResults = options?.queryResults;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { CapabilitySearchOptions, MediaQuery } from '../../camera-manager/types';
|
import { CapabilitySearchOptions, MediaQuery } from '../../camera-manager/types';
|
||||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
|
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
|
||||||
import { ClipsOrSnapshotsOrAll } from '../../types';
|
import { ClipsOrSnapshotsOrAll } from '../../types';
|
||||||
import { findBestMediaIndex } from '../../utils/find-best-media-index';
|
import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
|
||||||
|
import { ViewItem } from '../../view/item';
|
||||||
import {
|
import {
|
||||||
EventMediaQueries,
|
EventMediaQuery,
|
||||||
|
FolderViewQuery,
|
||||||
MediaQueries,
|
MediaQueries,
|
||||||
RecordingMediaQueries,
|
Query,
|
||||||
} from '../../view/media-queries';
|
RecordingMediaQuery,
|
||||||
import { MediaQueriesResults } from '../../view/media-queries-results';
|
} from '../../view/query';
|
||||||
|
import { QueryClassifier } from '../../view/query-classifier';
|
||||||
|
import { QueryResults } from '../../view/query-results';
|
||||||
import { CardViewAPI } from '../types';
|
import { CardViewAPI } from '../types';
|
||||||
import { QueryExecutorOptions, QueryExecutorResult } from './types';
|
import { QueryExecutorOptions, QueryExecutorResult } from './types';
|
||||||
|
|
||||||
@@ -48,14 +52,8 @@ export class QueryExecutor {
|
|||||||
if (!rawQueries) {
|
if (!rawQueries) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const queries = new EventMediaQueries(rawQueries);
|
const queries = new EventMediaQuery(rawQueries);
|
||||||
const results = await this.execute(queries, options?.executorOptions);
|
return await this.executeMediaQuery(queries, options?.executorOptions);
|
||||||
return results
|
|
||||||
? {
|
|
||||||
query: queries,
|
|
||||||
queryResults: results,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async executeDefaultRecordingQuery(options?: {
|
public async executeDefaultRecordingQuery(options?: {
|
||||||
@@ -76,16 +74,30 @@ export class QueryExecutor {
|
|||||||
if (!rawQueries) {
|
if (!rawQueries) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const queries = new RecordingMediaQueries(rawQueries);
|
const queries = new RecordingMediaQuery(rawQueries);
|
||||||
const results = await this.execute(queries, options?.executorOptions);
|
return await this.executeMediaQuery(queries, options?.executorOptions);
|
||||||
return results ? { query: queries, queryResults: results } : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execute(
|
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,
|
query: MediaQueries,
|
||||||
executorOptions?: QueryExecutorOptions,
|
executorOptions?: QueryExecutorOptions,
|
||||||
): Promise<MediaQueriesResults | null> {
|
): Promise<QueryExecutorResult | null> {
|
||||||
const queries = query.getQueries();
|
const queries = query.getQuery();
|
||||||
if (!queries) {
|
if (!queries) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -95,11 +107,17 @@ export class QueryExecutor {
|
|||||||
.executeMediaQueries<MediaQuery>(queries, {
|
.executeMediaQueries<MediaQuery>(queries, {
|
||||||
useCache: executorOptions?.useCache,
|
useCache: executorOptions?.useCache,
|
||||||
});
|
});
|
||||||
if (!mediaArray) {
|
const queryResults = mediaArray
|
||||||
return null;
|
? this._generateQueriesResults(mediaArray, executorOptions)
|
||||||
|
: null;
|
||||||
|
return queryResults ? { query, queryResults } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryResults = new MediaQueriesResults({ results: mediaArray });
|
private _generateQueriesResults(
|
||||||
|
itemArray: ViewItem[],
|
||||||
|
executorOptions?: QueryExecutorOptions,
|
||||||
|
): QueryResults | null {
|
||||||
|
const queryResults = new QueryResults({ results: itemArray });
|
||||||
if (executorOptions?.rejectResults?.(queryResults)) {
|
if (executorOptions?.rejectResults?.(queryResults)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -111,9 +129,9 @@ export class QueryExecutor {
|
|||||||
} else if (executorOptions?.selectResult?.func) {
|
} else if (executorOptions?.selectResult?.func) {
|
||||||
queryResults.selectResultIfFound(executorOptions.selectResult.func);
|
queryResults.selectResultIfFound(executorOptions.selectResult.func);
|
||||||
} else if (executorOptions?.selectResult?.time) {
|
} else if (executorOptions?.selectResult?.time) {
|
||||||
queryResults.selectBestResult((media) =>
|
queryResults.selectBestResult((itemArray) =>
|
||||||
findBestMediaIndex(
|
findBestMediaTimeIndex(
|
||||||
media,
|
itemArray,
|
||||||
executorOptions.selectResult?.time?.time as Date,
|
executorOptions.selectResult?.time?.time as Date,
|
||||||
executorOptions.selectResult?.time?.favorCameraID,
|
executorOptions.selectResult?.time?.favorCameraID,
|
||||||
),
|
),
|
||||||
@@ -122,6 +140,33 @@ export class QueryExecutor {
|
|||||||
return queryResults;
|
return queryResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async executeDefaultFolderQuery(
|
||||||
|
executorOptions?: QueryExecutorOptions,
|
||||||
|
): Promise<QueryExecutorResult | null> {
|
||||||
|
const query = this._api.getFoldersManager().generateDefaultFolderQuery();
|
||||||
|
return query
|
||||||
|
? this._executeFolderQuery(new FolderViewQuery(query), executorOptions)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, { useCache: executorOptions?.useCache });
|
||||||
|
|
||||||
|
const queryResults = itemArray
|
||||||
|
? this._generateQueriesResults(itemArray, executorOptions)
|
||||||
|
: null;
|
||||||
|
return queryResults ? { query, queryResults } : null;
|
||||||
|
}
|
||||||
|
|
||||||
protected _getChunkLimit(): number {
|
protected _getChunkLimit(): number {
|
||||||
const cardWideConfig = this._api.getConfigManager().getCardWideConfig();
|
const cardWideConfig = this._api.getConfigManager().getCardWideConfig();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { orderBy, uniqBy } from 'lodash-es';
|
||||||
|
import { ViewItem } from '../../view/item';
|
||||||
|
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||||
|
|
||||||
|
export const sortItems = <T extends ViewItem>(itemArray: T[]): T[] => {
|
||||||
|
return orderBy(
|
||||||
|
// Ensure uniqueness by the ID (if specified), otherwise all elements
|
||||||
|
// are assumed to be unique.
|
||||||
|
uniqBy(itemArray, (item) => item.getID() ?? item),
|
||||||
|
|
||||||
|
[
|
||||||
|
// Pull folders to the front.
|
||||||
|
(item) => !ViewItemClassifier.isFolder(item),
|
||||||
|
|
||||||
|
// Sort by time and id.
|
||||||
|
(item) =>
|
||||||
|
ViewItemClassifier.isMedia(item)
|
||||||
|
? item.getStartTime() ?? item.getID()
|
||||||
|
: item.getID(),
|
||||||
|
],
|
||||||
|
['asc', 'asc'],
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ViewContext } from 'view';
|
import { ViewContext } from 'view';
|
||||||
import { AdvancedCameraCardView } from '../../config/schema/common/const.js';
|
import { AdvancedCameraCardView } from '../../config/schema/common/const.js';
|
||||||
import { AdvancedCameraCardError } from '../../types.js';
|
import { AdvancedCameraCardError } from '../../types.js';
|
||||||
import { MediaQueriesResults } from '../../view/media-queries-results.js';
|
import { ViewItem } from '../../view/item.js';
|
||||||
import { MediaQueries } from '../../view/media-queries.js';
|
import { QueryResults } from '../../view/query-results.js';
|
||||||
import { ViewMedia } from '../../view/media.js';
|
import { Query } from '../../view/query.js';
|
||||||
import { View, ViewParameters } from '../../view/view.js';
|
import { View, ViewParameters } from '../../view/view.js';
|
||||||
|
|
||||||
export interface ViewModifier {
|
export interface ViewModifier {
|
||||||
@@ -20,15 +20,15 @@ export interface QueryExecutorOptions {
|
|||||||
favorCameraID?: string;
|
favorCameraID?: string;
|
||||||
};
|
};
|
||||||
id?: string;
|
id?: string;
|
||||||
func?: (media: ViewMedia) => boolean;
|
func?: (media: ViewItem) => boolean;
|
||||||
};
|
};
|
||||||
rejectResults?: (results: MediaQueriesResults) => boolean;
|
rejectResults?: (results: QueryResults) => boolean;
|
||||||
useCache?: boolean;
|
useCache?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueryExecutorResult {
|
export interface QueryExecutorResult {
|
||||||
query: MediaQueries;
|
query: Query;
|
||||||
queryResults: MediaQueriesResults;
|
queryResults: QueryResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ViewFactoryOptions {
|
export interface ViewFactoryOptions {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { ViewContext } from 'view';
|
|||||||
import { AdvancedCameraCardView } from '../../config/schema/common/const';
|
import { AdvancedCameraCardView } from '../../config/schema/common/const';
|
||||||
import { log } from '../../utils/debug';
|
import { log } from '../../utils/debug';
|
||||||
import { getStreamCameraID } from '../../utils/substream';
|
import { getStreamCameraID } from '../../utils/substream';
|
||||||
import { MediaQueriesClassifier } from '../../view/media-queries-classifier';
|
import { QueryClassifier } from '../../view/query-classifier';
|
||||||
import { View } from '../../view/view';
|
import { View } from '../../view/view';
|
||||||
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
|
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
|
||||||
|
import { InitializationAspect } from '../initialization-manager';
|
||||||
import { CardViewAPI } from '../types';
|
import { CardViewAPI } from '../types';
|
||||||
import { ViewFactory } from './factory';
|
import { ViewFactory } from './factory';
|
||||||
import { applyViewModifiers } from './modifiers';
|
import { applyViewModifiers } from './modifiers';
|
||||||
@@ -103,6 +104,10 @@ export class ViewManager implements ViewManagerInterface {
|
|||||||
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
|
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
|
||||||
options?: ViewFactoryOptions,
|
options?: ViewFactoryOptions,
|
||||||
): void {
|
): void {
|
||||||
|
if (!this._isAllowedToSetView()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let view: View | null = null;
|
let view: View | null = null;
|
||||||
try {
|
try {
|
||||||
view = viewFactoryFunc({
|
view = viewFactoryFunc({
|
||||||
@@ -112,7 +117,9 @@ export class ViewManager implements ViewManagerInterface {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._api.getMessageManager().setErrorIfHigherPriority(e);
|
this._api.getMessageManager().setErrorIfHigherPriority(e);
|
||||||
}
|
}
|
||||||
view && this._setView(view);
|
if (view) {
|
||||||
|
this._setView(view);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _markViewLoadingQuery(view: View, index: number): View {
|
protected _markViewLoadingQuery(view: View, index: number): View {
|
||||||
@@ -122,6 +129,19 @@ export class ViewManager implements ViewManagerInterface {
|
|||||||
return view.removeContextProperty('loading', 'query');
|
return view.removeContextProperty('loading', 'query');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _isAllowedToSetView(): boolean {
|
||||||
|
// It is possible to have a race condition where the view is being set at
|
||||||
|
// the same time as the cameras being initialized. Test case: Open
|
||||||
|
// folder-based media in the media viewer carousel, then attempt to edit the
|
||||||
|
// card -- this causes the cameras to re-initialize at the same time as
|
||||||
|
// folder media is reporting observed zoom settings in the view context.
|
||||||
|
// Without this check, that will result in a "No cameras support this view"
|
||||||
|
// message.
|
||||||
|
return this._api
|
||||||
|
.getInitializationManager()
|
||||||
|
.isInitialized(InitializationAspect.CAMERAS);
|
||||||
|
}
|
||||||
|
|
||||||
protected async _setViewThenModifyAsync(
|
protected async _setViewThenModifyAsync(
|
||||||
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
|
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
|
||||||
viewModifiersFunc: (
|
viewModifiersFunc: (
|
||||||
@@ -130,6 +150,10 @@ export class ViewManager implements ViewManagerInterface {
|
|||||||
) => Promise<ViewModifier[] | null>,
|
) => Promise<ViewModifier[] | null>,
|
||||||
options?: ViewFactoryOptions,
|
options?: ViewFactoryOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (!this._isAllowedToSetView()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let initialView: View | null = null;
|
let initialView: View | null = null;
|
||||||
try {
|
try {
|
||||||
initialView = viewFactoryFunc({
|
initialView = viewFactoryFunc({
|
||||||
@@ -220,10 +244,10 @@ export class ViewManager implements ViewManagerInterface {
|
|||||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
|
||||||
|
|
||||||
const switchingFromViewerToGallery =
|
const switchingFromViewerToGallery =
|
||||||
this._view?.isViewerView() && newView?.isGalleryView();
|
this._view?.isViewerView() && newView?.isMediaGalleryView();
|
||||||
const newMediaType = newView?.getDefaultMediaType();
|
const newMediaType = newView?.getDefaultMediaType();
|
||||||
const alreadyHasMatchingQuery =
|
const alreadyHasMatchingQuery =
|
||||||
MediaQueriesClassifier.getMediaType(this._view?.query) === newMediaType;
|
QueryClassifier.getMediaType(this._view?.query) === newMediaType;
|
||||||
return !!switchingFromViewerToGallery && alreadyHasMatchingQuery;
|
return !!switchingFromViewerToGallery && alreadyHasMatchingQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export class ViewQueryExecutor {
|
|||||||
return view.query
|
return view.query
|
||||||
? [
|
? [
|
||||||
new SetQueryViewModifier({
|
new SetQueryViewModifier({
|
||||||
queryResults: await this._executor.execute(view.query, queryExecutorOptions),
|
queryResults: (
|
||||||
|
await this._executor.executeQuery(view.query, queryExecutorOptions)
|
||||||
|
)?.queryResults,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
@@ -83,6 +85,12 @@ export class ViewQueryExecutor {
|
|||||||
return results ? [new SetQueryViewModifier(results)] : [];
|
return results ? [new SetQueryViewModifier(results)] : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const executeFolderQuery = async (): Promise<ViewModifier[]> => {
|
||||||
|
const results =
|
||||||
|
await this._executor.executeDefaultFolderQuery(queryExecutorOptions);
|
||||||
|
return results ? [new SetQueryViewModifier(results)] : [];
|
||||||
|
};
|
||||||
|
|
||||||
switch (view.view) {
|
switch (view.view) {
|
||||||
case 'live':
|
case 'live':
|
||||||
if (config.live.controls.thumbnails.mode !== 'none') {
|
if (config.live.controls.thumbnails.mode !== 'none') {
|
||||||
@@ -111,6 +119,9 @@ export class ViewQueryExecutor {
|
|||||||
case 'recordings':
|
case 'recordings':
|
||||||
viewModifiers.push(...(await executeMediaQuery(mediaType)));
|
viewModifiers.push(...(await executeMediaQuery(mediaType)));
|
||||||
break;
|
break;
|
||||||
|
case 'folder':
|
||||||
|
viewModifiers.push(...(await executeFolderQuery()));
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModifiers.push(...this._getTimelineWindowViewModifier(view));
|
viewModifiers.push(...this._getTimelineWindowViewModifier(view));
|
||||||
|
|||||||
+5
-3
@@ -247,12 +247,13 @@ class AdvancedCameraCard extends LitElement {
|
|||||||
this._config,
|
this._config,
|
||||||
this._controller.getCameraManager(),
|
this._controller.getCameraManager(),
|
||||||
{
|
{
|
||||||
inExpandedMode: this._controller.getExpandManager().isExpanded(),
|
|
||||||
fullscreenManager: this._controller.getFullscreenManager(),
|
|
||||||
currentMediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(),
|
currentMediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(),
|
||||||
showCameraUIButton: this._controller.getCameraURLManager().hasCameraURL(),
|
foldersManager: this._controller.getFoldersManager(),
|
||||||
|
fullscreenManager: this._controller.getFullscreenManager(),
|
||||||
|
inExpandedMode: this._controller.getExpandManager().isExpanded(),
|
||||||
mediaPlayerController: this._controller.getMediaPlayerManager(),
|
mediaPlayerController: this._controller.getMediaPlayerManager(),
|
||||||
microphoneManager: this._controller.getMicrophoneManager(),
|
microphoneManager: this._controller.getMicrophoneManager(),
|
||||||
|
showCameraUIButton: this._controller.getCameraURLManager().hasCameraURL(),
|
||||||
view: view,
|
view: view,
|
||||||
viewManager: this._controller.getViewManager(),
|
viewManager: this._controller.getViewManager(),
|
||||||
},
|
},
|
||||||
@@ -371,6 +372,7 @@ class AdvancedCameraCard extends LitElement {
|
|||||||
.hass=${this._hass}
|
.hass=${this._hass}
|
||||||
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
|
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
|
||||||
.cameraManager=${cameraManager}
|
.cameraManager=${cameraManager}
|
||||||
|
.viewItemManager=${this._controller.getViewItemManager()}
|
||||||
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
|
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
|
||||||
.config=${this._controller.getConfigManager().getConfig()}
|
.config=${this._controller.getConfigManager().getConfig()}
|
||||||
.cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
|
.cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { ViewManagerEpoch } from '../../card-controller/view/types';
|
||||||
|
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
|
||||||
|
import { ViewFolder, ViewItem } from '../../view/item';
|
||||||
|
import { QueryClassifier } from '../../view/query-classifier';
|
||||||
|
import { View } from '../../view/view';
|
||||||
|
|
||||||
|
export const upFolderClickHandler = (
|
||||||
|
_item: ViewItem,
|
||||||
|
ev: Event,
|
||||||
|
viewManagerEpoch?: ViewManagerEpoch,
|
||||||
|
): void => {
|
||||||
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
|
|
||||||
|
const query = viewManagerEpoch?.manager.getView()?.query;
|
||||||
|
if (!query || !QueryClassifier.isFolderQuery(query)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rawQuery = query?.getQuery();
|
||||||
|
if (!rawQuery?.path || rawQuery?.path.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = rawQuery.path.slice(0, -1);
|
||||||
|
|
||||||
|
viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||||
|
params: {
|
||||||
|
query: query.clone().setQuery({
|
||||||
|
folder: rawQuery.folder,
|
||||||
|
path: [path[0], ...path.slice(1)],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUpFolderMediaItem = (view?: View | null): ViewFolder | null => {
|
||||||
|
const query = view?.query;
|
||||||
|
if (!query || !QueryClassifier.isFolderQuery(query)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawQuery = query.getQuery();
|
||||||
|
if (!rawQuery?.folder || !rawQuery?.path || rawQuery.path.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ViewFolder(rawQuery.folder, {
|
||||||
|
icon: 'mdi:arrow-up-left',
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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,
|
||||||
|
): 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();
|
||||||
|
const id = item.getID();
|
||||||
|
if (!rawQuery || !id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewManager.setViewByParametersWithExistingQuery({
|
||||||
|
params: {
|
||||||
|
query: view.query.clone().setQuery({
|
||||||
|
folder: rawQuery.folder,
|
||||||
|
path: [...rawQuery.path, { id }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { LitElement, ReactiveController } from 'lit';
|
||||||
|
import { throttle } from 'lodash-es';
|
||||||
|
import { GalleryExtendEvent } from '../../components/gallery/types';
|
||||||
|
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
|
||||||
|
import { scrollIntoView } from '../../utils/scroll';
|
||||||
|
import { sleep } from '../../utils/sleep';
|
||||||
|
|
||||||
|
const GALLERY_MIN_EXTENSION_SECONDS = 0.5;
|
||||||
|
|
||||||
|
export type GalleryColumnCountRoundMethod = 'ceil' | 'floor';
|
||||||
|
|
||||||
|
interface GalleryCoreOptions {
|
||||||
|
columnWidth?: number;
|
||||||
|
columnCountRoundMethod?: GalleryColumnCountRoundMethod;
|
||||||
|
extendUp?: boolean;
|
||||||
|
extendDown?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GalleryCoreController implements ReactiveController {
|
||||||
|
private _host: LitElement;
|
||||||
|
private _intersectionObserver: IntersectionObserver;
|
||||||
|
private _resizeObserver: ResizeObserver;
|
||||||
|
|
||||||
|
private _options: GalleryCoreOptions | null = null;
|
||||||
|
private _touchScrollYPosition: number | null = null;
|
||||||
|
|
||||||
|
// Wheel / touch events may be voluminous, throttle extension calls.
|
||||||
|
private _throttledExtendUp = throttle(
|
||||||
|
this._extendUp.bind(this),
|
||||||
|
GALLERY_MIN_EXTENSION_SECONDS * 1000,
|
||||||
|
{
|
||||||
|
leading: true,
|
||||||
|
trailing: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
private _getSlot: () => HTMLSlotElement | null;
|
||||||
|
private _getSentintelBottom: () => HTMLElement | null;
|
||||||
|
private _showLoaderTop: (show: boolean) => void;
|
||||||
|
private _showSentinelBottom: (show: boolean) => void;
|
||||||
|
|
||||||
|
private _wasEverNonEmpty = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
host: LitElement,
|
||||||
|
getSlot: () => HTMLSlotElement | null,
|
||||||
|
getSentinelBottom: () => HTMLElement | null,
|
||||||
|
showLoaderTopCallback: (show: boolean) => void,
|
||||||
|
showSentinelBottomCallback: (show: boolean) => void,
|
||||||
|
) {
|
||||||
|
this._host = host;
|
||||||
|
this._host.addController(this);
|
||||||
|
|
||||||
|
this._getSlot = getSlot;
|
||||||
|
this._getSentintelBottom = getSentinelBottom;
|
||||||
|
this._showLoaderTop = showLoaderTopCallback;
|
||||||
|
this._showSentinelBottom = showSentinelBottomCallback;
|
||||||
|
|
||||||
|
this._resizeObserver = new ResizeObserver(() => this._setColumnCount());
|
||||||
|
this._intersectionObserver = new IntersectionObserver(
|
||||||
|
async (entries: IntersectionObserverEntry[]): Promise<void> => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
await this._extendDown();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeController(): void {
|
||||||
|
this._host.removeController(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setOptions(options: GalleryCoreOptions): void {
|
||||||
|
this._options = options;
|
||||||
|
this._setColumnCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public hostConnected(): void {
|
||||||
|
this._resizeObserver.observe(this._host);
|
||||||
|
|
||||||
|
// Since the scroll event does not fire if the user is already at the top of
|
||||||
|
// the container, instead we manually use the wheel and touchstart/end
|
||||||
|
// events to detect "top upwards scrolling" (to trigger an extension of the
|
||||||
|
// gallery).
|
||||||
|
this._host.addEventListener('wheel', this._wheelHandler, { passive: true });
|
||||||
|
this._host.addEventListener('touchstart', this._touchStartHandler, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
this._host.addEventListener('touchend', this._touchEndHandler);
|
||||||
|
|
||||||
|
// Request update in order to ensure the intersection observer reconnects
|
||||||
|
// with the loader sentinel.
|
||||||
|
this._host.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public hostDisconnected(): void {
|
||||||
|
this._host.removeEventListener('wheel', this._wheelHandler);
|
||||||
|
this._host.removeEventListener('touchstart', this._touchStartHandler);
|
||||||
|
this._host.removeEventListener('touchend', this._touchEndHandler);
|
||||||
|
this._resizeObserver.disconnect();
|
||||||
|
this._intersectionObserver.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public hostUpdated(): void {
|
||||||
|
const sentinel = this._getSentintelBottom();
|
||||||
|
this._intersectionObserver.disconnect();
|
||||||
|
|
||||||
|
if (sentinel) {
|
||||||
|
this._intersectionObserver.observe(sentinel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setColumnCount(): void {
|
||||||
|
if (!this._options?.columnWidth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roundFunc =
|
||||||
|
this._options.columnCountRoundMethod === 'ceil' ? Math.ceil : Math.floor;
|
||||||
|
|
||||||
|
const columns = Math.max(
|
||||||
|
1,
|
||||||
|
roundFunc(this._host.clientWidth / this._options.columnWidth),
|
||||||
|
);
|
||||||
|
this._host.style.setProperty(
|
||||||
|
'--advanced-camera-card-gallery-columns',
|
||||||
|
String(columns),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _touchStartHandler = (ev: TouchEvent): void => {
|
||||||
|
// Remember the Y touch position on touch start, so that we can calculate if
|
||||||
|
// the user gestured upwards or downards on touchend.
|
||||||
|
if (ev.touches.length === 1) {
|
||||||
|
this._touchScrollYPosition = ev.touches[0].screenY;
|
||||||
|
} else {
|
||||||
|
this._touchScrollYPosition = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private _touchEndHandler = async (ev: TouchEvent): Promise<void> => {
|
||||||
|
if (
|
||||||
|
!this._host.scrollTop &&
|
||||||
|
ev.changedTouches.length === 1 &&
|
||||||
|
this._touchScrollYPosition !== null
|
||||||
|
) {
|
||||||
|
if (ev.changedTouches[0].screenY > this._touchScrollYPosition) {
|
||||||
|
await this._throttledExtendUp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._touchScrollYPosition = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
private _wheelHandler = async (ev: WheelEvent): Promise<void> => {
|
||||||
|
if (!this._host.scrollTop && ev.deltaY < 0) {
|
||||||
|
await this._throttledExtendUp();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private async _extendUp(): Promise<void> {
|
||||||
|
if (!this._options?.extendUp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._showLoaderTop(true);
|
||||||
|
|
||||||
|
const start = new Date();
|
||||||
|
await this._waitForExtend('up');
|
||||||
|
const delta = new Date().getTime() - start.getTime();
|
||||||
|
|
||||||
|
if (delta < GALLERY_MIN_EXTENSION_SECONDS * 1000) {
|
||||||
|
// Hidden gem: "legitimate" (?!) use of sleep() :-) These calls can return
|
||||||
|
// very quickly even with caching disabled since the time window
|
||||||
|
// constraints on the query will usually be very narrow and the backend
|
||||||
|
// can thus very quickly reply. It's often so fast it actually looks like
|
||||||
|
// a rendering issue where the progress indictor barely registers before
|
||||||
|
// it's gone again. This optional pause ensures there is at least some
|
||||||
|
// visual feedback to the user that lasts long enough they can 'feel' the
|
||||||
|
// fetch has happened.
|
||||||
|
//
|
||||||
|
// This is only applied on the 'up' extend since the 'down' extend may be
|
||||||
|
// called multiple times for large card sizes (e.g. fullscreen) where a
|
||||||
|
// delay is not desirable.
|
||||||
|
await sleep(GALLERY_MIN_EXTENSION_SECONDS - delta / 1000);
|
||||||
|
}
|
||||||
|
this._showLoaderTop(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _extendDown(): Promise<void> {
|
||||||
|
if (!this._options?.extendDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._showSentinelBottom(false);
|
||||||
|
|
||||||
|
await this._waitForExtend('down');
|
||||||
|
|
||||||
|
// Sentinel will be re-shown next time the contents changes, see:
|
||||||
|
// updateContents() .
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _waitForExtend(direction: 'up' | 'down'): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
fireAdvancedCameraCardEvent<GalleryExtendEvent>(
|
||||||
|
this._host,
|
||||||
|
`gallery:extend:${direction}`,
|
||||||
|
{ resolve },
|
||||||
|
{
|
||||||
|
bubbles: false,
|
||||||
|
composed: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateContents(): void {
|
||||||
|
const slot = this._getSlot();
|
||||||
|
|
||||||
|
if (!slot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = slot
|
||||||
|
.assignedElements()
|
||||||
|
.filter((element) => element instanceof HTMLElement);
|
||||||
|
|
||||||
|
const firstSelected = contents.find(
|
||||||
|
(element) => element.getAttribute('selected') !== null,
|
||||||
|
);
|
||||||
|
if (contents.length) {
|
||||||
|
if (!this._wasEverNonEmpty && firstSelected) {
|
||||||
|
// As a special case, if this is the first setting of the slot contents,
|
||||||
|
// the gallery is scrolled to the selected element (if any). This is
|
||||||
|
// only done on the first setting, as subsequent gallery extensions
|
||||||
|
// should not cause the gallery to rescroll to the item that happens to
|
||||||
|
// be selected.
|
||||||
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
|
||||||
|
scrollIntoView(firstSelected, {
|
||||||
|
boundary: this._host,
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this._wasEverNonEmpty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always render the bottom sentinel when the contents changes, in order to allow
|
||||||
|
// the gallery to be extended downwards.
|
||||||
|
this._showSentinelBottom(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LitElement, ReactiveController } from 'lit';
|
import { LitElement, ReactiveController } from 'lit';
|
||||||
import isEqual from 'lodash-es/isEqual';
|
import { isEqual } from 'lodash-es';
|
||||||
import { KeyboardShortcut } from '../config/schema/view';
|
import { KeyboardShortcut } from '../config/schema/view';
|
||||||
import { setOrRemoveAttribute } from '../utils/basic';
|
import { setOrRemoveAttribute } from '../utils/basic';
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ export const getTechnologyForVideoRTC = (
|
|||||||
element: VideoRTC,
|
element: VideoRTC,
|
||||||
): MediaTechnology[] | undefined => {
|
): MediaTechnology[] | undefined => {
|
||||||
const tech = [
|
const tech = [
|
||||||
...(!!element.pc ? ['webrtc'] : []),
|
...(!!element.pc ? ['webrtc' as const] : []),
|
||||||
...(!element.pc && element.mseCodecs ? ['mse', 'hls'] : []),
|
...(!element.pc && element.mseCodecs ? ['mse' as const, 'hls' as const] : []),
|
||||||
];
|
];
|
||||||
return tech.length ? tech : undefined;
|
return tech.length ? tech : undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,19 +11,17 @@ import {
|
|||||||
sub,
|
sub,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { LitElement } from 'lit';
|
import { LitElement } from 'lit';
|
||||||
import isEqual from 'lodash-es/isEqual';
|
import { isEqual, orderBy, uniqWith } from 'lodash-es';
|
||||||
import orderBy from 'lodash-es/orderBy';
|
|
||||||
import uniqWith from 'lodash-es/uniqWith';
|
|
||||||
import { CameraManager } from '../camera-manager/manager';
|
import { CameraManager } from '../camera-manager/manager';
|
||||||
import { DateRange, PartialDateRange } from '../camera-manager/range';
|
import { DateRange, PartialDateRange } from '../camera-manager/range';
|
||||||
import { DataQuery, MediaMetadata, QueryType } from '../camera-manager/types';
|
import { CameraQuery, MediaMetadata, QueryType } from '../camera-manager/types';
|
||||||
import { ViewManagerInterface } from '../card-controller/view/types';
|
import { ViewManagerInterface } from '../card-controller/view/types';
|
||||||
import { SelectOption, SelectValues } from '../components/select';
|
import { SelectOption, SelectValues } from '../components/select';
|
||||||
import { CardWideConfig } from '../config/schema/types';
|
import { CardWideConfig } from '../config/schema/types';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
|
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
|
||||||
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
|
import { EventMediaQuery, RecordingMediaQuery } from '../view/query';
|
||||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
import { QueryClassifier } from '../view/query-classifier';
|
||||||
|
|
||||||
interface MediaFilterControls {
|
interface MediaFilterControls {
|
||||||
events: boolean;
|
events: boolean;
|
||||||
@@ -216,7 +214,7 @@ export class MediaFilterController {
|
|||||||
const what = getArrayValueAsSet(values.what);
|
const what = getArrayValueAsSet(values.what);
|
||||||
const tags = getArrayValueAsSet(values.tags);
|
const tags = getArrayValueAsSet(values.tags);
|
||||||
|
|
||||||
const queries = new EventMediaQueries([
|
const queries = new EventMediaQuery([
|
||||||
{
|
{
|
||||||
type: QueryType.Event,
|
type: QueryType.Event,
|
||||||
cameraIDs: cameraIDs,
|
cameraIDs: cameraIDs,
|
||||||
@@ -246,7 +244,7 @@ export class MediaFilterController {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const queries = new RecordingMediaQueries([
|
const queries = new RecordingMediaQuery([
|
||||||
{
|
{
|
||||||
type: QueryType.Recording,
|
type: QueryType.Recording,
|
||||||
cameraIDs: cameraIDs,
|
cameraIDs: cameraIDs,
|
||||||
@@ -283,9 +281,14 @@ export class MediaFilterController {
|
|||||||
|
|
||||||
public computeInitialDefaultsFromView(cameraManager: CameraManager): void {
|
public computeInitialDefaultsFromView(cameraManager: CameraManager): void {
|
||||||
const view = this._viewManager?.getView();
|
const view = this._viewManager?.getView();
|
||||||
const queries = view?.query?.getQueries();
|
const query = view?.query;
|
||||||
const allCameraIDs = this._getAllCameraIDs(cameraManager);
|
const allCameraIDs = this._getAllCameraIDs(cameraManager);
|
||||||
if (!view || !queries || !allCameraIDs.size) {
|
if (!view || !QueryClassifier.isMediaQuery(query) || !allCameraIDs.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queries = query.getQuery();
|
||||||
|
if (!queries) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +300,7 @@ export class MediaFilterController {
|
|||||||
let tags: string[] | undefined;
|
let tags: string[] | undefined;
|
||||||
|
|
||||||
const cameraIDSets = uniqWith(
|
const cameraIDSets = uniqWith(
|
||||||
queries.map((query: DataQuery) => query.cameraIDs),
|
queries.map((query: CameraQuery) => query.cameraIDs),
|
||||||
isEqual,
|
isEqual,
|
||||||
);
|
);
|
||||||
// Special note: If all visible cameras are selected, this is the same as no
|
// Special note: If all visible cameras are selected, this is the same as no
|
||||||
@@ -317,8 +320,8 @@ export class MediaFilterController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||||
if (MediaQueriesClassifier.areEventQueries(view.query)) {
|
if (QueryClassifier.isEventQuery(view.query)) {
|
||||||
const queries = view.query.getQueries();
|
const queries = view.query.getQuery();
|
||||||
|
|
||||||
/* istanbul ignore if: the if path cannot be reached -- @preserve */
|
/* istanbul ignore if: the if path cannot be reached -- @preserve */
|
||||||
if (!queries) {
|
if (!queries) {
|
||||||
@@ -362,7 +365,7 @@ export class MediaFilterController {
|
|||||||
if (tagsSets.length === 1 && queries[0].tags?.size) {
|
if (tagsSets.length === 1 && queries[0].tags?.size) {
|
||||||
tags = [...queries[0].tags];
|
tags = [...queries[0].tags];
|
||||||
}
|
}
|
||||||
} else if (MediaQueriesClassifier.areRecordingQueries(view.query)) {
|
} else if (QueryClassifier.isRecordingQuery(view.query)) {
|
||||||
mediaType = MediaFilterMediaType.Recordings;
|
mediaType = MediaFilterMediaType.Recordings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,8 +443,8 @@ export class MediaFilterController {
|
|||||||
|
|
||||||
public getControlsToShow(cameraManager: CameraManager): MediaFilterControls {
|
public getControlsToShow(cameraManager: CameraManager): MediaFilterControls {
|
||||||
const view = this._viewManager?.getView();
|
const view = this._viewManager?.getView();
|
||||||
const events = MediaQueriesClassifier.areEventQueries(view?.query);
|
const events = QueryClassifier.isEventQuery(view?.query);
|
||||||
const recordings = MediaQueriesClassifier.areRecordingQueries(view?.query);
|
const recordings = QueryClassifier.isRecordingQuery(view?.query);
|
||||||
const managerCapabilities = cameraManager.getAggregateCameraCapabilities();
|
const managerCapabilities = cameraManager.getAggregateCameraCapabilities();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user