diff --git a/docs/configuration/_sidebar.md b/docs/configuration/_sidebar.md index 6d0abdac..94db7468 100644 --- a/docs/configuration/_sidebar.md +++ b/docs/configuration/_sidebar.md @@ -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) diff --git a/docs/configuration/actions/_sidebar.md b/docs/configuration/actions/_sidebar.md index a4f890a9..70d82433 100644 --- a/docs/configuration/actions/_sidebar.md +++ b/docs/configuration/actions/_sidebar.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) diff --git a/docs/configuration/actions/custom/README.md b/docs/configuration/actions/custom/README.md index 925b4ccb..29425009 100644 --- a/docs/configuration/actions/custom/README.md +++ b/docs/configuration/actions/custom/README.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. diff --git a/docs/configuration/actions/custom/_sidebar.md b/docs/configuration/actions/custom/_sidebar.md index c2339fbb..ee3398dd 100644 --- a/docs/configuration/actions/custom/_sidebar.md +++ b/docs/configuration/actions/custom/_sidebar.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) diff --git a/docs/configuration/actions/stock/_sidebar.md b/docs/configuration/actions/stock/_sidebar.md index 53c99816..04169528 100644 --- a/docs/configuration/actions/stock/_sidebar.md +++ b/docs/configuration/actions/stock/_sidebar.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) diff --git a/docs/configuration/cameras/_sidebar.md b/docs/configuration/cameras/_sidebar.md index 50b09dd5..6c6e0563 100644 --- a/docs/configuration/cameras/_sidebar.md +++ b/docs/configuration/cameras/_sidebar.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) diff --git a/docs/configuration/common/experimental-warning.md b/docs/configuration/common/experimental-warning.md index 8afcc40a..40803318 100644 --- a/docs/configuration/common/experimental-warning.md +++ b/docs/configuration/common/experimental-warning.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. diff --git a/docs/configuration/elements/_sidebar.md b/docs/configuration/elements/_sidebar.md index 1de639f0..a8be3894 100644 --- a/docs/configuration/elements/_sidebar.md +++ b/docs/configuration/elements/_sidebar.md @@ -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) diff --git a/docs/configuration/elements/custom/_sidebar.md b/docs/configuration/elements/custom/_sidebar.md index 01f793f0..f173fcd2 100644 --- a/docs/configuration/elements/custom/_sidebar.md +++ b/docs/configuration/elements/custom/_sidebar.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) diff --git a/docs/configuration/elements/stock/_sidebar.md b/docs/configuration/elements/stock/_sidebar.md index e85b15f2..0103fa5d 100644 --- a/docs/configuration/elements/stock/_sidebar.md +++ b/docs/configuration/elements/stock/_sidebar.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) diff --git a/docs/configuration/folders.md b/docs/configuration/folders.md new file mode 100644 index 00000000..fedf89e3 --- /dev/null +++ b/docs/configuration/folders.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.*' +``` diff --git a/docs/configuration/media-gallery.md b/docs/configuration/media-gallery.md index efb0bf0a..66f7b0b5 100644 --- a/docs/configuration/media-gallery.md +++ b/docs/configuration/media-gallery.md @@ -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` diff --git a/docs/configuration/menu.md b/docs/configuration/menu.md index bf74de25..96d84d5a 100644 --- a/docs/configuration/menu.md +++ b/docs/configuration/menu.md @@ -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 diff --git a/docs/configuration/view.md b/docs/configuration/view.md index 2ff68403..7cb2c89e 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -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. | diff --git a/docs/developing.md b/docs/developing.md index 097525eb..e171222b 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -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 -[![translation badge](https://badge.inlang.com/?url=github.com/dermotduffy/advanced-camera-card)](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. diff --git a/docs/examples.md b/docs/examples.md index 9f75622e..c7e6b7ed 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -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 diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..aea9088e --- /dev/null +++ b/eslint.config.mjs @@ -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: {}, + }, +]); diff --git a/package.json b/package.json index 57f96b7f..49931e87 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index bef6bc52..18a4fcd9 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -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) {} }, ); diff --git a/src/cache/base.ts b/src/cache/base.ts new file mode 100644 index 00000000..49c08cd4 --- /dev/null +++ b/src/cache/base.ts @@ -0,0 +1,52 @@ +import { CacheInterface } from './types.js'; + +export class CacheBase implements CacheInterface { + private _cache: Map; + + constructor(cache: Map) { + 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); + } +} diff --git a/src/cache/cache.ts b/src/cache/cache.ts new file mode 100644 index 00000000..125b819d --- /dev/null +++ b/src/cache/cache.ts @@ -0,0 +1,7 @@ +import { CacheBase } from './base'; + +export class Cache extends CacheBase { + constructor() { + super(new Map()); + } +} diff --git a/src/cache/equality-cache.ts b/src/cache/equality-cache.ts new file mode 100644 index 00000000..7e17381e --- /dev/null +++ b/src/cache/equality-cache.ts @@ -0,0 +1,8 @@ +import { CacheBase } from './base'; +import { EqualityMap } from './equality-map'; + +export class EqualityCache extends CacheBase { + constructor() { + super(new EqualityMap()); + } +} diff --git a/src/cache/equality-map.ts b/src/cache/equality-map.ts new file mode 100644 index 00000000..4dc76693 --- /dev/null +++ b/src/cache/equality-map.ts @@ -0,0 +1,80 @@ +import { isEqual } from 'lodash-es'; + +interface EqualityMapItem { + key: Key; + value: Value; +} + +/** A simple equality based map. This is not performant and should be used for + * small datasets only. + */ +export class EqualityMap implements Map { + private _data: EqualityMapItem[] = []; + + 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) => 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 { + return this._data.map((pair) => pair.key).values(); + } + public values(): IterableIterator { + return this._data.map((pair) => pair.value).values(); + } +} diff --git a/src/cache/expiring-cache.ts b/src/cache/expiring-cache.ts new file mode 100644 index 00000000..7e51df29 --- /dev/null +++ b/src/cache/expiring-cache.ts @@ -0,0 +1,70 @@ +import { CacheInterface } from './types'; +import { EqualityCache } from './equality-cache'; + +interface ExpiringValue { + value: Value; + expires?: Date; +} + +export class ExpiringEqualityCache implements CacheInterface { + protected _data: EqualityCache> = 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); + } + } + } +} diff --git a/src/cache/lru.ts b/src/cache/lru.ts new file mode 100644 index 00000000..4fdfb78a --- /dev/null +++ b/src/cache/lru.ts @@ -0,0 +1,8 @@ +import QuickLRU from 'quick-lru'; +import { CacheBase } from './base'; + +export class LRUCache extends CacheBase { + constructor(maxSize: number) { + super(new QuickLRU({ maxSize })); + } +} diff --git a/src/cache/types.ts b/src/cache/types.ts new file mode 100644 index 00000000..b4b296d4 --- /dev/null +++ b/src/cache/types.ts @@ -0,0 +1,9 @@ +export interface CacheInterface { + 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[]; +} diff --git a/src/camera-manager/browse-media/camera.ts b/src/camera-manager/browse-media/camera.ts index 98a2363e..df2e8763 100644 --- a/src/camera-manager/browse-media/camera.ts +++ b/src/camera-manager/browse-media/camera.ts @@ -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'; diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts index 6514c870..b1e8d844 100644 --- a/src/camera-manager/browse-media/engine-browse-media.ts +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -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 { - 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 { + 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, diff --git a/src/camera-manager/browse-media/media.ts b/src/camera-manager/browse-media/media.ts deleted file mode 100644 index b3109074..00000000 --- a/src/camera-manager/browse-media/media.ts +++ /dev/null @@ -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; - protected _id: string; - - constructor( - mediaType: ViewMediaType, - cameraID: string, - browseMedia: RichBrowseMedia, - ) { - 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, - cameraID: string, - ): BrowseMediaEventViewMedia { - return new BrowseMediaEventViewMedia(mediaType, cameraID, browseMedia); - } -} diff --git a/src/camera-manager/browse-media/types.ts b/src/camera-manager/browse-media/types.ts deleted file mode 100644 index f50282fa..00000000 --- a/src/camera-manager/browse-media/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface BrowseMediaMetadata { - cameraID: string; - startDate: Date; - endDate: Date; - what?: string[]; -} diff --git a/src/camera-manager/browse-media/utils/browse-media-to-view-media.ts b/src/camera-manager/browse-media/utils/browse-media-to-view-media.ts deleted file mode 100644 index 3b33f638..00000000 --- a/src/camera-manager/browse-media/utils/browse-media-to-view-media.ts +++ /dev/null @@ -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[], -): ViewMedia[] | null => { - const lookup: Map = 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()]; -}; diff --git a/src/camera-manager/cache.ts b/src/camera-manager/cache.ts index 7607aef7..1755caa7 100644 --- a/src/camera-manager/cache.ts +++ b/src/camera-manager/cache.ts @@ -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: Request; - response: Response; - expires?: Date; -} - -interface CameraManagerCache { - get(request: Request): Response | null; - has(request: Request): boolean; - set(request: Request, response: Response, expiry?: Date): void; -} - -export class MemoryRequestCache - implements CameraManagerCache -{ - protected _data: RequestCacheItem[] = []; - - 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 {} +import { RecordingSegment } from './types'; class MemoryRangedCache { protected _ranges: MemoryRangeSet = new MemoryRangeSet(); diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index c09ed5ec..17b0a8f1 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -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'; diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 00a0cfdf..5c889729 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -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, ); } diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts index e75b4f5b..b7aac685 100644 --- a/src/camera-manager/engine.ts +++ b/src/camera-manager/engine.ts @@ -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; + ): Promise; favoriteMedia( hass: HomeAssistant, @@ -99,7 +99,7 @@ export interface CameraManagerEngine { favorite: boolean, ): Promise; - 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, diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index af085013..2c0dfa1e 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -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'; diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 72965902..d3b64d58 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -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 { + ): Promise { 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 { 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}` + diff --git a/src/camera-manager/frigate/event-watcher.ts b/src/camera-manager/frigate/event-watcher.ts index ba79dc2e..6a66fc74 100644 --- a/src/camera-manager/frigate/event-watcher.ts +++ b/src/camera-manager/frigate/event-watcher.ts @@ -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; diff --git a/src/camera-manager/frigate/media-classifier.ts b/src/camera-manager/frigate/media-classifier.ts index 442d6b0d..1115a673 100644 --- a/src/camera-manager/frigate/media-classifier.ts +++ b/src/camera-manager/frigate/media-classifier.ts @@ -1,4 +1,4 @@ -import { ViewMedia } from '../../view/media'; +import { ViewMedia } from '../../view/item'; import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media'; export class FrigateViewMediaClassifier { diff --git a/src/camera-manager/frigate/media.ts b/src/camera-manager/frigate/media.ts index f6c5a154..471bf9f9 100644 --- a/src/camera-manager/frigate/media.ts +++ b/src/camera-manager/frigate/media.ts @@ -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), diff --git a/src/camera-manager/frigate/requests.ts b/src/camera-manager/frigate/requests.ts index 5ca6a0f0..6f045394 100644 --- a/src/camera-manager/frigate/requests.ts +++ b/src/camera-manager/frigate/requests.ts @@ -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, diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index e0a01429..6f6d66c0 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -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 { + ): Promise { 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; diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 77f346a2..15af4d1f 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -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 { queries: T[]; - results: ViewMedia[]; + results: ViewItem[]; } export class CameraManager { @@ -318,7 +320,7 @@ export class CameraManager { }); } - protected _generateDefaultQueries( + protected _generateDefaultQueries( cameraIDs: string | Set, partialQuery: PQT, ): PartialQueryConcreteType[] | 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( queries: T[], - results: ViewMedia[], + results: ViewItem[], direction: 'earlier' | 'later', engineOptions?: EngineOptions, ): Promise | 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 { + public async getMediaDownloadPath(media: ViewMedia): Promise { 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( + protected async _handleQuery( query: QT | QT[], engineOptions?: EngineOptions, ): Promise>> { @@ -623,28 +628,28 @@ export class CameraManager { let engineResult: Map> | 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> | null; - } else if (QueryClassifier.isRecordingQuery(query)) { + } else if (CameraQueryClassifier.isRecordingQuery(query)) { engineResult = (await engine.getRecordings( hass, this._store, query, engineOptions, )) as Map> | null; - } else if (QueryClassifier.isRecordingSegmentsQuery(query)) { + } else if (CameraQueryClassifier.isRecordingSegmentsQuery(query)) { engineResult = (await engine.getRecordingSegments( hass, this._store, query, engineOptions, )) as Map> | 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( + protected _convertQueryResultsToMedia( results: ResultsMap, ): 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( diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index c2965c30..0ba24707 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -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 = { const MOTIONEYE_REPL_REGEXP = new RegExp(/(%Y|%m|%d|%H|%M|%S)/g); export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine { - protected _directoryCache = new MemoryRequestCache(); - protected _fileCache = new MemoryRequestCache(); + protected _directoryCache = new BrowseMediaCache(); + protected _fileCache = new BrowseMediaCache(); 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, diff --git a/src/camera-manager/motioneye/types.ts b/src/camera-manager/motioneye/types.ts index 107854aa..449f8ac9 100644 --- a/src/camera-manager/motioneye/types.ts +++ b/src/camera-manager/motioneye/types.ts @@ -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'; // ================================ diff --git a/src/camera-manager/range.ts b/src/camera-manager/range.ts index 789b4bbf..f01cb1a6 100644 --- a/src/camera-manager/range.ts +++ b/src/camera-manager/range.ts @@ -1,4 +1,4 @@ -import orderBy from 'lodash-es/orderBy'; +import { orderBy } from 'lodash-es'; interface Range { start: T; diff --git a/src/camera-manager/reolink/camera.ts b/src/camera-manager/reolink/camera.ts index bb9c67ab..5a93decc 100644 --- a/src/camera-manager/reolink/camera.ts +++ b/src/camera-manager/reolink/camera.ts @@ -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'; diff --git a/src/camera-manager/reolink/engine-reolink.ts b/src/camera-manager/reolink/engine-reolink.ts index 50806f8a..aa4c557c 100644 --- a/src/camera-manager/reolink/engine-reolink.ts +++ b/src/camera-manager/reolink/engine-reolink.ts @@ -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(); + protected _camerasCache = new BrowseMediaCache(); + protected _cache = new BrowseMediaCache(); 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[] = []; 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, diff --git a/src/camera-manager/reolink/types.ts b/src/camera-manager/reolink/types.ts index e045a6b1..867cb821 100644 --- a/src/camera-manager/reolink/types.ts +++ b/src/camera-manager/reolink/types.ts @@ -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 { diff --git a/src/camera-manager/store.ts b/src/camera-manager/store.ts index 6127b4a9..468b9b23 100644 --- a/src/camera-manager/store.ts +++ b/src/camera-manager/store.ts @@ -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; } /** diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index 87677f3b..683db45d 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -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; } -export type PartialDataQuery = Partial; +export type PartialCameraQuery = Partial; interface TimeBasedDataQuery { start: Date; @@ -44,7 +45,7 @@ interface LimitedDataQuery { } export interface MediaQuery - extends DataQuery, + extends CameraQuery, Partial, Partial { 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; @@ -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; } diff --git a/src/camera-manager/utils/go2rtc-endpoint.ts b/src/camera-manager/utils/go2rtc-endpoint.ts index e8e29332..6e8c4301 100644 --- a/src/camera-manager/utils/go2rtc-endpoint.ts +++ b/src/camera-manager/utils/go2rtc-endpoint.ts @@ -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; diff --git a/src/camera-manager/utils/sort-media.ts b/src/camera-manager/utils/sort-media.ts deleted file mode 100644 index 9f503330..00000000 --- a/src/camera-manager/utils/sort-media.ts +++ /dev/null @@ -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', - ); -}; diff --git a/src/card-controller/actions/actions-manager.ts b/src/card-controller/actions/actions-manager.ts index 895eed85..0de56568 100644 --- a/src/card-controller/actions/actions-manager.ts +++ b/src/card-controller/actions/actions-manager.ts @@ -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); diff --git a/src/card-controller/actions/actions/download.ts b/src/card-controller/actions/actions/download.ts index 1cb23fa7..3d05f66d 100644 --- a/src/card-controller/actions/actions/download.ts +++ b/src/card-controller/actions/actions/download.ts @@ -6,6 +6,9 @@ export class DownloadAction extends AdvancedCameraCardAction { await super.execute(api); - await api.getDownloadManager().downloadViewerMedia(); + const item = api.getViewManager().getView()?.queryResults?.getSelectedResult(); + if (item) { + await api.getViewItemManager().download(item); + } } } diff --git a/src/card-controller/actions/actions/folder.ts b/src/card-controller/actions/actions/folder.ts new file mode 100644 index 00000000..f4245f7a --- /dev/null +++ b/src/card-controller/actions/actions/folder.ts @@ -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 { + public async execute(api: CardActionsAPI): Promise { + 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), + }, + }); + } +} diff --git a/src/card-controller/actions/actions/media-player.ts b/src/card-controller/actions/actions/media-player.ts index d624f9b5..0a4fde1b 100644 --- a/src/card-controller/actions/actions/media-player.ts +++ b/src/card-controller/actions/actions/media-player.ts @@ -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 { setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this); const singleStep = async (): Promise => { - 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 { }); 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', - })); + }); + } }); } } diff --git a/src/card-controller/actions/actions/screenshot.ts b/src/card-controller/actions/actions/screenshot.ts index c7d401bf..c37432eb 100644 --- a/src/card-controller/actions/actions/screenshot.ts +++ b/src/card-controller/actions/actions/screenshot.ts @@ -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 { await super.execute(api); - await api.getDownloadManager().downloadScreenshot(); + const url = await api + .getMediaLoadedInfoManager() + .get() + ?.mediaPlayerController?.getScreenshotURL(); + + if (url) { + downloadURL(url, generateScreenshotFilename(api.getViewManager().getView())); + } } } diff --git a/src/card-controller/actions/actions/sleep.ts b/src/card-controller/actions/actions/sleep.ts index 69f6a9c0..05611006 100644 --- a/src/card-controller/actions/actions/sleep.ts +++ b/src/card-controller/actions/actions/sleep.ts @@ -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'; diff --git a/src/card-controller/actions/actions/status-bar.ts b/src/card-controller/actions/actions/status-bar.ts index 3d1a455e..def81c77 100644 --- a/src/card-controller/actions/actions/status-bar.ts +++ b/src/card-controller/actions/actions/status-bar.ts @@ -3,7 +3,6 @@ import { CardActionsAPI } from '../../types'; import { AdvancedCameraCardAction } from './base'; export class StatusBarAction extends AdvancedCameraCardAction { - // eslint-disable-next-line @typescript-eslint/no-unused-vars public async execute(api: CardActionsAPI): Promise { await super.execute(api); diff --git a/src/card-controller/actions/factory.ts b/src/card-controller/actions/factory.ts index d9b7a716..ddb7bdda 100644 --- a/src/card-controller/actions/factory.ts +++ b/src/card-controller/actions/factory.ts @@ -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 */ diff --git a/src/card-controller/actions/utils/action-state.ts b/src/card-controller/actions/utils/action-state.ts index 39a6bbe0..40f2af04 100644 --- a/src/card-controller/actions/utils/action-state.ts +++ b/src/card-controller/actions/utils/action-state.ts @@ -1,4 +1,4 @@ -import merge from 'lodash-es/merge'; +import { merge } from 'lodash-es'; import { Action, TargetedActionContext } from '../types'; import { ActionContext } from 'action'; diff --git a/src/card-controller/camera-url-manager.ts b/src/card-controller/camera-url-manager.ts index 58d9e782..a7c6edfe 100644 --- a/src/card-controller/camera-url-manager.ts +++ b/src/card-controller/camera-url-manager.ts @@ -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; diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index 4c55c552..33724238 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -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'; diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index 3c0638c9..b99d96cc 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -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 ( diff --git a/src/card-controller/config/load-automations.ts b/src/card-controller/config/load-automations.ts index 8c6b7ffa..5d5c35a7 100644 --- a/src/card-controller/config/load-automations.ts +++ b/src/card-controller/config/load-automations.ts @@ -1,6 +1,6 @@ import { CardConfigLoaderAPI } from '../types'; -export const setAutomationsFromConfig = (api: CardConfigLoaderAPI) => { +export const setAutomationsFromConfig = (api: CardConfigLoaderAPI): void => { api.getAutomationsManager().deleteAutomations(); api .getAutomationsManager() diff --git a/src/card-controller/config/load-folders.ts b/src/card-controller/config/load-folders.ts new file mode 100644 index 00000000..09b9ad31 --- /dev/null +++ b/src/card-controller/config/load-folders.ts @@ -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); + } +}; diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index 18e0ecde..486f4091 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -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 // ************************************************************************* diff --git a/src/card-controller/default-manager.ts b/src/card-controller/default-manager.ts index a56d9f93..7aa45bf8 100644 --- a/src/card-controller/default-manager.ts +++ b/src/card-controller/default-manager.ts @@ -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'; diff --git a/src/card-controller/download-manager.ts b/src/card-controller/download-manager.ts deleted file mode 100644 index 950a6a29..00000000 --- a/src/card-controller/download-manager.ts +++ /dev/null @@ -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 { - 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 { - const url = await this._api - .getMediaLoadedInfoManager() - .get() - ?.mediaPlayerController?.getScreenshotURL(); - if (url) { - downloadURL(url, generateScreenshotFilename(this._api.getViewManager().getView())); - } - } -} diff --git a/src/card-controller/folders/executor.ts b/src/card-controller/folders/executor.ts new file mode 100644 index 00000000..0d376c39 --- /dev/null +++ b/src/card-controller/folders/executor.ts @@ -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 { + 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 { + return await (this._getFolderEngine(item.getFolder()?.type)?.getDownloadPath( + hass, + item, + helpers, + ) ?? null); + } + + public async favorite( + hass: HomeAssistant | null, + item: ViewItem, + favorite: boolean, + ): Promise { + 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; + } +} diff --git a/src/card-controller/folders/ha/engine.ts b/src/card-controller/folders/ha/engine.ts new file mode 100644 index 00000000..1fed5a20 --- /dev/null +++ b/src/card-controller/folders/ha/engine.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + }, + }; + } +} diff --git a/src/card-controller/folders/manager.ts b/src/card-controller/folders/manager.ts new file mode 100644 index 00000000..550fdefc --- /dev/null +++ b/src/card-controller/folders/manager.ts @@ -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 = 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 { + 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 { + return await this._executor.getDownloadPath( + this._api.getHASSManager().getHASS(), + item, + { + resolvedMediaCache: this._api.getResolvedMediaCache(), + }, + ); + } + + public async favorite(item: ViewItem, favorite: boolean): Promise { + return await this._executor.favorite( + this._api.getHASSManager().getHASS(), + item, + favorite, + ); + } +} diff --git a/src/card-controller/folders/types.ts b/src/card-controller/folders/types.ts new file mode 100644 index 00000000..b59145b4 --- /dev/null +++ b/src/card-controller/folders/types.ts @@ -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; +}; + +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; +} + +// =============== +// Folders Engines +// =============== + +export interface DownloadHelpers { + resolvedMediaCache?: ResolvedMediaCache | null; +} + +export interface FoldersEngine { + generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null; + expandFolder( + hass: HomeAssistant, + query: FolderQuery, + engineOptions?: EngineOptions, + ): Promise; + + getItemCapabilities(item: ViewItem): ViewItemCapabilities | null; + getDownloadPath( + hass: HomeAssistant | null, + item: ViewItem, + options?: DownloadHelpers, + ): Promise; + favorite(hass: HomeAssistant | null, item: ViewItem, favorite: boolean): Promise; +} diff --git a/src/card-controller/hass/hass-manager.ts b/src/card-controller/hass/hass-manager.ts index b98486a5..c65fc5cf 100644 --- a/src/card-controller/hass/hass-manager.ts +++ b/src/card-controller/hass/hass-manager.ts @@ -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'; diff --git a/src/card-controller/hass/state-watcher.ts b/src/card-controller/hass/state-watcher.ts index 7c9e266a..0baff18d 100644 --- a/src/card-controller/hass/state-watcher.ts +++ b/src/card-controller/hass/state-watcher.ts @@ -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; diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts index 58f4d01e..7e82ff18 100644 --- a/src/card-controller/initialization-manager.ts +++ b/src/card-controller/initialization-manager.ts @@ -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) { diff --git a/src/card-controller/interaction-manager.ts b/src/card-controller/interaction-manager.ts index 16eb9e12..6edc4e2a 100644 --- a/src/card-controller/interaction-manager.ts +++ b/src/card-controller/interaction-manager.ts @@ -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'; diff --git a/src/card-controller/keyboard-state-manager.ts b/src/card-controller/keyboard-state-manager.ts index f78f4929..ca5c7f8d 100644 --- a/src/card-controller/keyboard-state-manager.ts +++ b/src/card-controller/keyboard-state-manager.ts @@ -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; diff --git a/src/card-controller/media-player-manager.ts b/src/card-controller/media-player-manager.ts index c0a6c23f..d7c92826 100644 --- a/src/card-controller/media-player-manager.ts +++ b/src/card-controller/media-player-manager.ts @@ -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 }), diff --git a/src/card-controller/status-bar-item-manager.ts b/src/card-controller/status-bar-item-manager.ts index 4d27f016..8fcb9e64 100644 --- a/src/card-controller/status-bar-item-manager.ts +++ b/src/card-controller/status-bar-item-manager.ts @@ -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'; diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index a4c14d35..a30b8fa0 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -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'; diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index c05750eb..04f35e49 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -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; diff --git a/src/card-controller/view/item-manager.ts b/src/card-controller/view/item-manager.ts new file mode 100644 index 00000000..dc4c720b --- /dev/null +++ b/src/card-controller/view/item-manager.ts @@ -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 { + 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 { + 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 { + 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'; + } +} diff --git a/src/card-controller/view/modifiers/set-query.ts b/src/card-controller/view/modifiers/set-query.ts index 8057c2fd..95c617e1 100644 --- a/src/card-controller/view/modifiers/set-query.ts +++ b/src/card-controller/view/modifiers/set-query.ts @@ -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; } diff --git a/src/card-controller/view/query-executor.ts b/src/card-controller/view/query-executor.ts index 08cab553..032e686f 100644 --- a/src/card-controller/view/query-executor.ts +++ b/src/card-controller/view/query-executor.ts @@ -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 { + /* 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 { - const queries = query.getQueries(); + ): Promise { + const queries = query.getQuery(); if (!queries) { return null; } @@ -95,11 +107,17 @@ export class QueryExecutor { .executeMediaQueries(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 { + const query = this._api.getFoldersManager().generateDefaultFolderQuery(); + return query + ? this._executeFolderQuery(new FolderViewQuery(query), executorOptions) + : null; + } + + private async _executeFolderQuery( + query: FolderViewQuery, + executorOptions?: QueryExecutorOptions, + ): Promise { + 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 ( diff --git a/src/card-controller/view/sort.ts b/src/card-controller/view/sort.ts new file mode 100644 index 00000000..592e715d --- /dev/null +++ b/src/card-controller/view/sort.ts @@ -0,0 +1,23 @@ +import { orderBy, uniqBy } from 'lodash-es'; +import { ViewItem } from '../../view/item'; +import { ViewItemClassifier } from '../../view/item-classifier'; + +export const sortItems = (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'], + ); +}; diff --git a/src/card-controller/view/types.ts b/src/card-controller/view/types.ts index 174e663c..b4ca9445 100644 --- a/src/card-controller/view/types.ts +++ b/src/card-controller/view/types.ts @@ -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 { diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts index 64b048c6..528d1c4f 100644 --- a/src/card-controller/view/view-manager.ts +++ b/src/card-controller/view/view-manager.ts @@ -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, options?: ViewFactoryOptions, ): Promise { + 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; } diff --git a/src/card-controller/view/view-query-executor.ts b/src/card-controller/view/view-query-executor.ts index de255996..df5a0a16 100644 --- a/src/card-controller/view/view-query-executor.ts +++ b/src/card-controller/view/view-query-executor.ts @@ -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 => { + 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)); diff --git a/src/card.ts b/src/card.ts index f4e67299..eb5ddf2d 100644 --- a/src/card.ts +++ b/src/card.ts @@ -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()} diff --git a/src/components-lib/folder/up-folder.ts b/src/components-lib/folder/up-folder.ts new file mode 100644 index 00000000..e25bf420 --- /dev/null +++ b/src/components-lib/folder/up-folder.ts @@ -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', + }); +}; diff --git a/src/components-lib/gallery/folder-gallery-controller.ts b/src/components-lib/gallery/folder-gallery-controller.ts new file mode 100644 index 00000000..5291157e --- /dev/null +++ b/src/components-lib/gallery/folder-gallery-controller.ts @@ -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 }], + }), + }, + }); + } + } +} diff --git a/src/components-lib/gallery/gallery-core-controller.ts b/src/components-lib/gallery/gallery-core-controller.ts new file mode 100644 index 00000000..ddf6b799 --- /dev/null +++ b/src/components-lib/gallery/gallery-core-controller.ts @@ -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 => { + 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 => { + 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 => { + if (!this._host.scrollTop && ev.deltaY < 0) { + await this._throttledExtendUp(); + } + }; + + private async _extendUp(): Promise { + 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 { + 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 { + await new Promise((resolve) => { + fireAdvancedCameraCardEvent( + 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); + } +} diff --git a/src/components-lib/gallery/media-gallery-controller.ts b/src/components-lib/gallery/media-gallery-controller.ts new file mode 100644 index 00000000..7a5e0830 --- /dev/null +++ b/src/components-lib/gallery/media-gallery-controller.ts @@ -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 { + 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 | null; + try { + extension = await cameraManager.extendMediaQueries( + 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, + ), + }, + }); + } +} diff --git a/src/components-lib/key-assigner-controller.ts b/src/components-lib/key-assigner-controller.ts index e6ac4d44..fac9be28 100644 --- a/src/components-lib/key-assigner-controller.ts +++ b/src/components-lib/key-assigner-controller.ts @@ -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'; diff --git a/src/components-lib/live/utils/get-technology-for-video-rtc.ts b/src/components-lib/live/utils/get-technology-for-video-rtc.ts index 28da7d10..fac82602 100644 --- a/src/components-lib/live/utils/get-technology-for-video-rtc.ts +++ b/src/components-lib/live/utils/get-technology-for-video-rtc.ts @@ -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; }; diff --git a/src/components-lib/media-filter-controller.ts b/src/components-lib/media-filter-controller.ts index 700bc1a1..6d14c8b2 100644 --- a/src/components-lib/media-filter-controller.ts +++ b/src/components-lib/media-filter-controller.ts @@ -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 { diff --git a/src/components-lib/media-grid-controller.ts b/src/components-lib/media-grid-controller.ts index dea4ebb6..9b877cf2 100644 --- a/src/components-lib/media-grid-controller.ts +++ b/src/components-lib/media-grid-controller.ts @@ -1,5 +1,4 @@ -import isEqual from 'lodash-es/isEqual'; -import throttle from 'lodash-es/throttle'; +import { isEqual, throttle } from 'lodash-es'; import Masonry from 'masonry-layout'; import { ViewDisplayConfig } from '../config/schema/common/display'; import { MediaLoadedInfo } from '../types'; diff --git a/src/components-lib/media-player/video.ts b/src/components-lib/media-player/video.ts index a51cd282..9a2808fd 100644 --- a/src/components-lib/media-player/video.ts +++ b/src/components-lib/media-player/video.ts @@ -36,7 +36,8 @@ export class VideoMediaPlayerController implements MediaPlayerController { await this.mute(); try { await video.play(); - } catch (_) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { // Pass. } } diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 5e5d9773..b14837c2 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -1,5 +1,6 @@ import { StyleInfo } from 'lit/directives/style-map'; import { CameraManager } from '../camera-manager/manager'; +import { FoldersManager } from '../card-controller/folders/manager'; import { FullscreenManager } from '../card-controller/fullscreen/fullscreen-manager'; import { MediaPlayerManager } from '../card-controller/media-player-manager'; import { MicrophoneManager } from '../card-controller/microphone-manager'; @@ -7,12 +8,14 @@ import { ViewManager } from '../card-controller/view/view-manager'; import { VIEWS_USER_SPECIFIED } from '../config/schema/common/const'; import { MenuItem } from '../config/schema/elements/custom/menu/types'; import { AdvancedCameraCardConfig } from '../config/schema/types'; +import { getEntityTitle } from '../ha/get-entity-title'; import { HomeAssistant } from '../ha/types'; import { localize } from '../localize/localize.js'; import { MediaLoadedInfo } from '../types'; import { createCameraAction, createDisplayModeAction, + createFolderAction, createGeneralAction, createMediaPlayerAction, createPTZControlsAction, @@ -22,15 +25,17 @@ import { } from '../utils/action'; import { arrayify, isTruthy } from '../utils/basic'; import { isBeingCasted } from '../utils/casting'; -import { getEntityTitle } from '../utils/ha'; import { getPTZTarget } from '../utils/ptz'; import { getStreamCameraID, hasSubstream } from '../utils/substream'; +import { ViewItemClassifier } from '../view/item-classifier'; +import { QueryClassifier } from '../view/query-classifier'; import { View } from '../view/view'; import { getCameraIDsForViewName } from '../view/view-to-cameras'; export interface MenuButtonControllerOptions { currentMediaLoadedInfo?: MediaLoadedInfo | null; showCameraUIButton?: boolean; + foldersManager?: FoldersManager | null; fullscreenManager?: FullscreenManager | null; inExpandedMode?: boolean; microphoneManager?: MicrophoneManager | null; @@ -97,6 +102,7 @@ export class MenuButtonController { this._getDisplayModeButton(config, cameraManager, options?.view), this._getPTZControlsButton(config, cameraManager, options?.view), this._getPTZHomeButton(config, cameraManager, options?.view), + this._getFoldersButton(config, options?.foldersManager, options?.view), ...this._dynamicMenuButtons.map((button) => ({ style: this._getStyleFromActions(config, button, options), @@ -330,10 +336,11 @@ export class MenuButtonController { cameraManager: CameraManager, view?: View | null, ): MenuItem | null { - const selectedMedia = view?.queryResults?.getSelectedResult(); - const mediaCapabilities = selectedMedia - ? cameraManager?.getMediaCapabilities(selectedMedia) - : null; + const selectedItem = view?.queryResults?.getSelectedResult(); + const mediaCapabilities = + selectedItem && ViewItemClassifier.isMedia(selectedItem) + ? cameraManager?.getMediaCapabilities(selectedItem) + : null; if (view?.isViewerView() && mediaCapabilities?.canDownload && !isBeingCasted()) { return { icon: 'mdi:download', @@ -629,6 +636,58 @@ export class MenuButtonController { }; } + protected _getFoldersButton( + config: AdvancedCameraCardConfig, + foldersManager?: FoldersManager | null, + view?: View | null, + ): MenuItem | null { + const folders = [...(foldersManager?.getFolders() ?? [])]; + if (!folders?.length) { + return null; + } + + if (folders.length === 1) { + const isSelected = + QueryClassifier.isFolderQuery(view?.query) && + view.query.getQuery()?.folder.id === folders[0][0]; + const folder = folders[0][1]; + + return { + icon: folder.icon ?? 'mdi:folder', + ...config.menu.buttons.folders, + type: 'custom:advanced-camera-card-menu-icon', + title: folder.title ?? localize('config.menu.buttons.folders'), + style: isSelected ? this._getEmphasizedStyle() : {}, + tap_action: createFolderAction(), + }; + } + + const submenuItems = folders.map(([id, folder]) => { + const isSelected = + QueryClassifier.isFolderQuery(view?.query) && + view.query.getQuery()?.folder.id === id; + + const action = createFolderAction({ folderID: id }); + return { + enabled: true, + title: folder.title ?? folder.id, + icon: folder.icon ?? 'mdi:folder', + selected: isSelected, + style: isSelected ? this._getEmphasizedStyle() : {}, + ...(action && { tap_action: action }), + }; + }); + + return { + icon: 'mdi:folder-multiple', + ...config.menu.buttons.folders, + type: 'custom:advanced-camera-card-menu-submenu', + title: localize('config.menu.buttons.folders'), + items: submenuItems, + style: view?.is('folder') ? this._getEmphasizedStyle() : {}, + }; + } + /** * Get the style of emphasized menu items. * @returns A StyleInfo. diff --git a/src/components-lib/message/controller.ts b/src/components-lib/message/controller.ts index d201a33e..2e28de66 100644 --- a/src/components-lib/message/controller.ts +++ b/src/components-lib/message/controller.ts @@ -1,6 +1,7 @@ import yaml from 'js-yaml'; import { TROUBLESHOOTING_URL } from '../../const'; -import { Message } from '../../types'; +import { localize } from '../../localize/localize.js'; +import { Message, MessageURL } from '../../types'; export class MessageController { public getMessageString(message: Message): string { @@ -20,12 +21,12 @@ export class MessageController { : 'mdi:information-outline'; } - public shouldShowTroubleshootingURL(message: Message): boolean { - return message.type === 'error'; - } - - public getTroubleshootingURL(message: Message): string { - return message.troubleshootingURL ?? TROUBLESHOOTING_URL; + public getURL(message: Message): MessageURL | null { + return message.url + ? message.url + : message.type === 'error' + ? { link: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') } + : null; } public getContextStrings(message: Message): string[] { diff --git a/src/components-lib/status-bar-controller.ts b/src/components-lib/status-bar-controller.ts index c39a3c6e..1bebda38 100644 --- a/src/components-lib/status-bar-controller.ts +++ b/src/components-lib/status-bar-controller.ts @@ -1,6 +1,5 @@ import { LitElement } from 'lit'; -import isEqual from 'lodash-es/isEqual'; -import orderBy from 'lodash-es/orderBy'; +import { isEqual, orderBy } from 'lodash-es'; import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request'; import { ActionsConfig, StatusBarItem } from '../config/schema/actions/types'; import { STATUS_BAR_PRIORITY_DEFAULT } from '../config/schema/common/const'; diff --git a/src/components-lib/thumbnail/details-controller.ts b/src/components-lib/thumbnail/details-controller.ts new file mode 100644 index 00000000..84a5c2c6 --- /dev/null +++ b/src/components-lib/thumbnail/details-controller.ts @@ -0,0 +1,161 @@ +import { format } from 'date-fns'; +import { CameraManager } from '../../camera-manager/manager'; +import { CameraManagerCameraMetadata } from '../../camera-manager/types'; +import { localize } from '../../localize/localize'; +import { Icon } from '../../types'; +import { getDurationString, prettifyTitle } from '../../utils/basic'; +import { ViewItem } from '../../view/item'; +import { ViewItemClassifier } from '../../view/item-classifier'; + +interface Detail { + icon?: Icon; + hint?: string; + title: string; +} + +export class ThumbnailDetailsController { + private _details: Detail[] = []; + private _heading: string | null = null; + + public calculate( + cameraManager?: CameraManager | null, + item?: ViewItem, + seek?: Date, + ): void { + const cameraID = ViewItemClassifier.isMedia(item) ? item.getCameraID() : null; + const cameraMetadata = cameraID + ? cameraManager?.getCameraMetadata(cameraID) ?? null + : null; + + this._calculateHeading(cameraMetadata, item); + this._calculateDetails(cameraMetadata, item, seek); + } + + private _calculateHeading( + cameraMetadata: CameraManagerCameraMetadata | null, + item?: ViewItem, + ): void { + if (ViewItemClassifier.isEvent(item)) { + const what = prettifyTitle(item.getWhat()?.join(', ')) ?? null; + const tags = prettifyTitle(item.getTags()?.join(', ')) ?? null; + const whatWithTags = + what || tags ? (what ?? '') + (what && tags ? ': ' : '') + (tags ?? '') : null; + const rawScore = item.getScore(); + const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null; + + this._heading = whatWithTags ? `${whatWithTags}${score ? ` ${score}` : ''}` : null; + return; + } + + if (cameraMetadata?.title) { + this._heading = cameraMetadata.title; + return; + } + + this._heading = null; + } + + private _calculateDetails( + cameraMetadata: CameraManagerCameraMetadata | null, + item?: ViewItem, + seek?: Date, + ): void { + const itemTitle = item?.getTitle() ?? null; + + const startTime = ViewItemClassifier.isMedia(item) ? item.getStartTime() : null; + const endTime = ViewItemClassifier.isMedia(item) ? item.getEndTime() : null; + const duration = startTime && endTime ? getDurationString(startTime, endTime) : null; + const inProgress = ViewItemClassifier.isMedia(item) + ? item.inProgress() + ? localize('thumbnail.in_progress') + : null + : null; + const where = ViewItemClassifier.isMedia(item) + ? prettifyTitle(item?.getWhere()?.join(', ')) ?? null + : null; + const tags = ViewItemClassifier.isEvent(item) + ? prettifyTitle(item?.getTags()?.join(', ')) ?? null + : null; + const seekString = seek ? format(seek, 'HH:mm:ss') : null; + + const details = [ + ...(startTime + ? [ + { + hint: localize('thumbnail.start'), + icon: { icon: 'mdi:calendar-clock-outline' }, + title: format(startTime, 'yyyy-MM-dd HH:mm:ss'), + }, + ] + : []), + ...(duration || inProgress + ? [ + { + hint: localize('thumbnail.duration'), + icon: { icon: 'mdi:clock-outline' }, + title: `${duration ?? ''}${duration && inProgress ? ' ' : ''}${inProgress ?? ''}`, + }, + ] + : []), + ...(cameraMetadata?.title + ? [ + { + hint: localize('thumbnail.camera'), + title: cameraMetadata.title, + icon: { icon: 'mdi:cctv' }, + }, + ] + : []), + ...(where + ? [ + { + hint: localize('thumbnail.where'), + title: where, + icon: { icon: 'mdi:map-marker-outline' }, + }, + ] + : []), + ...(tags + ? [ + { + hint: localize('thumbnail.tag'), + title: tags, + icon: { icon: 'mdi:tag' }, + }, + ] + : []), + ...(seekString + ? [ + { + hint: localize('thumbnail.seek'), + title: seekString, + icon: { icon: 'mdi:clock-fast' }, + }, + ] + : []), + ]; + + this._details = [ + ...(itemTitle + ? [ + { + title: itemTitle, + ...(details.length > 0 && { + icon: { icon: 'mdi:rename' }, + hint: localize('thumbnail.title'), + }), + }, + ] + : []), + ...details, + ]; + } + + public getHeading(): string | null { + return this._heading; + } + + public getDetails(): Detail[] { + return this._details; + } +} diff --git a/src/components-lib/thumbnail/feature/controller.ts b/src/components-lib/thumbnail/feature/controller.ts new file mode 100644 index 00000000..5c53b90d --- /dev/null +++ b/src/components-lib/thumbnail/feature/controller.ts @@ -0,0 +1,106 @@ +import { format } from 'date-fns'; +import { CameraManager } from '../../../camera-manager/manager'; +import { CameraManagerCameraMetadata } from '../../../camera-manager/types'; +import { + brandsUrl, + extractDomainFromBrandUrl, + isBrandUrl, +} from '../../../ha/brands-url'; +import { ViewItem } from '../../../view/item'; +import { ViewItemClassifier } from '../../../view/item-classifier'; + +export class ThumbnailFeatureController { + private _title: string | null = null; + private _subtitles: string[] = []; + private _icon: string | null = null; + private _thumbnail: string | null = null; + private _thumbnailClass: string | null = null; + + public calculate( + cameraManager?: CameraManager | null, + item?: ViewItem, + hasDetails?: boolean, + ): void { + const cameraID = ViewItemClassifier.isMedia(item) ? item.getCameraID() : null; + const cameraMetadata = cameraID + ? cameraManager?.getCameraMetadata(cameraID) ?? null + : null; + + this._calculateVisuals(cameraMetadata, item); + this._calculateTitles(cameraMetadata, item, hasDetails); + } + + private _calculateTitles( + cameraMetadata?: CameraManagerCameraMetadata | null, + item?: ViewItem, + hasDetails?: boolean, + ) { + if (hasDetails) { + return; + } + + if (this._thumbnail && ViewItemClassifier.isMedia(item)) { + this._title = null; + this._subtitles = []; + return; + } + + const startTime = + ViewItemClassifier.isEvent(item) || ViewItemClassifier.isRecording(item) + ? item.getStartTime() + : null; + + this._title = startTime ? format(startTime, 'HH:mm') : null; + + const day = startTime ? format(startTime, 'MMM do') : null; + const itemTitle = item?.getTitle() ?? null; + const src = cameraMetadata?.title ?? itemTitle ?? null; + + this._subtitles = [...(day ? [day] : []), ...(src ? [src] : [])]; + } + + private _calculateVisuals( + cameraMetadata?: CameraManagerCameraMetadata | null, + item?: ViewItem, + ) { + let thumbnail: string | null = item?.getThumbnail() ?? null; + if (thumbnail && isBrandUrl(thumbnail)) { + thumbnail = brandsUrl({ + domain: extractDomainFromBrandUrl(thumbnail), + type: 'icon', + useFallback: true, + brand: true, + }); + } + + if (thumbnail) { + this._thumbnail = thumbnail; + this._icon = null; + this._thumbnailClass = isBrandUrl(thumbnail) ? 'brand' : null; + } else { + this._thumbnail = null; + this._thumbnailClass = null; + this._icon = item?.getIcon() ?? cameraMetadata?.engineIcon ?? null; + } + } + + public getTitle(): string | null { + return this._title; + } + + public getSubtitles(): string[] { + return this._subtitles; + } + + public getIcon(): string | null { + return this._icon; + } + + public getThumbnail(): string | null { + return this._thumbnail; + } + + public getThumbnailClass(): string | null { + return this._thumbnailClass; + } +} diff --git a/src/components-lib/timeline-source.ts b/src/components-lib/timeline-source.ts index 4d9819b6..e77ccad9 100644 --- a/src/components-lib/timeline-source.ts +++ b/src/components-lib/timeline-source.ts @@ -12,7 +12,7 @@ import { capEndDate } from '../camera-manager/utils/cap-end-date'; import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly'; import { ClipsOrSnapshotsOrAll } from '../types'; import { errorToConsole, ModifyInterface } from '../utils/basic.js'; -import { ViewMedia } from '../view/media'; +import { ViewMedia } from '../view/item'; // Allow timeline freshness to be at least this number of seconds out of date // (caching times in the data-engine may increase the effective delay). @@ -139,10 +139,11 @@ export class TimelineDataSource { for (const media of mediaArray ?? []) { const startTime = media.getStartTime(); const id = media.getID(); - if (id && startTime) { + const cameraID = media.getCameraID(); + if (id && startTime && cameraID) { data.push({ id: id, - group: media.getCameraID(), + group: cameraID, content: '', media: media, start: startTime.getTime(), diff --git a/src/components-lib/zoom/zoom-controller.ts b/src/components-lib/zoom/zoom-controller.ts index 3df0517c..a787040d 100644 --- a/src/components-lib/zoom/zoom-controller.ts +++ b/src/components-lib/zoom/zoom-controller.ts @@ -1,6 +1,5 @@ import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom'; -import round from 'lodash-es/round'; -import throttle from 'lodash-es/throttle'; +import { round, throttle } from 'lodash-es'; import { arefloatsApproximatelyEqual, isHoverableDevice } from '../../utils/basic'; import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event'; import { diff --git a/src/components-lib/zoom/zoom-view-context.ts b/src/components-lib/zoom/zoom-view-context.ts index 3b67e80f..3e86358b 100644 --- a/src/components-lib/zoom/zoom-view-context.ts +++ b/src/components-lib/zoom/zoom-view-context.ts @@ -46,15 +46,17 @@ export const handleZoomSettingsObservedEvent = ( viewManager?: ViewManagerInterface, targetID?: string, ): void => { - viewManager && - targetID && - viewManager.setViewByParameters({ - modifiers: [ - new MergeContextViewModifier( - generateViewContextForZoom(targetID, { - observed: ev.detail, - }), - ), - ], - }); + if (!viewManager || !targetID) { + return; + } + + viewManager.setViewByParameters({ + modifiers: [ + new MergeContextViewModifier( + generateViewContextForZoom(targetID, { + observed: ev.detail, + }), + ), + ], + }); }; diff --git a/src/components/diagnostics.ts b/src/components/diagnostics.ts index 8c60be52..53c92c8f 100644 --- a/src/components/diagnostics.ts +++ b/src/components/diagnostics.ts @@ -2,11 +2,11 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit import { customElement, property } from 'lit/decorators.js'; import { until } from 'lit/directives/until.js'; import { RawAdvancedCameraCardConfig } from '../config/types'; +import { DeviceRegistryManager } from '../ha/registry/device'; import { HomeAssistant } from '../ha/types'; import { localize } from '../localize/localize'; import basicBlockStyle from '../scss/basic-block.scss'; import { getDiagnostics } from '../utils/diagnostics'; -import { DeviceRegistryManager } from '../utils/ha/registry/device'; import { renderMessage } from './message'; @customElement('advanced-camera-card-diagnostics') diff --git a/src/components/drawer.ts b/src/components/drawer.ts index 0fe03136..8a8ee19e 100644 --- a/src/components/drawer.ts +++ b/src/components/drawer.ts @@ -64,9 +64,6 @@ export class AdvancedCameraCardDrawer extends LitElement { this._refDrawer.value?.shadowRoot?.appendChild(style); } - /** - * Called when the slotted children in the drawer change. - */ protected _slotChanged(): void { const children = this._refSlot.value ? getChildrenFromElement(this._refSlot.value) @@ -80,10 +77,6 @@ export class AdvancedCameraCardDrawer extends LitElement { this._hideDrawerIfNecessary(); } - /** - * Hide the drawer if there is nothing to show. - * @returns - */ protected _hideDrawerIfNecessary(): void { if (!this._refDrawer.value) { return; @@ -108,9 +101,7 @@ export class AdvancedCameraCardDrawer extends LitElement { location="${this.location}" ?open=${this.open} @mouseleave=${() => { - if (this.open) { - this.open = false; - } + this.open = false; }} > ${this.control diff --git a/src/components/gallery.ts b/src/components/gallery.ts deleted file mode 100644 index d4a4df45..00000000 --- a/src/components/gallery.ts +++ /dev/null @@ -1,506 +0,0 @@ -import { - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS, -} from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import throttle from 'lodash-es/throttle'; -import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js'; -import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types'; -import { ViewManagerEpoch } from '../card-controller/view/types.js'; -import { GalleryConfig } from '../config/schema/gallery.js'; -import { CardWideConfig, configDefaults } from '../config/schema/types.js'; -import { HomeAssistant } from '../ha/types.js'; -import { localize } from '../localize/localize'; -import galleryCoreStyle from '../scss/gallery-core.scss'; -import galleryStyle from '../scss/gallery.scss'; -import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; -import { errorToConsole, sleep } from '../utils/basic'; -import { scrollIntoView } from '../utils/scroll.js'; -import { ViewMedia } from '../view/media'; -import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; -import { MediaQueriesClassifier } from '../view/media-queries-classifier'; -import { MediaQueriesResults } from '../view/media-queries-results'; -import './media-filter'; -import './message.js'; -import { renderMessage } from './message.js'; -import './progress-indicator.js'; -import { renderProgressIndicator } from './progress-indicator.js'; -import './surround-basic'; -import './thumbnail.js'; -import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; - -const GALLERY_MEDIA_FILTER_MENU_ICONS = { - closed: 'mdi:filter-cog-outline', - open: 'mdi:filter-cog', -}; - -const MIN_GALLERY_EXTENSION_SECONDS = 0.5; - -@customElement('advanced-camera-card-gallery') -export class AdvancedCameraCardGallery extends LitElement { - @property({ attribute: false }) - public hass?: HomeAssistant; - - @property({ attribute: false }) - public viewManagerEpoch?: ViewManagerEpoch; - - @property({ attribute: false }) - public galleryConfig?: GalleryConfig; - - @property({ attribute: false }) - public cameraManager?: CameraManager; - - @property({ attribute: false }) - public cardWideConfig?: CardWideConfig; - - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - const view = this.viewManagerEpoch?.manager.getView(); - if ( - !this.hass || - !view?.isGalleryView() || - !this.cameraManager || - !this.cardWideConfig - ) { - return; - } - - return html` - - ${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none' - ? html` - ` - : ''} - - - - `; - } - - static get styles(): CSSResultGroup { - return unsafeCSS(galleryStyle); - } -} - -@customElement('advanced-camera-card-gallery-core') -export class AdvancedCameraCardGalleryCore extends LitElement { - @property({ attribute: false }) - public hass?: HomeAssistant; - - @property({ attribute: false }) - public viewManagerEpoch?: ViewManagerEpoch; - - @property({ attribute: false }) - public galleryConfig?: GalleryConfig; - - @property({ attribute: false }) - public cameraManager?: CameraManager; - - @property({ attribute: false }) - public cardWideConfig?: CardWideConfig; - - protected _intersectionObserver: IntersectionObserver; - protected _resizeObserver: ResizeObserver; - protected _refLoaderBottom: Ref = createRef(); - protected _refSelected: Ref = createRef(); - - // Bottom loader: A progress indicator shown in a "cell" (not across) at the - // bottom of the gallery. Once visible this attempts to fetch new content from - // "earlier" (less recently) than the current query. This is rendered by - // default (and once visible, the fetch is triggered after which it is - // re-hidden). - @state() - protected _showLoaderBottom = true; - - // Top loader: A progress indicator is shown across the top of the gallery if - // the user is _already_ at the top of the gallery and scrolls upwards. This - // attempts to fetch new content from "later" (more recently) than the current - // query. This is hidden by default. - @state() - protected _showLoaderTop = false; - - protected _media?: ViewMedia[]; - - protected _boundWheelHandler = this._wheelHandler.bind(this); - protected _boundTouchStartHandler = this._touchStartHandler.bind(this); - protected _boundTouchEndHandler = this._touchEndHandler.bind(this); - - // Wheel / touch events may be voluminous, throttle extension calls. - protected _throttleExtendGalleryLater = throttle( - this._extendGallery.bind(this), - MIN_GALLERY_EXTENSION_SECONDS * 1000, - { - leading: true, - trailing: false, - }, - ); - - protected _touchScrollYPosition: number | null = null; - - constructor() { - super(); - this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); - this._intersectionObserver = new IntersectionObserver( - this._intersectionHandler.bind(this), - ); - } - - // 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). - - protected _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; - } - } - - protected async _touchEndHandler(ev: TouchEvent): Promise { - if ( - !this.scrollTop && - ev.changedTouches.length === 1 && - this._touchScrollYPosition - ) { - if (ev.changedTouches[0].screenY > this._touchScrollYPosition) { - await this._extendLater(); - } - } - this._touchScrollYPosition = null; - } - - protected async _wheelHandler(ev: WheelEvent): Promise { - if (!this.scrollTop && ev.deltaY < 0) { - await this._extendLater(); - } - } - - protected async _extendLater(): Promise { - const start = new Date(); - this._showLoaderTop = true; - await this._throttleExtendGalleryLater( - 'later', - // Ask the engine to avoid use of cache since the user is explicitly - // looking for the freshest possible data. - false, - ); - const delta = new Date().getTime() - start.getTime(); - if (delta < MIN_GALLERY_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 last long - // enough they can 'feel' the fetch has happened. - await sleep(MIN_GALLERY_EXTENSION_SECONDS - delta / 1000); - } - this._showLoaderTop = false; - } - - /** - * Component connected callback. - */ - connectedCallback(): void { - super.connectedCallback(); - this._resizeObserver.observe(this); - this.addEventListener('wheel', this._boundWheelHandler, { passive: true }); - this.addEventListener('touchstart', this._boundTouchStartHandler, { passive: true }); - this.addEventListener('touchend', this._boundTouchEndHandler); - - // Request update in order to ensure the intersection observer reconnects - // with the loader sentinel. - this.requestUpdate(); - } - - /** - * Component disconnected callback. - */ - disconnectedCallback(): void { - this.removeEventListener('wheel', this._boundWheelHandler); - this.removeEventListener('touchstart', this._boundTouchStartHandler); - this.removeEventListener('touchend', this._boundTouchEndHandler); - this._resizeObserver.disconnect(); - this._intersectionObserver.disconnect(); - super.disconnectedCallback(); - } - - /** - * Set gallery columns. - */ - protected _setColumnCount(): void { - const thumbnailSize = - this.galleryConfig?.controls.thumbnails.size ?? - configDefaults.media_gallery.controls.thumbnails.size; - const columns = this.galleryConfig?.controls.thumbnails.show_details - ? Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN)) - : Math.max(1, Math.ceil(this.clientWidth / thumbnailSize)); - - this.style.setProperty('--advanced-camera-card-gallery-columns', String(columns)); - } - - /** - * Handle gallery resize. - */ - protected _resizeHandler(): void { - this._setColumnCount(); - } - - protected async _intersectionHandler( - entries: IntersectionObserverEntry[], - ): Promise { - if (entries.every((entry) => !entry.isIntersecting)) { - return; - } - - this._showLoaderBottom = false; - await this._extendGallery('earlier'); - } - - protected async _extendGallery( - direction: 'earlier' | 'later', - useCache = true, - ): Promise { - const view = this.viewManagerEpoch?.manager.getView(); - - if (!this.cameraManager || !this.hass || !view) { - return; - } - - const query = view.query; - const rawQueries = query?.getQueries() ?? null; - const existingMedia = view.queryResults?.getResults(); - if (!query || !rawQueries || !existingMedia) { - return; - } - - let extension: ExtendedMediaQueryResult | null; - try { - extension = await this.cameraManager.extendMediaQueries( - rawQueries, - existingMedia, - direction, - { - useCache: useCache, - }, - ); - } catch (e) { - errorToConsole(e as Error); - return; - } - - if (extension) { - const newMediaQueries = MediaQueriesClassifier.areEventQueries(query) - ? new EventMediaQueries(extension.queries as EventQuery[]) - : MediaQueriesClassifier.areRecordingQueries(query) - ? new RecordingMediaQueries(extension.queries as RecordingQuery[]) - : null; - - if (newMediaQueries) { - this.viewManagerEpoch?.manager.setViewByParameters({ - baseView: view, - params: { - query: newMediaQueries, - queryResults: new MediaQueriesResults({ - results: extension.results, - }).selectResultIfFound( - (media) => media === view.queryResults?.getSelectedResult(), - ), - }, - }); - } - } - } - - /** - * Called when an update will occur. - * @param changedProps The changed properties - */ - protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('galleryConfig')) { - if (this.galleryConfig?.controls.thumbnails.show_details) { - this.setAttribute('details', ''); - } else { - this.removeAttribute('details'); - } - this._setColumnCount(); - if (this.galleryConfig?.controls.thumbnails.size) { - this.style.setProperty( - '--advanced-camera-card-thumbnail-size', - `${this.galleryConfig.controls.thumbnails.size}px`, - ); - } - } - if (changedProps.has('viewManagerEpoch')) { - // If the view changes, always render the bottom loader to allow for the - // view to be extended once the bottom loader becomes visible. - this._showLoaderBottom = true; - - const view = this.viewManagerEpoch?.manager.getView(); - const oldView = this.viewManagerEpoch?.oldView; - if ( - !this._media || - oldView?.queryResults?.getResults() !== view?.queryResults?.getResults() - ) { - // 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 = [...(view?.queryResults?.getResults() ?? [])].reverse(); - } - } - } - - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - if (!this._media || !this.hass) { - return html``; - } - - const view = this.viewManagerEpoch?.manager.getView(); - if (!view?.queryResults || view.queryResults.getResultsCount() === 0) { - // Note that this is not throwing up an error message for the card to - // handle (as typical), but rather directly rendering the message into the - // gallery. This is to allow the filter to still be available when a given - // filter selection returns no media. - const loadingMedia = !!view?.context?.loading?.query; - return renderMessage({ - type: 'info', - message: loadingMedia - ? localize('error.awaiting_media') - : localize('common.no_media'), - icon: 'mdi:multimedia', - dotdotdot: loadingMedia, - }); - } - - const selected = view.queryResults.getSelectedResult(); - return html`
- ${this._showLoaderTop - ? html`${renderProgressIndicator({ - cardWideConfig: this.cardWideConfig, - classes: { - top: true, - }, - size: 'small', - })}` - : ''} - ${this._media.map( - (media, index) => - html` { - if (this._media) { - this.viewManagerEpoch?.manager.setViewByParameters({ - params: { - view: 'media', - queryResults: view.queryResults?.clone().selectIndex( - // Media in the gallery is reversed vs the queryResults (see - // note above). - this._media.length - index - 1, - ), - }, - }); - } - stopEventFromActivatingCardWideActions(ev); - }} - > - `, - )} - ${this._showLoaderBottom - ? html`${renderProgressIndicator({ - cardWideConfig: this.cardWideConfig, - componentRef: this._refLoaderBottom, - })}` - : ''} -
`; - } - - public updated(changedProps: PropertyValues): void { - if (this._refLoaderBottom.value) { - this._intersectionObserver.disconnect(); - this._intersectionObserver.observe(this._refLoaderBottom.value); - } - - // This wait for updateComplete is necessary for the scrolling to work - // correctly. - this.updateComplete.then(() => { - // As a special case, if the view has changed and did not previously exist - // (i.e. first setting of it), we intentionally scroll the gallery to the - // selected element in that view (if any). - // See: https://github.com/dermotduffy/advanced-camera-card/issues/885 - if ( - // If this update cycle updated the view ... - changedProps.has('viewManagerEpoch') && - // ... and it wasn't set at all prior ... - !changedProps.get('viewManagerEpoch') && - // ... and there is a thumbnail rendered that is selected. - this._refSelected.value - ) { - scrollIntoView(this._refSelected.value, { - boundary: this, - block: 'center', - }); - } - }); - } - - static get styles(): CSSResultGroup { - return unsafeCSS(galleryCoreStyle); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'advanced-camera-card-gallery-core': AdvancedCameraCardGalleryCore; - 'advanced-camera-card-gallery': AdvancedCameraCardGallery; - } -} diff --git a/src/components/gallery/folder-gallery.ts b/src/components/gallery/folder-gallery.ts new file mode 100644 index 00000000..4d499d0c --- /dev/null +++ b/src/components/gallery/folder-gallery.ts @@ -0,0 +1,147 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { ViewItemManager } from '../../card-controller/view/item-manager.js'; +import { ViewManagerEpoch } from '../../card-controller/view/types.js'; +import { + getUpFolderMediaItem, + upFolderClickHandler, +} from '../../components-lib/folder/up-folder.js'; +import { FolderGalleryController } from '../../components-lib/gallery/folder-gallery-controller.js'; +import { MediaGalleryConfig } from '../../config/schema/media-gallery.js'; +import { HomeAssistant } from '../../ha/types.js'; +import { localize } from '../../localize/localize'; +import folderGalleryStyle from '../../scss/folder-gallery.scss'; +import { ViewItem } from '../../view/item.js'; +import '../media-filter'; +import '../message.js'; +import { renderMessage } from '../message.js'; +import '../surround-basic'; +import '../thumbnail/thumbnail.js'; +import './gallery-core.js'; + +@customElement('advanced-camera-card-folder-gallery') +export class AdvancedCameraCardFolderGallery extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public viewManagerEpoch?: ViewManagerEpoch; + + @property({ attribute: false }) + public viewItemManager?: ViewItemManager; + + @property({ attribute: false }) + public galleryConfig?: MediaGalleryConfig; + + protected _controller = new FolderGalleryController(this); + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('galleryConfig')) { + this._controller.setThumbnailSize(this.galleryConfig?.controls.thumbnails.size); + } + } + + protected _renderThumbnail( + item: ViewItem, + selected: boolean, + clickCallback: (item: ViewItem, ev: Event) => void, + ): TemplateResult | void { + return html` clickCallback(item, ev)} + > + `; + } + + protected _renderThumbnails(): TemplateResult | void { + const selected = this.viewManagerEpoch?.manager + .getView() + ?.queryResults?.getSelectedResult(); + + return html` + ${this.viewManagerEpoch?.manager + .getView() + ?.queryResults?.getResults() + ?.map((item) => + this._renderThumbnail(item, item === selected, (item: ViewItem, ev: Event) => { + const manager = this.viewManagerEpoch?.manager; + if (manager) { + this._controller.itemClickHandler(manager, item, ev); + } + }), + )} + `; + } + + protected render(): TemplateResult | void { + const folderIsLoading = + !!this.viewManagerEpoch?.manager.getView()?.context?.loading?.query; + const upThumbnail = getUpFolderMediaItem(this.viewManagerEpoch?.manager.getView()); + + return html` + + ${!this.viewManagerEpoch?.manager.getView()?.queryResults?.hasResults() && + (folderIsLoading || !upThumbnail) + ? renderMessage({ + type: 'info', + message: folderIsLoading + ? localize('error.awaiting_folder') + : localize('common.no_folder'), + icon: 'mdi:folder-play', + dotdotdot: folderIsLoading, + }) + : html` + ${upThumbnail + ? this._renderThumbnail( + upThumbnail, + false, + (item: ViewItem, ev: Event) => + upFolderClickHandler(item, ev, this.viewManagerEpoch), + ) + : ''} + ${this._renderThumbnails()} + `} + + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(folderGalleryStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-folder-gallery': AdvancedCameraCardFolderGallery; + } +} diff --git a/src/components/gallery/gallery-core.ts b/src/components/gallery/gallery-core.ts new file mode 100644 index 00000000..33abbfa5 --- /dev/null +++ b/src/components/gallery/gallery-core.ts @@ -0,0 +1,119 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { + GalleryColumnCountRoundMethod, + GalleryCoreController, +} from '../../components-lib/gallery/gallery-core-controller.js'; +import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails.js'; +import { CardWideConfig } from '../../config/schema/types.js'; +import { HomeAssistant } from '../../ha/types.js'; +import galleryCoreStyle from '../../scss/gallery-core.scss'; +import '../message.js'; +import '../progress-indicator.js'; +import { renderProgressIndicator } from '../progress-indicator.js'; + +@customElement('advanced-camera-card-gallery-core') +export class AdvancedCameraCardGalleryCore extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public columnWidth: number = THUMBNAIL_WIDTH_DEFAULT; + + @property({ attribute: false }) + public columnCountRoundMethod?: GalleryColumnCountRoundMethod; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + @property({ attribute: false }) + public extendUp = false; + + @property({ attribute: false }) + public extendDown = false; + + private _refSentinelBottom: Ref = createRef(); + private _refSlot: Ref = createRef(); + + private _controller = new GalleryCoreController( + this, + () => this._refSlot.value ?? null, + () => this._refSentinelBottom.value ?? null, + (show) => { + this._showLoaderTop = show; + }, + (show) => { + this._showSentinelBottom = show; + }, + ); + + // Top loader: A progress indicator is shown across the top of the gallery if + // the user is _already_ at the top of the gallery and scrolls upwards. This + // attempts to fetch new content from "later" (more recently) than the current + // query. This is hidden by default. + @state() + private _showLoaderTop = false; + + // Bottom sentinel: A progress indicator shown in a "cell" (not across) at the + // bottom of the gallery. Once visible an attempt is optionally made to extend + // the gallery downwards. This is rendered by default (so intersection can be + // detected), and hidden during fetches. + @state() + private _showSentinelBottom = true; + + protected willUpdate(changedProps: PropertyValues): void { + if ( + ['columnCountRoundMethod', 'columnWidth', 'extendUp', 'extendDown'].some((prop) => + changedProps.has(prop), + ) + ) { + this._controller.setOptions({ + extendUp: this.extendUp, + extendDown: this.extendDown, + columnWidth: this.columnWidth, + columnCountRoundMethod: this.columnCountRoundMethod, + }); + } + } + + protected render(): TemplateResult | void { + return html`
+ ${this.extendUp && this._showLoaderTop + ? html`${renderProgressIndicator({ + cardWideConfig: this.cardWideConfig, + classes: { + top: true, + }, + size: 'small', + })}` + : ''} + this._controller.updateContents()}> + + ${this.extendDown && this._showSentinelBottom + ? html`${renderProgressIndicator({ + cardWideConfig: this.cardWideConfig, + componentRef: this._refSentinelBottom, + size: 'small', + })}` + : ''} +
`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(galleryCoreStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-gallery-core': AdvancedCameraCardGalleryCore; + } +} diff --git a/src/components/gallery/media-gallery.ts b/src/components/gallery/media-gallery.ts new file mode 100644 index 00000000..5f4a68fc --- /dev/null +++ b/src/components/gallery/media-gallery.ts @@ -0,0 +1,194 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { CameraManager } from '../../camera-manager/manager.js'; +import { ViewItemManager } from '../../card-controller/view/item-manager.js'; +import { ViewManagerEpoch } from '../../card-controller/view/types.js'; +import { MediaGalleryController } from '../../components-lib/gallery/media-gallery-controller.js'; +import { MediaGalleryConfig } from '../../config/schema/media-gallery.js'; +import { CardWideConfig } from '../../config/schema/types.js'; +import { HomeAssistant } from '../../ha/types.js'; +import { localize } from '../../localize/localize'; +import mediaGalleryStyle from '../../scss/media-gallery.scss'; +import '../media-filter'; +import '../message.js'; +import { renderMessage } from '../message.js'; +import '../surround-basic'; +import '../thumbnail/thumbnail.js'; +import './gallery-core.js'; +import { GalleryExtendEvent } from './types.js'; + +const MEDIA_GALLERY_FILTER_MENU_ICONS = { + closed: 'mdi:filter-cog-outline', + open: 'mdi:filter-cog', +}; + +@customElement('advanced-camera-card-media-gallery') +export class AdvancedCameraCardMediaGallery extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public viewManagerEpoch?: ViewManagerEpoch; + + @property({ attribute: false }) + public galleryConfig?: MediaGalleryConfig; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public viewItemManager?: ViewItemManager; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + protected _controller = new MediaGalleryController(this); + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('viewManagerEpoch')) { + this._controller.setMediaFromView( + this.viewManagerEpoch?.manager.getView(), + this.viewManagerEpoch?.oldView, + ); + } + + if (changedProps.has('galleryConfig')) { + this._controller.setThumbnailSize(this.galleryConfig?.controls.thumbnails.size); + } + } + + protected _renderThumbnails(): TemplateResult | void { + const selected = this.viewManagerEpoch?.manager + .getView() + ?.queryResults?.getSelectedResult(); + + return html` + ${this._controller.getMedia()?.map( + (media, index) => + html` { + const manager = this.viewManagerEpoch?.manager; + if (manager) { + this._controller.itemClickHandler(manager, index, ev); + } + }} + > + `, + )} + `; + } + + protected render(): TemplateResult | void { + const mediaIsLoading = + !!this.viewManagerEpoch?.manager.getView()?.context?.loading?.query; + + return html` + + ${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none' + ? html` + ` + : ''} + ${!this._controller.getMedia()?.length + ? renderMessage({ + type: 'info', + message: mediaIsLoading + ? localize('error.awaiting_media') + : localize('common.no_media'), + icon: 'mdi:multimedia', + dotdotdot: mediaIsLoading, + }) + : html`, + ) => + this._extendGallery( + ev, + 'later', + // Avoid use of cache since the user is explicitly looking for + // the freshest possible data. + false, + )} + @advanced-camera-card:gallery:extend:down=${( + ev: CustomEvent, + ) => this._extendGallery(ev, 'earlier')} + > + ${this._renderThumbnails()} + `} + + `; + } + + protected async _extendGallery( + ev: CustomEvent, + direction: 'earlier' | 'later', + useCache = true, + ): Promise { + if (!this.cameraManager || !this.viewManagerEpoch) { + return; + } + await this._controller.extendMediaGallery( + this.cameraManager, + this.viewManagerEpoch, + direction, + useCache, + ); + ev.detail.resolve(); + } + + static get styles(): CSSResultGroup { + return unsafeCSS(mediaGalleryStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-media-gallery': AdvancedCameraCardMediaGallery; + } +} diff --git a/src/components/gallery/types.ts b/src/components/gallery/types.ts new file mode 100644 index 00000000..ffd9a645 --- /dev/null +++ b/src/components/gallery/types.ts @@ -0,0 +1,3 @@ +export interface GalleryExtendEvent { + resolve: () => void; +} diff --git a/src/components/image-player.ts b/src/components/image-player.ts index b7534f04..6c70f6ae 100644 --- a/src/components/image-player.ts +++ b/src/components/image-player.ts @@ -4,7 +4,12 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { ImageMediaPlayerController } from '../components-lib/media-player/image'; import imagePlayerStyle from '../scss/image-player.scss'; -import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types'; +import { + MediaPlayer, + MediaPlayerController, + MediaPlayerElement, + MediaTechnology, +} from '../types'; import { dispatchMediaLoadedEvent } from '../utils/media-info'; /** @@ -16,7 +21,7 @@ export class AdvancedCameraCardImagePlayer extends LitElement implements MediaPl public url?: string; @property() - public filetype?: string; + public technology?: MediaTechnology; protected _refImage: Ref> = createRef(); protected _mediaPlayerController = new ImageMediaPlayerController( @@ -37,7 +42,7 @@ export class AdvancedCameraCardImagePlayer extends LitElement implements MediaPl ...(this._mediaPlayerController && { mediaPlayerController: this._mediaPlayerController, }), - technology: [this.filetype ?? 'jpg'], + technology: [this.technology ?? ('jpg' as const)], }); }} />`; diff --git a/src/components/image-updating-player.ts b/src/components/image-updating-player.ts index 5dd5a2fc..952ed888 100644 --- a/src/components/image-updating-player.ts +++ b/src/components/image-updating-player.ts @@ -10,7 +10,7 @@ import { import { customElement, property, state } from 'lit/decorators.js'; import { live } from 'lit/directives/live.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import isEqual from 'lodash-es/isEqual'; +import { isEqual } from 'lodash-es'; import { CameraManager } from '../camera-manager/manager.js'; import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js'; import { CachedValueController } from '../components-lib/cached-value-controller.js'; @@ -18,6 +18,7 @@ import { UpdatingImageMediaPlayerController } from '../components-lib/media-play import { CameraConfig } from '../config/schema/cameras.js'; import { ImageMode } from '../config/schema/common/image.js'; import { ImageViewConfig } from '../config/schema/image.js'; +import { isHassDifferent } from '../ha/is-hass-different.js'; import { HomeAssistant } from '../ha/types.js'; import defaultImage from '../images/iris-screensaver.jpg'; import { localize } from '../localize/localize.js'; @@ -29,7 +30,6 @@ import { Message, } from '../types.js'; import { contentsChanged } from '../utils/basic.js'; -import { isHassDifferent } from '../utils/ha/index.js'; import { createMediaLoadedInfo, dispatchExistingMediaLoadedInfoAsEvent, diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index 9c19dfb6..ba720018 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -380,7 +380,10 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP type: 'error', icon: 'mdi:camera-off', message: localize('error.stream_not_loading'), - troubleshootingURL: STREAM_TROUBLESHOOTING_URL, + url: { + link: STREAM_TROUBLESHOOTING_URL, + title: localize('error.troubleshooting'), + }, }, { overlay: true }, ) diff --git a/src/components/live/providers/jsmpeg.ts b/src/components/live/providers/jsmpeg.ts index e25a266b..b19099bc 100644 --- a/src/components/live/providers/jsmpeg.ts +++ b/src/components/live/providers/jsmpeg.ts @@ -74,11 +74,6 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla } } - /** - * Create a JSMPEG player. - * @param url The URL for the player to connect to. - * @returns A JSMPEG player. - */ protected async _createJSMPEGPlayer(url: string): Promise { this._jsmpegVideoPlayer = await new Promise((resolve) => { let videoDecoded = false; @@ -136,16 +131,14 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla } } - /** - * Reset / destroy the player. - */ protected _resetPlayer(): void { this._message = null; this._refreshPlayerTimer.stop(); if (this._jsmpegVideoPlayer) { try { this._jsmpegVideoPlayer.destroy(); - } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { // Pass. } this._jsmpegVideoPlayer = undefined; @@ -156,9 +149,6 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla } } - /** - * Component connected callback. - */ connectedCallback(): void { super.connectedCallback(); if (this.isConnected) { @@ -166,9 +156,6 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla } } - /** - * Component disconnected callback. - */ disconnectedCallback(): void { if (!this.isConnected) { this._resetPlayer(); @@ -176,9 +163,6 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla super.disconnectedCallback(); } - /** - * Refresh the JSMPEG player. - */ protected async _refreshPlayer(): Promise { if (!this.hass) { return; @@ -221,9 +205,6 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla ); } - /** - * Master render method. - */ protected render(): TemplateResult | void { if (this._message) { return renderMessage(this._message); @@ -253,9 +234,6 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla )}`; } - /** - * Get styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(liveJSMPEGStyle); } diff --git a/src/components/live/providers/webrtc-card.ts b/src/components/live/providers/webrtc-card.ts index 86679213..b666ea67 100644 --- a/src/components/live/providers/webrtc-card.ts +++ b/src/components/live/providers/webrtc-card.ts @@ -120,7 +120,6 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi * Create the WebRTC element. May throw. */ protected _createWebRTC(): HTMLElement | null { - // eslint-disable-next-line @typescript-eslint/no-explicit-any const webrtcElement = this._webrtcTask.value; if (webrtcElement && this.hass && this.cameraConfig) { const webrtc = new webrtcElement() as HTMLElement & { diff --git a/src/components/menu.ts b/src/components/menu.ts index fd08afa9..ebf354e3 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -5,11 +5,11 @@ import { actionHandler } from '../action-handler-directive.js'; import { MenuController } from '../components-lib/menu-controller.js'; import { MenuItem } from '../config/schema/elements/custom/menu/types.js'; import { MenuConfig } from '../config/schema/menu.js'; +import { getEntityTitle } from '../ha/get-entity-title.js'; +import { EntityRegistryManager } from '../ha/registry/entity/types.js'; import { HomeAssistant } from '../ha/types.js'; import menuStyle from '../scss/menu.scss'; import { hasAction } from '../utils/action.js'; -import { getEntityTitle } from '../utils/ha/index.js'; -import { EntityRegistryManager } from '../utils/ha/registry/entity/types.js'; import './icon.js'; import './submenu/select-button.js'; import './submenu/submenu-button'; diff --git a/src/components/message.ts b/src/components/message.ts index 11dcd46c..d98007fa 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -2,7 +2,6 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { MessageController } from '../components-lib/message/controller.js'; -import { localize } from '../localize/localize.js'; import messageStyle from '../scss/message.scss'; import { Message } from '../types.js'; import './icon.js'; @@ -33,14 +32,10 @@ export class AdvancedCameraCardMessage extends LitElement { return; } + const url = this._controller.getURL(this.message); const messageTemplate = html` ${this._controller.getMessageString(this.message)} - ${this._controller.shouldShowTroubleshootingURL(this.message) - ? html`. - ${localize('error.troubleshooting')}` - : ''} + ${url ? html`. ${url.title}` : ''} `; const icon = this._controller.getIcon(this.message); @@ -52,6 +47,7 @@ export class AdvancedCameraCardMessage extends LitElement {
diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts index 5687e218..62d0f9f9 100644 --- a/src/components/next-prev-control.ts +++ b/src/components/next-prev-control.ts @@ -41,9 +41,6 @@ export class AdvancedCameraCardNextPreviousControl extends LitElement { // Label that is used for ARIA support and as tooltip. @property() label = ''; - @state() - protected _thumbnailError = false; - protected _embedThumbnailTask = createFetchThumbnailTask( this, () => this.hass, @@ -55,35 +52,45 @@ export class AdvancedCameraCardNextPreviousControl extends LitElement { return html``; } - const renderIcon = - !this.thumbnail || - ['chevrons', 'icons'].includes(this._controlConfig.style) || - this._thumbnailError; + const shouldRenderIcon = + !this.thumbnail || ['chevrons', 'icons'].includes(this._controlConfig.style); - const classes = { + const classesBase = { controls: true, left: this.side === 'left', right: this.side === 'right', - thumbnails: !renderIcon, - icons: renderIcon, }; - if (renderIcon) { + const renderIcon = (): TemplateResult => { const icon = - this.icon && !this._thumbnailError && this._controlConfig.style !== 'chevrons' + this.icon && this._controlConfig?.style !== 'chevrons' ? this.icon : this.side === 'left' ? { icon: 'mdi:chevron-left' } : { icon: 'mdi:chevron-right' }; + const classes = { + ...classesBase, + icons: true, + }; + return html` `; + }; + + if (shouldRenderIcon) { + return renderIcon(); } + const classes = { + ...classesBase, + thumbnails: true, + }; + return renderTask( this._embedThumbnailTask, (embeddedThumbnail: string | null) => @@ -98,9 +105,7 @@ export class AdvancedCameraCardNextPreviousControl extends LitElement { { inProgressFunc: () => html`
`, // eslint-disable-next-line @typescript-eslint/no-unused-vars - errorFunc: (_e: Error) => { - this._thumbnailError = true; - }, + errorFunc: (_ev: Error) => renderIcon(), }, ); } diff --git a/src/components/progress-indicator.ts b/src/components/progress-indicator.ts index b2abd3f2..15b56342 100644 --- a/src/components/progress-indicator.ts +++ b/src/components/progress-indicator.ts @@ -42,8 +42,7 @@ export class AdvancedCameraCardProgressIndicator extends LitElement { protected render(): TemplateResult { return html`
${this.animated - ? html` - ` + ? html` ` : html``} diff --git a/src/components/select.ts b/src/components/select.ts index 30571067..576b9f23 100644 --- a/src/components/select.ts +++ b/src/components/select.ts @@ -13,7 +13,7 @@ import { contentsChanged } from '../utils/basic'; import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event'; import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin'; import { grSelectElements } from '../scoped-elements/gr-select'; -import isEqual from 'lodash-es/isEqual'; +import { isEqual } from 'lodash-es'; import '../scoped-elements/gr-select'; export interface SelectOption { diff --git a/src/components/submenu/index.ts b/src/components/submenu/index.ts index 73bd0e20..f007305e 100644 --- a/src/components/submenu/index.ts +++ b/src/components/submenu/index.ts @@ -3,13 +3,13 @@ import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { styleMap } from 'lit/directives/style-map.js'; import { actionHandler } from '../../action-handler-directive.js'; +import { getEntityTitle } from '../../ha/get-entity-title.js'; import { HomeAssistant } from '../../ha/types.js'; import submenuStyle from '../../scss/submenu.scss'; import { hasAction, stopEventFromActivatingCardWideActions, } from '../../utils/action.js'; -import { getEntityTitle } from '../../utils/ha'; import '../icon.js'; import { SubmenuInteraction, SubmenuItem } from './types.js'; diff --git a/src/components/submenu/select-button.ts b/src/components/submenu/select-button.ts index 95a19ff6..b07ef5dc 100644 --- a/src/components/submenu/select-button.ts +++ b/src/components/submenu/select-button.ts @@ -11,13 +11,14 @@ import { styleMap } from 'lit/directives/style-map.js'; import { MenuSubmenuSelect } from '../../config/schema/elements/custom/menu/submenu-select.js'; import { MenuSubmenuItem } from '../../config/schema/elements/custom/menu/submenu.js'; import { computeDomain } from '../../ha/compute-domain.js'; +import { getEntityStateTranslation } from '../../ha/entity-state-translation.js'; +import { getEntityTitle } from '../../ha/get-entity-title.js'; +import { isHassDifferent } from '../../ha/is-hass-different.js'; +import { EntityRegistryManager } from '../../ha/registry/entity/types.js'; import { HomeAssistant } from '../../ha/types.js'; import menuButtonStyle from '../../scss/menu-button.scss'; import { Icon } from '../../types.js'; import { createSelectOptionAction } from '../../utils/action.js'; -import { getEntityTitle, isHassDifferent } from '../../utils/ha'; -import { getEntityStateTranslation } from '../../utils/ha/entity-state-translation.js'; -import { EntityRegistryManager } from '../../utils/ha/registry/entity/types.js'; import '../icon.js'; import './index.js'; diff --git a/src/components/surround.ts b/src/components/surround.ts index 329933b6..8b83ced0 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -8,7 +8,7 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { CameraManager } from '../camera-manager/manager.js'; -import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js'; +import { ViewItemManager } from '../card-controller/view/item-manager.js'; import { ViewManagerEpoch } from '../card-controller/view/types.js'; import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js'; import { MiniTimelineControlConfig } from '../config/schema/common/controls/timeline.js'; @@ -17,8 +17,9 @@ import { HomeAssistant } from '../ha/types.js'; import basicBlockStyle from '../scss/basic-block.scss'; import { contentsChanged } from '../utils/basic.js'; import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js'; +import { QueryClassifier } from '../view/query-classifier.js'; import './surround-basic.js'; -import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; +import './thumbnail-carousel'; @customElement('advanced-camera-card-surround') export class AdvancedCameraCardSurround extends LitElement { @@ -37,6 +38,9 @@ export class AdvancedCameraCardSurround extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public viewItemManager?: ViewItemManager; + @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @@ -92,8 +96,10 @@ export class AdvancedCameraCardSurround extends LitElement { .getAllDependentCameras(view.camera, capabilitySearch); } } - if (view.isViewerView()) { - return view.query?.getQueryCameraIDs() ?? null; + + const queries = view.query; + if (view.isViewerView() && QueryClassifier.isMediaQuery(queries)) { + return queries.getQueryCameraIDs() ?? null; } return null; } @@ -118,9 +124,7 @@ export class AdvancedCameraCardSurround extends LitElement { }; return html` - changeDrawer(ev, 'open')} - @advanced-camera-card:thumbnails:close=${(ev: CustomEvent) => + @advanced-camera-card:thumbnails-carousel:media-select=${(ev: CustomEvent) => changeDrawer(ev, 'close')} > ${this.thumbnailConfig && this.thumbnailConfig.mode !== 'none' @@ -129,27 +133,10 @@ export class AdvancedCameraCardSurround extends LitElement { .hass=${this.hass} .config=${this.thumbnailConfig} .cameraManager=${this.cameraManager} + .viewItemManager=${this.viewItemManager} .fadeThumbnails=${view.isViewerView()} .viewManagerEpoch=${this.viewManagerEpoch} .selected=${view.queryResults?.getSelectedIndex() ?? undefined} - @advanced-camera-card:thumbnail-carousel:tap=${( - ev: CustomEvent, - ) => { - const media = ev.detail.queryResults.getSelectedResult(); - if (media) { - this.viewManagerEpoch?.manager.setViewByParameters({ - params: { - view: 'media', - queryResults: ev.detail.queryResults, - ...(media.getCameraID() && { camera: media.getCameraID() }), - }, - modifiers: [ - new RemoveContextViewModifier(['timeline', 'mediaViewer']), - ], - }); - changeDrawer(ev, 'close'); - } - }} > ` : ''} @@ -168,6 +155,7 @@ export class AdvancedCameraCardSurround extends LitElement { .timelineConfig=${this.timelineConfig} .thumbnailConfig=${this.thumbnailConfig} .cameraManager=${this.cameraManager} + .viewItemManager=${this.viewItemManager} .cardWideConfig=${this.cardWideConfig} > ` diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 833b9bb0..3b1b8e3e 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -9,19 +9,27 @@ import { import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { CameraManager } from '../camera-manager/manager.js'; +import { ViewItemManager } from '../card-controller/view/item-manager.js'; +import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js'; import { ViewManagerEpoch } from '../card-controller/view/types.js'; +import { + getUpFolderMediaItem, + upFolderClickHandler, +} from '../components-lib/folder/up-folder.js'; import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js'; import { HomeAssistant } from '../ha/types.js'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { CarouselDirection } from '../utils/embla/carousel-controller.js'; import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js'; -import { MediaQueriesResults } from '../view/media-queries-results'; +import { ViewItemClassifier } from '../view/item-classifier.js'; +import { ViewItem, ViewMedia } from '../view/item.js'; +import { QueryClassifier } from '../view/query-classifier.js'; import './carousel.js'; -import './thumbnail.js'; +import './thumbnail/thumbnail.js'; -export interface ThumbnailCarouselTap { - queryResults: MediaQueriesResults; +export interface ThumbnailMediaSelect { + media: ViewMedia; } @customElement('advanced-camera-card-thumbnail-carousel') @@ -35,13 +43,16 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public viewItemManager?: ViewItemManager; + @property({ attribute: false }) public config?: ThumbnailsControlConfig; @property({ attribute: false }) public fadeThumbnails = false; - protected _thumbnailSlides: TemplateResult[] = []; + protected _thumbnails: TemplateResult[] = []; protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('config')) { @@ -66,7 +77,7 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { 'viewManagerEpoch', ] as const; if (renderProperties.some((prop) => changedProps.has(prop))) { - this._thumbnailSlides = this._renderSlides(); + this._thumbnails = this._renderThumbnails(); } if (changedProps.has('viewManagerEpoch')) { @@ -83,49 +94,114 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { ); } - protected _renderSlides(): TemplateResult[] { - const slides: TemplateResult[] = []; + protected _itemClickCallback(item: ViewItem, ev: Event): void { + stopEventFromActivatingCardWideActions(ev); + + const view = this.viewManagerEpoch?.manager.getView(); + const query = view?.query; + const results = view?.queryResults; + + if (!view || !query || !results) { + return; + } + + if (ViewItemClassifier.isMedia(item)) { + const newResults = results + .clone() + .selectResultIfFound((result) => result === item); + const cameraID = item.getCameraID(); + + fireAdvancedCameraCardEvent( + this, + 'thumbnails-carousel:media-select', + { media: item }, + ); + this.viewManagerEpoch?.manager.setViewByParameters({ + params: { + view: 'media', + queryResults: newResults, + ...(cameraID && { camera: cameraID }), + }, + modifiers: [new RemoveContextViewModifier(['timeline', 'mediaViewer'])], + }); + } else if ( + QueryClassifier.isFolderQuery(query) && + ViewItemClassifier.isFolder(item) + ) { + const id = item.getID(); + const rawQuery = query.getQuery(); + if (!id || !rawQuery) { + return; + } + + this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ + params: { + query: query.clone().setQuery({ + folder: rawQuery.folder, + path: [...(rawQuery.path ?? []), { id }], + }), + }, + }); + } + } + + protected _renderThumbnail( + item: ViewItem, + selected: boolean, + clickCallback: (item: ViewItem, ev: Event) => void, + seekTarget?: Date, + ): TemplateResult { + const classes = { + embla__slide: true, + 'slide-selected': selected, + }; + + return html` clickCallback(item, ev)} + > + `; + } + + protected _renderThumbnails(): TemplateResult[] { + const upThumbnail = getUpFolderMediaItem(this.viewManagerEpoch?.manager.getView()); + const thumbnails: TemplateResult[] = [ + ...(upThumbnail + ? [ + this._renderThumbnail(upThumbnail, false, (item: ViewItem, ev: Event) => + upFolderClickHandler(item, ev, this.viewManagerEpoch), + ), + ] + : []), + ]; const view = this.viewManagerEpoch?.manager.getView(); - const seekTarget = view?.context?.mediaViewer?.seek; const selectedIndex = this._getSelectedSlide(); - for (const media of view?.queryResults?.getResults() ?? []) { - const index = slides.length; - const classes = { - embla__slide: true, - 'slide-selected': selectedIndex === index, - }; - - slides.push( - html` { - const view = this.viewManagerEpoch?.manager.getView(); - if (view && view.queryResults) { - fireAdvancedCameraCardEvent( - this, - 'thumbnail-carousel:tap', - { - queryResults: view.queryResults.clone().selectIndex(index), - }, - ); - } - stopEventFromActivatingCardWideActions(ev); - }} - > - `, + for (const item of view?.queryResults?.getResults() ?? []) { + thumbnails.push( + this._renderThumbnail( + item, + selectedIndex === thumbnails.length, + (item: ViewItem, ev: Event) => this._itemClickCallback(item, ev), + view?.context?.mediaViewer?.seek, + ), ); } - return slides; + + return thumbnails; } protected _getDirection(): CarouselDirection | null { @@ -139,7 +215,7 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { protected render(): TemplateResult | void { const direction = this._getDirection(); - if (!this._thumbnailSlides.length || !this.config || !direction) { + if (!this._thumbnails.length || !this.config || !direction) { return; } @@ -148,7 +224,7 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { .selected=${this._getSelectedSlide() ?? 0} .dragFree=${true} > - ${this._thumbnailSlides} + ${this._thumbnails} `; } diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts deleted file mode 100644 index da936575..00000000 --- a/src/components/thumbnail.ts +++ /dev/null @@ -1,553 +0,0 @@ -import { Task, TaskStatus } from '@lit-labs/task'; -import { format } from 'date-fns'; -import { - CSSResult, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS, -} from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { CameraManager } from '../camera-manager/manager.js'; -import { CameraManagerCameraMetadata } from '../camera-manager/types.js'; -import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js'; -import { ViewManagerEpoch } from '../card-controller/view/types.js'; -import { dispatchAdvancedCameraCardErrorEvent } from '../components-lib/message/dispatch.js'; -import { HomeAssistant } from '../ha/types.js'; -import { localize } from '../localize/localize.js'; -import thumbnailDetailsStyle from '../scss/thumbnail-details.scss'; -import thumbnailFeatureTextStyle from '../scss/thumbnail-feature-text.scss'; -import thumbnailFeatureThumbnailStyle from '../scss/thumbnail-feature-thumbnail.scss'; -import thumbnailStyle from '../scss/thumbnail.scss'; -import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; -import { - errorToConsole, - formatDateAndTime, - getDurationString, - prettifyTitle, -} from '../utils/basic.js'; -import { downloadMedia } from '../utils/download.js'; -import { renderTask } from '../utils/task.js'; -import { createFetchThumbnailTask, FetchThumbnailTaskArgs } from '../utils/thumbnail.js'; -import { ViewMediaClassifier } from '../view/media-classifier.js'; -import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js'; - -// The minimum width of a thumbnail with details enabled. -export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; - -@customElement('advanced-camera-card-thumbnail-feature-thumbnail') -export class AdvancedCameraCardThumbnailFeatureThumbnail extends LitElement { - @property({ attribute: false }) - public thumbnail?: string; - - @property({ attribute: false }) - public hass?: HomeAssistant; - - @state() - protected _thumbnailError = false; - - protected _embedThumbnailTask?: Task; - - // Only load thumbnails on view in case there is a very large number of them. - protected _intersectionObserver: IntersectionObserver; - - constructor() { - super(); - this._intersectionObserver = new IntersectionObserver( - this._intersectionHandler.bind(this), - ); - } - - /** - * Component connected callback. - */ - connectedCallback(): void { - super.connectedCallback(); - this._intersectionObserver.observe(this); - } - - /** - * Component disconnected callback. - */ - disconnectedCallback(): void { - this._intersectionObserver.disconnect(); - super.disconnectedCallback(); - } - - protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('thumbnail')) { - this._embedThumbnailTask = createFetchThumbnailTask( - this, - () => this.hass, - () => this.thumbnail, - false, - ); - // Reset the observer so the initial intersection handler call will set - // the visibility correctly. - this._intersectionObserver.unobserve(this); - this._intersectionObserver.observe(this); - } - } - - /** - * Called when the live view intersects with the viewport. - * @param entries The IntersectionObserverEntry entries (should be only 1). - */ - protected _intersectionHandler(entries: IntersectionObserverEntry[]): void { - if ( - this._embedThumbnailTask?.status === TaskStatus.INITIAL && - entries.some((entry) => entry.isIntersecting) - ) { - this._embedThumbnailTask?.run(); - } - } - - protected render(): TemplateResult | void { - const imageOff = html` `; - - if (!this._embedThumbnailTask || this._thumbnailError) { - return imageOff; - } - - return html`${this.thumbnail - ? renderTask( - this._embedThumbnailTask, - (embeddedThumbnail: string | null) => - embeddedThumbnail ? html`` : html``, - { - inProgressFunc: () => imageOff, - errorFunc: () => { - this._thumbnailError = true; - }, - }, - ) - : imageOff} `; - } - - static get styles(): CSSResult { - return unsafeCSS(thumbnailFeatureThumbnailStyle); - } -} - -@customElement('advanced-camera-card-thumbnail-feature-text') -export class AdvancedCameraCardThumbnailFeatureText extends LitElement { - @property({ attribute: false }) - public date?: Date; - - @property({ attribute: false }) - public cameraMetadata?: CameraManagerCameraMetadata; - - @property({ attribute: false }) - public showCameraTitle?: boolean; - - protected render(): TemplateResult | void { - if (!this.date) { - return; - } - return html` - ${this.cameraMetadata?.engineIcon - ? html`` - : ''} -
-
${format(this.date, 'HH:mm')}
-
${format(this.date, 'MMM do')}
- ${this.showCameraTitle && this.cameraMetadata?.title - ? html`
${this.cameraMetadata.title}
` - : html``} -
- `; - } - - static get styles(): CSSResult { - return unsafeCSS(thumbnailFeatureTextStyle); - } -} - -@customElement('advanced-camera-card-thumbnail-details-event') -export class AdvancedCameraCardThumbnailDetailsEvent extends LitElement { - @property({ attribute: false }) - public media?: EventViewMedia; - - @property({ attribute: false }) - public seek?: Date; - - @property({ attribute: false }) - public cameraTitle?: string; - - protected render(): TemplateResult | void { - if (!this.media) { - return; - } - const rawScore = this.media.getScore(); - const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null; - const rawStartTime = this.media.getStartTime(); - const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null; - - const rawEndTime = this.media.getEndTime(); - const duration = - rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null; - const inProgress = this.media.inProgress() ? localize('event.in_progress') : null; - - const what = prettifyTitle(this.media.getWhat()?.join(', ')) ?? null; - const where = prettifyTitle(this.media.getWhere()?.join(', ')) ?? null; - const tags = prettifyTitle(this.media.getTags()?.join(', ')) ?? null; - const whatWithTags = - what || tags ? (what ?? '') + (what && tags ? ': ' : '') + (tags ?? '') : null; - - const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null; - - return html` - ${whatWithTags - ? html`
- ${whatWithTags} - ${score ? html`${score}` : ''} -
` - : ``} -
- ${startTime - ? html`
- - ${startTime} -
- ${duration || inProgress - ? html`
- - ${duration ? html`${duration}` : ''} - ${inProgress - ? html`${inProgress}` - : ''} -
` - : ''}` - : ''} - ${this.cameraTitle - ? html`
- - ${this.cameraTitle} -
` - : ''} - ${where - ? html`
- - ${where} -
` - : html``} - ${tags - ? html`
- - ${tags} -
` - : html``} - ${seek - ? html`
- - ${seek} -
` - : html``} -
- `; - } - - static get styles(): CSSResult { - return unsafeCSS(thumbnailDetailsStyle); - } -} - -@customElement('advanced-camera-card-thumbnail-details-recording') -export class AdvancedCameraCardThumbnailDetailsRecording extends LitElement { - @property({ attribute: false }) - public media?: RecordingViewMedia; - - @property({ attribute: false }) - public seek?: Date; - - @property({ attribute: false }) - public cameraTitle?: string; - - protected render(): TemplateResult | void { - if (!this.media) { - return; - } - const rawStartTime = this.media.getStartTime(); - const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null; - - const rawEndTime = this.media.getEndTime(); - const duration = - rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null; - const inProgress = this.media.inProgress() - ? localize('recording.in_progress') - : null; - - const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null; - - const eventCount = this.media.getEventCount(); - return html` - ${this.cameraTitle - ? html`
- ${this.cameraTitle} -
` - : ``} -
- ${startTime - ? html`
- - ${startTime} -
- ${duration || inProgress - ? html`
- - ${duration ? html`${duration}` : ''} - ${inProgress - ? html`${inProgress}` - : ''} -
` - : ''}` - : ''} - ${seek - ? html`
- - ${seek} -
` - : html``} - ${eventCount !== null - ? html`
- - ${eventCount} -
` - : ``} -
- `; - } - - static get styles(): CSSResult { - return unsafeCSS(thumbnailDetailsStyle); - } -} - -@customElement('advanced-camera-card-thumbnail') -export class AdvancedCameraCardThumbnail extends LitElement { - // Performance: During timeline scrubbing, hass may be updated continuously. - // As it is not needed for the thumbnail rendering itself, it does not trigger - // a re-render. The HomeAssistant object may be required for thumbnail signing - // (after initial signing the thumbnail is stored in a data URL, so the - // signing will not expire). - public hass?: HomeAssistant; - - // Performance: During timeline scrubbing, the view will be updated - // continuously. As it is not needed for the thumbnail rendering itself, it - // does not trigger a re-render. - public viewManagerEpoch?: ViewManagerEpoch; - - @property({ attribute: false }) - public cameraManager?: CameraManager; - - @property({ attribute: false }) - public media?: ViewMedia; - - @property({ attribute: true, type: Boolean }) - public details = false; - - @property({ attribute: true, type: Boolean }) - public show_favorite_control = false; - - @property({ attribute: true, type: Boolean }) - public show_timeline_control = false; - - @property({ attribute: true, type: Boolean }) - public show_download_control = false; - - @property({ attribute: false }) - public seek?: Date; - - /** - * Render the element. - * @returns A template to display to the user. - */ - protected render(): TemplateResult | void { - if (!this.media || !this.cameraManager || !this.hass) { - return; - } - - const thumbnail = this.media.getThumbnail(); - const title = this.media.getTitle() ?? ''; - - const starClasses = { - star: true, - starred: !!this.media?.isFavorite(), - }; - - const shouldShowTimelineControl = - this.show_timeline_control && - (!ViewMediaClassifier.isRecording(this.media) || - // Only show timeline control if the recording has a start & end time. - (this.media.getStartTime() && this.media.getEndTime())); - - const mediaCapabilities = this.cameraManager?.getMediaCapabilities(this.media); - - const shouldShowFavoriteControl = - this.show_favorite_control && - this.media && - this.hass && - mediaCapabilities?.canFavorite; - - const shouldShowDownloadControl = - this.show_download_control && - this.hass && - this.media.getID() && - mediaCapabilities?.canDownload; - - const cameraMetadata = this.cameraManager.getCameraMetadata( - this.media.getCameraID(), - ); - - return html` - ${ViewMediaClassifier.isEvent(this.media) && thumbnail - ? html`` - : ViewMediaClassifier.isEvent(this.media) || - ViewMediaClassifier.isRecording(this.media) - ? html`` - : html``} - ${shouldShowFavoriteControl - ? html` { - stopEventFromActivatingCardWideActions(ev); - if (this.hass && this.media) { - try { - await this.cameraManager?.favoriteMedia( - this.media, - !this.media?.isFavorite(), - ); - } catch (e) { - errorToConsole(e as Error); - return; - } - this.requestUpdate(); - } - }} - />` - : ``} - ${this.details && ViewMediaClassifier.isEvent(this.media) - ? html`` - : this.details && ViewMediaClassifier.isRecording(this.media) - ? html`` - : html``} - ${shouldShowTimelineControl - ? html` { - stopEventFromActivatingCardWideActions(ev); - if (!this.viewManagerEpoch || !this.media) { - return; - } - this.viewManagerEpoch.manager.setViewByParameters({ - params: { - view: 'timeline', - queryResults: this.viewManagerEpoch?.manager - .getView() - ?.queryResults?.clone() - .selectResultIfFound((media) => media === this.media), - }, - modifiers: [new RemoveContextViewModifier(['timeline'])], - }); - }} - >` - : ''} - ${shouldShowDownloadControl - ? html` { - stopEventFromActivatingCardWideActions(ev); - if (this.hass && this.cameraManager && this.media) { - try { - await downloadMedia(this.hass, this.cameraManager, this.media); - } catch (error: unknown) { - dispatchAdvancedCameraCardErrorEvent(this, error); - } - } - }} - >` - : ``} - `; - } - - /** - * Get element styles. - */ - static get styles(): CSSResult { - return unsafeCSS(thumbnailStyle); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'advanced-camera-card-thumbnail': AdvancedCameraCardThumbnail; - 'advanced-camera-card-thumbnail-details-recording': AdvancedCameraCardThumbnailDetailsRecording; - 'advanced-camera-card-thumbnail-details-event': AdvancedCameraCardThumbnailDetailsEvent; - 'advanced-camera-card-thumbnail-feature-text': AdvancedCameraCardThumbnailFeatureText; - 'advanced-camera-card-thumbnail-feature-thumbnail': AdvancedCameraCardThumbnailFeatureThumbnail; - } -} diff --git a/src/components/thumbnail/details.ts b/src/components/thumbnail/details.ts new file mode 100644 index 00000000..3304824a --- /dev/null +++ b/src/components/thumbnail/details.ts @@ -0,0 +1,75 @@ +import { + CSSResult, + LitElement, + PropertyValues, + TemplateResult, + html, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { CameraManager } from '../../camera-manager/manager'; +import { ThumbnailDetailsController } from '../../components-lib/thumbnail/details-controller'; +import { HomeAssistant } from '../../ha/types'; +import thumbnailDetailsStyle from '../../scss/thumbnail-details.scss'; +import { ViewItem } from '../../view/item'; +import '../icon'; + +@customElement('advanced-camera-card-thumbnail-details') +export class AdvancedCameraCardThumbnailDetails extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public item?: ViewItem; + + @property({ attribute: false }) + public seek?: Date; + + private _controller = new ThumbnailDetailsController(); + + protected willUpdate(changedProperties: PropertyValues): void { + if (['item', 'seek', 'cameraManager'].some((prop) => changedProperties.has(prop))) { + this._controller.calculate(this.cameraManager, this.item, this.seek); + } + } + + protected render(): TemplateResult | void { + const heading = this._controller.getHeading(); + const details = this._controller.getDetails(); + + return html`
+ ${heading + ? html`
+ ${heading} +
` + : ``} + ${details + ? details.map( + (detail) => + html`
+ ${detail.icon + ? html` ` + : ''} + ${detail.title} +
`, + ) + : ''} +
`; + } + + static get styles(): CSSResult { + return unsafeCSS(thumbnailDetailsStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-thumbnail-details': AdvancedCameraCardThumbnailDetails; + } +} diff --git a/src/components/thumbnail/feature/feature.ts b/src/components/thumbnail/feature/feature.ts new file mode 100644 index 00000000..a2ca6a6f --- /dev/null +++ b/src/components/thumbnail/feature/feature.ts @@ -0,0 +1,94 @@ +import { + CSSResult, + LitElement, + PropertyValues, + TemplateResult, + html, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { CameraManager } from '../../../camera-manager/manager'; +import { ThumbnailFeatureController } from '../../../components-lib/thumbnail/feature/controller'; +import { HomeAssistant } from '../../../ha/types'; +import thumbnailFeatureStyle from '../../../scss/thumbnail-feature.scss'; +import { ViewItem } from '../../../view/item'; +import '../../icon.js'; +import './thumbnail.js'; + +@customElement('advanced-camera-card-thumbnail-feature') +export class AdvancedCameraCardThumbnailFeature extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public item?: ViewItem; + + @property({ attribute: false }) + public hasDetails?: boolean; + + private _controller = new ThumbnailFeatureController(); + + protected willUpdate(changedProperties: PropertyValues): void { + if ( + ['item', 'hasDetails', 'cameraManager'].some((prop) => changedProperties.has(prop)) + ) { + this._controller.calculate(this.cameraManager, this.item, this.hasDetails); + } + } + + protected render(): TemplateResult | void { + const title = this._controller.getTitle(); + const subtitles = this._controller.getSubtitles(); + const iconClasses = classMap({ + background: title || subtitles.length, + }); + + const thumbnailClass = this._controller.getThumbnailClass(); + const thumbnailClasses = classMap({ + ...(thumbnailClass && { [thumbnailClass]: true }), + }); + + return html` + ${this._controller.getThumbnail() + ? html` ` + : this._controller.getIcon() + ? html`` + : ''} + ${title || subtitles.length + ? html` + ${title ? html`
${title}
` : ''} + ${subtitles.length + ? html`
+ ${subtitles.map( + (subtitle) => html`
${subtitle}
`, + )} +
` + : ''} + ` + : html``} + `; + } + + static get styles(): CSSResult { + return unsafeCSS(thumbnailFeatureStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-thumbnail-feature': AdvancedCameraCardThumbnailFeature; + } +} diff --git a/src/components/thumbnail/feature/thumbnail.ts b/src/components/thumbnail/feature/thumbnail.ts new file mode 100644 index 00000000..f0d07e43 --- /dev/null +++ b/src/components/thumbnail/feature/thumbnail.ts @@ -0,0 +1,105 @@ +import { Task, TaskStatus } from '@lit-labs/task'; +import { + CSSResult, + LitElement, + PropertyValues, + TemplateResult, + html, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { HomeAssistant } from '../../../ha/types'; +import { localize } from '../../../localize/localize'; +import thumbnailFeatureThumbnailStyle from '../../../scss/thumbnail-feature-thumbnail.scss'; +import { renderTask } from '../../../utils/task'; +import { + FetchThumbnailTaskArgs, + createFetchThumbnailTask, +} from '../../../utils/thumbnail'; + +@customElement('advanced-camera-card-thumbnail-feature-thumbnail') +export class AdvancedCameraCardThumbnailFeatureThumbnail extends LitElement { + @property({ attribute: false }) + public thumbnail?: string; + + @property({ attribute: false }) + public hass?: HomeAssistant; + + protected _embedThumbnailTask?: Task; + + // Only load thumbnails on view in case there is a very large number of them. + protected _intersectionObserver = new IntersectionObserver( + this._intersectionHandler.bind(this), + ); + + connectedCallback(): void { + super.connectedCallback(); + this._intersectionObserver.observe(this); + } + + disconnectedCallback(): void { + this._intersectionObserver.disconnect(); + super.disconnectedCallback(); + } + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('thumbnail')) { + this._embedThumbnailTask = createFetchThumbnailTask( + this, + () => this.hass, + () => this.thumbnail, + false, + ); + // Reset the observer so the initial intersection handler call will set + // the visibility correctly. + this._intersectionObserver.unobserve(this); + this._intersectionObserver.observe(this); + } + } + + protected _intersectionHandler(entries: IntersectionObserverEntry[]): void { + if ( + this._embedThumbnailTask?.status === TaskStatus.INITIAL && + entries.some((entry) => entry.isIntersecting) + ) { + this._embedThumbnailTask?.run(); + } + } + + protected render(): TemplateResult | void { + const imageOff = html` `; + + if (!this._embedThumbnailTask) { + return imageOff; + } + + return html`${this.thumbnail + ? renderTask( + this._embedThumbnailTask, + (embeddedThumbnail: string | null) => + embeddedThumbnail ? html`` : html``, + { + inProgressFunc: () => + html` `, + errorFunc: () => imageOff, + }, + ) + : imageOff} `; + } + + static get styles(): CSSResult { + return unsafeCSS(thumbnailFeatureThumbnailStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-thumbnail-feature-thumbnail': AdvancedCameraCardThumbnailFeatureThumbnail; + } +} diff --git a/src/components/thumbnail/thumbnail.ts b/src/components/thumbnail/thumbnail.ts new file mode 100644 index 00000000..975c6add --- /dev/null +++ b/src/components/thumbnail/thumbnail.ts @@ -0,0 +1,188 @@ +import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { CameraManager } from '../../camera-manager/manager.js'; +import { FoldersManager } from '../../card-controller/folders/manager.js'; +import { ViewItemManager } from '../../card-controller/view/item-manager.js'; +import { RemoveContextViewModifier } from '../../card-controller/view/modifiers/remove-context.js'; +import { ViewManagerEpoch } from '../../card-controller/view/types.js'; +import { dispatchAdvancedCameraCardErrorEvent } from '../../components-lib/message/dispatch.js'; +import { HomeAssistant } from '../../ha/types.js'; +import { localize } from '../../localize/localize.js'; +import thumbnailStyle from '../../scss/thumbnail.scss'; +import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; +import { errorToConsole } from '../../utils/basic.js'; +import { ViewItemClassifier } from '../../view/item-classifier.js'; +import { ViewItem } from '../../view/item.js'; +import './details.js'; +import './feature/feature.js'; +import './feature/thumbnail.js'; + +@customElement('advanced-camera-card-thumbnail') +export class AdvancedCameraCardThumbnail extends LitElement { + // Performance: During timeline scrubbing, hass may be updated continuously. + // As it is not needed for the thumbnail rendering itself, it does not trigger + // a re-render. The HomeAssistant object may be required for thumbnail signing + // (after initial signing the thumbnail is stored in a data URL, so the + // signing will not expire). + public hass?: HomeAssistant; + + // Performance: During timeline scrubbing, the view will be updated + // continuously. As it is not needed for the thumbnail rendering itself, it + // does not trigger a re-render. + public viewManagerEpoch?: ViewManagerEpoch; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public viewItemManager?: ViewItemManager; + + @property({ attribute: false }) + public folderManager?: FoldersManager; + + @property({ attribute: false }) + public item?: ViewItem; + + @property({ attribute: true, type: Boolean }) + public details = false; + + @property({ attribute: true, type: Boolean }) + public show_favorite_control = false; + + @property({ attribute: true, type: Boolean }) + public show_timeline_control = false; + + @property({ attribute: true, type: Boolean }) + public show_download_control = false; + + @property({ attribute: false }) + public seek?: Date; + + /** + * Render the element. + * @returns A template to display to the user. + */ + protected render(): TemplateResult | void { + if (!this.item) { + return; + } + + const starClasses = { + star: true, + starred: ViewItemClassifier.isMedia(this.item) && !!this.item?.isFavorite(), + }; + + const shouldShowTimelineControl = + this.show_timeline_control && + ((ViewItemClassifier.isEvent(this.item) && this.item.getStartTime()) || + (ViewItemClassifier.isRecording(this.item) && + this.item.getStartTime() && + this.item.getEndTime())); + + const mediaCapabilities = this.viewItemManager?.getCapabilities(this.item) ?? null; + + const shouldShowFavoriteControl = + this.show_favorite_control && + this.item && + this.hass && + mediaCapabilities?.canFavorite; + + const shouldShowDownloadControl = + this.show_download_control && + this.hass && + this.item.getID() && + mediaCapabilities?.canDownload; + + return html` + + + ${shouldShowFavoriteControl + ? html` { + stopEventFromActivatingCardWideActions(ev); + if (this.hass && this.item) { + try { + await this.viewItemManager?.favorite( + this.item, + !this.item.isFavorite(), + ); + } catch (e) { + errorToConsole(e as Error); + return; + } + this.requestUpdate(); + } + }} + />` + : ``} + ${this.details + ? html`` + : ''} + ${shouldShowTimelineControl + ? html` { + stopEventFromActivatingCardWideActions(ev); + if (!this.viewManagerEpoch || !this.item) { + return; + } + this.viewManagerEpoch.manager.setViewByParameters({ + params: { + view: 'timeline', + queryResults: this.viewManagerEpoch?.manager + .getView() + ?.queryResults?.clone() + .selectResultIfFound((media) => media === this.item), + }, + modifiers: [new RemoveContextViewModifier(['timeline'])], + }); + }} + >` + : ''} + ${shouldShowDownloadControl + ? html` { + stopEventFromActivatingCardWideActions(ev); + if (this.hass && this.item) { + try { + this.viewItemManager?.download(this.item); + } catch (error: unknown) { + dispatchAdvancedCameraCardErrorEvent(this, error); + } + } + }} + >` + : ``} + `; + } + + static get styles(): CSSResult { + return unsafeCSS(thumbnailStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-thumbnail': AdvancedCameraCardThumbnail; + } +} diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 196b4854..6303de73 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -9,8 +9,7 @@ import { } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js'; -import isEqual from 'lodash-es/isEqual'; -import throttle from 'lodash-es/throttle'; +import { isEqual, throttle } from 'lodash-es'; import { ViewContext } from 'view'; import { DataSet } from 'vis-data/esnext'; import type { @@ -31,6 +30,7 @@ import { CameraManager } from '../camera-manager/manager'; import { rangesOverlap } from '../camera-manager/range'; import { MediaQuery } from '../camera-manager/types'; import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly'; +import { ViewItemManager } from '../card-controller/view/item-manager'; import { MergeContextViewModifier } from '../card-controller/view/modifiers/merge-context'; import { ViewManagerEpoch } from '../card-controller/view/types'; import { @@ -56,25 +56,18 @@ import { isTruthy, setOrRemoveAttribute, } from '../utils/basic'; -import { findBestMediaIndex } from '../utils/find-best-media-index'; +import { findBestMediaTimeIndex } from '../utils/find-best-media-time-index'; import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event'; -import { ViewMedia } from '../view/media'; -import { ViewMediaClassifier } from '../view/media-classifier'; -import { - EventMediaQueries, - MediaQueries, - RecordingMediaQueries, -} from '../view/media-queries'; -import { - MediaQueriesClassifier, - MediaQueriesType, -} from '../view/media-queries-classifier'; -import { MediaQueriesResults } from '../view/media-queries-results'; +import { ViewMedia } from '../view/item'; +import { ViewItemClassifier } from '../view/item-classifier'; +import { EventMediaQuery, MediaQueries, RecordingMediaQuery } from '../view/query'; +import { QueryClassifier, QueryType } from '../view/query-classifier'; +import { QueryResults } from '../view/query-results'; import { mergeViewContext } from '../view/view'; import './date-picker.js'; import { AdvancedCameraCardDatePicker, DatePickerEvent } from './date-picker.js'; import './icon'; -import './thumbnail.js'; +import './thumbnail/thumbnail.js'; interface AdvancedCameraCardGroupData { id: string; @@ -112,6 +105,7 @@ interface ThumbnailDataRequest { cameraConfig?: CameraConfig; media?: ViewMedia; viewManagerEpoch?: ViewManagerEpoch; + viewItemManager?: ViewItemManager; } class ThumbnailDataRequestEvent extends CustomEvent {} @@ -119,7 +113,7 @@ class ThumbnailDataRequestEvent extends CustomEvent {} const TIMELINE_TARGET_BAR_ID = 'target_bar'; /** - * A simgple thumbnail wrapper class for use in the timeline where Lit data + * A simple thumbnail wrapper class for use in the timeline where Lit data * bindings are not available. */ @customElement('advanced-camera-card-timeline-thumbnail') @@ -166,6 +160,7 @@ export class AdvancedCameraCardTimelineThumbnail extends LitElement { !dataRequest.hass || !dataRequest.cameraManager || !dataRequest.cameraConfig || + !dataRequest.viewItemManager || !dataRequest.media || !dataRequest.viewManagerEpoch ) { @@ -175,7 +170,8 @@ export class AdvancedCameraCardTimelineThumbnail extends LitElement { return html` @@ -210,6 +206,9 @@ export class AdvancedCameraCardTimelineCore extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public viewItemManager?: ViewItemManager; + @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @@ -275,6 +274,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement { request.detail.hass = this.hass; request.detail.cameraConfig = cameraConfig; request.detail.cameraManager = this.cameraManager; + request.detail.viewItemManager = this.viewItemManager; request.detail.media = media; request.detail.viewManagerEpoch = this.viewManagerEpoch; } @@ -477,13 +477,13 @@ export class AdvancedCameraCardTimelineCore extends LitElement { } const canSeek = this._shouldSupportSeeking(); - let newResults: MediaQueriesResults | null = null; + let newResults: QueryResults | null = null; if (panMode === 'seek') { newResults = results .clone() .selectBestResult( - (mediaArray) => findBestMediaIndex(mediaArray, targetTime, view?.camera), + (mediaArray) => findBestMediaTimeIndex(mediaArray, targetTime, view?.camera), { allCameras: true, main: true, @@ -492,9 +492,12 @@ export class AdvancedCameraCardTimelineCore extends LitElement { } else if (panMode === 'seek-in-camera') { newResults = results .clone() - .selectBestResult((mediaArray) => findBestMediaIndex(mediaArray, targetTime), { - cameraID: view.camera, - }) + .selectBestResult( + (mediaArray) => findBestMediaTimeIndex(mediaArray, targetTime), + { + cameraID: view.camera, + }, + ) .promoteCameraSelectionToMainSelection(view.camera); } else if (panMode === 'seek-in-media') { newResults = results; @@ -506,7 +509,10 @@ export class AdvancedCameraCardTimelineCore extends LitElement { : 'media' : view.view; - const selectedCamera = newResults?.getSelectedResult()?.getCameraID(); + const selectedItem = newResults?.getSelectedResult(); + const selectedCamera = ViewItemClassifier.isMedia(selectedItem) + ? selectedItem.getCameraID() + : null; this.viewManagerEpoch?.manager.setViewByParameters({ params: { @@ -675,7 +681,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement { await this._timelineSource?.refresh(this._getPrefetchWindow(properties)); - const queryType = MediaQueriesClassifier.getQueriesType(view.query); + const queryType = QueryClassifier.getQueryType(view.query); if (!queryType) { return; } @@ -702,7 +708,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement { } protected _createMediaQueries( - type: MediaQueriesType, + type: QueryType, options?: { window?: TimelineWindow; }, @@ -717,11 +723,11 @@ export class AdvancedCameraCardTimelineCore extends LitElement { if (type === 'event') { const queries = this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow); - return queries ? new EventMediaQueries(queries) : null; + return queries ? new EventMediaQuery(queries) : null; } else if (type === 'recording') { const queries = this._timelineSource.getTimelineRecordingQueries(cacheFriendlyWindow); - return queries ? new RecordingMediaQueries(queries) : null; + return queries ? new RecordingMediaQuery(queries) : null; } return null; } @@ -877,8 +883,8 @@ export class AdvancedCameraCardTimelineCore extends LitElement { !selectedIDs.includes(second.id) && !!firstMedia && !!secondMedia && - ViewMediaClassifier.isEvent(firstMedia) && - ViewMediaClassifier.isEvent(secondMedia) && + ViewItemClassifier.isEvent(firstMedia) && + ViewItemClassifier.isEvent(secondMedia) && firstMedia.isGroupableWith(secondMedia) ); }, @@ -938,7 +944,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement { ...(view.isGrid() && { allCameras: true }), }) ?? [] ) - .filter((media) => ViewMediaClassifier.isEvent(media)) + .filter((media) => ViewItemClassifier.isEvent(media)) .map((media) => media.getID()) .filter(isTruthy); } @@ -961,10 +967,11 @@ export class AdvancedCameraCardTimelineCore extends LitElement { // perfectly center on the media. let desiredWindow = timelineWindow; - const media = view.queryResults?.getSelectedResult(); + const item = view.queryResults?.getSelectedResult(); + const media = item && ViewItemClassifier.isMedia(item) ? item : null; const mediaStartTime = media?.getStartTime() ?? null; const mediaEndTime = media?.getEndTime() ?? null; - const mediaIsEvent = media ? ViewMediaClassifier.isEvent(media) : false; + const mediaIsEvent = media ? ViewItemClassifier.isEvent(media) : false; const mediaWindow: TimelineWindow | null = media && mediaStartTime @@ -1044,7 +1051,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement { // Also don't generate thumbnails in mini-timelines (they will already have // been generated). - const queryType = MediaQueriesClassifier.getQueriesType(view.query); + const queryType = QueryClassifier.getQueryType(view.query); if (!queryType) { return; } @@ -1080,15 +1087,19 @@ export class AdvancedCameraCardTimelineCore extends LitElement { protected _alreadyHasAcceptableMediaQuery(freshMediaQuery: MediaQueries): boolean { const view = this.viewManagerEpoch?.manager.getView(); + const query = view?.query; - const currentQueries = view?.query?.getQueries(); + if (!this.cameraManager || !query || !QueryClassifier.isMediaQuery(query)) { + return false; + } + + const currentQueries = query?.getQuery(); const currentResultTimestamp = view?.queryResults?.getResultsTimestamp(); return ( - !!this.cameraManager && !!currentQueries && !!currentResultTimestamp && - !!view?.query?.isSupersetOf(freshMediaQuery) && + !!query?.isSupersetOf(freshMediaQuery) && this.cameraManager.areMediaQueriesResultsFresh( currentQueries, currentResultTimestamp, diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 8b691774..b821147d 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,6 +1,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { CameraManager } from '../camera-manager/manager'; +import { ViewItemManager } from '../card-controller/view/item-manager'; import { ViewManagerEpoch } from '../card-controller/view/types'; import { TimelineConfig } from '../config/schema/timeline'; import { CardWideConfig } from '../config/schema/types'; @@ -23,6 +24,9 @@ export class AdvancedCameraCardTimeline extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public viewItemManager?: ViewItemManager; + @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @@ -38,6 +42,7 @@ export class AdvancedCameraCardTimeline extends LitElement { .timelineConfig=${this.timelineConfig} .thumbnailConfig=${this.timelineConfig.controls.thumbnails} .cameraManager=${this.cameraManager} + .viewItemManager=${this.viewItemManager} .cameraIDs=${this.cameraManager?.getStore().getCameraIDsWithCapability({ anyCapabilities: ['clips', 'snapshots', 'recordings'], })} diff --git a/src/components/viewer/carousel.ts b/src/components/viewer/carousel.ts index b3cea5ef..422b3594 100644 --- a/src/components/viewer/carousel.ts +++ b/src/components/viewer/carousel.ts @@ -16,6 +16,7 @@ import { MediaActionsController } from '../../components-lib/media-actions-contr import { TransitionEffect } from '../../config/schema/common/transition-effect.js'; import { CardWideConfig, configDefaults } from '../../config/schema/types.js'; import { ViewerConfig } from '../../config/schema/viewer.js'; +import { ResolvedMediaCache } from '../../ha/resolved-media.js'; import { HomeAssistant } from '../../ha/types.js'; import { localize } from '../../localize/localize.js'; import '../../patches/ha-hls-player.js'; @@ -26,9 +27,9 @@ import { contentsChanged, setOrRemoveAttribute } from '../../utils/basic.js'; import { CarouselSelected } from '../../utils/embla/carousel-controller.js'; import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js'; import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js'; -import { ResolvedMediaCache } from '../../utils/ha/resolved-media.js'; import { getTextDirection } from '../../utils/text-direction.js'; -import { ViewMedia } from '../../view/media.js'; +import { ViewItemClassifier } from '../../view/item-classifier.js'; +import { ViewMedia } from '../../view/item.js'; import '../carousel'; import type { EmblaCarouselPlugins } from '../carousel.js'; import { renderMessage } from '../message.js'; @@ -80,33 +81,13 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { public showControls = true; @state() - protected _selected = 0; + protected _selected: number | null = null; protected _media: ViewMedia[] | null = null; protected _mediaActionsController = new MediaActionsController(); protected _loadedMediaPlayerController: MediaPlayerController | null = null; protected _refCarousel: Ref = createRef(); - updated(changedProperties: PropertyValues): void { - super.updated(changedProperties); - - if (changedProperties.has('viewManagerEpoch')) { - // Seek into the video if the seek time has changed (this is also called - // on media load, since the media may or may not have been loaded at - // this point). - if ( - this.viewManagerEpoch?.manager.getView()?.context?.mediaViewer !== - this.viewManagerEpoch?.oldView?.context?.mediaViewer - ) { - this._seekHandler(); - } - } - - if (this._refCarousel.value) { - this._mediaActionsController.setRoot(this._refCarousel.value); - } - } - public connectedCallback(): void { super.connectedCallback(); @@ -145,7 +126,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { */ protected _getMediaNeighbors(): MediaNeighbors | null { const mediaCount = this._media?.length ?? 0; - if (!this._media) { + if (!this._media || this._selected === null) { return null; } @@ -180,16 +161,20 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { // index). return; } - const newResults = view?.queryResults ?.clone() - .selectIndex(index, this.viewFilterCameraID); + .selectResultIfFound((item) => item === this._media?.[index], { + main: true, + cameraID: this.viewFilterCameraID, + }); if (!newResults) { return; } - const cameraID = newResults - .getSelectedResult(this.viewFilterCameraID) - ?.getCameraID(); + + const selectedItem = newResults.getSelectedResult(this.viewFilterCameraID); + const cameraID = ViewItemClassifier.isMedia(selectedItem) + ? selectedItem.getCameraID() + : null; this.viewManagerEpoch?.manager.setViewByParameters({ params: { @@ -243,33 +228,43 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { } if (changedProps.has('viewManagerEpoch')) { - const view = this.viewManagerEpoch?.manager.getView(); - const newMedia = view?.queryResults?.getResults(this.viewFilterCameraID) ?? null; - const newSelected = - view?.queryResults?.getSelectedIndex(this.viewFilterCameraID) ?? 0; - const newSeek = view?.context?.mediaViewer?.seek; + const newView = this.viewManagerEpoch?.manager.getView(); - if (newMedia !== this._media || newSelected !== this._selected || !newSeek) { + if (!newView?.context?.mediaViewer?.seek) { setOrRemoveAttribute(this, false, 'unseekable'); - this._media = newMedia; - this._selected = newSelected; } - if (!newMedia?.length) { - // No media will be rendered. - this._mediaActionsController.unsetTarget(); - } else { - if (this.viewFilterCameraID) { - this._mediaActionsController.setTarget( - newSelected, - // Camera in this carousel is only selected if the camera from the - // view matches the filtered camera. - view?.camera === this.viewFilterCameraID, - ); - } else { - // Carousel is not filtered, so the targeted camera is always selected. - this._mediaActionsController.setTarget(newSelected, true); - } + const oldView = this.viewManagerEpoch?.oldView; + const oldItems = + oldView?.queryResults?.getResults(this.viewFilterCameraID) ?? null; + const newItems = + newView?.queryResults?.getResults(this.viewFilterCameraID) ?? null; + let resetMedia = false; + if (!this._media || oldItems !== newItems) { + this._media = + newItems?.filter((item) => ViewItemClassifier.isMedia(item)) ?? null; + resetMedia = true; + } + + const oldSelectedItem = oldView?.queryResults?.getSelectedResult( + this.viewFilterCameraID, + ); + const newSelectedItem = newView?.queryResults?.getSelectedResult( + this.viewFilterCameraID, + ); + + // _selected is an index, it needs to be updated if either the selected + // item or the media changes. + if (oldSelectedItem !== newSelectedItem || resetMedia) { + const newSelected = + this._media?.findIndex((item) => item === newSelectedItem) ?? null; + + // If there's no selected item, just choose the last (most recent one) to + // avoid rendering a blank. This could happen if the selected item was a + // folder. + this._selected = + newSelected ?? + (this._media && this._media.length ? this._media.length - 1 : null); } } } @@ -327,12 +322,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { }); } - // If there's no selected media, just choose the last (most recent one) to - // avoid rendering a blank. This situation should not occur in practice, as - // this view should not be called without a selected media. - const selectedMedia = this._media[this._selected] ?? this._media[mediaCount - 1]; - - if (!this.hass || !this.cameraManager || !selectedMedia) { + if (!this.hass || !this.cameraManager || this._selected === null) { return; } @@ -379,13 +369,61 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { `; } + updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + + const rootChanged = this._refCarousel.value + ? this._mediaActionsController.setRoot(this._refCarousel.value) + : false; + + // If the view has changed, or if the media actions controller has just been + // initialized, then call the necessary media action. + // See: https://github.com/dermotduffy/advanced-camera-card/issues/1626 + if (rootChanged || changedProperties.has('viewManagerEpoch')) { + this._setMediaTarget(); + } + + if (changedProperties.has('viewManagerEpoch')) { + // Seek into the video if the seek time has changed (this is also called + // on media load, since the media may or may not have been loaded at + // this point). + if ( + this.viewManagerEpoch?.manager.getView()?.context?.mediaViewer !== + this.viewManagerEpoch?.oldView?.context?.mediaViewer + ) { + this._seekHandler(); + } + } + } + + protected _setMediaTarget(): void { + if (!this._media?.length || this._selected === null) { + this._mediaActionsController.unsetTarget(); + } else { + this._mediaActionsController.setTarget( + this._selected, + // Camera in this carousel is only selected if the camera from the view + // matches the filtered camera. + this.viewFilterCameraID + ? this.viewManagerEpoch?.manager.getView()?.camera === this.viewFilterCameraID + : true, + ); + } + } + /** * Fire a media show event when a slide is selected. */ protected async _seekHandler(): Promise { const view = this.viewManagerEpoch?.manager.getView(); const seek = view?.context?.mediaViewer?.seek; - if (!this.hass || !seek || !this._media || !this._loadedMediaPlayerController) { + if ( + !this.hass || + !seek || + !this._media || + !this._loadedMediaPlayerController || + this._selected === null + ) { return; } const selectedMedia = this._media[this._selected]; diff --git a/src/components/viewer/grid.ts b/src/components/viewer/grid.ts index e36922e0..f7d33f5b 100644 --- a/src/components/viewer/grid.ts +++ b/src/components/viewer/grid.ts @@ -14,9 +14,9 @@ import { MediaGridSelected } from '../../components-lib/media-grid-controller.js import { CardWideConfig } from '../../config/schema/types.js'; import { ViewerConfig } from '../../config/schema/viewer.js'; import { HomeAssistant } from '../../ha/types.js'; +import { ResolvedMediaCache } from '../../ha/resolved-media.js'; import '../../patches/ha-hls-player.js'; import basicBlockStyle from '../../scss/basic-block.scss'; -import { ResolvedMediaCache } from '../../utils/ha/resolved-media.js'; import './carousel'; @customElement('advanced-camera-card-viewer-grid') diff --git a/src/components/viewer/index.ts b/src/components/viewer/index.ts index fbb56140..40be8371 100644 --- a/src/components/viewer/index.ts +++ b/src/components/viewer/index.ts @@ -1,14 +1,22 @@ -import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { CameraManager } from '../../camera-manager/manager.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { CardWideConfig } from '../../config/schema/types.js'; import { ViewerConfig } from '../../config/schema/viewer.js'; +import { ResolvedMediaCache } from '../../ha/resolved-media.js'; import { HomeAssistant } from '../../ha/types.js'; import { localize } from '../../localize/localize.js'; import '../../patches/ha-hls-player.js'; import viewerStyle from '../../scss/viewer.scss'; -import { ResolvedMediaCache } from '../../utils/ha/resolved-media.js'; +import { ViewItemClassifier } from '../../view/item-classifier.js'; import { renderMessage } from '../message.js'; import './grid'; @@ -42,6 +50,18 @@ export class AdvancedCameraCardViewer extends LitElement { @property({ attribute: false }) public cardWideConfig?: CardWideConfig; + @property({ attribute: 'empty', reflect: true, type: Boolean }) + public isEmpty = false; + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has('viewManagerEpoch')) { + const view = this.viewManagerEpoch?.manager.getView(); + this.isEmpty = !view?.queryResults + ?.getResults() + ?.filter((result) => ViewItemClassifier.isMedia(result)).length; + } + } + protected render(): TemplateResult | void { if ( !this.hass || @@ -53,7 +73,7 @@ export class AdvancedCameraCardViewer extends LitElement { return; } - if (!this.viewManagerEpoch.manager.getView()?.queryResults?.hasResults()) { + if (this.isEmpty) { // Directly render an error message (instead of dispatching it upwards) // to preserve the mini-timeline if the user pans into an area with no // media. diff --git a/src/components/viewer/provider.ts b/src/components/viewer/provider.ts index 61aa4ae8..003d5c1e 100644 --- a/src/components/viewer/provider.ts +++ b/src/components/viewer/provider.ts @@ -16,31 +16,24 @@ import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js'; import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js'; import { CardWideConfig } from '../../config/schema/types.js'; import { ViewerConfig } from '../../config/schema/viewer.js'; -import { HomeAssistant } from '../../ha/types.js'; -import '../../patches/ha-hls-player.js'; -import viewerProviderStyle from '../../scss/viewer-provider.scss'; -import { - MediaPlayer, - MediaPlayerController, - MediaPlayerElement, - ResolvedMedia, -} from '../../types.js'; -import { aspectRatioToString, errorToConsole } from '../../utils/basic.js'; -import { - canonicalizeHAURL, - homeAssistantSignPath, - isHARelativeURL, -} from '../../utils/ha/index.js'; -import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media.js'; +import { canonicalizeHAURL } from '../../ha/canonical-url.js'; +import { isHARelativeURL } from '../../ha/is-ha-relative-url.js'; +import { ResolvedMediaCache, resolveMedia } from '../../ha/resolved-media.js'; +import { homeAssistantSignPath } from '../../ha/sign-path.js'; +import { HomeAssistant, ResolvedMedia } from '../../ha/types.js'; import { addDynamicProxyURL, getWebProxiedURL, shouldUseWebProxy, -} from '../../utils/ha/web-proxy.js'; +} from '../../ha/web-proxy.js'; +import '../../patches/ha-hls-player.js'; +import viewerProviderStyle from '../../scss/viewer-provider.scss'; +import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js'; +import { aspectRatioToString, errorToConsole } from '../../utils/basic.js'; import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; -import { ViewMediaClassifier } from '../../view/media-classifier.js'; -import { MediaQueriesClassifier } from '../../view/media-queries-classifier.js'; -import { VideoContentType, ViewMedia } from '../../view/media.js'; +import { ViewItemClassifier } from '../../view/item-classifier.js'; +import { VideoContentType, ViewMedia } from '../../view/item.js'; +import { QueryClassifier } from '../../view/query-classifier.js'; import '../image-player.js'; import { renderProgressIndicator } from '../progress-indicator.js'; import '../video-player.js'; @@ -93,8 +86,8 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi !this.media || // If this specific media item has no clip, then do nothing (even if all // the other media items do). - !ViewMediaClassifier.isEvent(this.media) || - !MediaQueriesClassifier.areEventQueries(view.query) + !ViewItemClassifier.isEvent(this.media) || + !QueryClassifier.isEventQuery(view.query) ) { return; } @@ -103,7 +96,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi const clipQuery = view.query.clone(); clipQuery.convertToClipsQueries(); - const queries = clipQuery.getQueries(); + const queries = clipQuery.getQuery(); if (!queries) { return; } @@ -154,7 +147,8 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi return; } - const camera = this.cameraManager?.getStore().getCamera(this.media.getCameraID()); + const cameraID = this.media.getCameraID(); + const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null; const proxyConfig = camera?.getProxyConfig(); if (proxyConfig && shouldUseWebProxy(this.hass, proxyConfig, 'media')) { @@ -224,7 +218,9 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi } const cameraID = this.media.getCameraID(); const mediaID = this.media.getID() ?? undefined; - const cameraConfig = this.cameraManager?.getStore().getCameraConfig(cameraID); + const cameraConfig = cameraID + ? this.cameraManager?.getStore().getCameraConfig(cameraID) ?? null + : null; const view = this.viewManagerEpoch?.manager.getView(); return this.viewerConfig?.zoomable @@ -269,7 +265,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi // Note: crossorigin="anonymous" is required on