@@ -6,6 +6,7 @@
|
||||
- [`conditions`](conditions.md)
|
||||
- [`dimensions`](dimensions.md)
|
||||
- [`elements`](elements/README.md)
|
||||
- [`folders`](folders.md)
|
||||
- [`image`](image.md)
|
||||
- [`live`](live.md)
|
||||
- [`media_gallery`](media-gallery.md)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- [`conditions`](../conditions.md)
|
||||
- [`dimensions`](../dimensions.md)
|
||||
- [`elements`](../elements/README.md)
|
||||
- [`folders`](../folders.md)
|
||||
- [`image`](../image.md)
|
||||
- [`live`](../live.md)
|
||||
- [`media_gallery`](../media-gallery.md)
|
||||
|
||||
@@ -112,6 +112,20 @@ action: custom:advanced-camera-card-action
|
||||
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`
|
||||
|
||||
Toggle fullscreen.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- [`conditions`](../../conditions.md)
|
||||
- [`dimensions`](../../dimensions.md)
|
||||
- [`elements`](../../elements/README.md)
|
||||
- [`folders`](../../folders.md)
|
||||
- [`image`](../../image.md)
|
||||
- [`live`](../../live.md)
|
||||
- [`media_gallery`](../../media-gallery.md)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- [`conditions`](../../conditions.md)
|
||||
- [`dimensions`](../../dimensions.md)
|
||||
- [`elements`](../../elements/README.md)
|
||||
- [`folders`](../../folders.md)
|
||||
- [`image`](../../image.md)
|
||||
- [`live`](../../live.md)
|
||||
- [`media_gallery`](../../media-gallery.md)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
- [`engine`](engine.md)
|
||||
- [`conditions`](../conditions.md)
|
||||
- [`dimensions`](../dimensions.md)
|
||||
- [`folders`](../folders.md)
|
||||
- [`elements`](../elements/README.md)
|
||||
- [`image`](../image.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)
|
||||
- [Custom Elements](./custom/README.md)
|
||||
- [Stock Elements](./stock/README.md)
|
||||
- [`folders`](../folders.md)
|
||||
- [`image`](../image.md)
|
||||
- [`live`](../live.md)
|
||||
- [`media_gallery`](../media-gallery.md)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
- [`elements`](../../elements/README.md)
|
||||
- [Custom Actions](README.md)
|
||||
- [Stock Actions](../stock/README.md)
|
||||
- [`folders`](../../folders.md)
|
||||
- [`image`](../../image.md)
|
||||
- [`live`](../../live.md)
|
||||
- [`media_gallery`](../../media-gallery.md)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
- [`elements`](../../elements/README.md)
|
||||
- [Custom Actions](../custom/README.md)
|
||||
- [Stock Actions](README.md)
|
||||
- [`folders`](../../folders.md)
|
||||
- [`image`](../../image.md)
|
||||
- [`live`](../../live.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,16 +1,17 @@
|
||||
# `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
|
||||
media_gallery:
|
||||
# [...]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `actions` | | [Actions](actions/README.md) to use for all views that use the `media_gallery` (e.g. `clips`, `snapshots`, `recordings`). |
|
||||
| `controls` | | Configuration for the Media viewer controls. See below. |
|
||||
| Option | Default | Description |
|
||||
| ---------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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 Gallery controls. See below. |
|
||||
|
||||
## `controls`
|
||||
|
||||
@@ -25,9 +26,9 @@ media_gallery:
|
||||
# [...]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------ | ------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `mode` | `right` | Whether to show the gallery media filter to the `left`, to the `right` or `none` for no media filter. |
|
||||
| Option | Default | Description |
|
||||
| ------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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`
|
||||
|
||||
|
||||
+53
-47
@@ -36,6 +36,7 @@ menu:
|
||||
| `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. |
|
||||
| `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). |
|
||||
| `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` . |
|
||||
@@ -80,66 +81,56 @@ This card supports several menu styles.
|
||||
menu:
|
||||
alignment: left
|
||||
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:
|
||||
priority: 50
|
||||
enabled: true
|
||||
alignment: matching
|
||||
icon: mdi:web
|
||||
fullscreen:
|
||||
cameras:
|
||||
priority: 50
|
||||
enabled: true
|
||||
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:
|
||||
priority: 50
|
||||
enabled: true
|
||||
alignment: matching
|
||||
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:
|
||||
priority: 50
|
||||
enabled: false
|
||||
@@ -171,6 +162,21 @@ menu:
|
||||
enabled: true
|
||||
alignment: matching
|
||||
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
|
||||
position: top
|
||||
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. |
|
||||
| `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`). |
|
||||
| `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. |
|
||||
|
||||
+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:
|
||||
|
||||
- **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.
|
||||
- **PATCH** version changes for backward compatible bug fixes
|
||||
|
||||
@@ -46,8 +46,6 @@ Releases follow [Semantic Versioning](https://semver.org/) with the following de
|
||||
|
||||
## 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
|
||||
`src/localize/languages` or use the [inlang](https://inlang.com/) online editor.
|
||||
|
||||
|
||||
@@ -336,6 +336,79 @@ cameras:
|
||||
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
|
||||
|
||||
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/lodash-es": "^4.17.12",
|
||||
"@types/masonry-layout": "^4.2.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.0",
|
||||
"@typescript-eslint/parser": "^7.13.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.30.1",
|
||||
"@typescript-eslint/parser": "^8.30.1",
|
||||
"@vitest/coverage-istanbul": "^1.6.0",
|
||||
"conventional-changelog-conventionalcommits": "^8.0.0",
|
||||
"docsify-cli": "^4.4.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
@@ -91,8 +91,8 @@
|
||||
"semantic-release": "^24.1.1",
|
||||
"semantic-release-export-data": "^1.1.0",
|
||||
"ts-prune": "^0.10.3",
|
||||
"type-fest": "^4.20.0",
|
||||
"typescript": "^5.4.5",
|
||||
"type-fest": "^4.41.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^1.6.0",
|
||||
"vitest-mock-extended": "^1.3.1"
|
||||
},
|
||||
|
||||
@@ -189,7 +189,7 @@ export const actionHandler = directive(
|
||||
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) {}
|
||||
},
|
||||
);
|
||||
|
||||
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 { localize } from '../../localize/localize';
|
||||
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
|
||||
import { Camera, CameraInitializationOptions } from '../camera';
|
||||
import { CameraInitializationError } from '../error';
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
||||
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 { canonicalizeHAURL } from '../../utils/ha';
|
||||
import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager';
|
||||
import { BROWSE_MEDIA_CACHE_SECONDS } from '../../utils/ha/browse-media/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 { Endpoint } from '../../types';
|
||||
import { ViewMedia } from '../../view/item';
|
||||
import { ViewItemCapabilities } from '../../view/types';
|
||||
import { CameraManagerEngine } from '../engine';
|
||||
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||
import {
|
||||
CameraEndpoint,
|
||||
CameraEventCallback,
|
||||
CameraManagerMediaCapabilities,
|
||||
DataQuery,
|
||||
CameraManagerRequestCache,
|
||||
CameraQuery,
|
||||
EventQuery,
|
||||
PartialEventQuery,
|
||||
QueryType,
|
||||
@@ -28,22 +28,22 @@ export class BrowseMediaCameraManagerEngine
|
||||
extends GenericCameraManagerEngine
|
||||
implements CameraManagerEngine
|
||||
{
|
||||
protected _browseMediaManager: BrowseMediaManager;
|
||||
protected _browseMediaWalker: BrowseMediaWalker;
|
||||
protected _entityRegistryManager: EntityRegistryManager;
|
||||
protected _resolvedMediaCache: ResolvedMediaCache;
|
||||
protected _requestCache: RequestCache;
|
||||
protected _requestCache: CameraManagerRequestCache;
|
||||
|
||||
public constructor(
|
||||
entityRegistryManager: EntityRegistryManager,
|
||||
stateWatcher: StateWatcherSubscriptionInterface,
|
||||
browseMediaManager: BrowseMediaManager,
|
||||
browseMediaManager: BrowseMediaWalker,
|
||||
resolvedMediaCache: ResolvedMediaCache,
|
||||
requestCache: RequestCache,
|
||||
requestCache: CameraManagerRequestCache,
|
||||
eventCallback?: CameraEventCallback,
|
||||
) {
|
||||
super(stateWatcher, eventCallback);
|
||||
this._entityRegistryManager = entityRegistryManager;
|
||||
this._browseMediaManager = browseMediaManager;
|
||||
this._browseMediaWalker = browseMediaManager;
|
||||
this._resolvedMediaCache = resolvedMediaCache;
|
||||
this._requestCache = requestCache;
|
||||
}
|
||||
@@ -66,18 +66,11 @@ export class BrowseMediaCameraManagerEngine
|
||||
hass: HomeAssistant,
|
||||
_cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
): Promise<CameraEndpoint | null> {
|
||||
const contentID = media.getContentID();
|
||||
if (!contentID) {
|
||||
return null;
|
||||
}
|
||||
const resolvedMedia = await resolveMedia(hass, contentID, this._resolvedMediaCache);
|
||||
return resolvedMedia
|
||||
? { endpoint: canonicalizeHAURL(hass, resolvedMedia.url) }
|
||||
: null;
|
||||
): Promise<Endpoint | null> {
|
||||
return getMediaDownloadPath(hass, media.getContentID(), this._resolvedMediaCache);
|
||||
}
|
||||
|
||||
public getQueryResultMaxAge(query: DataQuery): number | null {
|
||||
public getQueryResultMaxAge(query: CameraQuery): number | null {
|
||||
if (query.type === QueryType.Event) {
|
||||
return BROWSE_MEDIA_CACHE_SECONDS;
|
||||
}
|
||||
@@ -85,7 +78,7 @@ export class BrowseMediaCameraManagerEngine
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities {
|
||||
public getMediaCapabilities(_media: ViewMedia): ViewItemCapabilities {
|
||||
return {
|
||||
canFavorite: false,
|
||||
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 from 'lodash-es/orderBy';
|
||||
import sortedUniqBy from 'lodash-es/sortedUniqBy';
|
||||
import { orderBy, sortedUniqBy } from 'lodash-es';
|
||||
import { DateRange, MemoryRangeSet } from './range';
|
||||
import { DataQuery, QueryResults, 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> {}
|
||||
import { RecordingSegment } from './types';
|
||||
|
||||
class MemoryRangedCache<Data> {
|
||||
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 { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
|
||||
import { CameraConfig } from '../config/schema/cameras';
|
||||
import { isTriggeredState } from '../ha/is-triggered-state';
|
||||
import { HassStateDifference } from '../ha/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { HassStateDifference, isTriggeredState } from '../utils/ha';
|
||||
import { Capabilities } from './capabilities';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { CameraNoIDError } from './error';
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
|
||||
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 { localize } from '../localize/localize';
|
||||
import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
|
||||
import { EntityRegistryManager } from '../utils/ha/registry/entity/types';
|
||||
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
||||
import { RecordingSegmentsCache, RequestCache } from './cache';
|
||||
import { RecordingSegmentsCache } from './cache';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { CameraInitializationError } from './error';
|
||||
import { CameraEventCallback, Engine } from './types';
|
||||
import { CameraEventCallback, CameraManagerRequestCache, Engine } from './types';
|
||||
import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
|
||||
|
||||
interface CameraManagerEngineFactoryOptions {
|
||||
@@ -44,7 +44,7 @@ export class CameraManagerEngineFactory {
|
||||
this._entityRegistryManager,
|
||||
options.stateWatcher,
|
||||
new RecordingSegmentsCache(),
|
||||
new RequestCache(),
|
||||
new CameraManagerRequestCache(),
|
||||
options.eventCallback,
|
||||
);
|
||||
break;
|
||||
@@ -55,9 +55,9 @@ export class CameraManagerEngineFactory {
|
||||
cameraManagerEngine = new MotionEyeCameraManagerEngine(
|
||||
this._entityRegistryManager,
|
||||
options.stateWatcher,
|
||||
new BrowseMediaManager(),
|
||||
new BrowseMediaWalker(),
|
||||
options.resolvedMediaCache,
|
||||
new RequestCache(),
|
||||
new CameraManagerRequestCache(),
|
||||
options.eventCallback,
|
||||
);
|
||||
break;
|
||||
@@ -66,9 +66,9 @@ export class CameraManagerEngineFactory {
|
||||
cameraManagerEngine = new ReolinkCameraManagerEngine(
|
||||
this._entityRegistryManager,
|
||||
options.stateWatcher,
|
||||
new BrowseMediaManager(),
|
||||
new BrowseMediaWalker(),
|
||||
options.resolvedMediaCache,
|
||||
new RequestCache(),
|
||||
new CameraManagerRequestCache(),
|
||||
options.eventCallback,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { CameraConfig } from '../config/schema/cameras';
|
||||
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 { CameraManagerReadOnlyConfigStore } from './store';
|
||||
import {
|
||||
CameraEndpoint,
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerMediaCapabilities,
|
||||
DataQuery,
|
||||
CameraQuery,
|
||||
Engine,
|
||||
EngineOptions,
|
||||
EventQuery,
|
||||
@@ -90,7 +90,7 @@ export interface CameraManagerEngine {
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
): Promise<CameraEndpoint | null>;
|
||||
): Promise<Endpoint | null>;
|
||||
|
||||
favoriteMedia(
|
||||
hass: HomeAssistant,
|
||||
@@ -99,7 +99,7 @@ export interface CameraManagerEngine {
|
||||
favorite: boolean,
|
||||
): Promise<void>;
|
||||
|
||||
getQueryResultMaxAge(query: DataQuery): number | null;
|
||||
getQueryResultMaxAge(query: CameraQuery): number | null;
|
||||
|
||||
getMediaSeekTime(
|
||||
hass: HomeAssistant,
|
||||
@@ -121,7 +121,7 @@ export interface CameraManagerEngine {
|
||||
cameraConfig: CameraConfig,
|
||||
): CameraManagerCameraMetadata;
|
||||
|
||||
getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null;
|
||||
getMediaCapabilities(media: ViewMedia): ViewItemCapabilities | null;
|
||||
|
||||
getCameraEndpoints(
|
||||
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 { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
||||
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
|
||||
import { CameraConfig } from '../../config/schema/cameras';
|
||||
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { PTZCapabilities, PTZMovementType } from '../../types';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
|
||||
import { Camera, CameraInitializationOptions } from '../camera';
|
||||
import { Capabilities } from '../capabilities';
|
||||
import { CameraManagerEngine } from '../engine';
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { add, endOfHour, format, fromUnixTime, startOfHour } from 'date-fns';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import uniqWith from 'lodash-es/uniqWith';
|
||||
import { isEqual, orderBy, throttle, uniqWith } from 'lodash-es';
|
||||
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
||||
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 { Endpoint } from '../../types';
|
||||
import {
|
||||
allPromises,
|
||||
formatDate,
|
||||
prettifyTitle,
|
||||
runWhenIdleIfSupported,
|
||||
} from '../../utils/basic';
|
||||
import { getEntityTitle } from '../../utils/ha';
|
||||
import { EntityRegistryManager } from '../../utils/ha/registry/entity/types';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import { ViewMediaClassifier } from '../../view/media-classifier';
|
||||
import { RecordingSegmentsCache, RequestCache } from '../cache';
|
||||
import { ViewMedia, ViewMediaType } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
import { ViewItemCapabilities } from '../../view/types';
|
||||
import { RecordingSegmentsCache } from '../cache';
|
||||
import { Camera } from '../camera';
|
||||
import {
|
||||
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
|
||||
@@ -26,13 +25,12 @@ import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||
import { DateRange } from '../range';
|
||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||
import {
|
||||
CameraEndpoint,
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraEventCallback,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerMediaCapabilities,
|
||||
DataQuery,
|
||||
CameraManagerRequestCache,
|
||||
CameraQuery,
|
||||
Engine,
|
||||
EngineOptions,
|
||||
EventQuery,
|
||||
@@ -112,7 +110,7 @@ export class FrigateCameraManagerEngine
|
||||
protected _entityRegistryManager: EntityRegistryManager;
|
||||
protected _frigateEventWatcher: FrigateEventWatcher;
|
||||
protected _recordingSegmentsCache: RecordingSegmentsCache;
|
||||
protected _requestCache: RequestCache;
|
||||
protected _requestCache: CameraManagerRequestCache;
|
||||
|
||||
// Garbage collect segments at most once an hour.
|
||||
protected _throttledSegmentGarbageCollector = throttle(
|
||||
@@ -125,7 +123,7 @@ export class FrigateCameraManagerEngine
|
||||
entityRegistryManager: EntityRegistryManager,
|
||||
stateWatcher: StateWatcherSubscriptionInterface,
|
||||
recordingSegmentsCache: RecordingSegmentsCache,
|
||||
requestCache: RequestCache,
|
||||
requestCache: CameraManagerRequestCache,
|
||||
eventCallback?: CameraEventCallback,
|
||||
) {
|
||||
super(stateWatcher, eventCallback);
|
||||
@@ -158,13 +156,13 @@ export class FrigateCameraManagerEngine
|
||||
_hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
): Promise<CameraEndpoint | null> {
|
||||
): Promise<Endpoint | null> {
|
||||
if (FrigateViewMediaClassifier.isFrigateEvent(media)) {
|
||||
return {
|
||||
endpoint:
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
`/notifications/${media.getID()}/` +
|
||||
`${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
|
||||
`${ViewItemClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
|
||||
`?download=true`,
|
||||
sign: true,
|
||||
};
|
||||
@@ -541,7 +539,7 @@ export class FrigateCameraManagerEngine
|
||||
|
||||
protected _getCameraIDMatch(
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
query: DataQuery,
|
||||
query: CameraQuery,
|
||||
instanceID: string,
|
||||
cameraName: string,
|
||||
): string | null {
|
||||
@@ -588,17 +586,17 @@ export class FrigateCameraManagerEngine
|
||||
if (!cameraConfig) {
|
||||
continue;
|
||||
}
|
||||
let mediaType: 'clip' | 'snapshot' | null = null;
|
||||
let mediaType: ViewMediaType | null = null;
|
||||
if (
|
||||
!query.hasClip &&
|
||||
!query.hasSnapshot &&
|
||||
(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) {
|
||||
mediaType = 'snapshot';
|
||||
mediaType = ViewMediaType.Snapshot;
|
||||
} else if (query.hasClip && event.has_clip) {
|
||||
mediaType = 'clip';
|
||||
mediaType = ViewMediaType.Clip;
|
||||
}
|
||||
if (!mediaType) {
|
||||
continue;
|
||||
@@ -646,7 +644,7 @@ export class FrigateCameraManagerEngine
|
||||
return output;
|
||||
}
|
||||
|
||||
public getQueryResultMaxAge(query: DataQuery): number | null {
|
||||
public getQueryResultMaxAge(query: CameraQuery): number | null {
|
||||
if (query.type === QueryType.Event) {
|
||||
return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
|
||||
} else if (query.type === QueryType.Recording) {
|
||||
@@ -664,11 +662,12 @@ export class FrigateCameraManagerEngine
|
||||
): Promise<number | null> {
|
||||
const start = media.getStartTime();
|
||||
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;
|
||||
}
|
||||
|
||||
const cameraID = media.getCameraID();
|
||||
const query: RecordingSegmentsQuery = {
|
||||
cameraIDs: new Set([cameraID]),
|
||||
start: start,
|
||||
@@ -898,9 +897,9 @@ export class FrigateCameraManagerEngine
|
||||
return seekMilliseconds / 1000;
|
||||
}
|
||||
|
||||
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities {
|
||||
public getMediaCapabilities(media: ViewMedia): ViewItemCapabilities {
|
||||
return {
|
||||
canFavorite: ViewMediaClassifier.isEvent(media),
|
||||
canFavorite: ViewItemClassifier.isEvent(media),
|
||||
canDownload: true,
|
||||
};
|
||||
}
|
||||
@@ -926,7 +925,7 @@ export class FrigateCameraManagerEngine
|
||||
cameraConfig: CameraConfig,
|
||||
context?: CameraEndpointsContext,
|
||||
): CameraEndpoints | null {
|
||||
const getUIEndpoint = (): CameraEndpoint | null => {
|
||||
const getUIEndpoint = (): Endpoint | null => {
|
||||
if (!cameraConfig.frigate.url) {
|
||||
return null;
|
||||
}
|
||||
@@ -976,7 +975,7 @@ export class FrigateCameraManagerEngine
|
||||
};
|
||||
};
|
||||
|
||||
const getJSMPEG = (): CameraEndpoint | null => {
|
||||
const getJSMPEG = (): Endpoint | null => {
|
||||
return {
|
||||
endpoint:
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
|
||||
@@ -53,6 +53,7 @@ export class FrigateEventWatcher implements FrigateEventWatcherSubscriptionInter
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(data);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.warn('Received non-JSON payload as Frigate event', data);
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import { ViewMedia } from '../../view/item';
|
||||
import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media';
|
||||
|
||||
export class FrigateViewMediaClassifier {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fromUnixTime } from 'date-fns';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { CameraConfig } from '../../config/schema/cameras';
|
||||
import {
|
||||
EventViewMedia,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
VideoContentType,
|
||||
ViewMedia,
|
||||
ViewMediaType,
|
||||
} from '../../view/media';
|
||||
} from '../../view/item';
|
||||
import { FrigateEvent, FrigateRecording } from './types';
|
||||
import {
|
||||
getEventMediaContentID,
|
||||
@@ -36,7 +36,7 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
|
||||
// sublabels (`_splitSubLabels` in engine-frigate.ts).
|
||||
subLabels?: string[],
|
||||
) {
|
||||
super(mediaType, cameraID);
|
||||
super(mediaType, { cameraID });
|
||||
this._event = event;
|
||||
this._contentID = contentID;
|
||||
this._thumbnail = thumbnail;
|
||||
@@ -88,8 +88,6 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
|
||||
public getTags(): string[] | null {
|
||||
return this._subLabels;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public isGroupableWith(that: EventViewMedia): boolean {
|
||||
return (
|
||||
this.getMediaType() === that.getMediaType() &&
|
||||
@@ -113,7 +111,7 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
|
||||
contentID: string,
|
||||
title: string,
|
||||
) {
|
||||
super(mediaType, cameraID);
|
||||
super(mediaType, { cameraID });
|
||||
this._recording = recording;
|
||||
this._id = id;
|
||||
this._contentID = contentID;
|
||||
@@ -150,7 +148,7 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
|
||||
|
||||
export class FrigateViewMediaFactory {
|
||||
static createEventViewMedia(
|
||||
mediaType: 'clip' | 'snapshot',
|
||||
mediaType: ViewMediaType,
|
||||
cameraID: string,
|
||||
cameraConfig: CameraConfig,
|
||||
event: FrigateEvent,
|
||||
@@ -191,7 +189,7 @@ export class FrigateViewMediaFactory {
|
||||
}
|
||||
|
||||
return new FrigateRecordingViewMedia(
|
||||
'recording',
|
||||
ViewMediaType.Recording,
|
||||
cameraID,
|
||||
recording,
|
||||
getRecordingID(cameraConfig, recording),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { homeAssistantWSRequest } from '../../ha/ws-request';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { AdvancedCameraCardError } from '../../types';
|
||||
import { homeAssistantWSRequest } from '../../utils/ha/ws-request';
|
||||
import { RecordingSegment } from '../types';
|
||||
import {
|
||||
EventSummary,
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
||||
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
|
||||
import { CameraConfig } from '../../config/schema/cameras';
|
||||
import { getEntityTitle } from '../../ha/get-entity-title';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { getEntityTitle } from '../../utils/ha';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import { Endpoint } from '../../types';
|
||||
import { ViewMedia } from '../../view/item';
|
||||
import { ViewItemCapabilities } from '../../view/types';
|
||||
import { Camera } from '../camera';
|
||||
import { Capabilities } from '../capabilities';
|
||||
import { CameraManagerEngine } from '../engine';
|
||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||
import {
|
||||
CameraEndpoint,
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraEventCallback,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerMediaCapabilities,
|
||||
DataQuery,
|
||||
CameraQuery,
|
||||
Engine,
|
||||
EngineOptions,
|
||||
EventQuery,
|
||||
@@ -155,7 +154,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
_hass: HomeAssistant,
|
||||
_cameraConfig: CameraConfig,
|
||||
_media: ViewMedia,
|
||||
): Promise<CameraEndpoint | null> {
|
||||
): Promise<Endpoint | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -168,7 +167,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
public getQueryResultMaxAge(_query: DataQuery): number | null {
|
||||
public getQueryResultMaxAge(_query: CameraQuery): number | 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;
|
||||
}
|
||||
|
||||
@@ -219,7 +218,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
cameraConfig: CameraConfig,
|
||||
_context?: CameraEndpointsContext,
|
||||
): CameraEndpoints | null {
|
||||
const getWebRTCCard = (): CameraEndpoint | null => {
|
||||
const getWebRTCCard = (): Endpoint | null => {
|
||||
// The user may override this in their webrtc_card configuration.
|
||||
const endpoint = cameraConfig.camera_entity ? cameraConfig.camera_entity : null;
|
||||
return endpoint ? { endpoint: endpoint } : null;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { add } from 'date-fns';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import sum from 'lodash-es/sum';
|
||||
import { cloneDeep, sum } from 'lodash-es';
|
||||
import PQueue from 'p-queue';
|
||||
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 { CameraConfig, CamerasConfig } from '../config/schema/cameras.js';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import { Endpoint } from '../types.js';
|
||||
import {
|
||||
allPromises,
|
||||
arrayify,
|
||||
@@ -16,19 +17,19 @@ import {
|
||||
} from '../utils/basic.js';
|
||||
import { getCameraID } from '../utils/camera.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 { CameraManagerEngineFactory } from './engine-factory.js';
|
||||
import { CameraManagerEngine } from './engine.js';
|
||||
import { CameraInitializationError } from './error.js';
|
||||
import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js';
|
||||
import {
|
||||
CameraEndpoint,
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerMediaCapabilities,
|
||||
DataQuery,
|
||||
CameraQuery,
|
||||
Engine,
|
||||
EngineOptions,
|
||||
EventQuery,
|
||||
@@ -38,7 +39,7 @@ import {
|
||||
MediaMetadataQuery,
|
||||
MediaMetadataQueryResults,
|
||||
MediaQuery,
|
||||
PartialDataQuery,
|
||||
PartialCameraQuery,
|
||||
PartialEventQuery,
|
||||
PartialQueryConcreteType,
|
||||
PartialRecordingQuery,
|
||||
@@ -55,24 +56,25 @@ import {
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
ResultsMap,
|
||||
} from './types.js';
|
||||
import { sortMedia } from './utils/sort-media.js';
|
||||
|
||||
export class QueryClassifier {
|
||||
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
|
||||
export class CameraQueryClassifier {
|
||||
public static isEventQuery(
|
||||
query: CameraQuery | PartialCameraQuery,
|
||||
): query is EventQuery {
|
||||
return query.type === QueryType.Event;
|
||||
}
|
||||
public static isRecordingQuery(
|
||||
query: DataQuery | PartialDataQuery,
|
||||
query: CameraQuery | PartialCameraQuery,
|
||||
): query is RecordingQuery {
|
||||
return query.type === QueryType.Recording;
|
||||
}
|
||||
public static isRecordingSegmentsQuery(
|
||||
query: DataQuery | PartialDataQuery,
|
||||
query: CameraQuery | PartialCameraQuery,
|
||||
): query is RecordingSegmentsQuery {
|
||||
return query.type === QueryType.RecordingSegments;
|
||||
}
|
||||
public static isMediaMetadataQuery(
|
||||
query: DataQuery | PartialDataQuery,
|
||||
query: CameraQuery | PartialCameraQuery,
|
||||
): query is MediaMetadataQuery {
|
||||
return query.type === QueryType.MediaMetadata;
|
||||
}
|
||||
@@ -103,7 +105,7 @@ export class QueryResultClassifier {
|
||||
|
||||
export interface ExtendedMediaQueryResult<T extends MediaQuery> {
|
||||
queries: T[];
|
||||
results: ViewMedia[];
|
||||
results: ViewItem[];
|
||||
}
|
||||
|
||||
export class CameraManager {
|
||||
@@ -318,7 +320,7 @@ export class CameraManager {
|
||||
});
|
||||
}
|
||||
|
||||
protected _generateDefaultQueries<PQT extends PartialDataQuery>(
|
||||
protected _generateDefaultQueries<PQT extends PartialCameraQuery>(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery: PQT,
|
||||
): PartialQueryConcreteType<PQT>[] | null {
|
||||
@@ -330,17 +332,17 @@ export class CameraManager {
|
||||
}
|
||||
|
||||
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 */
|
||||
if (QueryClassifier.isEventQuery(partialQuery)) {
|
||||
if (CameraQueryClassifier.isEventQuery(partialQuery)) {
|
||||
queries = engine.generateDefaultEventQuery(this._store, cameraIDs, partialQuery);
|
||||
} else if (QueryClassifier.isRecordingQuery(partialQuery)) {
|
||||
} else if (CameraQueryClassifier.isRecordingQuery(partialQuery)) {
|
||||
queries = engine.generateDefaultRecordingQuery(
|
||||
this._store,
|
||||
cameraIDs,
|
||||
partialQuery,
|
||||
);
|
||||
} else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
|
||||
} else if (CameraQueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
|
||||
queries = engine.generateDefaultRecordingSegmentsQuery(
|
||||
this._store,
|
||||
cameraIDs,
|
||||
@@ -426,7 +428,7 @@ export class CameraManager {
|
||||
|
||||
public async extendMediaQueries<T extends MediaQuery>(
|
||||
queries: T[],
|
||||
results: ViewMedia[],
|
||||
results: ViewItem[],
|
||||
direction: 'earlier' | 'later',
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<ExtendedMediaQueryResult<T> | null> {
|
||||
@@ -438,6 +440,9 @@ export class CameraManager {
|
||||
const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => {
|
||||
let output: Date | null = null;
|
||||
for (const result of results) {
|
||||
if (!ViewItemClassifier.isMedia(result)) {
|
||||
continue;
|
||||
}
|
||||
const startTime = result.getStartTime();
|
||||
if (
|
||||
startTime &&
|
||||
@@ -495,7 +500,7 @@ export class CameraManager {
|
||||
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
|
||||
// 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 engine = this._store.getEngineForMedia(media);
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
@@ -520,7 +525,7 @@ export class CameraManager {
|
||||
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);
|
||||
if (!engine) {
|
||||
return null;
|
||||
@@ -603,7 +608,7 @@ export class CameraManager {
|
||||
);
|
||||
}
|
||||
|
||||
protected async _handleQuery<QT extends DataQuery>(
|
||||
protected async _handleQuery<QT extends CameraQuery>(
|
||||
query: QT | QT[],
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<Map<QT, QueryReturnType<QT>>> {
|
||||
@@ -623,28 +628,28 @@ export class CameraManager {
|
||||
let engineResult: Map<QT, QueryReturnType<QT>> | null = null;
|
||||
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (QueryClassifier.isEventQuery(query)) {
|
||||
if (CameraQueryClassifier.isEventQuery(query)) {
|
||||
engineResult = (await engine.getEvents(
|
||||
hass,
|
||||
this._store,
|
||||
query,
|
||||
engineOptions,
|
||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||
} else if (QueryClassifier.isRecordingQuery(query)) {
|
||||
} else if (CameraQueryClassifier.isRecordingQuery(query)) {
|
||||
engineResult = (await engine.getRecordings(
|
||||
hass,
|
||||
this._store,
|
||||
query,
|
||||
engineOptions,
|
||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
|
||||
} else if (CameraQueryClassifier.isRecordingSegmentsQuery(query)) {
|
||||
engineResult = (await engine.getRecordingSegments(
|
||||
hass,
|
||||
this._store,
|
||||
query,
|
||||
engineOptions,
|
||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||
} else if (QueryClassifier.isMediaMetadataQuery(query)) {
|
||||
} else if (CameraQueryClassifier.isMediaMetadataQuery(query)) {
|
||||
engineResult = (await engine.getMediaMetadata(
|
||||
hass,
|
||||
this._store,
|
||||
@@ -697,7 +702,7 @@ export class CameraManager {
|
||||
return results;
|
||||
}
|
||||
|
||||
protected _convertQueryResultsToMedia<QT extends DataQuery>(
|
||||
protected _convertQueryResultsToMedia<QT extends CameraQuery>(
|
||||
results: ResultsMap<QT>,
|
||||
): ViewMedia[] {
|
||||
const mediaArray: ViewMedia[] = [];
|
||||
@@ -714,12 +719,12 @@ export class CameraManager {
|
||||
let media: ViewMedia[] | null = null;
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (
|
||||
QueryClassifier.isEventQuery(query) &&
|
||||
CameraQueryClassifier.isEventQuery(query) &&
|
||||
QueryResultClassifier.isEventQueryResult(result)
|
||||
) {
|
||||
media = engine.generateMediaFromEvents(hass, this._store, query, result);
|
||||
} else if (
|
||||
QueryClassifier.isRecordingQuery(query) &&
|
||||
CameraQueryClassifier.isRecordingQuery(query) &&
|
||||
QueryResultClassifier.isRecordingQueryResult(result)
|
||||
) {
|
||||
media = engine.generateMediaFromRecordings(hass, this._store, query, result);
|
||||
@@ -729,7 +734,7 @@ export class CameraManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortMedia(mediaArray);
|
||||
return sortItems(mediaArray);
|
||||
}
|
||||
|
||||
public getCameraEndpoints(
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
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 { HomeAssistant } from '../../ha/types';
|
||||
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||
import {
|
||||
BrowseMediaStep,
|
||||
BrowseMediaTarget,
|
||||
} from '../../utils/ha/browse-media/browse-media-manager';
|
||||
import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media';
|
||||
import {
|
||||
BROWSE_MEDIA_CACHE_SECONDS,
|
||||
BrowseMedia,
|
||||
BrowseMediaCache,
|
||||
BrowseMediaMetadata,
|
||||
MEDIA_CLASS_IMAGE,
|
||||
MEDIA_CLASS_VIDEO,
|
||||
RichBrowseMedia,
|
||||
} from '../../utils/ha/browse-media/types';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
} from '../../ha/browse-media/types';
|
||||
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 { 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 { Capabilities } from '../capabilities';
|
||||
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||
import {
|
||||
CameraEndpoint,
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
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);
|
||||
|
||||
export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||
protected _directoryCache = new MemoryRequestCache<string, BrowseMedia>();
|
||||
protected _fileCache = new MemoryRequestCache<string, BrowseMedia>();
|
||||
protected _directoryCache = new BrowseMediaCache<BrowseMediaMetadata>();
|
||||
protected _fileCache = new BrowseMediaCache<BrowseMediaMetadata>();
|
||||
|
||||
public getEngineType(): Engine {
|
||||
return Engine.MotionEye;
|
||||
@@ -224,7 +221,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
||||
};
|
||||
|
||||
// For motionEye snapshots and clips are mutually exclusive.
|
||||
return await this._browseMediaManager.walkBrowseMedias(
|
||||
return await this._browseMediaWalker.walk(
|
||||
hass,
|
||||
[
|
||||
...(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 media = await this._browseMediaManager.walkBrowseMedias(
|
||||
const media = await this._browseMediaWalker.walk(
|
||||
hass,
|
||||
[
|
||||
{
|
||||
@@ -431,7 +428,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
||||
cameraConfig: CameraConfig,
|
||||
context?: CameraEndpointsContext,
|
||||
): CameraEndpoints | null {
|
||||
const getUIEndpoint = (): CameraEndpoint | null => {
|
||||
const getUIEndpoint = (): Endpoint | null => {
|
||||
return cameraConfig.motioneye?.url
|
||||
? {
|
||||
endpoint: cameraConfig.motioneye.url,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
|
||||
import { BrowseMediaMetadata } from '../browse-media/types';
|
||||
import { BrowseMediaMetadata, RichBrowseMedia } from '../../ha/browse-media/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> {
|
||||
start: T;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ActionsExecutor } from '../../card-controller/actions/types';
|
||||
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
|
||||
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { PTZCapabilities, PTZMovementType } from '../../types';
|
||||
import { createSelectOptionAction } from '../../utils/action.js';
|
||||
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
|
||||
import { BrowseMediaCamera } from '../browse-media/camera';
|
||||
import { Camera, CameraInitializationOptions } from '../camera';
|
||||
import { Capabilities } from '../capabilities';
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { add, endOfDay, parse, startOfDay } from 'date-fns';
|
||||
import { orderBy } from 'lodash-es';
|
||||
import { CameraConfig } from '../../config/schema/cameras';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||
import { sortMediaByStartDate } from '../../utils/ha/browse-media/browse-media-manager';
|
||||
import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media';
|
||||
import { sortMediaByStartDate } from '../../ha/browse-media/sort-browse-media-by-start-date';
|
||||
import {
|
||||
BROWSE_MEDIA_CACHE_SECONDS,
|
||||
BrowseMedia,
|
||||
BrowseMediaCache,
|
||||
BrowseMediaMetadata,
|
||||
MEDIA_CLASS_VIDEO,
|
||||
RichBrowseMedia,
|
||||
} from '../../utils/ha/browse-media/types';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
} from '../../ha/browse-media/types';
|
||||
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 { 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_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||
import {
|
||||
CameraEndpoint,
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraManagerCameraMetadata,
|
||||
@@ -48,7 +48,8 @@ export class ReolinkQueryResultsClassifier {
|
||||
}
|
||||
|
||||
export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||
protected _cache = new MemoryRequestCache<string, BrowseMedia>();
|
||||
protected _camerasCache = new BrowseMediaCache<BrowseMediaReolinkCameraMetadata>();
|
||||
protected _cache = new BrowseMediaCache<BrowseMediaMetadata>();
|
||||
|
||||
public getEngineType(): Engine {
|
||||
return Engine.Reolink;
|
||||
@@ -172,7 +173,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||
// that match the expected camera. Some Reolink cameras will not show up
|
||||
// here causing errors.
|
||||
// https://github.com/dermotduffy/advanced-camera-card/issues/1723
|
||||
const camerasWithMedia = await this._browseMediaManager.walkBrowseMedias(
|
||||
const camerasWithMedia = await this._browseMediaWalker.walk(
|
||||
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 await this._browseMediaManager.walkBrowseMedias(
|
||||
return await this._browseMediaWalker.walk(
|
||||
hass,
|
||||
[
|
||||
{
|
||||
@@ -263,7 +264,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||
let media: RichBrowseMedia<BrowseMediaMetadata>[] = [];
|
||||
|
||||
if (directories?.length) {
|
||||
media = await this._browseMediaManager.walkBrowseMedias(
|
||||
media = await this._browseMediaWalker.walk(
|
||||
hass,
|
||||
[
|
||||
{
|
||||
@@ -395,7 +396,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||
cameraConfig: CameraConfig,
|
||||
context?: CameraEndpointsContext,
|
||||
): CameraEndpoints | null {
|
||||
const getUIEndpoint = (): CameraEndpoint | null => {
|
||||
const getUIEndpoint = (): Endpoint | null => {
|
||||
return cameraConfig.reolink?.url
|
||||
? {
|
||||
endpoint: cameraConfig.reolink.url,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
|
||||
import { BrowseMediaMetadata } from '../browse-media/types';
|
||||
import { BrowseMediaMetadata, RichBrowseMedia } from '../../ha/browse-media/types';
|
||||
import { Engine, EventQueryResults } from '../types';
|
||||
|
||||
export interface BrowseMediaReolinkCameraMetadata {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CameraConfig } from '../config/schema/cameras';
|
||||
import { CapabilityKey } from '../types';
|
||||
import { allPromises } from '../utils/basic';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { ViewMedia } from '../view/item';
|
||||
import { Camera } from './camera';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { CapabilitySearchOptions, Engine } from './types';
|
||||
@@ -133,7 +133,8 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -163,7 +164,8 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
|
||||
}
|
||||
|
||||
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 { AdvancedCameraCardView } from '../config/schema/common/const';
|
||||
import { CapabilityKey, Icon } from '../types';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { CapabilityKey, Endpoint, Icon } from '../types';
|
||||
import { ViewMedia } from '../view/item';
|
||||
|
||||
// ====
|
||||
// Base
|
||||
@@ -28,11 +29,11 @@ export enum Engine {
|
||||
Reolink = 'reolink',
|
||||
}
|
||||
|
||||
export interface DataQuery {
|
||||
export interface CameraQuery {
|
||||
type: QueryType;
|
||||
cameraIDs: Set<string>;
|
||||
}
|
||||
export type PartialDataQuery = Partial<DataQuery>;
|
||||
export type PartialCameraQuery = Partial<CameraQuery>;
|
||||
|
||||
interface TimeBasedDataQuery {
|
||||
start: Date;
|
||||
@@ -44,7 +45,7 @@ interface LimitedDataQuery {
|
||||
}
|
||||
|
||||
export interface MediaQuery
|
||||
extends DataQuery,
|
||||
extends CameraQuery,
|
||||
Partial<TimeBasedDataQuery>,
|
||||
Partial<LimitedDataQuery> {
|
||||
favorite?: boolean;
|
||||
@@ -100,11 +101,6 @@ interface CapabilitySearchAllAny {
|
||||
}
|
||||
export type CapabilitySearchOptions = CapabilityKey | CapabilitySearchAllAny;
|
||||
|
||||
export interface CameraManagerMediaCapabilities {
|
||||
canFavorite: boolean;
|
||||
canDownload: boolean;
|
||||
}
|
||||
|
||||
export interface CameraManagerCameraMetadata {
|
||||
title: string;
|
||||
icon: Icon;
|
||||
@@ -118,16 +114,11 @@ export interface CameraEndpointsContext {
|
||||
view?: AdvancedCameraCardView;
|
||||
}
|
||||
|
||||
export interface CameraEndpoint {
|
||||
endpoint: string;
|
||||
sign?: boolean;
|
||||
}
|
||||
|
||||
export interface CameraEndpoints {
|
||||
ui?: CameraEndpoint;
|
||||
go2rtc?: CameraEndpoint;
|
||||
jsmpeg?: CameraEndpoint;
|
||||
webrtcCard?: CameraEndpoint;
|
||||
ui?: Endpoint;
|
||||
go2rtc?: Endpoint;
|
||||
jsmpeg?: Endpoint;
|
||||
webrtcCard?: Endpoint;
|
||||
}
|
||||
|
||||
export interface CameraProxyConfig {
|
||||
@@ -157,6 +148,11 @@ export interface CameraEvent {
|
||||
}
|
||||
export type CameraEventCallback = (ev: CameraEvent) => void;
|
||||
|
||||
export class CameraManagerRequestCache extends ExpiringEqualityCache<
|
||||
CameraQuery,
|
||||
QueryResults
|
||||
> {}
|
||||
|
||||
// ===========
|
||||
// Event Query
|
||||
// ===========
|
||||
@@ -202,7 +198,7 @@ export interface RecordingQueryResults extends QueryResults {
|
||||
// Recording Segments Query
|
||||
// ========================
|
||||
|
||||
export interface RecordingSegmentsQuery extends DataQuery, TimeBasedDataQuery {
|
||||
export interface RecordingSegmentsQuery extends CameraQuery, TimeBasedDataQuery {
|
||||
type: QueryType.RecordingSegments;
|
||||
}
|
||||
export type PartialRecordingSegmentsQuery = Partial<RecordingSegmentsQuery>;
|
||||
@@ -216,7 +212,7 @@ export interface RecordingSegmentsQueryResults extends QueryResults {
|
||||
// Media metadata Query
|
||||
// ====================
|
||||
|
||||
export interface MediaMetadataQuery extends DataQuery {
|
||||
export interface MediaMetadataQuery extends CameraQuery {
|
||||
type: QueryType.MediaMetadata;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CameraConfig } from '../../config/schema/cameras';
|
||||
import { CameraEndpoint } from '../types';
|
||||
import { Endpoint } from '../../types';
|
||||
|
||||
export const getDefaultGo2RTCEndpoint = (
|
||||
cameraConfig: CameraConfig,
|
||||
@@ -7,7 +7,7 @@ export const getDefaultGo2RTCEndpoint = (
|
||||
url?: string;
|
||||
stream?: string;
|
||||
},
|
||||
): CameraEndpoint | null => {
|
||||
): Endpoint | null => {
|
||||
const url = options?.url ?? cameraConfig.go2rtc?.url;
|
||||
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,
|
||||
isAdvancedCameraCardCustomAction,
|
||||
} from '../../utils/action.js';
|
||||
import { allPromises } from '../../utils/basic.js';
|
||||
import { allPromises, errorToConsole } from '../../utils/basic.js';
|
||||
import { TemplateRenderer } from '../templates/index.js';
|
||||
import { CardActionsManagerAPI } from '../types.js';
|
||||
import { ActionSet } from './actions/set.js';
|
||||
@@ -53,7 +53,7 @@ export class ActionsManager implements ActionsExecutor {
|
||||
let specificActions: Actions | undefined = undefined;
|
||||
if (view?.is('live')) {
|
||||
specificActions = config?.live.actions;
|
||||
} else if (view?.isGalleryView()) {
|
||||
} else if (view?.isMediaGalleryView()) {
|
||||
specificActions = config?.media_gallery?.actions;
|
||||
} else if (view?.isViewerView()) {
|
||||
specificActions = config?.media_viewer.actions;
|
||||
@@ -150,6 +150,7 @@ export class ActionsManager implements ActionsExecutor {
|
||||
await actionSet.execute(this._api);
|
||||
forwardHaptic('success');
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
forwardHaptic('warning');
|
||||
}
|
||||
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> {
|
||||
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 { getStreamCameraID } from '../../../utils/substream';
|
||||
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
@@ -9,15 +10,19 @@ export class MediaPlayerAction extends AdvancedCameraCardAction<MediaPlayerActio
|
||||
|
||||
const mediaPlayer = this._action.media_player;
|
||||
const mediaPlayerController = api.getMediaPlayerManager();
|
||||
const view = api.getViewManager().getView();
|
||||
const media = view?.queryResults?.getSelectedResult() ?? null;
|
||||
|
||||
if (this._action.media_player_action === 'stop') {
|
||||
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));
|
||||
} else if (view?.isViewerView() && media) {
|
||||
await mediaPlayerController.playMedia(mediaPlayer, media);
|
||||
} else if (view?.isViewerView() && item && ViewItemClassifier.isMedia(item)) {
|
||||
await mediaPlayerController.playMedia(mediaPlayer, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import clamp from 'lodash-es/clamp';
|
||||
import { clamp } from 'lodash-es';
|
||||
import {
|
||||
PartialZoomSettings,
|
||||
ZOOM_DEFAULT_PAN_X,
|
||||
|
||||
@@ -86,12 +86,15 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
|
||||
setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this);
|
||||
|
||||
const singleStep = async (): Promise<void> => {
|
||||
this._action.ptz_action &&
|
||||
(await api
|
||||
/* istanbul ignore else: the else path cannot be reached as ptz_action
|
||||
being present is checked above -- @preserve */
|
||||
if (this._action.ptz_action) {
|
||||
await api
|
||||
.getCameraManager()
|
||||
.executePTZAction(ptzCameraID, this._action.ptz_action, {
|
||||
preset: this._action.ptz_preset,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
if (!this._stopped) {
|
||||
// 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._action.ptz_action &&
|
||||
(await api
|
||||
/* istanbul ignore else: the else path cannot be reached as ptz_action
|
||||
being present is checked above -- @preserve */
|
||||
if (this._action.ptz_action) {
|
||||
await api
|
||||
.getCameraManager()
|
||||
.executePTZAction(ptzCameraID, this._action.ptz_action, {
|
||||
preset: this._action.ptz_preset,
|
||||
phase: 'stop',
|
||||
}));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
|
||||
import { downloadURL } from '../../../utils/download';
|
||||
import { generateScreenshotFilename } from '../../../utils/screenshot';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
@@ -6,6 +8,13 @@ export class ScreenshotAction extends AdvancedCameraCardAction<GeneralActionConf
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
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 { sleep } from '../../../utils/basic';
|
||||
import { sleep } from '../../../utils/sleep';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { timeDeltaToSeconds } from '../utils/time-delta';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
@@ -3,7 +3,6 @@ import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class StatusBarAction extends AdvancedCameraCardAction<StatusBarActionConfig> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DefaultAction } from './actions/default';
|
||||
import { DisplayModeSelectAction } from './actions/display-mode-select';
|
||||
import { DownloadAction } from './actions/download';
|
||||
import { ExpandAction } from './actions/expand';
|
||||
import { FolderAction } from './actions/folder';
|
||||
import { FullscreenAction } from './actions/fullscreen';
|
||||
import { InternalCallbackAction } from './actions/internal-callback';
|
||||
import { LogAction } from './actions/log';
|
||||
@@ -150,6 +151,8 @@ export class ActionFactory {
|
||||
return new StatusBarAction(context, action, options?.config);
|
||||
case INTERNAL_CALLBACK_ACTION:
|
||||
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 */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import merge from 'lodash-es/merge';
|
||||
import { merge } from 'lodash-es';
|
||||
import { Action, TargetedActionContext } from '../types';
|
||||
import { ActionContext } from 'action';
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ViewItemClassifier } from '../view/item-classifier';
|
||||
import { CardCameraURLAPI } from './types';
|
||||
|
||||
export class CameraURLManager {
|
||||
@@ -20,11 +21,11 @@ export class CameraURLManager {
|
||||
|
||||
public getCameraURL(): string | null {
|
||||
const view = this._api.getViewManager().getView();
|
||||
const media = view?.queryResults?.getSelectedResult() ?? null;
|
||||
const item = view?.queryResults?.getSelectedResult() ?? null;
|
||||
const endpoints = view?.camera
|
||||
? this._api.getCameraManager().getCameraEndpoints(view.camera, {
|
||||
view: view.view,
|
||||
...(media && { media: media }),
|
||||
...(item && ViewItemClassifier.isMedia(item) && { media: item }),
|
||||
}) ?? null
|
||||
: null;
|
||||
return endpoints?.ui?.endpoint ?? null;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { LitElement, ReactiveControllerHost } from 'lit';
|
||||
import { ActionEventTarget } from '../action-handler-directive';
|
||||
import { isCardInPanel } from '../ha/panel';
|
||||
import { setOrRemoveAttribute } from '../utils/basic';
|
||||
import { isBeingCasted } from '../utils/casting';
|
||||
import { isCardInPanel } from '../utils/ha';
|
||||
import { ActionExecutionRequestEventTarget } from './actions/utils/execution-request';
|
||||
import { InitializationAspect } from './initialization-manager';
|
||||
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 { isConfigUpgradeable } from '../../config/management.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 { setAutomationsFromConfig } from './load-automations.js';
|
||||
import { setRemoteControlEntityFromConfig } from './load-control-entities.js';
|
||||
import { setFoldersFromConfig } from './load-folders.js';
|
||||
import { setKeyboardShortcutsFromConfig } from './load-keyboard-shortcuts.js';
|
||||
|
||||
export class ConfigManager {
|
||||
@@ -136,6 +137,7 @@ export class ConfigManager {
|
||||
const previousConfig = this._overriddenConfig;
|
||||
this._overriddenConfig = overriddenConfig;
|
||||
|
||||
setFoldersFromConfig(this._api);
|
||||
this._api.getStyleManager().updateFromConfig();
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CardConfigLoaderAPI } from '../types';
|
||||
|
||||
export const setAutomationsFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
export const setAutomationsFromConfig = (api: CardConfigLoaderAPI): void => {
|
||||
api.getAutomationsManager().deleteAutomations();
|
||||
api
|
||||
.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 { ConditionStateManager } from '../conditions/state-manager';
|
||||
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 {
|
||||
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 { AutomationsManager } from './automations-manager';
|
||||
import { CameraURLManager } from './camera-url-manager';
|
||||
@@ -24,8 +19,8 @@ import {
|
||||
} from './card-element-manager';
|
||||
import { ConfigManager } from './config/config-manager';
|
||||
import { DefaultManager } from './default-manager';
|
||||
import { DownloadManager } from './download-manager';
|
||||
import { ExpandManager } from './expand-manager';
|
||||
import { FoldersManager } from './folders/manager';
|
||||
import { FullscreenManager } from './fullscreen/fullscreen-manager';
|
||||
import { HASSManager } from './hass/hass-manager';
|
||||
import { InitializationManager } from './initialization-manager';
|
||||
@@ -65,6 +60,7 @@ import {
|
||||
CardTriggersAPI,
|
||||
CardViewAPI,
|
||||
} from './types';
|
||||
import { ViewItemManager } from './view/item-manager';
|
||||
import { ViewManager } from './view/view-manager';
|
||||
|
||||
export class CardController
|
||||
@@ -98,12 +94,8 @@ export class CardController
|
||||
|
||||
// These properties may be used in the construction of 'managers' (and should
|
||||
// be created first).
|
||||
protected _deviceRegistryManager = new DeviceRegistryManager(
|
||||
createDeviceRegistryCache(),
|
||||
);
|
||||
protected _entityRegistryManager = new EntityRegistryManagerLive(
|
||||
createEntityRegistryCache(),
|
||||
);
|
||||
protected _deviceRegistryManager = new DeviceRegistryManager(new DeviceCache());
|
||||
protected _entityRegistryManager = new EntityRegistryManagerLive(new EntityCache());
|
||||
protected _resolvedMediaCache = new ResolvedMediaCache();
|
||||
|
||||
protected _actionsManager = new ActionsManager(this, new TemplateRenderer());
|
||||
@@ -113,8 +105,8 @@ export class CardController
|
||||
protected _cardElementManager: CardElementManager;
|
||||
protected _configManager = new ConfigManager(this);
|
||||
protected _defaultManager = new DefaultManager(this);
|
||||
protected _downloadManager = new DownloadManager(this);
|
||||
protected _expandManager = new ExpandManager(this);
|
||||
protected _foldersManager = new FoldersManager(this);
|
||||
protected _fullscreenManager = new FullscreenManager(this);
|
||||
protected _hassManager = new HASSManager(this);
|
||||
protected _initializationManager = new InitializationManager(this);
|
||||
@@ -129,6 +121,7 @@ export class CardController
|
||||
protected _styleManager = new StyleManager(this);
|
||||
protected _triggersManager = new TriggersManager(this);
|
||||
protected _viewManager = new ViewManager(this);
|
||||
protected _viewItemManager = new ViewItemManager(this);
|
||||
|
||||
constructor(
|
||||
host: CardHTMLElement,
|
||||
@@ -193,10 +186,6 @@ export class CardController
|
||||
return this._deviceRegistryManager;
|
||||
}
|
||||
|
||||
public getDownloadManager(): DownloadManager {
|
||||
return this._downloadManager;
|
||||
}
|
||||
|
||||
public getEntityRegistryManager(): EntityRegistryManager {
|
||||
return this._entityRegistryManager;
|
||||
}
|
||||
@@ -205,6 +194,10 @@ export class CardController
|
||||
return this._expandManager;
|
||||
}
|
||||
|
||||
public getFoldersManager(): FoldersManager {
|
||||
return this._foldersManager;
|
||||
}
|
||||
|
||||
public getFullscreenManager(): FullscreenManager {
|
||||
return this._fullscreenManager;
|
||||
}
|
||||
@@ -282,6 +275,10 @@ export class CardController
|
||||
return this._viewManager;
|
||||
}
|
||||
|
||||
public getViewItemManager(): ViewItemManager {
|
||||
return this._viewItemManager;
|
||||
}
|
||||
|
||||
// *************************************************************************
|
||||
// Handlers
|
||||
// *************************************************************************
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { AdvancedCameraCardConfig } from '../config/schema/types';
|
||||
import { createGeneralAction } from '../utils/action';
|
||||
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 { localize } from '../../localize/localize';
|
||||
import { hasHAConnectionStateChanged } from '../../utils/ha';
|
||||
import { CardHASSAPI } from '../types';
|
||||
import { StateWatcher, StateWatcherSubscriptionInterface } from './state-watcher';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { getHassDifferences, HassStateDifference } from '../../utils/ha';
|
||||
import { getHassDifferences } from '../../ha/get-hass-differences';
|
||||
import { HassStateDifference, HomeAssistant } from '../../ha/types';
|
||||
|
||||
type StateWatcherCallback = (difference: HassStateDifference) => void;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import PQueue from 'p-queue';
|
||||
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 { CardInitializerAPI } from './types';
|
||||
|
||||
@@ -42,6 +42,10 @@ export class InitializationManager {
|
||||
return this._everInitialized;
|
||||
}
|
||||
|
||||
public isInitialized(aspect: InitializationAspect): boolean {
|
||||
return this._initializer.isInitialized(aspect);
|
||||
}
|
||||
|
||||
public isInitializedMandatory(): boolean {
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
if (!config) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { setOrRemoveAttribute } from '../utils/basic';
|
||||
import { Timer } from '../utils/timer';
|
||||
import { CardInteractionAPI } from './types';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CardKeyboardStateAPI, KeysState } from './types';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
export class KeyboardStateManager {
|
||||
protected _api: CardKeyboardStateAPI;
|
||||
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
MEDIA_PLAYER_SUPPORT_STOP,
|
||||
MEDIA_PLAYER_SUPPORT_TURN_OFF,
|
||||
} from '../const';
|
||||
import { Entity } from '../ha/registry/entity/types';
|
||||
import { supportsFeature } from '../ha/supports-feature';
|
||||
import { localize } from '../localize/localize';
|
||||
import { errorToConsole } from '../utils/basic';
|
||||
import { supportsFeature } from '../utils/ha';
|
||||
import { Entity } from '../utils/ha/registry/entity/types';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { ViewMediaClassifier } from '../view/media-classifier';
|
||||
import { ViewMedia } from '../view/item';
|
||||
import { ViewItemClassifier } from '../view/item-classifier';
|
||||
import { CardMediaPlayerAPI } from './types';
|
||||
|
||||
export class MediaPlayerManager {
|
||||
@@ -196,7 +196,7 @@ export class MediaPlayerManager {
|
||||
await hass.callService('media_player', 'play_media', {
|
||||
entity_id: mediaPlayer,
|
||||
media_content_id: media.getContentID(),
|
||||
media_content_type: ViewMediaClassifier.isVideo(media) ? 'video' : 'image',
|
||||
media_content_type: ViewItemClassifier.isVideo(media) ? 'video' : 'image',
|
||||
extra: {
|
||||
...(title && { title: title }),
|
||||
...(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 { StatusBarItem } from '../config/schema/actions/types';
|
||||
import { StatusBarConfig } from '../config/schema/status-bar';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import { orderBy, throttle } from 'lodash-es';
|
||||
import { CameraEvent } from '../camera-manager/types';
|
||||
import { Timer } from '../utils/timer';
|
||||
import { CardTriggersAPI } from './types';
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { CameraManager } from '../camera-manager/manager';
|
||||
import type { ConditionStateManager } from '../conditions/state-manager';
|
||||
import type { Automation } from '../config/schema/automations';
|
||||
import type { EntityRegistryManager } from '../utils/ha/registry/entity/types';
|
||||
import type { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
||||
import type { EntityRegistryManager } from '../ha/registry/entity/types';
|
||||
import type { ResolvedMediaCache } from '../ha/resolved-media';
|
||||
import type { ActionsManager } from './actions/actions-manager';
|
||||
import type { AutomationsManager } from './automations-manager';
|
||||
import type { CameraURLManager } from './camera-url-manager';
|
||||
import type { CardElementManager } from './card-element-manager';
|
||||
import type { ConfigManager } from './config/config-manager';
|
||||
import type { DefaultManager } from './default-manager';
|
||||
import type { DownloadManager } from './download-manager';
|
||||
import type { ExpandManager } from './expand-manager';
|
||||
import type { FoldersManager } from './folders/manager';
|
||||
import type { FullscreenManager } from './fullscreen/fullscreen-manager';
|
||||
import type { HASSManager } from './hass/hass-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 { StyleManager } from './style-manager';
|
||||
import type { TriggersManager } from './triggers-manager';
|
||||
import type { ViewItemManager } from './view/item-manager';
|
||||
import type { ViewManager } from './view/view-manager';
|
||||
|
||||
// *************************************************************************
|
||||
@@ -40,8 +41,8 @@ export interface CardActionsAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getDownloadManager(): DownloadManager;
|
||||
getExpandManager(): ExpandManager;
|
||||
getFoldersManager(): FoldersManager;
|
||||
getFullscreenManager(): FullscreenManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
@@ -50,6 +51,7 @@ export interface CardActionsAPI {
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getStatusBarItemManager(): StatusBarItemManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
getViewItemManager(): ViewItemManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
export type CardActionsManagerAPI = CardActionsAPI;
|
||||
@@ -90,6 +92,7 @@ export interface CardConfigAPI {
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getDefaultManager(): DefaultManager;
|
||||
getFoldersManager(): FoldersManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getInitializationManager(): InitializationManager;
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
@@ -104,6 +107,8 @@ export interface CardConfigAPI {
|
||||
export interface CardConfigLoaderAPI {
|
||||
getAutomationsManager(): AutomationsManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getFoldersManager(): FoldersManager;
|
||||
getMessageManager(): MessageManager;
|
||||
getHASSManager(): HASSManager;
|
||||
}
|
||||
|
||||
@@ -148,6 +153,12 @@ export interface CardExpandAPI {
|
||||
getFullscreenManager(): FullscreenManager;
|
||||
}
|
||||
|
||||
export interface CardFoldersAPI {
|
||||
getConfigManager(): ConfigManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getResolvedMediaCache(): ResolvedMediaCache;
|
||||
}
|
||||
|
||||
export interface CardFullscreenAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
@@ -270,7 +281,9 @@ export interface CardViewAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getFoldersManager(): FoldersManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getInitializationManager(): InitializationManager;
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
getMessageManager(): MessageManager;
|
||||
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 { MediaQueriesResults } from '../../../view/media-queries-results';
|
||||
import { Query } from '../../../view/query';
|
||||
import { QueryResults } from '../../../view/query-results';
|
||||
import { View } from '../../../view/view';
|
||||
import { ViewModifier } from '../types';
|
||||
|
||||
export class SetQueryViewModifier implements ViewModifier {
|
||||
protected _query?: MediaQueries | null;
|
||||
protected _queryResults?: MediaQueriesResults | null;
|
||||
protected _query?: Query | null;
|
||||
protected _queryResults?: QueryResults | null;
|
||||
|
||||
constructor(options?: {
|
||||
query?: MediaQueries | null;
|
||||
queryResults?: MediaQueriesResults | null;
|
||||
}) {
|
||||
constructor(options?: { query?: Query | null; queryResults?: QueryResults | null }) {
|
||||
this._query = options?.query;
|
||||
this._queryResults = options?.queryResults;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { CapabilitySearchOptions, MediaQuery } from '../../camera-manager/types';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
|
||||
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 {
|
||||
EventMediaQueries,
|
||||
EventMediaQuery,
|
||||
FolderViewQuery,
|
||||
MediaQueries,
|
||||
RecordingMediaQueries,
|
||||
} from '../../view/media-queries';
|
||||
import { MediaQueriesResults } from '../../view/media-queries-results';
|
||||
Query,
|
||||
RecordingMediaQuery,
|
||||
} from '../../view/query';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { QueryResults } from '../../view/query-results';
|
||||
import { CardViewAPI } from '../types';
|
||||
import { QueryExecutorOptions, QueryExecutorResult } from './types';
|
||||
|
||||
@@ -48,14 +52,8 @@ export class QueryExecutor {
|
||||
if (!rawQueries) {
|
||||
return null;
|
||||
}
|
||||
const queries = new EventMediaQueries(rawQueries);
|
||||
const results = await this.execute(queries, options?.executorOptions);
|
||||
return results
|
||||
? {
|
||||
query: queries,
|
||||
queryResults: results,
|
||||
}
|
||||
: null;
|
||||
const queries = new EventMediaQuery(rawQueries);
|
||||
return await this.executeMediaQuery(queries, options?.executorOptions);
|
||||
}
|
||||
|
||||
public async executeDefaultRecordingQuery(options?: {
|
||||
@@ -76,16 +74,30 @@ export class QueryExecutor {
|
||||
if (!rawQueries) {
|
||||
return null;
|
||||
}
|
||||
const queries = new RecordingMediaQueries(rawQueries);
|
||||
const results = await this.execute(queries, options?.executorOptions);
|
||||
return results ? { query: queries, queryResults: results } : null;
|
||||
const queries = new RecordingMediaQuery(rawQueries);
|
||||
return await this.executeMediaQuery(queries, options?.executorOptions);
|
||||
}
|
||||
|
||||
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,
|
||||
executorOptions?: QueryExecutorOptions,
|
||||
): Promise<MediaQueriesResults | null> {
|
||||
const queries = query.getQueries();
|
||||
): Promise<QueryExecutorResult | null> {
|
||||
const queries = query.getQuery();
|
||||
if (!queries) {
|
||||
return null;
|
||||
}
|
||||
@@ -95,11 +107,17 @@ export class QueryExecutor {
|
||||
.executeMediaQueries<MediaQuery>(queries, {
|
||||
useCache: executorOptions?.useCache,
|
||||
});
|
||||
if (!mediaArray) {
|
||||
return null;
|
||||
}
|
||||
const queryResults = mediaArray
|
||||
? 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)) {
|
||||
return null;
|
||||
}
|
||||
@@ -111,9 +129,9 @@ export class QueryExecutor {
|
||||
} else if (executorOptions?.selectResult?.func) {
|
||||
queryResults.selectResultIfFound(executorOptions.selectResult.func);
|
||||
} else if (executorOptions?.selectResult?.time) {
|
||||
queryResults.selectBestResult((media) =>
|
||||
findBestMediaIndex(
|
||||
media,
|
||||
queryResults.selectBestResult((itemArray) =>
|
||||
findBestMediaTimeIndex(
|
||||
itemArray,
|
||||
executorOptions.selectResult?.time?.time as Date,
|
||||
executorOptions.selectResult?.time?.favorCameraID,
|
||||
),
|
||||
@@ -122,6 +140,33 @@ export class QueryExecutor {
|
||||
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 {
|
||||
const cardWideConfig = this._api.getConfigManager().getCardWideConfig();
|
||||
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 { AdvancedCameraCardView } from '../../config/schema/common/const.js';
|
||||
import { AdvancedCameraCardError } from '../../types.js';
|
||||
import { MediaQueriesResults } from '../../view/media-queries-results.js';
|
||||
import { MediaQueries } from '../../view/media-queries.js';
|
||||
import { ViewMedia } from '../../view/media.js';
|
||||
import { ViewItem } from '../../view/item.js';
|
||||
import { QueryResults } from '../../view/query-results.js';
|
||||
import { Query } from '../../view/query.js';
|
||||
import { View, ViewParameters } from '../../view/view.js';
|
||||
|
||||
export interface ViewModifier {
|
||||
@@ -20,15 +20,15 @@ export interface QueryExecutorOptions {
|
||||
favorCameraID?: string;
|
||||
};
|
||||
id?: string;
|
||||
func?: (media: ViewMedia) => boolean;
|
||||
func?: (media: ViewItem) => boolean;
|
||||
};
|
||||
rejectResults?: (results: MediaQueriesResults) => boolean;
|
||||
rejectResults?: (results: QueryResults) => boolean;
|
||||
useCache?: boolean;
|
||||
}
|
||||
|
||||
export interface QueryExecutorResult {
|
||||
query: MediaQueries;
|
||||
queryResults: MediaQueriesResults;
|
||||
query: Query;
|
||||
queryResults: QueryResults;
|
||||
}
|
||||
|
||||
export interface ViewFactoryOptions {
|
||||
|
||||
@@ -2,9 +2,10 @@ import { ViewContext } from 'view';
|
||||
import { AdvancedCameraCardView } from '../../config/schema/common/const';
|
||||
import { log } from '../../utils/debug';
|
||||
import { getStreamCameraID } from '../../utils/substream';
|
||||
import { MediaQueriesClassifier } from '../../view/media-queries-classifier';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { View } from '../../view/view';
|
||||
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
|
||||
import { InitializationAspect } from '../initialization-manager';
|
||||
import { CardViewAPI } from '../types';
|
||||
import { ViewFactory } from './factory';
|
||||
import { applyViewModifiers } from './modifiers';
|
||||
@@ -103,6 +104,10 @@ export class ViewManager implements ViewManagerInterface {
|
||||
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
|
||||
options?: ViewFactoryOptions,
|
||||
): void {
|
||||
if (!this._isAllowedToSetView()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let view: View | null = null;
|
||||
try {
|
||||
view = viewFactoryFunc({
|
||||
@@ -112,7 +117,9 @@ export class ViewManager implements ViewManagerInterface {
|
||||
} catch (e) {
|
||||
this._api.getMessageManager().setErrorIfHigherPriority(e);
|
||||
}
|
||||
view && this._setView(view);
|
||||
if (view) {
|
||||
this._setView(view);
|
||||
}
|
||||
}
|
||||
|
||||
protected _markViewLoadingQuery(view: View, index: number): View {
|
||||
@@ -122,6 +129,19 @@ export class ViewManager implements ViewManagerInterface {
|
||||
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(
|
||||
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
|
||||
viewModifiersFunc: (
|
||||
@@ -130,6 +150,10 @@ export class ViewManager implements ViewManagerInterface {
|
||||
) => Promise<ViewModifier[] | null>,
|
||||
options?: ViewFactoryOptions,
|
||||
): Promise<void> {
|
||||
if (!this._isAllowedToSetView()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let initialView: View | null = null;
|
||||
try {
|
||||
initialView = viewFactoryFunc({
|
||||
@@ -220,10 +244,10 @@ export class ViewManager implements ViewManagerInterface {
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
|
||||
|
||||
const switchingFromViewerToGallery =
|
||||
this._view?.isViewerView() && newView?.isGalleryView();
|
||||
this._view?.isViewerView() && newView?.isMediaGalleryView();
|
||||
const newMediaType = newView?.getDefaultMediaType();
|
||||
const alreadyHasMatchingQuery =
|
||||
MediaQueriesClassifier.getMediaType(this._view?.query) === newMediaType;
|
||||
QueryClassifier.getMediaType(this._view?.query) === newMediaType;
|
||||
return !!switchingFromViewerToGallery && alreadyHasMatchingQuery;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ export class ViewQueryExecutor {
|
||||
return view.query
|
||||
? [
|
||||
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)] : [];
|
||||
};
|
||||
|
||||
const executeFolderQuery = async (): Promise<ViewModifier[]> => {
|
||||
const results =
|
||||
await this._executor.executeDefaultFolderQuery(queryExecutorOptions);
|
||||
return results ? [new SetQueryViewModifier(results)] : [];
|
||||
};
|
||||
|
||||
switch (view.view) {
|
||||
case 'live':
|
||||
if (config.live.controls.thumbnails.mode !== 'none') {
|
||||
@@ -111,6 +119,9 @@ export class ViewQueryExecutor {
|
||||
case 'recordings':
|
||||
viewModifiers.push(...(await executeMediaQuery(mediaType)));
|
||||
break;
|
||||
case 'folder':
|
||||
viewModifiers.push(...(await executeFolderQuery()));
|
||||
break;
|
||||
}
|
||||
|
||||
viewModifiers.push(...this._getTimelineWindowViewModifier(view));
|
||||
|
||||
+5
-3
@@ -247,12 +247,13 @@ class AdvancedCameraCard extends LitElement {
|
||||
this._config,
|
||||
this._controller.getCameraManager(),
|
||||
{
|
||||
inExpandedMode: this._controller.getExpandManager().isExpanded(),
|
||||
fullscreenManager: this._controller.getFullscreenManager(),
|
||||
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(),
|
||||
microphoneManager: this._controller.getMicrophoneManager(),
|
||||
showCameraUIButton: this._controller.getCameraURLManager().hasCameraURL(),
|
||||
view: view,
|
||||
viewManager: this._controller.getViewManager(),
|
||||
},
|
||||
@@ -371,6 +372,7 @@ class AdvancedCameraCard extends LitElement {
|
||||
.hass=${this._hass}
|
||||
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
|
||||
.cameraManager=${cameraManager}
|
||||
.viewItemManager=${this._controller.getViewItemManager()}
|
||||
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
|
||||
.config=${this._controller.getConfigManager().getConfig()}
|
||||
.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 isEqual from 'lodash-es/isEqual';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { KeyboardShortcut } from '../config/schema/view';
|
||||
import { setOrRemoveAttribute } from '../utils/basic';
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ export const getTechnologyForVideoRTC = (
|
||||
element: VideoRTC,
|
||||
): MediaTechnology[] | undefined => {
|
||||
const tech = [
|
||||
...(!!element.pc ? ['webrtc'] : []),
|
||||
...(!element.pc && element.mseCodecs ? ['mse', 'hls'] : []),
|
||||
...(!!element.pc ? ['webrtc' as const] : []),
|
||||
...(!element.pc && element.mseCodecs ? ['mse' as const, 'hls' as const] : []),
|
||||
];
|
||||
return tech.length ? tech : undefined;
|
||||
};
|
||||
|
||||
@@ -11,19 +11,17 @@ import {
|
||||
sub,
|
||||
} from 'date-fns';
|
||||
import { LitElement } from 'lit';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import uniqWith from 'lodash-es/uniqWith';
|
||||
import { isEqual, orderBy, uniqWith } from 'lodash-es';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
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 { SelectOption, SelectValues } from '../components/select';
|
||||
import { CardWideConfig } from '../config/schema/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
|
||||
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
|
||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||
import { EventMediaQuery, RecordingMediaQuery } from '../view/query';
|
||||
import { QueryClassifier } from '../view/query-classifier';
|
||||
|
||||
interface MediaFilterControls {
|
||||
events: boolean;
|
||||
@@ -216,7 +214,7 @@ export class MediaFilterController {
|
||||
const what = getArrayValueAsSet(values.what);
|
||||
const tags = getArrayValueAsSet(values.tags);
|
||||
|
||||
const queries = new EventMediaQueries([
|
||||
const queries = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: cameraIDs,
|
||||
@@ -246,7 +244,7 @@ export class MediaFilterController {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const queries = new RecordingMediaQueries([
|
||||
const queries = new RecordingMediaQuery([
|
||||
{
|
||||
type: QueryType.Recording,
|
||||
cameraIDs: cameraIDs,
|
||||
@@ -283,9 +281,14 @@ export class MediaFilterController {
|
||||
|
||||
public computeInitialDefaultsFromView(cameraManager: CameraManager): void {
|
||||
const view = this._viewManager?.getView();
|
||||
const queries = view?.query?.getQueries();
|
||||
const query = view?.query;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -297,7 +300,7 @@ export class MediaFilterController {
|
||||
let tags: string[] | undefined;
|
||||
|
||||
const cameraIDSets = uniqWith(
|
||||
queries.map((query: DataQuery) => query.cameraIDs),
|
||||
queries.map((query: CameraQuery) => query.cameraIDs),
|
||||
isEqual,
|
||||
);
|
||||
// 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 */
|
||||
if (MediaQueriesClassifier.areEventQueries(view.query)) {
|
||||
const queries = view.query.getQueries();
|
||||
if (QueryClassifier.isEventQuery(view.query)) {
|
||||
const queries = view.query.getQuery();
|
||||
|
||||
/* istanbul ignore if: the if path cannot be reached -- @preserve */
|
||||
if (!queries) {
|
||||
@@ -362,7 +365,7 @@ export class MediaFilterController {
|
||||
if (tagsSets.length === 1 && queries[0].tags?.size) {
|
||||
tags = [...queries[0].tags];
|
||||
}
|
||||
} else if (MediaQueriesClassifier.areRecordingQueries(view.query)) {
|
||||
} else if (QueryClassifier.isRecordingQuery(view.query)) {
|
||||
mediaType = MediaFilterMediaType.Recordings;
|
||||
}
|
||||
|
||||
@@ -440,8 +443,8 @@ export class MediaFilterController {
|
||||
|
||||
public getControlsToShow(cameraManager: CameraManager): MediaFilterControls {
|
||||
const view = this._viewManager?.getView();
|
||||
const events = MediaQueriesClassifier.areEventQueries(view?.query);
|
||||
const recordings = MediaQueriesClassifier.areRecordingQueries(view?.query);
|
||||
const events = QueryClassifier.isEventQuery(view?.query);
|
||||
const recordings = QueryClassifier.isRecordingQuery(view?.query);
|
||||
const managerCapabilities = cameraManager.getAggregateCameraCapabilities();
|
||||
|
||||
return {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user