diff --git a/README.md b/README.md index 16682aa8..d1769e87 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,13 @@ A full-featured Frigate Lovelace card: -* Live viewing. +* Live viewing of multiple cameras. * Clips and snapshot browsing via mini-gallery. * Automatic updating to continually show latest clip / snapshot. * Support for filtering events by zone and label. * Arbitrary entity access via menu (e.g. motion sensor access). * Fullscreen mode. -* Carousel/Swipeable media & thumbnails. +* Carousel/Swipeable media, thumbnails and cameras. * Direct media downloads. * Lovelace visual editing support. * Full [Picture Elements](https://www.home-assistant.io/lovelace/picture-elements/) support. @@ -73,28 +73,56 @@ lovelace: ## Options -### Basic Options +At least 1 camera must be configured in the `cameras` options, but otherwise all configuration parameters are optional. -| Option | Default | Description | -| - | - | - | -| `camera_entity` | | The optional Frigate camera entity to use in the `frigate` live provider view. Also used to automatically detect the value of `frigate.camera_name`.| +### Camera Options -### Frigate Server Options +The `cameras` block configures a list of cameras the card should support, under: -All configuration is under: - - ```yaml -frigate: +```yaml +cameras: + - [...camera 1...] + - [...camera 2...] ``` +| Option | Default | Overridable | Description | +| - | - | - | - | +| `camera_entity` | | :heavy_multiplication_x: | The Home Assistant camera entity to use with the `frigate` live provider view. Also used to automatically detect the name of the underlying Frigate camera, and the title/icon of the camera. | +| `camera_name` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | The Frigate camera name to use when communicating with the Frigate server, e.g. for viewing clips/snapshots or the JSMPEG live view. To view the birdseye view set this to `birdseye` and use the `frigate-jsmpeg` live provider.| +| `frigate_url` | | :heavy_multiplication_x: | The URL of the frigate server. If set, this value will be (exclusively) used for a `Frigate UI` menu button. | +| `label` | | :heavy_multiplication_x: | A Frigate label / object filter used to filter events (clips & snapshots), e.g. 'person'.| +| `zone` | | :heavy_multiplication_x: | A Frigate zone used to filter events (clips & snapshots), e.g. 'front_door'.| +| `client_id` | `frigate` | :heavy_multiplication_x: | The Frigate client id to use. If this Home Assistant server has multiple Frigate server backends configured, this selects which server should be used. It should be set to the MQTT client id configured for this server, see [Frigate Integration Multiple Instance Support](https://docs.frigate.video/integrations/home-assistant/#multiple-instance-support).| +| `title` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | A friendly name for this camera to use in the card. | +| `icon` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. | +| `webrtc` | | :heavy_multiplication_x: | The WebRTC entity/URL to use for this camera. See below. | +| `id` | `camera_entity`, `webrtc.entity` or `camera_name` if set (in that preference order). | :heavy_multiplication_x: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). | -| Option | Default | Description | -| - | - | - | -| `camera_name` | Autodetected from `camera_entity` if that is specified. | The Frigate camera name to use when communicating with the Frigate server, e.g. for viewing clips/snapshots or the JSMPEG live view. To view the birdseye view set this to `birdseye` and use the `frigate-jsmpeg` live provider.| -| `url` | | The URL of the frigate server. If set, this value will be (exclusively) used for a `Frigate UI` menu button. | -| `label` | | A Frigate label / object filter used to filter events (clips & snapshots), e.g. 'person'.| -| `zone` | | A Frigate zone used to filter events (clips & snapshots), e.g. 'front_door'.| -| `client_id` | `frigate` | The Frigate client id to use. If this Home Assistant server has multiple Frigate server backends configured, this selects which server should be used. It should be set to the MQTT client id configured for this server, see [Frigate Integration Multiple Instance Support](https://docs.frigate.video/integrations/home-assistant/#multiple-instance-support).| +#### Camera WebRTC configuration + +The `webrtc` block configures only the entity/URL for this camera to be used with the WebRTC live provider. This configuration is included as part of a camera entry in the `cameras` array. + +```yaml +cameras: + - webrtc: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `entity` | | :heavy_multiplication_x: | The RTSP entity to pass WebRTC for this camera. Specify this OR `url` (below). | +| `url` | | :heavy_multiplication_x: | The RTSP url to pass to WebRTC. Specify this OR `entity` (above). | + +See [Using WebRTC](#webrtc) below for more details on how to use WebRTC with this card. + + + +#### Camera IDs: Refering to cameras in card configuration + +Each camera configured in the card has a single identifier (`id`). For a given camera, this will be one of the camera {`id`, `camera_entity`, `webrtc.entity` or `camera_name`} parameters for that camera -- in that order of precedence. These ids may be used in conditions or custom actions to refer to a given camera unambiguously. | + +#### Example + +See [the basic cameras configuration example](#basic-cameras-configuration) below. ### View Options @@ -104,13 +132,13 @@ All configuration is under: view: ``` -| Option | Default | Description | -| - | - | - | -| `default` | `live` | The view to show in the card by default. See [views](#views) below.| -| `timeout` | | A numbers of seconds of inactivity after which the card will reset to the default configured view. Inactivity is defined as lack of interaction with the Frigate menu.| -| `actions` | | Actions to use for all views, individual actions may be overriden by view-specific actions. See [actions](#actions) below.| -| `update_force` | `false` | Whether card updates/refreshes should ignore playing media and human interaction. See [card updates](#card-updates) below for behavior and usecases.| -| `update_entities` | | **YAML only**: A list of entity ids that should cause the whole card to re-render. Entities used in picture elements / included in the menu do not need to be explicitly included here to be kept updated. See [card updates](#card-updates) below for behavior and usecases.| +| Option | Default | Overridable | Description | +| - | - | - | - | +| `default` | `live` | :heavy_multiplication_x: | The view to show in the card by default. See [views](#views) below.| +| `timeout` | | :heavy_multiplication_x: | A numbers of seconds of inactivity after which the card will reset to the default configured view. Inactivity is defined as lack of interaction with the Frigate menu.| +| `actions` | | :heavy_multiplication_x: | Actions to use for all views, individual actions may be overriden by view-specific actions. See [actions](#actions) below.| +| `update_force` | `false` | :heavy_multiplication_x: | Whether card updates/refreshes should ignore playing media and human interaction. See [card updates](#card-updates) below for behavior and usecases.| +| `update_entities` | | :heavy_multiplication_x: | **YAML only**: A list of entity ids that should cause the view to reset to the default. See [card updates](#card-updates) below for behavior and usecases.| ### Menu Options @@ -120,12 +148,11 @@ All configuration is under: menu: ``` -| Option | Default | Description | -| - | - | - | -| `mode` | `hidden-top` | The menu mode to show by default. See [menu modes](#menu-modes) below.| -| `button_size` | `40px` | The size of the menu buttons [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| -| `buttons` | | Whether to show or hide built-in buttons. See below. | -| `conditions` | | Condition(s) that must be met in order for the menu to be displayed. These conditions use the same format as the `custom:frigate-card-conditional` card (see [Possible conditions](#frigate-card-conditions) below). If conditions are specified but not met, then the menu is not rendered.| +| Option | Default | Overridable | Description | +| - | - | - | - | +| `mode` | `hidden-top` | :white_check_mark: | The menu mode to show by default. See [menu modes](#menu-modes) below.| +| `button_size` | `40px` | :white_check_mark: | The size of the menu buttons [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| +| `buttons` | | :white_check_mark: | Whether to show or hide built-in buttons. See below. | #### Menu Options: Buttons @@ -136,16 +163,17 @@ menu: buttons: ``` -| Option | Default | Description | -| - | - | - | -| `frigate` | `true` | Whether to show the `Frigate` menu button: brings the user to the default configured view (`view.default`), or collapses/expands the menu if the `menu.mode` is `hidden-*` . | -| `live` | `true` | Whether to show the `live` view menu button: brings the user to the `live` view. See [views](#views) below.| -| `clips` | `true` | Whether to show the `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. See [views](#views) below.| -| `snapshots` | `true` | Whether to show the `snapshots` view menu button: brings the user to the `clips` view on tap and the most-recent `snapshot` view on hold. See [views](#views) below.| -| `image` | `false` | Whether to show the `image` view menu button: brings the user to the static `image` view. See [views](#views) below.| -| `download` | `true` | Whether to show the `download` menu button: allow direct download of the media being displayed.| -| `frigate_ui` | `true` | Whether to show the `frigate_ui` menu button: brings the user to a context-appropriate page on the Frigate UI (e.g. the camera homepage). Will only appear if the `frigate.url` option is set.| -| `fullscreen` | `true` | Whether to show the `fullscreen` menu button: expand the card to consume the fullscreen. | +| Option | Default | Overridable | Description | +| - | - | - | - | +| `frigate` | `true` | :white_check_mark: | Whether to show the `Frigate` menu button: brings the user to the default configured view (`view.default`), or collapses/expands the menu if the `menu.mode` is `hidden-*` . | +| `cameras` | `true` | :white_check_mark: | Whether to show the camera selection submenu. Will only appear if multiple cameras are configured. | +| `live` | `true` | :white_check_mark: | Whether to show the `live` view menu button: brings the user to the `live` view. See [views](#views) below.| +| `clips` | `true` | :white_check_mark: | Whether to show the `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. See [views](#views) below.| +| `snapshots` | `true` | :white_check_mark: | Whether to show the `snapshots` view menu button: brings the user to the `clips` view on tap and the most-recent `snapshot` view on hold. See [views](#views) below.| +| `image` | `false` | :white_check_mark: | Whether to show the `image` view menu button: brings the user to the static `image` view. See [views](#views) below.| +| `download` | `true` | :white_check_mark: | Whether to show the `download` menu button: allow direct download of the media being displayed.| +| `frigate_ui` | `true` | :white_check_mark: | Whether to show the `frigate_ui` menu button: brings the user to a context-appropriate page on the Frigate UI (e.g. the camera homepage). Will only appear if the `frigate.url` option is set.| +| `fullscreen` | `true` | :white_check_mark: | Whether to show the `fullscreen` menu button: expand the card to consume the fullscreen. | ### Live Options @@ -155,14 +183,16 @@ All configuration is under: live: ``` -| Option | Default | Description | -| - | - | - | -| `preload` | `false` | Whether or not to preload the live view. Preloading causes the live view to render in the background so it's instantly available when requested. This consumes additional network/CPU resources continually.| -| `provider` | `frigate` | The means through which the live camera view is displayed. See [Live Provider](#live-provider) below.| -| `actions` | | Actions to use for the `live` view. See [actions](#actions) below.| -| `controls` | | Configuration for the `live` view controls. See below. | -| `jsmpeg` | | Configuration for the `frigate-jsmpeg` live provider. See below.| -| `webrtc` | | Configuration for the `webrtc` live provider. See below.| +| Option | Default | Overridable | Description | +| - | - | - | - | +| `preload` | `false` | :heavy_multiplication_x: | Whether or not to preload the live view. Preloading causes the live view to render in the background regardless of what view is actually shown, so it's instantly available when requested. This consumes additional network/CPU resources continually. | +| `lazy_load` | `true` | :heavy_multiplication_x: | Whether or not to lazily load camera views in the camera carousel. Setting this will `false` will cause all cameras to load simultaneously when the `live` carousel is opened (or cause all cameras to load continually if both `lazy_load` and `preload` are `true`). This will result in a smoother carousel experience at a cost of (potentially) a substantial amount of continually streamed data. | +| `draggable` | `true` | :heavy_multiplication_x: | Whether or not the live carousel can be dragged left or right, via touch/swipe and mouse dragging. | +| `provider` | `frigate` | :white_check_mark: | The means through which the live camera view is displayed. See [Live Provider](#live-provider) below.| +| `actions` | | :white_check_mark: | Actions to use for the `live` view. See [actions](#actions) below.| +| `controls` | | :white_check_mark: | Configuration for the `live` view controls. See below. | +| `jsmpeg` | | :white_check_mark: | Configuration for the `frigate-jsmpeg` live provider. See below.| +| `webrtc` | | :white_check_mark: | Configuration for the `webrtc` live provider. See below.| #### Available Live Providers @@ -172,7 +202,6 @@ live: |`frigate-jsmpeg`|Lower|Low|Builtin|Stream the JSMPEG stream from Frigate (proxied via the Frigate integration). See [note below on the required integration version](#jsmpeg-troubleshooting) for this live provider to function. This is the only live provider that can view the Frigate `birdseye` view.| |`webrtc`|Lowest|High|Separate installation required|Uses [WebRTC](https://github.com/AlexxIT/WebRTC) to stream live feed, requires manual extra setup, see [below](#webrtc).| - #### Live Provider: JSMPEG Configuration All configuration is under: @@ -182,9 +211,11 @@ live: jsmpeg: ``` -| Option | Default | Description | -| - | - | - | -| `options` | | **Advanced users only**: Control the underlying [JSMPEG library options](https://github.com/phoboslab/jsmpeg#usage). Supports setting these JSMPEG options `{audio, video, pauseWhenHidden, disableGl, disableWebAssembly, preserveDrawingBuffer, progressive, throttled, chunkSize, maxAudioLag, videoBufferSize, audioBufferSize}`. This is not necessary for the vast majority of users: only set these flags if you know what you're doing, as you may entirely break video rendering in the card.| +| Option | Default | Overridable | Description | +| - | - | - | - | +| `options` | | :white_check_mark: | **Advanced users only**: Control the underlying [JSMPEG library options](https://github.com/phoboslab/jsmpeg#usage). Supports setting these JSMPEG options `{audio, video, pauseWhenHidden, disableGl, disableWebAssembly, preserveDrawingBuffer, progressive, throttled, chunkSize, maxAudioLag, videoBufferSize, audioBufferSize}`. This is not necessary for the vast majority of users: only set these flags if you know what you're doing, as you may entirely break video rendering in the card.| + + #### Live Provider: WebRTC Configuration @@ -195,11 +226,11 @@ live: webrtc: ``` -| Option | Default | Description | -| - | - | - | -| `entity` | | The RTSP entity to pass WebRTC. Specify this OR `webrtc.url` (above). | -| `url` | | The RTSP url to pass to WebRTC. Specify this OR `webrtc.entity` (below).| -| `*`| | Any other options in the `webrtc:` YAML dictionary are silently passed through to WebRTC. See [WebRTC Configuration](https://github.com/AlexxIT/WebRTC#configuration) for full details this external card provides.| +| Option | Default | Overridable | Description | +| - | - | - | - | +| `*`| | :white_check_mark: | Any options specified in the `webrtc:` YAML dictionary are silently passed through to WebRTC. See [WebRTC Configuration](https://github.com/AlexxIT/WebRTC#configuration) for full details this external card provides. This implies that if `entity` or `url` are specified here they will override the matching named parameters under the per camera configuration. | + +See [Using WebRTC](#webrtc) below for more details on how to use WebRTC with this card. #### Live Controls: Thumbnails @@ -211,11 +242,27 @@ live: thumbnails: ``` -| Option | Default | Description | -| - | - | - | -| `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media or to hide it entirely (`none`).| -| `size` | `100px` | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| -| `media` | `clips` | Whether to show `clips` or `snapshots` in the thumbnail carousel in the `live` view.| +| Option | Default | Overridable | Description | +| - | - | - | - | +| `mode` | `none` | :white_check_mark: | Whether to show the thumbnail carousel `below` the media, `above` the media or to hide it entirely (`none`).| +| `size` | `100px` | :white_check_mark: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| +| `media` | `clips` | :white_check_mark: | Whether to show `clips` or `snapshots` in the thumbnail carousel in the `live` view.| + +#### Live Controls: Next / Previous + +All configuration is under: + +```yaml +live: + controls: + next_previous: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `style` | `chevrons` | :white_check_mark: | When viewing live cameras, what kind of controls to show to move to the previous/next camera. Acceptable values: `chevrons`, `icons`, `none` . | +| `size` | `48px` | :white_check_mark: | The size of the next/previous controls [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| + ### Event Viewer Options @@ -227,14 +274,13 @@ All configuration is under: event_viewer: ``` - -| Option | Default | Description | -| - | - | - | -| `autoplay_clip` | `false` | Whether or not to autoplay clips in the 'clip' [view](#views). Clips manually chosen in the clips gallery will still autoplay.| -| `lazy_load` | `true` | Whether or not to lazily load media in the event viewer carousel. Setting this will false will fetch all media immediately which may make the carousel experience smoother at a cost of (potentially) a substantial number of simultaneous media fetches on load. | -| `draggable` | `true` | Whether or not the event viewer carousel can be dragged left or right, via touch/swipe and mouse dragging. | -| `controls` | | Configuration for the event viewer. See below. | -| `actions` | | Actions to use for all views that use the `event_viewer` (e.g. `clip`, `snapshot`). See [actions](#actions) below.| +| Option | Default | Overridable | Description | +| - | - | - | - | +| `autoplay_clip` | `true` | :heavy_multiplication_x: | Whether or not to autoplay clips.| +| `lazy_load` | `true` | :heavy_multiplication_x: | Whether or not to lazily load media in the event viewer carousel. Setting this will false will fetch all media immediately which may make the carousel experience smoother at a cost of (potentially) a substantial number of simultaneous media fetches on load. | +| `draggable` | `true` | :heavy_multiplication_x: | Whether or not the event viewer carousel can be dragged left or right, via touch/swipe and mouse dragging. | +| `controls` | | :heavy_multiplication_x: | Configuration for the event viewer. See below. | +| `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `event_viewer` (e.g. `clip`, `snapshot`). See [actions](#actions) below.| #### Event Viewer Controls: Next / Previous @@ -246,10 +292,10 @@ event_viewer: next_previous: ``` -| Option | Default | Description | -| - | - | - | -| `style` | `thumbnails` | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . | -| `size` | `48px` | The size of the next/previous controls [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| +| Option | Default | Overridable | Description | +| - | - | - | - | +| `style` | `thumbnails` | :heavy_multiplication_x: | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . | +| `size` | `48px` | :heavy_multiplication_x: | The size of the next/previous controls [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| #### Event Viewer Controls: Thumbnails @@ -261,10 +307,10 @@ event_viewer: thumbnails: ``` -| Option | Default | Description | -| - | - | - | -| `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media or to hide it entirely (`none`).| -| `size` | `100px` | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| +| Option | Default | Overridable | Description | +| - | - | - | - | +| `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media or to hide it entirely (`none`).| +| `size` | `100px` | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| ### Event Gallery Options @@ -276,9 +322,9 @@ All configuration is under: event_gallery: ``` -| Option | Default | Description | -| - | - | - | -| `actions` | | Actions to use for all views that use the `event_gallery` (e.g. `clips`, `snapshots`). See [actions](#actions) below.| +| Option | Default | Overridable | Description | +| - | - | - | - | +| `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `event_gallery` (e.g. `clips`, `snapshots`). See [actions](#actions) below.| ### Image Options @@ -288,10 +334,10 @@ All configuration is under: image: ``` -| Option | Default | Description | -| - | - | - | -| `src` | [embedded image](https://www.flickr.com/photos/dianasch/47543120431) | A static image URL for use with the `image` [view](#views).| -| `actions` | | Actions to use for the `image` view. See [actions](#actions) below.| +| Option | Default | Overridable | Description | +| - | - | - | - | +| `src` | | :heavy_multiplication_x: | [embedded image](https://www.flickr.com/photos/dianasch/47543120431) | A static image URL for use with the `image` [view](#views).| +| `actions` | | :heavy_multiplication_x: | Actions to use for the `image` view. See [actions](#actions) below.| ### Dimension Options @@ -301,10 +347,10 @@ All configuration is under: dimensions: ``` -| Option | Default | Description | -| - | - | - | -| `aspect_ratio_mode` | `dynamic` | The aspect ratio mode to use. Acceptable values: `dynamic`, `static`, `unconstrained`. See [aspect ratios](#aspect-ratios) below.| -| `aspect_ratio` | `16:9` | The aspect ratio to use. Acceptable values: `:` or `/`. See [aspect ratios](#aspect-ratios) below.| +| Option | Default | Overridable | Description | +| - | - | - | - | +| `aspect_ratio_mode` | `dynamic` | :heavy_multiplication_x: | The aspect ratio mode to use. Acceptable values: `dynamic`, `static`, `unconstrained`. See [aspect ratios](#aspect-ratios) below.| +| `aspect_ratio` | `16:9` | :heavy_multiplication_x: | The aspect ratio to use. Acceptable values: `:` or `/`. See [aspect ratios](#aspect-ratios) below.| #### `dimensions.aspect_ratio_mode`: @@ -337,6 +383,34 @@ be used by default. +### Override Options + +All configuration is a list under: + +```yaml +overrides: +``` + +Various parts of this configuration may conditionally (see [Frigate Card +Conditions](#frigate-card-conditions)) be overridden, for example to use custom +WebRTC paramters for a particular camera or to hide the menu in fullscreen mode. + +Not all configuration parameters are overriddable (only those with check marks +in this documentation) -- some because it doesn't make sense for that parameter +to vary, and many because of the extra complexity of supporting overriding given +the lack of compelling usecases ([please request new overridable parameters +here!](https://github.com/dermotduffy/frigate-hass-card/issues/new/choose)). + +Each entry under the top-level `overrides` configuration block should be a list +item, that has both of the following parameters set: + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `conditions` | | :heavy_multiplication_x: | A set of conditions that must evaluate to `true` in order for the overrides to be applied. See [Frigate Card Conditions](#frigate-card-conditions). | +| `overrides` | | :heavy_multiplication_x: |Configuration overrides to be applied. Any configuration parameter described in this documentation as 'Overridable' is supported. | + + + ### Using WebRTC WebRTC support blends the use of the ultra-realtime [WebRTC live @@ -345,40 +419,64 @@ events/snapshots/UI. A perfect combination! Live viewing - **Note**: WebRTC must be installed and configured separately (see [details](https://github.com/AlexxIT/WebRTC)) before it can be used with this card. #### Specifying The WebRTC Camera WebRTC does **not** support use of Frigate-provided camera entities, as it -requires an RTSP stream which Frigate does not provide. There are two ways to -specify the WebRTC source camera: +requires an RTSP stream which Frigate does not currently provide. There are two +ways to specify the WebRTC source camera: * Manual setup of separate RTSP camera entities in Home Assistant ([see example](https://www.home-assistant.io/integrations/generic/#live-stream)). These entities will then be available for selection in the GUI card editor for - the Frigate card under the WebRTC options, or can be manually specified with a - `webrtc.entity` option in the YAML configuration for this card: + the camera, or can be manually specified with a `webrtc.entity` option under + that particular cameras configuration: ```yaml -[rest of Frigate card configuration] -live: - webrtc: - entity: 'camera.front_door_rstp` +cameras: + - webrtc: + entity: 'camera.front_door_rstp` ``` * OR manually entering the WebRTC camera URL parameter in the GUI card editor, or configuring the `url` parameter as part of a manual Frigate card - configuration, like the following example: + configuration, as illustrated in the following example: ```yaml -[rest of Frigate card configuration] -live: - webrtc: - url: 'rtsp://USERNAME:PASSWORD@CAMERA:554/RTSP_PATH' +cameras: + - webrtc: + url: 'rtsp://USERNAME:PASSWORD@CAMERA:554/RTSP_PATH' ``` -See [WebRTC configuration](https://github.com/AlexxIT/WebRTC#configuration) for full configuration options. +Other WebRTC options may be specified under the `live` section, like so: + +```yaml +live: + webrtc: + ui: true +``` + +See [the WebRTC live configuration](#webrtc-live-configuration) above, and the +[external WebRTC configuration +documentation](https://github.com/AlexxIT/WebRTC#configuration) for full +configuration options that can be used here. + + + +## Frigate Card Conditions + +Conditions are used to apply certain configuration depending on runtime evaluations. Conditions may be used in `elements` configuration (as part of a `custom:frigate-card-conditional` element) or the `overrides` configuration (see below for both). + +All variables listed are under a `conditions:` section. + +| Condition | Description | +| ------------- | --------------------------------------------- | +| `view` | A list of [views](#views) in which this condition is satified (e.g. `clips`) | +| `camera` | A list of camera ids in which this condition is satisfied. See [camera IDs](#camera-ids).| +| `fullscreen` | If `true` the condition is satisfied if the card is in fullscreen mode. If `false` the condition is satisfied if the card is **NOT** in fullscreen mode.| + +See the [PTZ example below](#frigate-card-conditional-example) for a real-world example of how these conditions can be used. ## Picture Elements / Menu Customizations @@ -421,9 +519,10 @@ Parameters for the `custom:frigate-card-menu-submenu` element are identical to t | Parameter | Default | Description | | - | - | - | | `title` | | An optional title to display. | -| `icon` | | An optional item icon to display. | +| `icon` | | An optional item icon to display, e.g. `mdi:car` | | `entity` | | An optional Home Assistant entity from which title, icon and style can be automatically computed. | | `state_color` | `true` | Whether or not the title and icon should be stylized based on state. | +| `selected` | `false` | Whether or not to show this item as selected. | | `style` | | Position and style the element using CSS. | | `tap_action`, `double_tap_action` or `hold_action` | | Standard [Home Assistant action configuration](https://www.home-assistant.io/lovelace/actions). | @@ -438,21 +537,8 @@ Parameters for the `custom:frigate-card-conditional` element: | Parameter | Description | | ------------- | --------------------------------------------- | | `type` | Must be `custom:frigate-card-conditional`. | -| `conditions` | A set of conditions that must evaluate to true in order for the elements to be rendered. See below. | `elements` | The elements to render. Can be any supported element, include additional condition or custom elements. | - - - -##### Frigate Card Conditions - -All variables listed are under a `conditions:` section. - -| Condition | Description | -| ------------- | --------------------------------------------- | -| `view` | A list of [views](#views) in which these elements should be rendered. | -| `fullscreen` | If `true` the elements are only rendered if the card is in fullscreen mode. If `false` the elements are only rendered if the card is **NOT** in fullscreen mode.| - -See the [PTZ example below](#frigate-card-conditional-example) for a real-world example. +| `conditions` | A set of conditions that must evaluate to true in order for the elements to be rendered. See [Frigate Card Conditions](#frigate-card-conditions). | ### Special Actions @@ -469,7 +555,7 @@ See the [PTZ example below](#frigate-card-conditional-example) for a real-world |`download`|Download the displayed media.| |`frigate_ui`|Open the Frigate UI at the configured URL.| |`fullscreen`|Toggle fullscreen.| - +|`camera_select`|Select a given camera. Takes a single additional `camera` parameter with the [camera ID](#camera-ids) of the camera to select.| @@ -541,6 +627,13 @@ This card supports several menu configurations. ## Screenshots +### Live Viewing of Multiple Cameras + +Scroll through your live cameras, or choose from a menu. Seamlessly supports +cameras of different dimensions, and custom submenus per camera. + +Gallery + ### Full Viewing Of Events Gallery @@ -557,13 +650,13 @@ This card supports several menu configurations. Viewer with event thumbnails -## Card Editing +### Card Editing This card supports full editing via the Lovelace card editor. Additional arbitrary configuration for WebRTC may be specified in YAML mode. -Live viewing +Live viewing -## Configurable Submenus +### Configurable Submenus This card supports fully configurable submenus. @@ -571,6 +664,42 @@ This card supports fully configurable submenus. ## Examples +### Basic cameras configuration + +
+ Expand: Basic cameras configuration + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.sitting_room + - camera_entity: camera.front_door +``` +
+ + +
+ Expand: Different providers for a single camera + +Cameras can be repeated with different providers (note the required use of `id` +to provide a separate unambiguous way of referring to that camera, since the +`camera_entity` is shared between the two cameras). + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.front_door + live_provider: frigate-jsmpeg + title: Front Door (JSMPEG) + - camera_entity: camera.front_door + live_provider: webrtc + title: Front Door (WebRTC) + webrtc: + entity: camera.front_door_rtsp + id: front-door-webrtc +``` +
+ ### WebRTC
@@ -818,29 +947,6 @@ live: ```
-### Hiding The Menu In Certain Circumstances - -You can add conditions to the menu, which will hide the menu unless met. - -
- Expand: Hiding the menu - -This example hides the menu unless the card is in fullscreen mode, and uses a card-wide action to enable fullscreen mode on `double_tap`: - -```yaml -[...] -view: - actions: - double_tap_action: - action: custom:frigate-card-action - frigate_card_action: fullscreen -menu: - conditions: - fullscreen: true -``` - -
- ### Configuring a submenu @@ -877,27 +983,118 @@ elements: +
+ Expand: Custom submenus per camera + +This example shows submenus conditional on the camera selected. + +```yaml +[...] +elements: + - type: custom:frigate-card-conditional + conditions: + camera: + - camera.front_door + elements: + - type: custom:frigate-card-menu-submenu + icon: mdi:door + items: + - title: Front Door Lights + icon: mdi:lightbulb + entity: light.front_door_lights + tap_action: + action: toggle + - type: custom:frigate-card-conditional + conditions: + camera: + - camera.living_room + elements: + - type: custom:frigate-card-menu-submenu + icon: mdi:sofa + items: + - title: Living Room Lights + icon: mdi:lightbulb + entity: light.living_room_lights + tap_action: + action: toggle + - title: Living Room Lamp + icon: mdi:lightbulb + entity: light.living_room_lamp + tap_action: + action: toggle +``` + +
+ + +### Overriding card behavior + +You can override card configuration when certain [conditions](#frigate-card-conditions) are met. + +
+ Expand: Hiding the menu in fullscreen mode + +This example disables the menu unless the card is in fullscreen mode, and uses a +card-wide action to enable fullscreen mode on `double_tap`: + +```yaml +view: + actions: + double_tap_action: + action: custom:frigate-card-action + frigate_card_action: fullscreen +overrides: + - conditions: + fullscreen: true + overrides: + menu: + mode: none +``` + +
+ +
+ Expand: Enable WebRTC UI for a particular camera + +This example enables WebRTC UI mode for a particular camera. + +```yaml +cameras: + - camera_entity: camera.office +[...] +overrides: + - conditions: + camera: + - camera.office + overrides: + live: + provider: webrtc + webrtc: + ui: true +``` + +
+ -## Card Refreshes / Updates +## Card Refreshes -Automated card refreshes / updates are minimized to avoid disruption to the -user, in particular when media is playing. Three sets of flags govern when the -card will automatically re-render in the absence of human interaction. +Three sets of flags govern when the card will automatically re-render in the +absence of human interaction. The following table describes the behavior these 3 flags have. ### Card Update Truth Table -| `view.timeout` | `view.update_force` | `view.update_entities` & `camera_entity` | Behavior | +| `view.timeout` | `view.update_force` | `view.update_entities` | Behavior | | :-: | :-: | :-: | - | -| Unset or `0` | *(Any value)* | Unset | Card will not automatically re-render. | -| Unset or `0` | `false` | *(Any entity)* | Card will reload **current** view when entity state changes, unless media is playing. | -| Unset or `0` | `true` | *(Any entity)* | Card will reload **current** view when entity state changes. | -| `X` seconds | `false` | Unset | Card will reload **default** view `X` seconds after human interaction stops, unless media is playing. | -| `X` seconds | `false` | *(Any entity)* | Card will reload **default** view `X` seconds after human interaction stops and reload the **current** view when entity state changes -- in both cases unless media is playing. | -| `X` seconds | `true` | Unset | Card will reload **default** view every `X` seconds. | -| `X` seconds | `true` | *(Any entity)* | Card will reload **default** view every `X` seconds and reload the **current** view when entity state changes. | +| Unset or `0` | *(Any value)* | Unset | Card will not automatically refresh. | +| Unset or `0` | `false` | *(Any entity)* | Card will reload default view when entity state changes, unless media is playing. | +| Unset or `0` | `true` | *(Any entity)* | Card will reload default view when entity state changes. | +| `X` seconds | `false` | Unset | Card will reload default view `X` seconds after human interaction stops, unless media is playing. | +| `X` seconds | `false` | *(Any entity)* | Card will reload default view `X` seconds after human interaction stops or when entity state changes -- in both cases unless media is playing. | +| `X` seconds | `true` | Unset | Card will reload default view every `X` seconds. | +| `X` seconds | `true` | *(Any entity)* | Card will reload default view every `X` seconds or when entity state changes. | ### Usecases For Automated Refreshes @@ -910,9 +1107,7 @@ view: ``` * Using `clip` or `snapshot` as the default view (for the most recent clip or snapshot respectively) and having the card automatically refresh (to fetch a - newer clip/snapshot) when an entity state changes. A Frigate `camera_entity` - is generally not sufficient for this since the Home Assistant state for - Frigate camera entities does not change often. Instead, use the Frigate + newer clip/snapshot) when an entity state changes. Use the Frigate binary_sensor for that camera (or any other entity at your discretion) to trigger the update: ```yaml diff --git a/images/camera-carousel.gif b/images/camera-carousel.gif new file mode 100644 index 00000000..3d9f1651 Binary files /dev/null and b/images/camera-carousel.gif differ diff --git a/images/editor.gif b/images/editor.gif new file mode 100644 index 00000000..963a6d2a Binary files /dev/null and b/images/editor.gif differ diff --git a/images/editor.png b/images/editor.png deleted file mode 100644 index 745eb35e..00000000 Binary files a/images/editor.png and /dev/null differ diff --git a/package.json b/package.json index 5a077cf0..8fb94131 100644 --- a/package.json +++ b/package.json @@ -16,15 +16,16 @@ "license": "MIT", "dependencies": { "@cycjimmy/jsmpeg-player": "^5.0.1", + "@lit-labs/task": "^1.0.0", "@material/image-list": "^12.0.0", + "@material/mwc-menu": "^0.25.3", "@material/rtl": "^13.0.0", "custom-card-helpers": "^1.8.0", "dayjs": "^1.10.7", - "dlv": "github:developit/dlv", - "dset": "^3.1.1", "embla-carousel": "^5.0.1", "home-assistant-js-websocket": "^5.11.1", "lit": "^2.0.2", + "lodash-es": "^4.17.21", "quick-lru": "github:sindresorhus/quick-lru", "screenfull": "^5.1.0", "zod": "^3.11.6" @@ -35,6 +36,7 @@ "@babel/plugin-proposal-decorators": "^7.15.8", "@rollup/plugin-image": "^2.1.1", "@rollup/plugin-json": "^4.1.0", + "@types/lodash-es": "^4.17.5", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", "eslint": "^7.32.0", diff --git a/rollup.config.js b/rollup.config.js index f75ade85..58bfaa3c 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -41,7 +41,7 @@ const plugins = [ exclude: 'node_modules/**', }), dev && serve(serveopts), - !dev && terser(), + //!dev && terser(), ]; export default [ diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index ccd8f904..cc281235 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -15,7 +15,7 @@ interface ActionHandler extends HTMLElement { bind(element: Element, options): void; } interface ActionHandlerElement extends HTMLElement { - actionHandler?: boolean; + actionHandlerOptions?: ActionHandlerOptions; } declare global { @@ -25,7 +25,7 @@ declare global { } class ActionHandler extends HTMLElement implements ActionHandler { - public holdTime = 500; + public holdTime = 400; // eslint-disable-next-line @typescript-eslint/no-explicit-any public ripple: any; @@ -78,10 +78,12 @@ class ActionHandler extends HTMLElement implements ActionHandler { } public bind(element: ActionHandlerElement, options): void { - if (element.actionHandler) { + if (element.actionHandlerOptions) { + // Reset the options on an existing actionHandler. + element.actionHandlerOptions = options; return; } - element.actionHandler = true; + element.actionHandlerOptions = options; element.addEventListener('contextmenu', (ev: Event) => { const e = ev || window.event; @@ -97,6 +99,7 @@ class ActionHandler extends HTMLElement implements ActionHandler { }); const start = (ev: Event): void => { + const options = element.actionHandlerOptions; let x; let y; if ((ev as TouchEvent).touches) { @@ -107,7 +110,7 @@ class ActionHandler extends HTMLElement implements ActionHandler { y = (ev as MouseEvent).pageY; } - if (options.hasHold) { + if (options?.hasHold) { this.held = false; this.timer = window.setTimeout(() => { this.startAnimation(x, y); @@ -117,21 +120,22 @@ class ActionHandler extends HTMLElement implements ActionHandler { }; const end = (ev: Event): void => { + const options = element.actionHandlerOptions; if (['touchend', 'touchcancel'].includes(ev.type) // This action handler by default relies on synthetic click events for // touch devices, in order to ensure that embedded cards (e.g. WebRTC) // can use stock click handlers. The exception is for hold events. - && !(options.hasHold && this.held)) { + && !(options?.hasHold && this.held)) { return; } - if (options.hasHold) { + if (options?.hasHold) { clearTimeout(this.timer); this.stopAnimation(); this.timer = undefined; } - if (options.hasHold && this.held) { + if (options?.hasHold && this.held) { fireEvent(element, 'action', { action: 'hold' }); - } else if (options.hasDoubleClick) { + } else if (options?.hasDoubleClick) { if ( (ev.type === 'click' && (ev as MouseEvent).detail < 2) || !this.dblClickTimeout diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index d635ca1f..336dcc1c 100644 --- a/src/browse-media-util.ts +++ b/src/browse-media-util.ts @@ -1,11 +1,22 @@ -import type { BrowseMediaQueryParameters, BrowseMediaSource, ExtendedHomeAssistant } from './types.js'; import { HomeAssistant } from 'custom-card-helpers'; -import { homeAssistantWSRequest } from './common.js'; -import { browseMediaSourceSchema } from './types.js'; - import dayjs from 'dayjs'; import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js'; - + +import type { + BrowseMediaQueryParameters, + BrowseMediaSource, + CameraConfig, + ExtendedHomeAssistant, +} from './types.js'; +import { View } from './view.js'; +import { browseMediaSourceSchema } from './types.js'; +import { + dispatchErrorMessageEvent, + dispatchMessageEvent, + homeAssistantWSRequest, +} from './common.js'; +import { localize } from './localize/localize.js'; + dayjs.extend(dayjs_custom_parse_format); export class BrowseMediaUtil { @@ -16,7 +27,8 @@ export class BrowseMediaUtil { */ static extractEventID(media: BrowseMediaSource): string | null { const result = media.media_content_id.match( - /^media-source:\/\/frigate\/.*\/(?[.0-9]+-[a-zA-Z0-9]+)$/); + /^media-source:\/\/frigate\/.*\/(?[.0-9]+-[a-zA-Z0-9]+)$/, + ); return result && result.groups ? result.groups['id'] : null; } @@ -25,9 +37,7 @@ export class BrowseMediaUtil { * @param browseMedia The media object to extract the start time from. * @returns The start time in unix/epoch time, or null if it cannot be determined. */ - static extractEventStartTime( - browseMedia: BrowseMediaSource, - ): number | null { + static extractEventStartTime(browseMedia: BrowseMediaSource): number | null { // Example: 2021-08-27 20:57:22 [10s, Person 76%] const result = browseMedia.title.match(/^(?.+) \[/); if (result && result.groups) { @@ -57,16 +67,14 @@ export class BrowseMediaUtil { * @param media The media object with children. * @returns The first true media item found. */ - static getFirstTrueMediaChildIndex( - media: BrowseMediaSource | null, - ): number | null { + static getFirstTrueMediaChildIndex(media: BrowseMediaSource | null): number | null { if (!media || !media.children) { return null; } const index = media.children.findIndex((child) => this.isTrueMedia(child)); return index >= 0 ? index : null; } - + /** * Browse Frigate media with a media content id. May throw. * @param hass The HomeAssistant object. @@ -74,19 +82,16 @@ export class BrowseMediaUtil { * @returns A BrowseMediaSource object or null on malformed. */ static async browseMedia( - hass: (HomeAssistant & ExtendedHomeAssistant) | null, + hass: HomeAssistant & ExtendedHomeAssistant, media_content_id: string, - ): Promise { - if (!hass) { - return null; - } + ): Promise { const request = { type: 'media_source/browse_media', media_content_id: media_content_id, }; return homeAssistantWSRequest(hass, browseMediaSourceSchema, request); } - + // Browse Frigate media with query parameters. /** @@ -98,7 +103,7 @@ export class BrowseMediaUtil { static async browseMediaQuery( hass: HomeAssistant & ExtendedHomeAssistant, params: BrowseMediaQueryParameters, - ): Promise { + ): Promise { return this.browseMedia( hass, // Defined in: @@ -117,4 +122,126 @@ export class BrowseMediaUtil { ].join('/'), ); } + + /** + * Get the parameters to search for media. + * @returns A BrowseMediaQueryParameters object. + */ + static getBrowseMediaQueryParameters( + mediaType: 'clips' | 'snapshots', + cameraConfig?: CameraConfig, + ): BrowseMediaQueryParameters | undefined { + if (!cameraConfig || !cameraConfig.camera_name) { + return undefined; + } + return { + mediaType: mediaType, + clientId: cameraConfig.client_id, + cameraName: cameraConfig.camera_name, + label: cameraConfig.label, + zone: cameraConfig.zone, + }; + } + + /** + * Get the parameters to search for media related to the current view. + * @returns A BrowseMediaQueryParameters object. + */ + static getBrowseMediaQueryParametersOrDispatchError( + node: HTMLElement, + view: View, + cameraConfig: CameraConfig, + ): BrowseMediaQueryParameters | undefined { + if (!view.isClipRelatedView() && !view.isSnapshotRelatedView()) { + return undefined; + } + + // Verify there is a camera name, otherwise getBrowseMediaQueryParameters() + // will return undefined. + if (!cameraConfig.camera_name) { + dispatchErrorMessageEvent( + node, + localize('error.no_camera_name') + `: ${JSON.stringify(cameraConfig)}`, + ); + return undefined; + } + + return BrowseMediaUtil.getBrowseMediaQueryParameters( + view.isClipRelatedView() ? 'clips' : 'snapshots', + cameraConfig, + ); + } + + /** + * Fetch the latest media and dispatch a change view event to reflect the + * results. If no media is found a suitable message event will be triggered + * instead. + * @param node The HTMLElement to dispatch events from. + * @param hass The Home Assistant object. + * @param view The current view to evolve. + * @param browseMediaQueryParameters The media parameters to query with. + * @returns + */ + static async fetchLatestMediaAndDispatchViewChange( + node: HTMLElement, + hass: HomeAssistant & ExtendedHomeAssistant, + view: Readonly, + browseMediaQueryParameters: BrowseMediaQueryParameters, + ): Promise { + let parent: BrowseMediaSource | null; + try { + parent = await BrowseMediaUtil.browseMediaQuery(hass, browseMediaQueryParameters); + } catch (e) { + return dispatchErrorMessageEvent(node, (e as Error).message); + } + const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent); + if (!parent || !parent.children || childIndex == null) { + return dispatchMessageEvent( + node, + browseMediaQueryParameters.mediaType == 'clips' + ? localize('common.no_clip') + : localize('common.no_snapshot'), + browseMediaQueryParameters.mediaType == 'clips' + ? 'mdi:filmstrip-off' + : 'mdi:camera-off', + ); + } + + view + .evolve({ + target: parent, + childIndex: childIndex, + }) + .dispatchChangeEvent(node); + } + + /** + * Fetch the media of a child BrowseMediaSource object and dispatch a change + * view event to reflect the results. + * @param node The HTMLElement to dispatch events from. + * @param hass The Home Assistant object. + * @param view The current view to evolve. + * @param child The BrowseMediaSource child to query for. + * @returns + */ + static async fetchChildMediaAndDispatchViewChange( + node: HTMLElement, + hass: HomeAssistant & ExtendedHomeAssistant, + view: Readonly, + child: Readonly, + ): Promise { + let parent: BrowseMediaSource; + try { + parent = await BrowseMediaUtil.browseMedia(hass, child.media_content_id); + } catch (e) { + return dispatchErrorMessageEvent(node, (e as Error).message); + } + + view + .evolve({ + target: parent, + previous: view, + }) + .dispatchChangeEvent(node); + } } diff --git a/src/card-condition.ts b/src/card-condition.ts index 8574d9bf..fb21ae0d 100644 --- a/src/card-condition.ts +++ b/src/card-condition.ts @@ -1,9 +1,14 @@ -import type { FrigateCardCondition } from './types'; -import { View } from './view'; +import type { + FrigateCardCondition, + OverrideConfigurationKey, + RawFrigateCardConfig, +} from './types'; +import { merge, cloneDeep } from 'lodash-es'; export interface ConditionState { - view?: View; + view?: string; fullscreen?: boolean; + camera?: string; } class ConditionStateRequestEvent extends Event { @@ -11,15 +16,23 @@ class ConditionStateRequestEvent extends Event { } export function evaluateCondition( - condition?: FrigateCardCondition, - state?: ConditionState, + condition?: Readonly, + state?: Readonly, ): boolean { - let result = true; - if (condition?.view?.length && state?.view) { - result &&= condition?.view.includes(state?.view.view); + if (!state) { + return false; } - if (condition?.fullscreen !== undefined && state?.fullscreen !== undefined) { - result &&= condition?.fullscreen == state?.fullscreen; + + let result = true; + if (condition?.view?.length) { + result &&= !!state.view && condition.view.includes(state.view); + } + if (condition?.fullscreen !== undefined) { + result &&= + state.fullscreen !== undefined && condition.fullscreen == state.fullscreen; + } + if (condition?.camera?.length) { + result &&= !!state.camera && condition.camera.includes(state.camera); } return result; } @@ -66,3 +79,42 @@ export function conditionStateRequestHandler( ): void { ev.conditionState = conditionState; } + +type RawOverrides = { + conditions: FrigateCardCondition; + overrides: RawFrigateCardConfig; +}[]; + +export function getOverriddenConfig( + config: Readonly, + overrides: Readonly | undefined, + conditionState?: Readonly, +): RawFrigateCardConfig { + const output = cloneDeep(config); + let overridden = false; + if (overrides) { + for (const override of overrides) { + if (evaluateCondition(override.conditions, conditionState)) { + merge(output, override.overrides); + overridden = true; + } + } + } + // Attempt to return the same configuration object if it has not been + // overridden (to reduce re-renders for a configuration that has not changed). + return overridden ? output : config; +} + +export function getOverridesByKey( + overrides: Readonly | undefined, + key: OverrideConfigurationKey, +): RawOverrides { + return ( + overrides + ?.filter((o) => key in o.overrides) + .map((o) => ({ + conditions: o.conditions, + overrides: o.overrides[key] as RawFrigateCardConfig, + })) ?? [] + ); +} diff --git a/src/card.ts b/src/card.ts index d8068f44..5dd5079d 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { CSSResultGroup, LitElement, @@ -22,15 +21,15 @@ import screenfull from 'screenfull'; import { z } from 'zod'; import { + Actions, ActionType, + CameraConfig, GetFrigateCardMenuButtonParameters, RawFrigateCardConfig, entitySchema, frigateCardConfigSchema, - Actions, } from './types.js'; import type { - BrowseMediaQueryParameters, Entity, ExtendedHomeAssistant, FrigateCardConfig, @@ -44,9 +43,12 @@ import { FrigateCardElements } from './components/elements.js'; import { FRIGATE_BUTTON_MENU_ICON, FrigateCardMenu } from './components/menu.js'; import { View } from './view.js'; import { + contentsChanged, convertActionToFrigateCardCustomAction, createFrigateCardCustomAction, getActionConfigGivenAction, + getCameraIcon, + getCameraTitle, homeAssistantSignPath, homeAssistantWSRequest, isValidMediaShowInfo, @@ -72,7 +74,12 @@ import { ResolvedMediaCache } from './resolved-media.js'; import { BrowseMediaUtil } from './browse-media-util.js'; import { isConfigUpgradeable } from './config-mgmt.js'; import { actionHandler } from './action-handler-directive.js'; -import { ConditionState, conditionStateRequestHandler } from './card-condition.js'; +import { + ConditionState, + conditionStateRequestHandler, + getOverriddenConfig, + getOverridesByKey, +} from './card-condition.js'; /** A note on media callbacks: * @@ -107,8 +114,9 @@ console.info( 'color: white; font-weight: bold; background: dimgray', ); -// This puts your card into the UI card picker dialog +// eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).customCards = (window as any).customCards || []; +// eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).customCards.push({ type: 'frigate-card', name: localize('common.frigate_card'), @@ -126,12 +134,13 @@ export class FrigateCard extends LitElement { protected _hass?: HomeAssistant & ExtendedHomeAssistant; @state() - public config!: FrigateCardConfig; + public _baseConfig!: FrigateCardConfig; - protected _interactionTimerID: number | null = null; + @state() + public _overriddenConfig?: FrigateCardConfig; @property({ attribute: false }) - protected _view: View = new View(); + protected _view?: View; @state() protected _conditionState?: ConditionState; @@ -142,26 +151,20 @@ export class FrigateCard extends LitElement { @query('frigate-card-elements') _elements?: FrigateCardElements; + // Human interaction timer ID. + protected _interactionTimerID: number | null = null; + // Whether or not media is actively playing (live or clip). protected _mediaPlaying = false; - // A small cache to avoid needing to create a new list of entities every time - // a hass update arrives. - protected _entitiesToMonitor: string[] = []; - // Information about the most recently loaded media item. protected _mediaShowInfo: MediaShowInfo | null = null; // Array of dynamic menu buttons to be added to menu. protected _dynamicMenuButtons: MenuButton[] = []; - // The frigate camera name to use (may be manually specified or automatically - // derived). - // Values: - // - string: Camera name on the Frigate backend. - // - null: Attempted to find name, but failed. - // - undefined: Have not yet attempted to find name. - protected _frigateCameraName?: string | null; + @state() + protected _cameras?: Map; // Error/info message to render. protected _message: Message | null = null; @@ -208,8 +211,15 @@ export class FrigateCard extends LitElement { ): FrigateCardConfig { const cameraEntity = entities.find((element) => element.startsWith('camera.')); return { - camera_entity: cameraEntity, - } as FrigateCardConfig; + cameras: [ + { + camera_entity: cameraEntity, + }, + ], + // Need to use 'as unknown' to convince Typescript that this really isn't a + // mistake, despite the miniscule size of the configuration vs the full type + // description. + } as unknown as FrigateCardConfig; } /** @@ -217,9 +227,22 @@ export class FrigateCard extends LitElement { */ protected _generateConditionState(): void { this._conditionState = { - view: this._view, + view: this._view?.view, fullscreen: screenfull.isEnabled && screenfull.isFullscreen, + camera: this._view?.camera, }; + + const overriddenConfig = getOverriddenConfig( + this._baseConfig, + this._baseConfig.overrides, + this._conditionState, + ) as FrigateCardConfig; + + // Save on Lit re-rendering costs by only updating the configuration if it + // actually changes. + if (contentsChanged(overriddenConfig, this._overriddenConfig)) { + this._overriddenConfig = overriddenConfig; + } } /** @@ -261,7 +284,7 @@ export class FrigateCard extends LitElement { protected _getMenuButtons(): MenuButton[] { const buttons: MenuButton[] = []; - if (this.config.menu.buttons.frigate) { + if (this._getConfig().menu.buttons.frigate) { buttons.push( this._getFrigateCardMenuButton({ tap_action: 'frigate', @@ -273,50 +296,75 @@ export class FrigateCard extends LitElement { }), ); } - if (this.config.menu.buttons.live) { + + if ( + this._getConfig().menu.buttons.cameras && + this._cameras && + this._cameras.size > 1 + ) { + const menuItems = Array.from(this._cameras, ([camera, config]) => { + return { + icon: getCameraIcon(this._hass, config), + entity: config.camera_entity, + state_color: true, + title: getCameraTitle(this._hass, config), + selected: this._view?.camera === camera, + tap_action: createFrigateCardCustomAction('camera_select', camera), + }; + }); + + buttons.push({ + type: 'custom:frigate-card-menu-submenu', + title: localize('config.menu.buttons.cameras'), + icon: 'mdi:video-switch', + items: menuItems, + }); + } + + if (this._getConfig().menu.buttons.live) { buttons.push( this._getFrigateCardMenuButton({ tap_action: 'live', title: localize('config.view.views.live'), icon: 'mdi:cctv', - emphasize: this._view.is('live'), + emphasize: this._view?.is('live'), }), ); } - if (this.config.menu.buttons.clips) { + if (this._getConfig().menu.buttons.clips) { buttons.push( this._getFrigateCardMenuButton({ tap_action: 'clips', hold_action: 'clip', title: localize('config.view.views.clips'), icon: 'mdi:filmstrip', - emphasize: this._view.is('clips'), + emphasize: this._view?.is('clips'), }), ); } - if (this.config.menu.buttons.snapshots) { + if (this._getConfig().menu.buttons.snapshots) { buttons.push( this._getFrigateCardMenuButton({ tap_action: 'snapshots', hold_action: 'snapshot', title: localize('config.view.views.snapshots'), icon: 'mdi:camera', - emphasize: this._view.is('snapshots'), + emphasize: this._view?.is('snapshots'), }), ); } - if (this.config.menu.buttons.image) { + if (this._getConfig().menu.buttons.image) { buttons.push( this._getFrigateCardMenuButton({ tap_action: 'image', title: localize('config.view.views.image'), icon: 'mdi:image', - emphasize: this._view.is('image'), + emphasize: this._view?.is('image'), }), ); } - if (this.config.menu.buttons.download && this._view.isViewerView()) { + if (this._getConfig().menu.buttons.download && this._view?.isViewerView()) { buttons.push( this._getFrigateCardMenuButton({ tap_action: 'download', @@ -325,7 +373,13 @@ export class FrigateCard extends LitElement { }), ); } - if (this.config.menu.buttons.frigate_ui && this.config.frigate.url) { + + const cameraConfig = this._getSelectedCameraConfig(); + if ( + this._getConfig().menu.buttons.frigate_ui && + cameraConfig && + cameraConfig.frigate_url + ) { buttons.push( this._getFrigateCardMenuButton({ tap_action: 'frigate_ui', @@ -334,7 +388,7 @@ export class FrigateCard extends LitElement { }), ); } - if (this.config.menu.buttons.fullscreen && screenfull.isEnabled) { + if (this._getConfig().menu.buttons.fullscreen && screenfull.isEnabled) { buttons.push( this._getFrigateCardMenuButton({ tap_action: 'fullscreen', @@ -369,49 +423,100 @@ export class FrigateCard extends LitElement { } /** - * Get the Frigate camera name through a variety of means. + * Fully load the configured cameras. + */ + protected async _loadCameras(): Promise { + const cameras: Map = new Map(); + let errorFree = true; + + const addCameraConfig = async (config: CameraConfig) => { + if (!config.camera_name && config.camera_entity) { + const resolvedName = await this._getFrigateCameraNameFromEntity( + config.camera_entity, + ); + if (resolvedName) { + config.camera_name = resolvedName; + } + } + + const id = + config.id || config.camera_entity || config.webrtc?.entity || config.camera_name; + + if (!id) { + this._setMessageAndUpdate({ + message: localize('error.no_camera_id') + `: ${JSON.stringify(config)}`, + type: 'error', + }); + errorFree = false; + } else if (cameras.has(id)) { + this._setMessageAndUpdate({ + message: localize('error.duplicate_camera_id') + `: ${JSON.stringify(config)}`, + type: 'error', + }); + errorFree = false; + } else { + cameras.set(id, config); + } + }; + + if (this._getConfig().cameras && Array.isArray(this._getConfig().cameras)) { + await Promise.all(this._getConfig().cameras.map(addCameraConfig.bind(this))); + } + + if (!cameras.size) { + return this._setMessageAndUpdate({ + message: localize('error.no_cameras'), + type: 'error', + }); + errorFree = false; + } + + if (errorFree) { + this._cameras = cameras; + } + } + + /** + * Get the camera configuration for the selected camera. + * @returns The CameraConfig object or null if not found. + */ + protected _getSelectedCameraConfig(): CameraConfig | null { + if (!this._cameras || !this._cameras.size || !this._view?.camera) { + return null; + } + return this._cameras.get(this._view.camera) || null; + } + + /** + * Get the Frigate camera name from an entity name. * @returns The Frigate camera name or null if unavailable. */ - protected async _getFrigateCameraName(): Promise { - // No camera name specified, apply two heuristics in this order: - // - Get the entity information and pull out the camera name from the unique_id. - // - Apply basic entity name guesswork. - - if (!this._hass || !this.config) { + protected async _getFrigateCameraNameFromEntity( + entity: string, + ): Promise { + if (!this._hass) { return null; } - // Option 1: Name specified in config -> done! - if (this.config.frigate.camera_name) { - return this.config.frigate.camera_name; - } - - if (this.config.camera_entity) { - // Option 2: Find entity unique_id in registry. - const request = { - type: 'config/entity_registry/get', - entity_id: this.config.camera_entity, - }; - try { - const entityResult = await homeAssistantWSRequest( - this._hass, - entitySchema, - request, - ); - if (entityResult && entityResult.platform == 'frigate') { - const match = entityResult.unique_id.match(/:camera:(?[^:]+)$/); - if (match && match.groups) { - return match.groups['camera']; - } + // Find entity unique_id in registry. + const request = { + type: 'config/entity_registry/get', + entity_id: entity, + }; + try { + const entityResult = await homeAssistantWSRequest( + this._hass, + entitySchema, + request, + ); + if (entityResult && entityResult.platform == 'frigate') { + const match = entityResult.unique_id.match(/:camera:(?[^:]+)$/); + if (match && match.groups) { + return match.groups['camera']; } - } catch (e: any) { - // Pass. - } - - // Option 3: Guess from the entity_id. - if (this.config.camera_entity.includes('.')) { - return this.config.camera_entity.split('.', 2)[1]; } + } catch (e: unknown) { + // Pass. } return null; @@ -510,29 +615,47 @@ export class FrigateCard extends LitElement { getLovelace().setEditMode(true); } - this._frigateCameraName = undefined; - this.config = config; + this._baseConfig = config; + this._cameras = undefined; + this._view = undefined; - this._entitiesToMonitor = this.config.view.update_entities || []; - if (this.config.camera_entity) { - this._entitiesToMonitor.push(this.config.camera_entity); - } - if (this.config.view.update_force) { + if (this._getConfig().view.update_force) { // If update force is enabled, start a timer right away. this._resetInteractionTimer(); } this._changeView(); } - protected _changeView(view?: View): void { - this._message = null; + /** + * Card the card config, prioritizing the overriden config if present. + * @returns A FrigateCardConfig. + */ + protected _getConfig(): FrigateCardConfig { + return this._overriddenConfig || this._baseConfig; + } - if (view === undefined) { - this._view = new View({ view: this.config.view.default }); + protected _changeView(args?: { view?: View; resetMessage?: boolean }): void { + if (args?.resetMessage ?? true) { + this._message = null; + } + + if (args?.view === undefined) { + let camera = this._view?.camera; + if (!camera && this._cameras?.size) { + camera = this._cameras.keys().next().value; + } + + if (camera) { + this._view = new View({ + view: this._getConfig().view.default, + camera: camera, + }); + this._generateConditionState(); + } } else { - this._view = view; + this._view = args.view; + this._generateConditionState(); } - this._generateConditionState(); } /** @@ -540,7 +663,7 @@ export class FrigateCard extends LitElement { * @param e The change view event. */ protected _changeViewHandler(e: CustomEvent): void { - this._changeView(e.detail); + this._changeView({ view: e.detail }); } /** @@ -549,10 +672,6 @@ export class FrigateCard extends LitElement { * @returns True if the card should be updated. */ protected shouldUpdate(changedProps: PropertyValues): boolean { - if (!this.config) { - return false; - } - if (changedProps.size > 1) { return true; } @@ -565,10 +684,23 @@ export class FrigateCard extends LitElement { // are browsing the mini-gallery). Do not allow re-rendering from a Home // Assistant update if there's been recent interaction (e.g. clicks on the // card) or if there is media active playing. - if (!this.config.view.update_force && (this._interactionTimerID || this._mediaPlaying)) { - return false; + if ( + (this._getConfig().view.update_force || + !(this._interactionTimerID && this._mediaPlaying)) && + shouldUpdateBasedOnHass( + this._hass, + oldHass, + this._getConfig().view.update_entities || [], + ) + ) { + // If entities being monitored have changed then reset the view to the + // default and allow a re-render. Note that as per the Lit lifecycle, + // the setting of the view itself will not trigger an *additional* + // re-render here. + this._changeView(); + return true; } - return shouldUpdateBasedOnHass(this._hass, oldHass, this._entitiesToMonitor); + return false; } return true; } @@ -577,7 +709,7 @@ export class FrigateCard extends LitElement { * Download media being displayed in the viewer. */ protected async _downloadViewerMedia(): Promise { - if (!this._hass || !this._view.isViewerView()) { + if (!this._hass || !this._view?.isViewerView()) { // Should not occur. return; } @@ -598,8 +730,13 @@ export class FrigateCard extends LitElement { return; } + const cameraConfig = this._getSelectedCameraConfig(); + if (!cameraConfig) { + return; + } + const path = - `/api/frigate/${this.config.frigate.client_id}` + + `/api/frigate/${cameraConfig.client_id}` + `/notifications/${event_id}/` + `${this._view.isClipRelatedView() ? 'clip.mp4' : 'snapshot.jpg'}` + `?download=true`; @@ -618,7 +755,10 @@ export class FrigateCard extends LitElement { return; } - if (navigator.userAgent.startsWith("Home Assistant/") || navigator.userAgent.startsWith("HomeAssistant/")) { + if ( + navigator.userAgent.startsWith('Home Assistant/') || + navigator.userAgent.startsWith('HomeAssistant/') + ) { // Home Assistant companion apps cannot download files without opening a // new browser window. // @@ -662,7 +802,14 @@ export class FrigateCard extends LitElement { case 'live': case 'snapshot': case 'snapshots': - this._changeView(new View({ view: action })); + if (this._view) { + this._changeView({ + view: new View({ + view: action, + camera: this._view.camera, + }), + }); + } break; case 'download': this._downloadViewerMedia(); @@ -678,6 +825,17 @@ export class FrigateCard extends LitElement { screenfull.toggle(this); } break; + case 'camera_select': + const camera = frigateCardAction.camera; + if (this._cameras?.has(camera) && this._view) { + this._changeView({ + view: new View({ + view: this._view.view, + camera: camera, + }), + }); + } + break; default: console.warn(`Frigate card received unknown card action: ${action}`); } @@ -688,15 +846,17 @@ export class FrigateCard extends LitElement { * @returns The URL or null if unavailable. */ protected _getFrigateURLFromContext(): string | null { - if (!this.config.frigate.url) { + const cameraConfig = this._getSelectedCameraConfig(); + if (!cameraConfig || !cameraConfig.frigate_url || !this._view) { return null; } - if (!this._frigateCameraName) { - return this.config.frigate.url; - } else if (this._view.is('live')) { - return `${this.config.frigate.url}/cameras/${this._frigateCameraName}`; + if (!cameraConfig.camera_name) { + return cameraConfig.frigate_url; } - return `${this.config.frigate.url}/events?camera=${this._frigateCameraName}`; + if (this._view.isViewerView() || this._view.isGalleryView()) { + return `${cameraConfig.frigate_url}/events?camera=${cameraConfig.camera_name}`; + } + return `${cameraConfig.frigate_url}/cameras/${cameraConfig.camera_name}`; } /** @@ -728,18 +888,18 @@ export class FrigateCard extends LitElement { } protected _resetInteractionTimer(): void { - if (this.config.view.timeout) { + if (this._getConfig().view.timeout) { if (this._interactionTimerID) { window.clearTimeout(this._interactionTimerID); } this._interactionTimerID = window.setTimeout(() => { this._interactionTimerID = null; this._changeView(); - if (this.config.view.update_force) { + if (this._getConfig().view.update_force) { // If force is enabled, the timer just resets and starts over. this._resetInteractionTimer(); } - }, this.config.view.timeout * 1000); + }, this._getConfig().view.timeout * 1000); } } @@ -749,12 +909,12 @@ export class FrigateCard extends LitElement { */ protected _renderMenu(): TemplateResult | void { const classes = { - 'hover-menu': this.config.menu.mode.startsWith('hover-'), + 'hover-menu': this._getConfig().menu.mode.startsWith('hover-'), }; return html` this._actionHandler(ev, actions)} @ll-custom=${this._cardActionHandler.bind(this)} + @frigate-card:message=${this._messageHandler} + @frigate-card:change-view=${this._changeViewHandler} + @frigate-card:media-show=${this._mediaShowHandler} + @frigate-card:pause=${this._pauseHandler} + @frigate-card:play=${this._playHandler} > - ${this.config.menu.mode == 'above' ? this._renderMenu() : ''} + ${this._getConfig().menu.mode == 'above' ? this._renderMenu() : ''}
- ${this._frigateCameraName == undefined - ? until( - (async () => { - this._frigateCameraName = await this._getFrigateCameraName(); - return this._render(); - })(), - renderProgressIndicator(), - ) - : this._render()} +
+ ${this._getConfig().elements + ? // Always show elements to allow for custom menu items (etc.) to + // be present even if a particular view has an error. + html` { + this._addDynamicMenuButton(e.detail); + }} + @frigate-card:menu-remove=${(e) => { + this._removeDynamicMenuButton(e.detail); + }} + @frigate-card:condition-state-request=${(ev) => { + conditionStateRequestHandler(ev, this._conditionState); + }} + > + ` + : ``} + ${this._cameras === undefined + ? until( + (async () => { + await this._loadCameras(); + // Don't reset messages as errors may have been generated + // during the camera load. + this._changeView({ resetMessage: false }); + return this._render(); + })(), + renderProgressIndicator(), + ) + : // Always want to call render even if there's a message, to + // ensure live preload is always present (even if not displayed). + this._render()} + ${ + // Keep message rendering to last to show messages that may have + // been generated during the render. + this._message ? renderMessage(this._message) : '' + } +
- ${this.config.menu.mode != 'above' ? this._renderMenu() : ''} + ${this._getConfig().menu.mode != 'above' ? this._renderMenu() : ''} `; } @@ -992,119 +1169,65 @@ export class FrigateCard extends LitElement { * Sub-render method for the card. */ protected _render(): TemplateResult | void { - if (!this._hass) { + const cameraConfig = this._getSelectedCameraConfig(); + + if (!this._hass || !this._view || !cameraConfig) { return html``; } - if (!this._frigateCameraName) { - this._setMessageAndUpdate( - { - message: localize('error.no_frigate_camera_name'), - type: 'error', - }, - true, - ); - } - const pictureElementsClasses = { - 'picture-elements': true, - gallery: this._view.isGalleryView(), - }; - const galleryClasses = { - hidden: this.config.live.preload && !this._view.isGalleryView(), - }; - const viewerClasses = { - hidden: this.config.live.preload && !this._view.isViewerView(), - }; + // Render but hide the live view if there's a message, or if it's preload + // mode and the view is not live. const liveClasses = { - hidden: this.config.live.preload && this._view.view != 'live', - }; - const imageClasses = { - hidden: this.config.live.preload && this._view.view != 'image', + hidden: + !!this._message || (this._getConfig().live.preload && !this._view.is('live')), }; return html` -
- ${this._message ? renderMessage(this._message) : ``} - ${!this._message && this._view.is('image') - ? html` - ` - : ``} - ${!this._message && this._view.isGalleryView() - ? html` - ` - : ``} - ${!this._message && this._view.isViewerView() - ? html` - ` - : ``} - ${ - // Note the subtle difference in condition below vs the other views in order - // to always render the live view for live.preload mode. - (!this._message && this._view.is('live')) || this.config.live.preload - ? html` - - - ` - : `` - } - ${this.config.elements + ${!this._message && this._view.is('image') + ? html` + ` + : ``} + ${!this._message && this._view.isGalleryView() + ? html` + ` + : ``} + ${!this._message && this._view.isViewerView() + ? html` + ` + : ``} + ${ + // Note: Subtle difference in condition below vs the other views in order + // to always render the live view for live.preload mode. + + // Note: uses the baseConfig rather than the + // overriden config, as it does it's own overriding as part of the + // camera carousel. + this._getConfig().live.preload || (!this._message && this._view.is('live')) ? html` - { - this._addDynamicMenuButton(e.detail); - }} - @frigate-card:menu-remove=${(e) => { - this._removeDynamicMenuButton(e.detail); - }} - @frigate-card:condition-state-request=${(ev) => { - conditionStateRequestHandler(ev, this._conditionState); - }} + .liveOverrides=${getOverridesByKey(this._getConfig().overrides, 'live')} + .cameras=${this._cameras} + .preloaded=${this._getConfig().live.preload && !this._view.is('live')} + class="${classMap(liveClasses)}" > - + ` - : ``} -
+ : `` + } `; } diff --git a/src/common.ts b/src/common.ts index 4c15471f..46327533 100644 --- a/src/common.ts +++ b/src/common.ts @@ -2,11 +2,14 @@ import { HassEntity, MessageBase } from 'home-assistant-js-websocket'; import { HomeAssistant, stateIcon } from 'custom-card-helpers'; import { StyleInfo } from 'lit/directives/style-map'; import { ZodSchema, z } from 'zod'; +import { isEqual } from 'lodash-es'; import { localize } from './localize/localize.js'; import { ActionType, + CameraConfig, ExtendedHomeAssistant, + FrigateCardAction, FrigateCardCustomAction, frigateCardCustomActionSchema, MediaShowInfo, @@ -40,7 +43,7 @@ export async function homeAssistantWSRequest( hass: HomeAssistant & ExtendedHomeAssistant, schema: ZodSchema, request: MessageBase, -): Promise { +): Promise { const response = await hass.callWS(request); if (!response) { @@ -283,7 +286,20 @@ export function convertActionToFrigateCardCustomAction( * @param action The Frigate card action string (e.g. 'fullscreen') * @returns A FrigateCardCustomAction for that action string. */ -export function createFrigateCardCustomAction(action: string): FrigateCardCustomAction { +export function createFrigateCardCustomAction( + action: FrigateCardAction, + camera?: string, +): FrigateCardCustomAction | undefined { + if (action == 'camera_select') { + if (!camera) { + return undefined; + } + return { + action: 'fire-dom-event', + frigate_card_action: action, + camera: camera, + }; + } return { action: 'fire-dom-event', frigate_card_action: action, @@ -382,3 +398,101 @@ export function refreshDynamicStateParameters( params.icon = params.icon ?? stateIcon(state); return params; } + +/** + * Prettify a Frigate name by converting '_' to spaces and capitalizing words. + * @param input The input Frigate (camera/label/zone) name. + * @returns A prettified name. + */ +export function prettifyFrigateName(input?: string): string | undefined { + if (!input) { + return undefined; + } + const words = input.split(/[_\s]+/); + return words + .map((word) => { + return word[0].toUpperCase() + word.substring(1); + }) + .join(' '); +} + +/** + * Get the title of an entity. + * @param entity The entity id. + * @param hass The Home Assistant object. + * @returns The title or undefined. + */ +export function getEntityTitle( + hass?: HomeAssistant, + entity?: string, +): string | undefined { + return entity ? hass?.states[entity]?.attributes?.friendly_name : undefined; +} + +/** + * Get the icon of an entity. + * @param entity The entity id. + * @param hass The Home Assistant object. + * @returns The icon or undefined. + */ +export function getEntityIcon( + hass?: HomeAssistant, + entity?: string, +): string | undefined { + return hass && entity ? stateIcon(hass.states[entity]) : undefined; +} + +/** + * Get a camera text title. + * @param hass The Home Assistant object. + * @param config The camera config. + * @returns A title string. + */ +export function getCameraTitle( + hass?: HomeAssistant, + config?: CameraConfig | null, +): string { + return ( + config?.title || + (config?.camera_entity ? getEntityTitle(hass, config.camera_entity) : '') || + (config?.camera_name ? prettifyFrigateName(config.camera_name) : '') || + '' + ); +} + +/** + * Get a camera icon. + * @param hass The Home Assistant object. + * @param config The camera config. + * @returns An icon string. + */ +export function getCameraIcon( + hass?: HomeAssistant, + config?: CameraConfig | null, +): string { + return config?.icon || getEntityIcon(hass, config?.camera_entity) || 'mdi:video'; +} + +/** + * Move an element within an array. + * @param target Target array. + * @param from From index. + * @param to To index. + */ +export function arrayMove(target: unknown[], from: number, to: number): void { + const element = target[from]; + target.splice(from, 1); + target.splice(to, 0, element); +} + +/** + * Determine if the contents of the n(ew) and o(ld) values have changed. For use + * in lit web components that may have a value that changes address but not + * contents -- and for which a re-render is expensive/jarring. + * @param n The new value. + * @param o The old value. + * @returns `true` is the contents have changed. + */ +export function contentsChanged(n: unknown, o: unknown): boolean { + return !isEqual(n, o); +} \ No newline at end of file diff --git a/src/components/carousel.ts b/src/components/carousel.ts index cd8da910..540b3ae5 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -1,9 +1,4 @@ -import { - CSSResultGroup, - LitElement, - unsafeCSS, - PropertyValues, -} from 'lit'; +import { CSSResultGroup, LitElement, unsafeCSS, PropertyValues } from 'lit'; import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel'; import { dispatchFrigateCardEvent } from '../common'; @@ -15,7 +10,6 @@ export interface CarouselSelect { } export class FrigateCardCarousel extends LitElement { - protected _options?: EmblaOptionsType; protected _carousel?: EmblaCarouselType; /** @@ -43,21 +37,36 @@ export class FrigateCardCarousel extends LitElement { if (!this._carousel) { this.updateComplete.then(() => { - this._loadCarousel(); + this._initCarousel(); }); } } /** - * Load the carousel with "slides". + * Get the Embla options to use. + * @returns An EmblaOptionsType object or undefined for no options. */ - protected _loadCarousel(): void { + protected _getOptions(): EmblaOptionsType | undefined { + return undefined; + } + + protected _destroyCarousel(): void { + if (this._carousel) { + this._carousel.destroy(); + } + this._carousel = undefined; + } + + /** + * Initialize the carousel. + */ + protected _initCarousel(): void { const carouselNode = this.renderRoot.querySelector( '.embla__viewport', ) as HTMLElement; - if (!this._carousel && carouselNode) { - this._carousel = EmblaCarousel(carouselNode, this._options); + if (carouselNode) { + this._carousel = EmblaCarousel(carouselNode, this._getOptions()); this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init')); this._carousel.on('select', () => { const selected = this.carouselSelected(); diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 214f821b..278dea11 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -3,21 +3,13 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit import { HomeAssistant } from 'custom-card-helpers'; import { customElement, property, state } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; -import { until } from 'lit/directives/until.js'; -import type { - BrowseMediaSource, - BrowseMediaQueryParameters, - ExtendedHomeAssistant, -} from '../types.js'; +import type { CameraConfig, ExtendedHomeAssistant } from '../types.js'; import { BrowseMediaUtil } from '../browse-media-util.js'; import { View } from '../view.js'; -import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../common.js'; -import { localize } from '../localize/localize.js'; import { renderProgressIndicator } from './message.js'; import galleryStyle from '../scss/gallery.scss'; -import { actionHandler } from '../action-handler-directive.js'; const MAX_THUMBNAIL_WIDTH = 175; const DEFAULT_COLUMNS = 5; @@ -28,68 +20,44 @@ export class FrigateCardGallery extends LitElement { protected hass?: HomeAssistant & ExtendedHomeAssistant; @property({ attribute: false }) - protected view?: View; + protected view?: Readonly; @property({ attribute: false }) - protected browseMediaQueryParameters?: BrowseMediaQueryParameters; + protected cameraConfig?: CameraConfig; /** * Master render method. * @returns A rendered template. */ protected render(): TemplateResult | void { - return html`${until(this._render(), renderProgressIndicator())}`; - } - - /** - * Asyncronously render the element. - * @returns A rendered template. - */ - protected async _render(): Promise { - if ( - !this.hass || - !this.view || - !this.browseMediaQueryParameters || - !(this.view.is('clips') || this.view.is('snapshots')) - ) { - return html``; + if (!this.hass || !this.view || !this.cameraConfig) { + return; } - let parent: BrowseMediaSource | null; - try { - if (this.view.target) { - parent = await BrowseMediaUtil.browseMedia( - this.hass, - this.view.target.media_content_id, - ); - } else { - parent = await BrowseMediaUtil.browseMediaQuery( - this.hass, - this.browseMediaQueryParameters, + if (!this.view.target) { + const browseMediaQueryParameters = + BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError( + this, + this.view, + this.cameraConfig, ); + if (!browseMediaQueryParameters) { + return; } - } catch (e: any) { - return dispatchErrorMessageEvent(this, e.message); - } - if ( - !parent || - !parent.children || - BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) == null - ) { - return dispatchMessageEvent( + BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange( this, - this.view.is('clips') - ? localize('common.no_clips') - : localize('common.no_snapshots'), - this.view.is('clips') ? 'mdi:filmstrip-off' : 'mdi:camera-off', + this.hass, + this.view, + browseMediaQueryParameters, ); + return renderProgressIndicator(); } - this.view.target = parent; - - return html` - `; + return html` + + + `; } /** @@ -106,7 +74,7 @@ export class FrigateCardGalleryCore extends LitElement { protected hass?: HomeAssistant & ExtendedHomeAssistant; @property({ attribute: false }) - protected view?: View; + protected view?: Readonly; protected _resizeObserver: ResizeObserver; @@ -137,6 +105,7 @@ export class FrigateCardGalleryCore extends LitElement { protected render(): TemplateResult | void { if ( + !this.hass || !this.view || !this.view.target || !this.view.target.children || @@ -156,11 +125,7 @@ export class FrigateCardGalleryCore extends LitElement {
{ + @click=${() => { if (this.view && this.view.previous) { this.view.previous.dispatchChangeEvent(this); } @@ -181,17 +146,14 @@ export class FrigateCardGalleryCore extends LitElement { ${child.can_expand ? html`
{ - if (this.view) { - new View({ - view: this.view.view, - target: child, - previous: this.view ?? undefined, - }).dispatchChangeEvent(this); + @click=${() => { + if (this.hass && this.view) { + BrowseMediaUtil.fetchChildMediaAndDispatchViewChange( + this, + this.hass, + this.view, + child, + ); } }} outlined="" @@ -202,23 +164,19 @@ export class FrigateCardGalleryCore extends LitElement {
` : child.thumbnail ? html` { + title="${child.title}" + @click=${() => { if (this.view) { - new View({ - view: this.view.is('clips') - ? 'clip-specific' - : 'snapshot-specific', - target: this.view.target ?? undefined, - childIndex: index, - previous: this.view ?? undefined, - }).dispatchChangeEvent(this); + this.view + .evolve({ + view: this.view.is('clips') ? 'clip' : 'snapshot', + childIndex: index, + previous: this.view, + }) + .dispatchChangeEvent(this); } }} />` diff --git a/src/components/live.ts b/src/components/live.ts index 3d119ca6..ee748991 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -1,28 +1,47 @@ -import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; -import type { - BrowseMediaQueryParameters, +import { + CSSResultGroup, + LitElement, + TemplateResult, + html, + unsafeCSS, + PropertyValues, +} from 'lit'; +import { BrowseMediaSource, ExtendedHomeAssistant, - FrigateCardConfig, + CameraConfig, JSMPEGConfig, + LiveConfig, MediaShowInfo, WebRTCConfig, + FrigateCardError, + LiveOverrides, + LiveProvider, + frigateCardConfigDefaults, } from '../types.js'; +import { EmblaOptionsType } from 'embla-carousel'; import { HomeAssistant } from 'custom-card-helpers'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref'; import { until } from 'lit/directives/until.js'; import { BrowseMediaUtil } from '../browse-media-util.js'; +import { ConditionState, getOverriddenConfig } from '../card-condition.js'; +import { FrigateCardMediaCarousel } from './media-carousel.js'; +import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; import { View } from '../view.js'; import { localize } from '../localize/localize.js'; import { + contentsChanged, dispatchErrorMessageEvent, dispatchExistingMediaShowInfoAsEvent, dispatchMediaShowEvent, dispatchMessageEvent, dispatchPauseEvent, dispatchPlayEvent, + getCameraIcon, + getCameraTitle, homeAssistantSignPath, } from '../common.js'; import { renderProgressIndicator } from '../components/message.js'; @@ -46,22 +65,31 @@ export class FrigateCardLive extends LitElement { protected hass?: HomeAssistant & ExtendedHomeAssistant; @property({ attribute: false }) - protected config?: FrigateCardConfig; + protected view?: Readonly; @property({ attribute: false }) - protected browseMediaQueryParameters?: BrowseMediaQueryParameters; + protected cameras?: Map; @property({ attribute: false }) - set preload(preload: boolean) { - this._preload = preload; + protected liveConfig?: LiveConfig; - if (!preload && this._savedMediaShowInfo) { + @property({ attribute: false }) + protected liveOverrides?: LiveOverrides; + + @property({ attribute: false }) + protected conditionState?: ConditionState; + + set preloaded(preloaded: boolean) { + this._preloaded = preloaded; + + if (!preloaded && this._savedMediaShowInfo) { dispatchExistingMediaShowInfoAsEvent(this, this._savedMediaShowInfo); } } // Whether or not the live view is currently being preloaded. - protected _preload?: boolean; + @state() + protected _preloaded?: boolean; // MediaShowInfo object from the underlying live object. In the case of // pre-loading it may be propagated upwards later. @@ -73,7 +101,7 @@ export class FrigateCardLive extends LitElement { */ protected _mediaShowHandler(e: CustomEvent): void { this._savedMediaShowInfo = e.detail; - if (this._preload) { + if (this._preloaded) { // If live is being pre-loaded, don't let the event propogate upwards yet // as the media is not really being shown. e.stopPropagation(); @@ -84,35 +112,41 @@ export class FrigateCardLive extends LitElement { * Render thumbnails carousel. * @returns A rendered template or void. */ - protected renderThumbnails(): TemplateResult | void { - if (!this.config) { + protected renderThumbnails(config: LiveConfig): TemplateResult | void { + if (!this.liveConfig || !this.view) { return; } const fetchThumbnailsThenRender = async (): Promise => { - if (!this.hass || !this.browseMediaQueryParameters) { + if (!this.hass || !this.cameras || !this.view) { + return; + } + const browseMediaParams = BrowseMediaUtil.getBrowseMediaQueryParameters( + config.controls.thumbnails.media, + this.cameras.get(this.view.camera), + ); + if (!browseMediaParams) { return; } let parent: BrowseMediaSource | null; try { - parent = await BrowseMediaUtil.browseMediaQuery( - this.hass, - this.browseMediaQueryParameters, - ); + parent = await BrowseMediaUtil.browseMediaQuery(this.hass, browseMediaParams); } catch (e) { return dispatchErrorMessageEvent(this, (e as Error).message); } if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) { - return html` ) => { - const mediaType = this.browseMediaQueryParameters?.mediaType; - if (mediaType && ['snapshots', 'clips'].includes(mediaType)) { + const mediaType = browseMediaParams.mediaType; + if (mediaType && this.view && ['snapshots', 'clips'].includes(mediaType)) { new View({ - view: mediaType === 'clips' ? 'clip-specific' : 'snapshot-specific', + view: mediaType === 'clips' ? 'clip' : 'snapshot', + camera: this.view.camera, target: ev.detail.target, childIndex: ev.detail.childIndex, }).dispatchChangeEvent(this); @@ -123,7 +157,10 @@ export class FrigateCardLive extends LitElement { } }; - return html`${until(fetchThumbnailsThenRender(), renderProgressIndicator())}`; + // Don't render a progress indicator for live thumbnails, as it's jarring + // during live-carousel scrolling (the progress indicator repeatedly + // flashes). Just render nothing during loading. + return html`${until(fetchThumbnailsThenRender(), html``)}`; } /** @@ -131,39 +168,43 @@ export class FrigateCardLive extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.config) { + if (!this.hass || !this.liveConfig || !this.cameras) { return; } + const config = getOverriddenConfig( + this.liveConfig, + this.liveOverrides, + this.conditionState, + ) as LiveConfig; + + // Note use of liveConfig and not config below -- the carousel will + // independently override the liveconfig to reflect the camera in the + // carousel (not necessarily the selected camera). return html` - ${this.config.live.controls.thumbnails.mode === 'above' - ? this.renderThumbnails() - : ''} - ${this.config.live.provider == 'frigate' - ? html` - ` - : this.config.live.provider == 'webrtc' - ? html` - ` - : html` - `} - ${this.config.live.controls.thumbnails.mode === 'below' - ? this.renderThumbnails() - : ''} + ${config.controls.thumbnails.mode === 'above' ? this.renderThumbnails(config) : ''} + { + if (this._preloaded) { + // Don't allow change-view events to propagate upwards if the card + // is only preloaded rather than being live displayed. These events + // could be triggered if the camera is switched and the carousel + // moves to focus on that camera -- as the card isn't actually being + // displayed, do not allow the view to actually be updated. + ev.stopPropagation(); + } + }} + > + + ${config.controls.thumbnails.mode === 'below' ? this.renderThumbnails(config) : ''} `; } @@ -175,6 +216,356 @@ export class FrigateCardLive extends LitElement { } } +@customElement('frigate-card-live-carousel') +export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { + @property({ attribute: false }) + protected hass?: HomeAssistant & ExtendedHomeAssistant; + + @property({ attribute: false }) + protected view?: Readonly; + + @property({ attribute: false }) + protected cameras?: Map; + + @property({ attribute: false }) + protected liveConfig?: LiveConfig; + + @property({ attribute: false }) + protected liveOverrides?: LiveOverrides; + + @property({ attribute: false }) + protected preloaded?: boolean; + + @property({ attribute: false }) + protected conditionState?: ConditionState; + + // Index between camera name and slide number. + protected _cameraToSlide: Record = {}; + + /** + * The updated lifecycle callback for this element. + * @param changedProperties The properties that were changed in this render. + */ + updated(changedProperties: PropertyValues): void { + if ( + changedProperties.has('cameras') || + changedProperties.has('liveConfig') || + changedProperties.has('preloaded') + ) { + // All of these properties may fundamentally change the contents/size of + // the DOM, and the carousel should be reset when they change. + this._destroyCarousel(); + } + + if (changedProperties.has('view')) { + const oldView = changedProperties.get('view') as View | undefined; + if ( + this._carousel && + oldView && + this.view?.camera && + this.view?.camera != oldView.camera + ) { + const slide: number | undefined = this._cameraToSlide[this.view.camera]; + if (slide !== undefined && slide !== this.carouselSelected()) { + this.carouselScrollTo(slide); + } + } + } + + super.updated(changedProperties); + } + + /** + * Get the Embla options to use. + * @returns An EmblaOptionsType object or undefined for no options. + */ + protected _getOptions(): EmblaOptionsType { + let startIndex = -1; + if (this.cameras && this.view) { + startIndex = Array.from(this.cameras.keys()).indexOf(this.view.camera); + } + + return { + startIndex: startIndex < 0 ? undefined : startIndex, + draggable: this.liveConfig?.draggable, + }; + } + + /** + * Returns the number of slides to lazily load. 0 means all slides are lazy + * loaded, 1 means that 1 slide on each side of the currently selected slide + * should lazy load, etc. `null` means lazy loading is disabled and everything + * should load simultaneously. + * @returns + */ + protected _getLazyLoadCount(): number | null { + // Defaults to fully-lazy loading. + return this.liveConfig?.lazy_load === false ? null : 0; + } + + /** + * Get slides to include in the render. + * @returns The slides to include in the render and an index keyed by camera + * name to slide number. + */ + protected _getSlides(): [TemplateResult[], Record] { + if (!this.cameras) { + return [[], {}]; + } + + const slides: TemplateResult[] = []; + const cameraToSlide: Record = {}; + + for (const [camera, cameraConfig] of this.cameras) { + const slide = this._renderLive(camera, cameraConfig, slides.length); + if (slide) { + cameraToSlide[camera] = slides.length; + slides.push(slide); + } + } + return [slides, cameraToSlide]; + } + + /** + * Handle the user selecting a new slide in the carousel. + */ + protected _selectSlideSetViewHandler(): void { + if (!this._carousel || !this.view || !this.cameras) { + return; + } + + const selectedSnap = this._carousel.selectedScrollSnap(); + this.view + .evolve({ + camera: Array.from(this.cameras.keys())[selectedSnap], + previous: this.view, + }) + .dispatchChangeEvent(this); + } + + /** + * Lazy load a slide. + * @param _index The slide number to lazy load. + * @param slide The slide to lazy load. + */ + protected _lazyLoadSlide(_index: number, slide: HTMLElement): void { + const liveProvider = slide.querySelector( + 'frigate-card-live-provider', + ) as FrigateCardLiveProvider; + if (liveProvider) { + liveProvider.disabled = false; + } + } + + protected _renderLive( + camera: string, + cameraConfig: CameraConfig, + slideIndex: number, + ): TemplateResult | void { + if (!this.liveConfig) { + return; + } + // The conditionState object contains the currently live camera, which (in + // the carousel for example) is not necessarily the live camera this + // is rendering right now. + const conditionState = Object.assign({ + ...this.conditionState, + camera: camera, + }); + + const config = getOverriddenConfig( + this.liveConfig, + this.liveOverrides, + conditionState, + ) as LiveConfig; + + return html`
+ ) => + this._mediaShowEventHandler(slideIndex, e)} + > + +
`; + } + + protected _getCameraNeighbors(): [CameraConfig | null, CameraConfig | null] { + if (!this.cameras || !this.view || !this.hass) { + return [null, null]; + } + const keys = Array.from(this.cameras.keys()); + const currentIndex = keys.indexOf(this.view.camera); + + if (currentIndex < 0) { + return [null, null]; + } + + let prev: CameraConfig | null = null, + next: CameraConfig | null = null; + if (currentIndex > 0) { + prev = this.cameras.get(keys[currentIndex - 1]) ?? null; + } + if (currentIndex + 1 < this.cameras.size) { + next = this.cameras.get(keys[currentIndex + 1]) ?? null; + } + return [prev, next]; + } + + /** + * Handle updating of the next/previous controls when the carousel is moved. + */ + protected _selectSlideNextPreviousHandler(): void { + const updateNextPreviousControl = ( + control: FrigateCardNextPreviousControl, + direction: 'previous' | 'next', + ): void => { + const [prev, next] = this._getCameraNeighbors(); + const target = direction == 'previous' ? prev : next; + + control.disabled = target == null; + control.title = getCameraTitle(this.hass, target); + control.icon = getCameraIcon(this.hass, target); + }; + + if (this._previousControlRef.value) { + updateNextPreviousControl(this._previousControlRef.value, 'previous'); + } + if (this._nextControlRef.value) { + updateNextPreviousControl(this._nextControlRef.value, 'next'); + } + } + + /** + * Render the element. + * @returns A template to display to the user. + */ + protected render(): TemplateResult | void { + const [slides, cameraToSlide] = this._getSlides(); + this._cameraToSlide = cameraToSlide; + if (!slides || !this.liveConfig) { + return; + } + + const config = getOverriddenConfig( + this.liveConfig, + this.liveOverrides, + this.conditionState, + ) as LiveConfig; + + const [prev, next] = this._getCameraNeighbors(); + return html` +
+ { + this._nextPreviousHandler('previous'); + }} + > + +
+
${slides}
+
+ { + this._nextPreviousHandler('next'); + }} + > + +
+ `; + } +} + +@customElement('frigate-card-live-provider') +export class FrigateCardLiveProvider extends LitElement { + @property({ attribute: false }) + protected hass?: HomeAssistant & ExtendedHomeAssistant; + + @property({ attribute: false }) + protected cameraConfig?: CameraConfig; + + @property({ attribute: false }) + protected liveConfig?: LiveConfig; + + // Whether or not to disable this entity. If `true`, no contents are rendered + // until this attribute is set to `false` (this is useful for lazy loading). + @property({ attribute: true, type: Boolean }) + public disabled = false; + + // Label that is used for ARIA support and as tooltip. + @property({ attribute: false }) + public label = ''; + + protected _getResolvedProvider(): LiveProvider { + if (this.cameraConfig?.live_provider === 'auto') { + if (this.cameraConfig?.webrtc?.entity || this.cameraConfig?.webrtc?.url) { + return 'webrtc'; + } else if (this.cameraConfig?.camera_entity) { + return 'frigate'; + } else if (this.cameraConfig?.camera_name) { + return 'frigate-jsmpeg'; + } + return frigateCardConfigDefaults.cameras.live_provider; + } + return ( + this.cameraConfig?.live_provider || frigateCardConfigDefaults.cameras.live_provider + ); + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (this.disabled || !this.hass || !this.liveConfig || !this.cameraConfig) { + return; + } + + // Set title and ariaLabel from the provided label property. + this.title = this.label; + this.ariaLabel = this.label; + + const provider = this._getResolvedProvider(); + + return html` + ${provider == 'frigate' + ? html` + ` + : provider == 'webrtc' + ? html` + ` + : html` + `} + `; + } +} + @customElement('frigate-card-live-frigate') export class FrigateCardLiveFrigate extends LitElement { @property({ attribute: false }) @@ -220,26 +611,38 @@ export class FrigateCardLiveFrigate extends LitElement { // - https://github.com/AlexxIT/WebRTC @customElement('frigate-card-live-webrtc') export class FrigateCardLiveWebRTC extends LitElement { - @property({ attribute: false }) + @property({ attribute: false, hasChanged: contentsChanged }) protected webRTCConfig?: WebRTCConfig; + @property({ attribute: false }) + protected cameraConfig?: CameraConfig; + protected hass?: HomeAssistant & ExtendedHomeAssistant; - protected _webRTCElement: HTMLElement | null = null; - protected _callbacksAdded = false; /** * Create the WebRTC element. May throw. */ - protected _createWebRTC(): TemplateResult | void { + protected _createWebRTC(): HTMLElement | undefined { // eslint-disable-next-line @typescript-eslint/no-explicit-any const webrtcElement = customElements.get('webrtc-camera') as any; if (webrtcElement) { const webrtc = new webrtcElement(); - webrtc.setConfig(this.webRTCConfig); + const config = { ...this.webRTCConfig }; + + // If the live WebRTC configuration does not specify a URL/entity to use, + // then take values from the camera configuration instead (if there are + // any). + if (!config.url) { + config.url = this.cameraConfig?.webrtc?.url; + } + if (!config.entity) { + config.entity = this.cameraConfig?.webrtc?.entity; + } + webrtc.setConfig(config); webrtc.hass = this.hass; - this._webRTCElement = webrtc; + return webrtc; } else { - throw new Error(localize('error.missing_webrtc')); + throw new FrigateCardError(localize('error.webrtc_missing')); } } @@ -251,24 +654,24 @@ export class FrigateCardLiveWebRTC extends LitElement { if (!this.hass) { return; } - if (!this._webRTCElement) { - try { - this._createWebRTC(); - } catch (e) { - return dispatchErrorMessageEvent(this, (e as Error).message); - } + let webrtcElement: HTMLElement | undefined; + try { + webrtcElement = this._createWebRTC(); + } catch (e) { + return dispatchErrorMessageEvent( + this, + e instanceof FrigateCardError + ? (e as FrigateCardError).message + : localize('error.webrtc_reported_error') + ': ' + (e as Error).message, + ); } - return html`${this._webRTCElement}`; + return html`${webrtcElement}`; } /** * Updated lifecycle callback. */ public updated(): void { - if (this._callbacksAdded) { - return; - } - // Extract the video component after it has been rendered and generate the // media load event. this.updateComplete.then(() => { @@ -296,7 +699,6 @@ export class FrigateCardLiveWebRTC extends LitElement { } dispatchPauseEvent(this); }; - this._callbacksAdded = true; } }); } @@ -312,18 +714,15 @@ export class FrigateCardLiveWebRTC extends LitElement { @customElement('frigate-card-live-jsmpeg') export class FrigateCardLiveJSMPEG extends LitElement { @property({ attribute: false }) - protected cameraName?: string; + protected cameraConfig?: CameraConfig; - @property({ attribute: false }) - protected clientId?: string; - - @property({ attribute: false }) + @property({ attribute: false, hasChanged: contentsChanged }) protected jsmpegConfig?: JSMPEGConfig; protected hass?: HomeAssistant & ExtendedHomeAssistant; + protected _jsmpegCanvasElement?: HTMLCanvasElement; protected _jsmpegVideoPlayer?: JSMpeg.VideoElement; - protected _jsmpegURL?: string | null; protected _refreshPlayerTimerID?: number; /** @@ -331,7 +730,7 @@ export class FrigateCardLiveJSMPEG extends LitElement { * @returns A URL or null. */ protected async _getURL(): Promise { - if (!this.hass || !this.clientId || !this.cameraName) { + if (!this.hass || !this.cameraConfig?.client_id || !this.cameraConfig?.camera_name) { return null; } @@ -339,7 +738,8 @@ export class FrigateCardLiveJSMPEG extends LitElement { try { response = await homeAssistantSignPath( this.hass, - `/api/frigate/${this.clientId}` + `/jsmpeg/${this.cameraName}`, + `/api/frigate/${this.cameraConfig.client_id}` + + `/jsmpeg/${this.cameraConfig.camera_name}`, URL_SIGN_EXPIRY_SECONDS, ); } catch (err) { @@ -356,7 +756,7 @@ export class FrigateCardLiveJSMPEG extends LitElement { * Create a JSMPEG player. * @returns A JSMPEG player. */ - protected _createJSMPEGPlayer(): JSMpeg.VideoElement { + protected _createJSMPEGPlayer(url: string): JSMpeg.VideoElement { let videoDecoded = false; const jsmpegOptions = { @@ -380,7 +780,7 @@ export class FrigateCardLiveJSMPEG extends LitElement { return new JSMpeg.VideoElement( this, - this._jsmpegURL, + url, { canvas: this._jsmpegCanvasElement, hooks: { @@ -416,7 +816,6 @@ export class FrigateCardLiveJSMPEG extends LitElement { this._jsmpegCanvasElement.remove(); this._jsmpegCanvasElement = undefined; } - this._jsmpegURL = undefined; } /** @@ -448,35 +847,38 @@ export class FrigateCardLiveJSMPEG extends LitElement { this._jsmpegCanvasElement = document.createElement('canvas'); this._jsmpegCanvasElement.className = 'media'; - this._jsmpegURL = await this._getURL(); - if (this._jsmpegURL) { - this._jsmpegVideoPlayer = this._createJSMPEGPlayer(); + if (!this.cameraConfig?.camera_name) { + return dispatchErrorMessageEvent( + this, + localize('error.no_camera_name') + `: ${JSON.stringify(this.cameraConfig)}`, + ); + } + + const url = await this._getURL(); + if (url) { + this._jsmpegVideoPlayer = this._createJSMPEGPlayer(url); this._refreshPlayerTimerID = window.setTimeout(() => { - this._refreshPlayer(); + this.requestUpdate(); }, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); + } else { + dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign')); } - this.requestUpdate(); } /** * Master render method. */ protected render(): TemplateResult | void { - if ( - this._jsmpegURL === undefined || - !this._jsmpegVideoPlayer || - !this._jsmpegCanvasElement - ) { - return html`${until(this._refreshPlayer(), renderProgressIndicator())}`; - } - if (!this._jsmpegURL) { - return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign')); - } - if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) { - return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player')); - } - return html`${this._jsmpegCanvasElement}`; + const _render = async (): Promise => { + await this._refreshPlayer(); + + if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) { + return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player')); + } + return html`${this._jsmpegCanvasElement}`; + }; + return html`${until(_render(), renderProgressIndicator())}`; } /** diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts new file mode 100644 index 00000000..9b1b73b1 --- /dev/null +++ b/src/components/media-carousel.ts @@ -0,0 +1,303 @@ +import { CSSResultGroup, unsafeCSS } from 'lit'; +import { EmblaCarouselType } from 'embla-carousel'; +import { createRef, Ref } from 'lit/directives/ref'; +import { customElement } from 'lit/decorators.js'; + +import { FrigateCardCarousel } from './carousel.js'; +import type { MediaShowInfo } from '../types.js'; +import { + dispatchExistingMediaShowInfoAsEvent, + isValidMediaShowInfo, +} from '../common.js'; + +import './next-prev-control.js'; + +import mediaCarouselStyle from '../scss/media-carousel.scss'; + +import { FrigateCardNextPreviousControl } from './next-prev-control.js'; + +const getEmptyImageSrc = (width: number, height: number) => + `data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"%3E%3C/svg%3E`; +export const IMG_EMPTY = getEmptyImageSrc(16, 9); + +@customElement('frigate-card-media-carousel') +export class FrigateCardMediaCarousel extends FrigateCardCarousel { + // A "map" from slide number to MediaShowInfo object. + protected _mediaShowInfo: Record = {}; + + // Whether or not a given slide has been successfully lazily loaded. + protected _slideHasBeenLazyLoaded: Record = {}; + + protected _nextControlRef: Ref = createRef(); + protected _previousControlRef: Ref = createRef(); + + /** + * Returns the number of slides to lazily load. 0 means all slides are lazy + * loaded, 1 means that 1 slide on each side of the currently selected slide + * should lazy load, etc. `null` means lazy loading is disabled and everything + * should load simultaneously. + * @returns + */ + protected _getLazyLoadCount(): number | null { + // Defaults to fully-lazy loading. + return 0; + } + + /** + * Determine if lazy loading is being used. + * @returns `true` is lazy loading is in use. + */ + protected _isLazyLoading(): boolean { + return this._getLazyLoadCount() !== null; + } + + protected _destroyCarousel(): void { + super._destroyCarousel(); + + // Notes on instance variables: + // * this._mediaShowInfo: This is set when the media in the DOM loads. If a + // new View included the same media, the DOM would not change and so the + // prior contents would still be valid and would not re-appear (as the + // media would not reload) -- as such, leave this alone on carousel + // destroy. New media in that slide will replace the prior contents on + // load. + // * this._slideHasBeenLazyLoaded: This is a performance optimization and + // can be safely reset. + this._slideHasBeenLazyLoaded = {}; + } + + /** + * Initialize the carousel. + */ + protected _initCarousel(): void { + super._initCarousel(); + + // Necessary because typescript local type narrowing is not paying attention + // to the side-effect of the call to super._initCarousel(). + const carousel = this._carousel as EmblaCarouselType | undefined; + + // Update the view object as the carousel is moved. + carousel?.on('select', this._selectSlideSetViewHandler.bind(this)); + + // Update the next/previous controls as the carousel is moved. + carousel?.on('select', this._selectSlideNextPreviousHandler.bind(this)); + + // Dispatch MediaShow events as the carousel is moved. + carousel?.on('init', this._selectSlideMediaShowHandler.bind(this)); + carousel?.on('select', this._selectSlideMediaShowHandler.bind(this)); + + // Adapt the height of the container to the media as the carousel is moved. + carousel?.on('init', this._adaptiveHeightResizeHandler.bind(this)); + carousel?.on('resize', this._adaptiveHeightResizeHandler.bind(this)); + carousel?.on('init', this._adaptiveHeightSetHandler.bind(this)); + carousel?.on('select', this._adaptiveHeightSetHandler.bind(this)); + carousel?.on('resize', this._adaptiveHeightSetHandler.bind(this)); + + if (this._getLazyLoadCount() != null) { + // Load media as the carousel is moved (if lazy loading is in use). + carousel?.on('init', this._lazyLoadMediaHandler.bind(this)); + carousel?.on('select', this._lazyLoadMediaHandler.bind(this)); + carousel?.on('resize', this._lazyLoadMediaHandler.bind(this)); + } + } + + /** + * Remove height restrictions on the media when the carousel is resized to let + * it naturally render. + * @returns + */ + protected _adaptiveHeightResizeHandler(): void { + if (!this._carousel) { + return; + } + this._carousel.containerNode().style.removeProperty('max-height') + } + + /** + * Adapt the height of the container to the height of the media (for cases + * where the carousel has different media heights, e.g. live cameras with + * different aspect ratios). + */ + protected _adaptiveHeightSetHandler(): void { + if (!this._carousel) { + return; + } + const slides = this._carousel.slideNodes(); + const heights = this._carousel.slidesInView(true).map((index) => { + const firstChild = slides[index].querySelector("*"); + return firstChild ? firstChild.getBoundingClientRect().height : 0; + }) + const targetHeight = Math.max(...heights); + if (targetHeight > 0) { + this._carousel.containerNode().style.maxHeight = `${targetHeight}px`; + } else { + this._carousel.containerNode().style.removeProperty('max-height') + } + } + + /** + * Handle the user selecting a new slide in the carousel. + */ + protected _selectSlideSetViewHandler(): void { + // To be overridden in children. + } + + /** + * Handle updating of the next/previous controls when the carousel is moved. + */ + protected _selectSlideNextPreviousHandler(): void { + // To be overridden in children. + } + + /** + * Handle a next/previous control interaction. + * @param direction The direction requested, previous or next. + */ + protected _nextPreviousHandler(direction: 'previous' | 'next'): void { + if (direction == 'previous') { + this._carousel?.scrollPrev(); + } else if (direction == 'next') { + this._carousel?.scrollNext(); + } + } + + /** + * Lazily load media in the carousel. + */ + protected _lazyLoadMediaHandler(): void { + if (!this._carousel) { + return; + } + const lazyLoadCount = this._getLazyLoadCount(); + if (lazyLoadCount === null) { + return; + } + + const slides = this._carousel.slideNodes(); + const slidesInView = this._carousel.slidesInView(true); + const slidesToLoad = new Set(); + + const minSlide = Math.min(...slidesInView); + const maxSlide = Math.max(...slidesInView); + + // Lazily load 'lazyLoadCount' slides on either side of the slides in view. + for (let i = 1; i <= lazyLoadCount && minSlide - i >= 0; i++) { + slidesToLoad.add(minSlide - i); + } + slidesInView.forEach((index) => slidesToLoad.add(index)); + for (let i = 1; i <= lazyLoadCount && maxSlide + i < slides.length; i++) { + slidesToLoad.add(maxSlide + i); + } + + slidesToLoad.forEach((index) => { + // Only lazy load slides that are not already loaded. + if (this._slideHasBeenLazyLoaded[index]) { + return; + } + this._slideHasBeenLazyLoaded[index] = true; + this._lazyLoadSlide(index, slides[index]); + }); + } + + /** + * Lazy load a slide. + * @param _index The index of the slide to lazy load. + * @param _slide The slide to lazy load. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected _lazyLoadSlide(_index: number, _slide: HTMLElement): void { + // To be overridden in children. + } + + /** + * Fire a media show event when a slide is selected. + */ + protected _selectSlideMediaShowHandler(): void { + if (!this._carousel) { + return; + } + + this._carousel.slidesInView(true).forEach((slideIndex) => { + if (slideIndex in this._mediaShowInfo) { + dispatchExistingMediaShowInfoAsEvent(this, this._mediaShowInfo[slideIndex]); + } + }); + } + + /** + * Handle a media-show event that is generated by a child component, saving the + * contents for future use when the relevant slide is actually shown. + * @param slideIndex The relevant slide index. + * @param event The media-show event from the child component. + */ + protected _mediaShowEventHandler( + slideIndex: number, + event: CustomEvent, + ): void { + // Don't allow the inbound event to propagate upwards, that will be + // automatically done at the appropriate time as the slide is shown. + event.stopPropagation(); + this._mediaLoadedHandler(slideIndex, event.detail); + } + + /** + * Handle a MediaShowInfo object that is generated on media load, by saving it + * for future, or immediate use, when the relevant slide is displayed. + * @param slideIndex The relevant slide index. + * @param mediaShowInfo The MediaShowInfo object generated by the media. + */ + protected _mediaLoadedHandler( + slideIndex: number, + mediaShowInfo?: MediaShowInfo | null, + ): void { + // isValidMediaShowInfo is used to prevent saving media info that will be + // rejected upstream (empty 1x1 images will be rejected here). + if (mediaShowInfo && isValidMediaShowInfo(mediaShowInfo)) { + this._mediaShowInfo[slideIndex] = mediaShowInfo; + if (this._carousel && this._carousel?.slidesInView(true).includes(slideIndex)) { + dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo); + } + + // After media has been loaded, the height of the container may need to be + // re-adjusted. + this._adaptiveHeightSetHandler(); + /** + * Images need a width/height from initial load, and browsers will assume + * that the aspect ratio of the initial dummy-image load will persist. In + * lazy-loading, this can cause a 1x1 pixel dummy image to cause the + * browser to assume all images will be square, so the whole carousel will + * have the wrong aspect-ratio until every single image has been lazily + * loaded. Adaptive height helps in that the carousel gets resized on each + * img display to the correct size, but it still causes a minor noticeable + * flicker until the height change is complete. + * + * To avoid this, we use a 16:9 dummy image at first (most + * likely?) and once the first piece of real media has been loaded, all + * dummy images are replaced with dummy images that match the aspect ratio + * of the real image. It still might be wrong, but it's the best option + * available. + */ + const firstMediaLoad = !Object.keys(this._mediaShowInfo).length; + if (firstMediaLoad && this._getLazyLoadCount() != null) { + const replacementImageSrc = getEmptyImageSrc( + mediaShowInfo.width, + mediaShowInfo.height, + ); + + this.renderRoot.querySelectorAll('.embla__container img').forEach((img) => { + const imageElement = img as HTMLImageElement; + if (imageElement.src === IMG_EMPTY) { + imageElement.src = replacementImageSrc; + } + }); + } + } + } + + /** + * Get element styles. + */ + static get styles(): CSSResultGroup { + return [super.styles, unsafeCSS(mediaCarouselStyle)]; + } +} diff --git a/src/components/menu.ts b/src/components/menu.ts index 787ea5d6..36046859 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -1,12 +1,6 @@ -import { HomeAssistant, handleAction, hasAction } from 'custom-card-helpers'; -import { - CSSResultGroup, - LitElement, - TemplateResult, - html, - unsafeCSS, -} from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { HASSDomEvent, HomeAssistant, handleAction, hasAction } from 'custom-card-helpers'; +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { styleMap } from 'lit/directives/style-map.js'; @@ -16,6 +10,7 @@ import './submenu.js'; import type { Actions, + ActionType, ExtendedHomeAssistant, MenuButton, MenuConfig, @@ -28,7 +23,7 @@ import { } from '../common.js'; import menuStyle from '../scss/menu.scss'; -import { ConditionState, evaluateCondition } from '../card-condition.js'; +import { Corner } from '@material/mwc-menu'; export const FRIGATE_BUTTON_MENU_ICON = 'frigate'; @@ -40,23 +35,29 @@ export class FrigateCardMenu extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant & ExtendedHomeAssistant; - @property({ attribute: false }) set menuConfig(menuConfig: MenuConfig) { + this.expanded = !menuConfig?.mode.startsWith('hidden-'); + this._menuConfig = menuConfig; if (menuConfig) { this.style.setProperty('--frigate-card-menu-button-size', menuConfig.button_size); } } + @state() protected _menuConfig?: MenuConfig; - @property({ attribute: false }) - protected expand = false; + @state() + protected expanded = false; @property({ attribute: false }) public buttons: MenuButton[] = []; - @property({ attribute: false }) - protected conditionState?: ConditionState; + protected _isFrigateCardAction(action: ActionType): boolean { + // Determine if this action is a Frigate card action, if so handle it + // internally. + const frigateCardAction = convertActionToFrigateCardCustomAction(action); + return !!frigateCardAction && frigateCardAction.frigate_card_action == 'frigate'; + } /** * Handle an action on a menu button. @@ -64,7 +65,7 @@ export class FrigateCardMenu extends LitElement { * @param button The button configuration. */ protected _actionHandler( - ev: CustomEvent<{ action: string; config?: Actions }>, + ev: HASSDomEvent<{ action: string; config?: Actions }>, config?: Actions, ): void { if (!ev) { @@ -89,22 +90,19 @@ export class FrigateCardMenu extends LitElement { return; } - // Determine if this action is a Frigate card action, if so handle it - // internally. - const frigateCardAction = convertActionToFrigateCardCustomAction(action); if ( - frigateCardAction && - frigateCardAction.frigate_card_action == 'frigate' && + action && + this._isFrigateCardAction(action) && this._menuConfig?.mode.startsWith('hidden-') ) { // If the user presses the frigate button and it's a hide-away menu, // then expand the menu and return. - this.expand = !this.expand; + this.expanded = !this.expanded; return; } // Collapse menu after the user clicks on something. - this.expand = false; + this.expanded = false; handleAction(this, this.hass as HomeAssistant, config, interaction); } @@ -115,7 +113,15 @@ export class FrigateCardMenu extends LitElement { */ protected _renderButton(button: MenuButton): TemplateResult | void { if (button.type == 'custom:frigate-card-menu-submenu') { + let corner: Corner | undefined; + if (this._menuConfig?.mode.endsWith('-left')) { + // Minor nicety: Start the menu to the right of the menu itself is on + // the left, otherwise use the default. + corner = 'BOTTOM_RIGHT'; + } + return html` `; } - let stateParameters: StateParameters = {...button}; + let stateParameters: StateParameters = { ...button }; if (stateParameters.icon == FRIGATE_BUTTON_MENU_ICON) { stateParameters.icon = - this._menuConfig?.mode.startsWith('hidden-') && !this.expand + this._menuConfig?.mode.startsWith('hidden-') && !this.expanded ? 'mdi:alpha-f-box-outline' : 'mdi:alpha-f-box'; } @@ -146,21 +152,15 @@ export class FrigateCardMenu extends LitElement { button: true, }; - // TODO: Upon a safe distance from the release of HA 2021.11 these - // attributes can be removed from the . - // - icon (replaced with the embedded ) - // - title (replaced with .label) return html` this._actionHandler(ev, button)} .actionHandler=${actionHandler({ hasHold: hasHold, hasDoubleClick: hasDoubleClick, })} + .label=${stateParameters.title || ''} + @action=${(ev) => this._actionHandler(ev, button)} > `; @@ -176,10 +176,7 @@ export class FrigateCardMenu extends LitElement { } const mode = this._menuConfig.mode; - if ( - mode == 'none' || - !evaluateCondition(this._menuConfig.conditions, this.conditionState) - ) { + if (mode == 'none') { return; } @@ -190,10 +187,14 @@ export class FrigateCardMenu extends LitElement { mode.startsWith('overlay-') || mode.startsWith('hover-'), 'expanded-horizontal': - (mode.startsWith('overlay-') || mode.startsWith('hover-') || this.expand) && + (mode.startsWith('overlay-') || + mode.startsWith('hover-') || + (mode.startsWith('hidden-') && this.expanded)) && (mode.endsWith('-top') || mode.endsWith('-bottom')), 'expanded-vertical': - (mode.startsWith('overlay-') || mode.startsWith('hover-') || this.expand) && + (mode.startsWith('overlay-') || + mode.startsWith('hover-') || + (mode.startsWith('hidden-') && this.expanded)) && (mode.endsWith('-left') || mode.endsWith('-right')), full: mode == 'above' || mode == 'below', left: mode.endsWith('-left'), @@ -202,9 +203,14 @@ export class FrigateCardMenu extends LitElement { bottom: mode.endsWith('-bottom'), }; + // If the hidden menu isn't expanded, only show the Frigate button. + const buttons = + !mode.startsWith('hidden-') || this.expanded + ? this.buttons + : this.buttons.filter((button) => button.icon === FRIGATE_BUTTON_MENU_ICON); return html`
- ${Array.from(this.buttons).map((button) => this._renderButton(button))} + ${buttons.map((button) => this._renderButton(button))}
`; } diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts index e9e3d68b..6c37f651 100644 --- a/src/components/next-prev-control.ts +++ b/src/components/next-prev-control.ts @@ -1,5 +1,5 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { NextPreviousControlConfig } from '../types.js'; @@ -9,22 +9,32 @@ import controlStyle from '../scss/next-previous-control.scss'; @customElement('frigate-card-next-previous-control') export class FrigateCardNextPreviousControl extends LitElement { @property({ attribute: false }) - protected direction?: 'next' | 'previous'; + public direction?: 'next' | 'previous'; - @property({ attribute: false }) set controlConfig(controlConfig: NextPreviousControlConfig | undefined) { if (controlConfig?.size) { this.style.setProperty('--frigate-card-next-prev-size', controlConfig.size); } this._controlConfig = controlConfig; } + + @state() protected _controlConfig?: NextPreviousControlConfig; @property({ attribute: false }) - protected thumbnail?: string; + public thumbnail?: string; + + @property({ attribute: false }) + public icon?: string; + + @property({ attribute: true, type: Boolean }) + public disabled = false; + + // Label that is used for ARIA support and as tooltip. + @property() label = ""; protected render(): TemplateResult { - if (!this._controlConfig || this._controlConfig.style == 'none') { + if (this.disabled || !this._controlConfig || this._controlConfig.style == 'none') { return html``; } @@ -33,22 +43,24 @@ export class FrigateCardNextPreviousControl extends LitElement { previous: this.direction == 'previous', next: this.direction == 'next', thumbnails: this._controlConfig.style == 'thumbnails', - chevrons: this._controlConfig.style == 'chevrons', - button: this._controlConfig.style == 'chevrons', + icons: ['chevrons', 'icons'].includes(this._controlConfig.style), + button: ['chevrons', 'icons'].includes(this._controlConfig.style), }; - if (this._controlConfig.style == 'chevrons') { - const icon = this.direction == 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right'; + if (['chevrons', 'icons'].includes(this._controlConfig.style)) { + let icon: string; + if (this._controlConfig.style === 'chevrons') { + icon = this.direction == 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right'; + } else { + if (!this.icon) { + return html``; + } + icon = this.icon + } - // TODO: Upon a safe distance from the release of HA 2021.11 these - // attributes can be removed from the . - // - icon (replaced with the embedded ) - // - title (replaced with .label) return html` `; @@ -60,7 +72,8 @@ export class FrigateCardNextPreviousControl extends LitElement { return html``; } diff --git a/src/components/submenu.ts b/src/components/submenu.ts index c9b9f60e..fc45cbb9 100644 --- a/src/components/submenu.ts +++ b/src/components/submenu.ts @@ -8,6 +8,7 @@ import { actionHandler } from '../action-handler-directive.js'; import { refreshDynamicStateParameters } from '../common.js'; import submenuStyle from '../scss/submenu.scss'; +import type { Corner } from "@material/mwc-menu"; @customElement('frigate-card-submenu') export class FrigateCardSubmenu extends LitElement { @@ -17,6 +18,9 @@ export class FrigateCardSubmenu extends LitElement { @property({ attribute: false }) public submenu?: MenuSubmenu; + @property({ attribute: false }) + public corner?: Corner; + protected _renderItem(item: MenuSubmenuItem): TemplateResult | void { if (!this.hass) { return; @@ -27,6 +31,8 @@ export class FrigateCardSubmenu extends LitElement { { // Attach the action config so ascendants have access to it. @@ -56,7 +62,9 @@ export class FrigateCardSubmenu extends LitElement { } return html` - + { + @click=${() => { if (this._carousel && this._carousel.clickAllowed()) { dispatchFrigateCardEvent(this, 'carousel:tap', { slideIndex: slideIndex, @@ -122,7 +121,11 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { } }} > - +
`; } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 5f3c3d39..3c43bfca 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -1,46 +1,43 @@ -import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { + CSSResultGroup, + LitElement, + PropertyValues, + TemplateResult, + html, + unsafeCSS, +} from 'lit'; import { BrowseMediaUtil } from '../browse-media-util.js'; -import { EmblaCarouselType } from 'embla-carousel'; +import { EmblaOptionsType } from 'embla-carousel'; import { HomeAssistant } from 'custom-card-helpers'; +import { Task } from '@lit-labs/task'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { until } from 'lit/directives/until.js'; import type { BrowseMediaNeighbors, BrowseMediaQueryParameters, BrowseMediaSource, + CameraConfig, ExtendedHomeAssistant, MediaShowInfo, ViewerConfig, } from '../types.js'; -import { FrigateCardCarousel } from './carousel.js'; -import { FrigateCardThumbnailCarousel, ThumbnailCarouselTap } from './thumbnail-carousel.js'; +import { FrigateCardMediaCarousel, IMG_EMPTY } from './media-carousel.js'; +import { FrigateCardNextPreviousControl } from './next-prev-control.js'; +import { + FrigateCardThumbnailCarousel, + ThumbnailCarouselTap, +} from './thumbnail-carousel.js'; import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js'; import { View } from '../view.js'; -import { actionHandler } from '../action-handler-directive.js'; -import { - createMediaShowInfo, - dispatchErrorMessageEvent, - dispatchMessageEvent, - dispatchPauseEvent, - dispatchPlayEvent, - dispatchExistingMediaShowInfoAsEvent, - isValidMediaShowInfo, -} from '../common.js'; -import { localize } from '../localize/localize.js'; +import { contentsChanged, createMediaShowInfo, dispatchErrorMessageEvent } from '../common.js'; import { renderProgressIndicator } from '../components/message.js'; import './next-prev-control.js'; import viewerStyle from '../scss/viewer.scss'; import viewerCoreStyle from '../scss/viewer-core.scss'; -import mediaCarouselStyle from '../scss/media-carousel.scss'; - -const getEmptyImageSrc = (width: number, height: number) => - `data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"%3E%3C/svg%3E`; -const IMG_EMPTY = getEmptyImageSrc(16, 9); @customElement('frigate-card-viewer') export class FrigateCardViewer extends LitElement { @@ -48,85 +45,44 @@ export class FrigateCardViewer extends LitElement { protected hass?: HomeAssistant & ExtendedHomeAssistant; @property({ attribute: false }) - protected view?: View; + protected view?: Readonly; @property({ attribute: false }) protected viewerConfig?: ViewerConfig; @property({ attribute: false }) - protected browseMediaQueryParameters?: BrowseMediaQueryParameters; + protected cameraConfig?: CameraConfig; @property({ attribute: false }) protected resolvedMediaCache?: ResolvedMediaCache; - /** - * Resolve all the given media for a target. - * @param target The target to resolve media from. - * @returns True if the resolutions were all error free. - */ - protected async _resolveAllMediaForTarget( - target: BrowseMediaSource, - ): Promise { - if (!this.hass) { - return false; - } - - let errorFree = true; - for (let i = 0; target.children && i < (target.children || []).length; ++i) { - if (BrowseMediaUtil.isTrueMedia(target.children[i])) { - errorFree &&= !!(await ResolvedMediaUtil.resolveMedia( - this.hass, - target.children[i], - this.resolvedMediaCache, - )); - } - } - return errorFree; - } - /** * Master render method. * @returns A rendered template. */ protected render(): TemplateResult | void { - return html`${until(this._render(), renderProgressIndicator())}`; - } - - /** - * Asyncronously render the element. - * @returns A rendered template. - */ - protected async _render(): Promise { - if (!this.hass || !this.view || !this.browseMediaQueryParameters) { - return html``; + if (!this.hass || !this.view || !this.cameraConfig) { + return; } - if (this.view.is('clip') || this.view.is('snapshot')) { - let parent: BrowseMediaSource | null = null; - try { - parent = await BrowseMediaUtil.browseMediaQuery( - this.hass, - this.browseMediaQueryParameters, - ); - } catch (e) { - return dispatchErrorMessageEvent(this, (e as Error).message); - } - const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent); - if (!parent || !parent.children || childIndex == null) { - return dispatchMessageEvent( - this, - this.view.is('clip') - ? localize('common.no_clip') - : localize('common.no_snapshot'), - this.view.is('clip') ? 'mdi:filmstrip-off' : 'mdi:camera-off', - ); - } - this.view.target = parent; - this.view.childIndex = childIndex; + const browseMediaQueryParameters = + BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError( + this, + this.view, + this.cameraConfig, + ); + if (!browseMediaQueryParameters) { + return; } - if (this.view.target && !(await this._resolveAllMediaForTarget(this.view.target))) { - return dispatchErrorMessageEvent(this, localize('error.could_not_resolve')); + if (!this.view.target) { + BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange( + this, + this.hass, + this.view, + browseMediaQueryParameters, + ); + return renderProgressIndicator(); } return html` `; } @@ -153,9 +109,10 @@ export class FrigateCardViewerCore extends LitElement { protected hass?: HomeAssistant & ExtendedHomeAssistant; @property({ attribute: false }) - protected view?: View; + protected view?: Readonly; - @property({ attribute: false }) + // See note on viewerConfig in . + @property({ attribute: false, hasChanged: contentsChanged }) protected viewerConfig?: ViewerConfig; @property({ attribute: false }) @@ -164,11 +121,11 @@ export class FrigateCardViewerCore extends LitElement { @property({ attribute: false }) protected resolvedMediaCache?: ResolvedMediaCache; - protected _mediaCarouselRef: Ref = createRef(); + protected _viewerCarouselRef: Ref = createRef(); protected _thumbnailCarouselRef: Ref = createRef(); protected _syncThumbnailCarousel(): void { - const mediaSelected = this._mediaCarouselRef.value?.carouselSelected(); + const mediaSelected = this._viewerCarouselRef.value?.carouselSelected(); if (mediaSelected !== undefined) { this._thumbnailCarouselRef.value?.carouselScrollTo(mediaSelected); } @@ -184,7 +141,7 @@ export class FrigateCardViewerCore extends LitElement { .target=${this.view.target} .config=${this.viewerConfig.controls.thumbnails} @frigate-card:carousel:tap=${(ev: CustomEvent) => { - this._mediaCarouselRef.value?.carouselScrollTo(ev.detail.slideIndex); + this._viewerCarouselRef.value?.carouselScrollTo(ev.detail.slideIndex); }} @frigate-card:carousel:init=${this._syncThumbnailCarousel.bind(this)} > @@ -199,8 +156,8 @@ export class FrigateCardViewerCore extends LitElement { this.viewerConfig.controls.thumbnails.mode === 'above' ? this._renderThumbnails() : ''} - - + ${this.viewerConfig && this.viewerConfig.controls.thumbnails.mode === 'below' ? this._renderThumbnails() : ''}`; @@ -222,15 +179,20 @@ export class FrigateCardViewerCore extends LitElement { } } -@customElement('frigate-card-media-carousel') -export class FrigateCardMediaCarousel extends FrigateCardCarousel { +@customElement('frigate-card-viewer-carousel') +export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { @property({ attribute: false }) protected hass?: HomeAssistant & ExtendedHomeAssistant; @property({ attribute: false }) - protected view?: View; + protected view?: Readonly; - @property({ attribute: false }) + // Resetting the viewer configuration causes a full reset so ensure the config + // has actually changed with a full comparison (dynamic configuration + // overrides may causes changes elsewhere in the full card configuration that + // could lead to the address of the viewerConfig changing without it being + // semantically different). + @property({ attribute: false, hasChanged: contentsChanged }) protected viewerConfig?: ViewerConfig; @property({ attribute: false }) @@ -243,48 +205,100 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { // (Folders are not media items that can be rendered). protected _slideToChild: Record = {}; - // A "map" from slide number to MediaShowInfo object. - protected _mediaShowInfo: Record = {}; - - // Whether or not a given slide has been successfully lazily loaded. - protected _slideHasBeenLazyLoaded: Record = {}; + // A task to resolve target media if lazy loading is disabled. + protected _mediaResolutionTask = new Task<[BrowseMediaSource | undefined], void>( + this, + async ([target]: (BrowseMediaSource | undefined)[]): Promise => { + for ( + let i = 0; + !this._isLazyLoading() && + this.hass && + target && + target.children && + i < (target.children || []).length; + ++i + ) { + if (BrowseMediaUtil.isTrueMedia(target.children[i])) { + await ResolvedMediaUtil.resolveMedia( + this.hass, + target.children[i], + this.resolvedMediaCache, + ); + } + } + }, + () => [this.view?.target], + ); /** - * Load the carousel with "slides" (clips or snapshots). + * The updated lifecycle callback for this element. + * @param changedProperties The properties that were changed in this render. */ - protected _loadCarousel(): void { - if (this._carousel || !this.viewerConfig) { - return; + updated(changedProperties: PropertyValues): void { + if (changedProperties.has('viewerConfig')) { + this._destroyCarousel(); } - // Start the carousel on the selected child number. - const startIndex = Number( - Object.keys(this._slideToChild).find( - (key) => this._slideToChild[key] === this.view?.childIndex, - ), + if (changedProperties.has('view')) { + const oldView = changedProperties.get('view') as View | undefined; + if (oldView) { + if (oldView.target != this.view?.target) { + // If the media target is different entirely, reset the carousel. + this._destroyCarousel(); + } else if (this._carousel && this.view?.childIndex != oldView.childIndex) { + const slide = this._getSlideForChild(this.view?.childIndex); + if (slide !== undefined && slide !== this.carouselSelected()) { + // If the media target is the same as already loaded, but isn't of + // the selected slide, scroll to that slide. + this.carouselScrollTo(slide); + } + } + } + } + + super.updated(changedProperties); + } + + protected _destroyCarousel(): void { + super._destroyCarousel(); + + // Notes on instance variables: + // * this._slideToChild: This is set as part of each render and does not + // need to be destroyed here. + } + + protected _getSlideForChild(childIndex: number | undefined): number | undefined { + if (childIndex === undefined) { + return undefined; + } + const slideIndex = Object.keys(this._slideToChild).find( + (key) => this._slideToChild[key] === childIndex, ); + return slideIndex !== undefined ? Number(slideIndex) : undefined; + } - this._options = { - startIndex: isNaN(startIndex) ? undefined : startIndex, - draggable: this.viewerConfig.draggable, + /** + * Get the Embla options to use. + * @returns An EmblaOptionsType object or undefined for no options. + */ + protected _getOptions(): EmblaOptionsType { + return { + // Start the carousel on the selected child number. + startIndex: this._getSlideForChild(this.view?.childIndex), + draggable: this.viewerConfig?.draggable, }; + } - super._loadCarousel(); - - // Necessary because typescript local type narrowing is not paying attention - // to the side-effect of the call to super._loadCarousel(). - const carousel = this._carousel as EmblaCarouselType | undefined; - - // Update views and dispatch media-show events based on slide selections. - carousel?.on('select', this._selectSlideSetViewHandler.bind(this)); - carousel?.on('select', this._selectSlideMediaShowHandler.bind(this)); - - // Lazily load media that is displayed. These handlers are registered - // regardless of the value of this.lazyLoad to allow that value to change - // after the carousel has been initialized. - carousel?.on('init', this._lazyLoadMediaHandler.bind(this)); - carousel?.on('select', this._lazyLoadMediaHandler.bind(this)); - carousel?.on('resize', this._lazyLoadMediaHandler.bind(this)); + /** + * Returns the number of slides to lazily load. 0 means all slides are lazy + * loaded, 1 means that 1 slide on each side of the currently selected slide + * should lazy load, etc. `null` means lazy loading is disabled and everything + * should load simultaneously. + * @returns + */ + protected _getLazyLoadCount(): number | null { + // Defaults to fully-lazy loading. + return this.viewerConfig?.lazy_load === false ? null : 0; } /** @@ -410,7 +424,8 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { const clipStartTime = BrowseMediaUtil.extractEventStartTime(child); if (clipStartTime && clipStartTime === snapshotStartTime) { return new View({ - view: 'clip-specific', + view: 'clip', + camera: this.view.camera, target: clips, childIndex: i, previous: this.view, @@ -433,56 +448,44 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { if (slidesInView.length) { const childIndex = this._slideToChild[slidesInView[0]]; if (childIndex !== undefined) { - // Update the currently live view in place. - this.view.childIndex = childIndex; - this.requestUpdate(); + this.view + .evolve({ + childIndex: childIndex, + previous: this.view, + }) + .dispatchChangeEvent(this); } } } /** - * Handle a next/previous control interaction. - * @param direction The direction requested, previous or next. + * Lazy load a slide. + * @param index The index of the slide to lazy load. + * @param slide The slide to lazy load. */ - protected _nextPreviousHandler(direction: 'previous' | 'next'): void { - if (direction == 'previous') { - this._carousel?.scrollPrev(); - } else if (direction == 'next') { - this._carousel?.scrollNext(); - } - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected _lazyLoadSlide(index: number, slide: HTMLElement): void { + const childIndex: number | undefined = this._slideToChild[index]; - /** - * Lazily load media in the carousel. - */ - protected _lazyLoadMediaHandler(): void { - if (!this.viewerConfig?.lazy_load || !this._carousel) { + if ( + childIndex == undefined || + !this.hass || + !this.view || + !this.view.target || + !this.view.target.children || + !BrowseMediaUtil.isTrueMedia(this.view.target.children[childIndex]) + ) { return; } - const slides = this._carousel.slideNodes(); - const slidesInView = this._carousel.slidesInView(true); - const slidesToLoad = new Set(); - // Lazily load the selected slide and the one on each side of it to improve - // the user navigation experience. - for (let i = 0; i < slidesInView.length; i++) { - const index = slidesInView[i]; - if (index > 0) { - slidesToLoad.add(index - 1); - } - slidesToLoad.add(index); - if (index < slides.length - 1) { - slidesToLoad.add(index + 1); - } - } - - slidesToLoad.forEach((index) => { - // Only lazy load slides that are not already loaded. - if (this._slideHasBeenLazyLoaded[index]) { + ResolvedMediaUtil.resolveMedia( + this.hass, + this.view.target.children[childIndex], + this.resolvedMediaCache, + ).then((resolvedMedia) => { + if (!resolvedMedia) { return; } - this._slideHasBeenLazyLoaded[index] = true; - const slide = slides[index]; // Snapshots. const img = slide.querySelector('img') as HTMLImageElement; @@ -492,181 +495,177 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { 'frigate-card-ha-hls-player', ) as HTMLElement & { url: string }; - // Frigate < 0.9.0 clips. frigate-card-ha-hls-player will also have a - // video source element, so search for that first. - const video_source = slide.querySelector('video source') as HTMLElement & { - src: string; - }; - if (img) { - img.src = img.getAttribute('data-src') || img.src; + img.src = resolvedMedia.url; } else if (hls_player) { - hls_player.url = hls_player.getAttribute('data-url') || hls_player.url; - } else if (video_source) { - video_source.src = video_source.getAttribute('data-src') || video_source.src; + hls_player.url = resolvedMedia.url; } }); } /** - * Get slides to include in the render. - * @returns The slides to include in the render. + * Handle updating of the next/previous controls when the carousel is moved. */ - protected _getSlides(): TemplateResult[] { + protected _selectSlideNextPreviousHandler(): void { + const updateNextPreviousControl = ( + control: FrigateCardNextPreviousControl, + direction: 'previous' | 'next', + ): void => { + const neighbors = this._getMediaNeighbors(); + const [prev, next] = [neighbors?.previous, neighbors?.next]; + const target = direction == 'previous' ? prev : next; + + control.disabled = target == null; + control.title = target && target.title ? target.title : ''; + control.thumbnail = target && target.thumbnail ? target.thumbnail : undefined; + }; + + if (this._previousControlRef.value) { + updateNextPreviousControl(this._previousControlRef.value, 'previous'); + } + if (this._nextControlRef.value) { + updateNextPreviousControl(this._nextControlRef.value, 'next'); + } + } + + /** + * Play the clip being shown to the user (video player may already be loaded + * depending on the lazyload configuration). + */ + protected _autoplayHandler(): void { + if (!this._carousel) { + return; + } + const nodes = this._carousel.slideNodes(); + this._carousel.slidesInView(true).forEach((slide) => { + const player = nodes[slide].querySelector('frigate-card-ha-hls-player') as + | (HTMLElement & { play: () => void }) + | undefined; + if (player) { + player.play(); + } + }); + } + + /** + * Initialize the carousel. + */ + protected _initCarousel(): void { + super._initCarousel(); + + if (this._carousel && this.viewerConfig && this.viewerConfig.autoplay_clip) { + this._carousel.on('init', this._autoplayHandler.bind(this)); + this._carousel.on('select', this._autoplayHandler.bind(this)); + } + } + + /** + * Get slides to include in the render. + * @returns The slides to include in the render and an index keyed by slide + * number that maps to child number. + */ + protected _getSlides(): [TemplateResult[], Record] { if ( !this.view || !this.view.target || !this.view.target.children || !this.view.target.children.length ) { - return []; + return [[], {}]; } - this._slideToChild = {}; + const slideToChild: Record = {}; const slides: TemplateResult[] = []; for (let i = 0; i < this.view.target.children?.length; ++i) { const slide = this._renderMediaItem(this.view.target.children[i], slides.length); if (slide) { - this._slideToChild[slides.length] = i; + slideToChild[slides.length] = i; slides.push(slide); } } - return slides; + return [slides, slideToChild]; + } + + /** + * Determine if all the media in the carousel are resolved. + */ + protected _isMediaFullyResolved(): boolean { + for (const child of this.view?.target?.children || []) { + if (!this.resolvedMediaCache?.has(child.media_content_id)) { + return false; + } + } + return true; + } + + /** + * Render the element, resolving the media first if necessary. + */ + protected render(): TemplateResult | void { + this._slideToChild = {}; + + // If lazy loading is not enabled, wait for the media resolver task to + // complete and show a progress indictator until this. + if (!this._isLazyLoading() && !this._isMediaFullyResolved()) { + return html`${this._mediaResolutionTask.render({ + initial: () => renderProgressIndicator(), + pending: () => renderProgressIndicator(), + error: (e: unknown) => dispatchErrorMessageEvent(this, (e as Error).message), + complete: () => this._render(), + })}`; + } + return this._render(); } /** * Render the element. * @returns A template to display to the user. */ - protected render(): TemplateResult | void { - const slides = this._getSlides(); + protected _render(): TemplateResult | void { + const [slides, slideToChild] = this._getSlides(); + this._slideToChild = slideToChild; if (!slides) { return; } const neighbors = this._getMediaNeighbors(); + const [prev, next] = [neighbors?.previous, neighbors?.next]; return html`
- ${neighbors && neighbors.previous - ? html` { - this._nextPreviousHandler('previous'); - }} - >` - : ``} + { + this._nextPreviousHandler('previous'); + }} + >
${slides}
- ${neighbors && neighbors.next - ? html` { - this._nextPreviousHandler('next'); - }} - >` - : ``} + { + this._nextPreviousHandler('next'); + }} + >
`; } - /** - * Fire a media show event when a slide is selected. - */ - protected _selectSlideMediaShowHandler(): void { - if (!this._carousel || !this.view) { - return; - } - - this._carousel.slidesInView(true).forEach((slideIndex) => { - if (slideIndex in this._mediaShowInfo) { - dispatchExistingMediaShowInfoAsEvent(this, this._mediaShowInfo[slideIndex]); - } - }); - } - - /** - * Handle a media-show event that is generated by a child component, saving the - * contents for future use when the relevant slide is shown. - * @param slideIndex The relevant slide index. - * @param event The media-show event from the child component. - */ - protected _mediaShowEventHandler( - slideIndex: number, - event: CustomEvent, - ): void { - // Don't allow the inbound event to propagate upwards, that will be - // automatically done at the appropriate time as the slide is shown. - event.stopPropagation(); - this._mediaShowInfoHandler(slideIndex, event.detail); - } - - /** - * Handle a MediaShowInfo object that is generated on media load, by saving it - * for future, or immediate use, when the relevant slide is displayed. - * @param slideIndex The relevant slide index. - * @param mediaShowInfo The MediaShowInfo object generated by the media. - */ - protected _mediaShowInfoHandler( - slideIndex: number, - mediaShowInfo?: MediaShowInfo | null, - ): void { - // isValidMediaShowInfo is used to prevent saving media info that will be - // rejected upstream. - if (this.viewerConfig && mediaShowInfo && isValidMediaShowInfo(mediaShowInfo)) { - const firstMediaLoad = !Object.keys(this._mediaShowInfo).length; - this._mediaShowInfo[slideIndex] = mediaShowInfo; - if (this._carousel && this._carousel?.slidesInView(true).includes(slideIndex)) { - dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo); - } - /** - * Images need a width/height from initial load, and browsers will assume - * that the aspect ratio of the initial dummy-image load will persist. In - * lazy-loading, this can cause a 1x1 pixel dummy image to cause the - * browser to assume all images will be square, so the whole carousel will - * have the wrong aspect-ratio until every single image has been lazily - * loaded. To avoid this, we use a 16:9 dummy image at first (most - * likely?) and once the first piece of real media has been loaded, all - * dummy images are replaced with dummy images that match the aspect ratio - * of the real image. It still might be wrong, but it's the best option - * available. - */ - if (firstMediaLoad && this.viewerConfig.lazy_load) { - const replacementImageSrc = getEmptyImageSrc( - mediaShowInfo.width, - mediaShowInfo.height, - ); - - this.renderRoot.querySelectorAll('.embla__container img').forEach((img) => { - const imageElement: HTMLImageElement = img as HTMLImageElement; - if (imageElement.src === IMG_EMPTY) { - imageElement.src = replacementImageSrc; - } - }); - } - } - } - protected _renderMediaItem( mediaToRender: BrowseMediaSource, slideIndex: number, ): TemplateResult | void { - // media that can be expanded (folders) cannot be resolved to a single media - // item, skip them. + // Skip folders as they cannot be rendered by this viewer. if ( !this.view || !this.viewerConfig || @@ -675,68 +674,33 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { return; } + const lazyLoad = this._isLazyLoading(); const resolvedMedia = this.resolvedMediaCache?.get(mediaToRender.media_content_id); - if (!resolvedMedia) { + if (!resolvedMedia && !lazyLoad) { return; } - // In this block, no clip has been manually selected, so this is loading - // the most recent clip on card load. In this mode, autoplay of the clip - // may be disabled by configuration. If does not make sense to disable - // autoplay when the user has explicitly picked an event to play in the - // gallery. - let autoplay = true; - if (this.view.is('clip') || this.view.is('snapshot')) { - autoplay = this.viewerConfig.autoplay_clip; - } - - const lazyLoad = this.viewerConfig.lazy_load; - return html`
${this.view.isClipRelatedView() - ? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl' - ? html`) => - this._mediaShowEventHandler(slideIndex, e)} - > - ` - : html`` - : html` { + url=${ifDefined(lazyLoad ? undefined : resolvedMedia?.url)} + .hass=${this.hass} + @frigate-card:media-show=${(e: CustomEvent) => + this._mediaShowEventHandler(slideIndex, e)} + > + ` + : html` { if (this._carousel?.clickAllowed()) { this._findRelatedClipView(mediaToRender).then((view) => { if (view) { @@ -747,22 +711,19 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { }} @load="${(e: Event) => { if ( - this.viewerConfig && - (!this.viewerConfig.lazy_load || - this._slideHasBeenLazyLoaded[slideIndex]) + // This handler will be called on the empty image (including + // an updated empty image that is the same dimensions large as + // the previously fully loaded image -- see the note on dummy + // images in media-carousel.ts). Here we need to only call the + // media load handler on a 'real' load. + !lazyLoad || + this._slideHasBeenLazyLoaded[slideIndex] ) { - this._mediaShowInfoHandler(slideIndex, createMediaShowInfo(e)); + this._mediaLoadedHandler(slideIndex, createMediaShowInfo(e)); } }}" />`}
`; } - - /** - * Get element styles. - */ - static get styles(): CSSResultGroup { - return [super.styles, unsafeCSS(mediaCarouselStyle)]; - } } diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts index d9691677..a80b9e1b 100644 --- a/src/config-mgmt.ts +++ b/src/config-mgmt.ts @@ -1,24 +1,29 @@ -import delve from 'dlv'; -import { dset } from 'dset'; +import { get, set } from 'lodash-es'; import { + CONF_CAMERAS, + CONF_CAMERAS_ARRAY_CAMERA_ENTITY, + CONF_CAMERAS_ARRAY_CAMERA_NAME, + CONF_CAMERAS_ARRAY_CLIENT_ID, + CONF_CAMERAS_ARRAY_LABEL, + CONF_CAMERAS_ARRAY_LIVE_PROVIDER, + CONF_CAMERAS_ARRAY_URL, + CONF_CAMERAS_ARRAY_WEBRTC_ENTITY, + CONF_CAMERAS_ARRAY_WEBRTC_URL, + CONF_CAMERAS_ARRAY_ZONE, CONF_EVENT_VIEWER_AUTOPLAY_CLIP, CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, - CONF_FRIGATE_CAMERA_NAME, - CONF_FRIGATE_CLIENT_ID, - CONF_FRIGATE_LABEL, - CONF_FRIGATE_URL, - CONF_FRIGATE_ZONE, CONF_IMAGE_SRC, CONF_LIVE_PRELOAD, - CONF_LIVE_PROVIDER, + CONF_MENU, CONF_MENU_BUTTON_SIZE, CONF_MENU_MODE, + CONF_OVERRIDES, CONF_VIEW_DEFAULT, CONF_VIEW_TIMEOUT, CONF_VIEW_UPDATE_ENTITIES, } from './const'; -import { RawFrigateCardConfig } from './types'; +import { RawFrigateCardConfig, RawFrigateCardConfigArray } from './types'; /** * Set a configuration value. @@ -26,12 +31,13 @@ import { RawFrigateCardConfig } from './types'; * @param key The key to the property to set. * @param value The value to set. */ + export const setConfigValue = ( obj: RawFrigateCardConfig, - key: string, + keys: string | (string | number)[], value: unknown, ): void => { - dset(obj, key, value); + set(obj, keys, value); }; /** @@ -42,10 +48,10 @@ export const setConfigValue = ( */ export const getConfigValue = ( obj: RawFrigateCardConfig, - key: string, + keys: string | (string | number)[], def?: unknown, ): unknown => { - return delve(obj, key, def); + return get(obj, keys, def); }; /** @@ -93,19 +99,23 @@ export const isConfigUpgradeable = function (obj: RawFrigateCardConfig): boolean /** * Remove empty sections from a configuration. * @param obj Configuration object. + * @returns `true` if the configuration was modified. */ -export const trimConfig = function (obj: RawFrigateCardConfig): void { +export const trimConfig = function (obj: RawFrigateCardConfig): boolean { const keys = Object.keys(obj); + let modified = false; for (let i = 0; i < keys.length; i++) { const key = keys[i]; if (typeof obj[key] === 'object' && obj[key] != null) { - trimConfig(obj[key] as RawFrigateCardConfig); + modified ||= trimConfig(obj[key] as RawFrigateCardConfig); if (!Object.keys(obj[key] as RawFrigateCardConfig).length) { delete obj[key]; + modified = true; } } } + return modified; }; /** @@ -128,6 +138,42 @@ const isNotObject = function (value: unknown) { /** * Move a property from one location to another. + * @param obj The configuration object in which the property resides. + * @param oldPath The old property path. + * @param newPath The new property path. + * @param transform An optional transform for the value. + * @returns `true` if the configuration was modified. + */ +export const moveConfigValue = ( + obj: RawFrigateCardConfig, + oldPath: string, + newPath: string, + transform?: (valueIn: unknown) => unknown, +): boolean => { + let value = getConfigValue(obj, oldPath); + if (transform) { + value = transform(value); + } + if (typeof value !== 'undefined') { + deleteConfigValue(obj, oldPath); + setConfigValue(obj, newPath, value); + return true; + } + return false; +}; + +/** + * Given an array path, return a true path. + * @param path The array path (should have a '#'). + * @param index The numeric array index to use. + * @returns The true config path. + */ +export const getArrayConfigPath = (path: string, index: number): string => { + return path.replace('#', `[${index.toString()}]`); +}; + +/** + * Upgrade by moving a property from one location to another. * @param oldPath The old property path. * @param newPath The new property path. * @param transform An optional transform for the value. @@ -139,29 +185,88 @@ const upgradeMoveTo = function ( transform?: (valueIn: unknown) => unknown, ): (obj: RawFrigateCardConfig) => boolean { return function (obj: RawFrigateCardConfig): boolean { - let value = getConfigValue(obj, oldPath); - if (transform) { - value = transform(value); + return moveConfigValue(obj, oldPath, newPath, transform); + }; +}; + +/** + * Upgrade from a singular camera model to multiple. + * @param key A string key. + * @returns A safe key. + */ +const upgradeToMultipleCameras = (): ((obj: RawFrigateCardConfig) => boolean) => { + return function (obj: RawFrigateCardConfig): boolean { + let modified = false; + const cameras = getConfigValue(obj, CONF_CAMERAS) as RawFrigateCardConfigArray; + + // Only do an upgrade if the cameras section does not exist. + if (cameras !== undefined) { + return false; } - if (typeof value !== 'undefined') { - deleteConfigValue(obj, oldPath); - setConfigValue(obj, newPath, value); - return true; + + const imports = { + camera_entity: CONF_CAMERAS_ARRAY_CAMERA_ENTITY, + 'frigate.camera_name': CONF_CAMERAS_ARRAY_CAMERA_NAME, + 'frigate.client_id': CONF_CAMERAS_ARRAY_CLIENT_ID, + 'frigate.label': CONF_CAMERAS_ARRAY_LABEL, + 'frigate.url': CONF_CAMERAS_ARRAY_URL, + 'frigate.zone': CONF_CAMERAS_ARRAY_ZONE, + 'live.webrtc.entity': CONF_CAMERAS_ARRAY_WEBRTC_ENTITY, + 'live.webrtc.url': CONF_CAMERAS_ARRAY_WEBRTC_URL, + 'live.provider': CONF_CAMERAS_ARRAY_LIVE_PROVIDER, + }; + Object.keys(imports).forEach((key) => { + modified = + moveConfigValue(obj, key, getArrayConfigPath(imports[key], 0)) || modified; + }); + return modified; + }; +}; + +/** + * Upgrade from a condition on the menu (to allow rendering) to a menu mode + * override instead. + * @param key A string key. + * @returns A safe key. + */ +const updateMenuConditionToMenuOverride = (): (( + obj: RawFrigateCardConfig, +) => boolean) => { + return function (obj: RawFrigateCardConfig): boolean { + const menuConditions = getConfigValue( + obj, + `${CONF_MENU}.conditions`, + ) as RawFrigateCardConfig; + + if (menuConditions === undefined) { + return false; } - return false; + + const overrides = + (getConfigValue(obj, `${CONF_OVERRIDES}`) as RawFrigateCardConfigArray) || []; + setConfigValue(obj, `${CONF_OVERRIDES}.[${overrides.length}]`, { + conditions: menuConditions, + overrides: { + menu: { + mode: 'none', + }, + }, + }); + deleteConfigValue(obj, `${CONF_MENU}.conditions`); + return true; }; }; const UPGRADES = [ // v1.2.1 -> v2.0.0 - upgradeMoveTo('frigate_url', CONF_FRIGATE_URL), - upgradeMoveTo('frigate_client_id', CONF_FRIGATE_CLIENT_ID), - upgradeMoveTo('frigate_camera_name', CONF_FRIGATE_CAMERA_NAME), - upgradeMoveTo('label', CONF_FRIGATE_LABEL), - upgradeMoveTo('zone', CONF_FRIGATE_ZONE), + upgradeMoveTo('frigate_url', 'frigate.url'), + upgradeMoveTo('frigate_client_id', 'frigate.client_id'), + upgradeMoveTo('frigate_camera_name', 'frigate.camera_name'), + upgradeMoveTo('label', 'frigate.label'), + upgradeMoveTo('zone', 'frigate.zone'), upgradeMoveTo('view_default', CONF_VIEW_DEFAULT), upgradeMoveTo('view_timeout', CONF_VIEW_TIMEOUT), - upgradeMoveTo('live_provider', CONF_LIVE_PROVIDER), + upgradeMoveTo('live_provider', 'live.provider'), upgradeMoveTo('live_preload', CONF_LIVE_PRELOAD), upgradeMoveTo('webrtc', 'live.webrtc'), upgradeMoveTo('autoplay_clip', CONF_EVENT_VIEWER_AUTOPLAY_CLIP), @@ -174,4 +279,8 @@ const UPGRADES = [ // v2.0.0 -> v2.1.0 upgradeMoveTo('update_entities', CONF_VIEW_UPDATE_ENTITIES), + + // v2.1.0 -> v3.0.0 + upgradeToMultipleCameras(), + updateMenuConditionToMenuOverride(), ]; diff --git a/src/const.ts b/src/const.ts index 9a3cea39..a270cc8e 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,55 +1,82 @@ -export const CARD_VERSION = '2.1.0'; -export const REPO_URL = 'https://github.com/dermotduffy/frigate-hass-card'; -export const TROUBLESHOOTING_URL = `${REPO_URL}#troubleshooting`; +export const CARD_VERSION = '2.1.0' as const; +export const REPO_URL = 'https://github.com/dermotduffy/frigate-hass-card' as const; +export const TROUBLESHOOTING_URL = `${REPO_URL}#troubleshooting` as const; -export const CONF_CAMERA_ENTITY = 'camera_entity'; -export const CONF_FRIGATE_CAMERA_NAME = 'frigate.camera_name'; -export const CONF_FRIGATE_CLIENT_ID = 'frigate.client_id'; -export const CONF_FRIGATE_LABEL = 'frigate.label'; -export const CONF_FRIGATE_URL = 'frigate.url'; -export const CONF_FRIGATE_ZONE = 'frigate.zone'; +export const CONF_CAMERAS = 'cameras' as const; +export const CONF_CAMERAS_ARRAY_CAMERA_ENTITY = + `${CONF_CAMERAS}.#.camera_entity` as const; +export const CONF_CAMERAS_ARRAY_CAMERA_NAME = `${CONF_CAMERAS}.#.camera_name` as const; +export const CONF_CAMERAS_ARRAY_CLIENT_ID = `${CONF_CAMERAS}.#.client_id` as const; +export const CONF_CAMERAS_ARRAY_LABEL = `${CONF_CAMERAS}.#.label` as const; +export const CONF_CAMERAS_ARRAY_URL = `${CONF_CAMERAS}.#.frigate_url` as const; +export const CONF_CAMERAS_ARRAY_ZONE = `${CONF_CAMERAS}.#.zone` as const; +export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const; +export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` as const; +export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const; +export const CONF_CAMERAS_ARRAY_WEBRTC_ENTITY = + `${CONF_CAMERAS}.#.webrtc.entity` as const; +export const CONF_CAMERAS_ARRAY_WEBRTC_URL = `${CONF_CAMERAS}.#.webrtc.url` as const; +export const CONF_CAMERAS_ARRAY_LIVE_PROVIDER = `${CONF_CAMERAS}.#.live_provider` as const; -export const CONF_VIEW_DEFAULT = 'view.default'; -export const CONF_VIEW_TIMEOUT = 'view.timeout'; -export const CONF_VIEW_UPDATE_FORCE = 'view.update_force'; -export const CONF_VIEW_UPDATE_ENTITIES = 'view.update_entities'; +export const CONF_VIEW = 'view' as const; +export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const; +export const CONF_VIEW_TIMEOUT = `${CONF_VIEW}.timeout` as const; +export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const; +export const CONF_VIEW_UPDATE_ENTITIES = `${CONF_VIEW}.update_entities` as const; -export const CONF_EVENT_VIEWER_AUTOPLAY_CLIP = 'event_viewer.autoplay_clip'; -export const CONF_EVENT_VIEWER_DRAGGABLE = 'event_viewer.draggable'; -export const CONF_EVENT_VIEWER_LAZY_LOAD = 'event_viewer.lazy_load'; +export const CONF_EVENT_VIEWER = 'event_viewer' as const; +export const CONF_EVENT_VIEWER_AUTOPLAY_CLIP = + `${CONF_EVENT_VIEWER}.autoplay_clip` as const; +export const CONF_EVENT_VIEWER_DRAGGABLE = `${CONF_EVENT_VIEWER}.draggable` as const; +export const CONF_EVENT_VIEWER_LAZY_LOAD = `${CONF_EVENT_VIEWER}.lazy_load` as const; export const CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE = - 'event_viewer.controls.next_previous.style'; + `${CONF_EVENT_VIEWER}.controls.next_previous.style` as const; export const CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE = - 'event_viewer.controls.next_previous.size'; + `${CONF_EVENT_VIEWER}.controls.next_previous.size` as const; export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE = - 'event_viewer.controls.thumbnails.mode'; + `${CONF_EVENT_VIEWER}.controls.thumbnails.mode` as const; export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE = - 'event_viewer.controls.thumbnails.size'; - -export const CONF_LIVE_PRELOAD = 'live.preload'; -export const CONF_LIVE_PROVIDER = 'live.provider'; -export const CONF_LIVE_WEBRTC_ENTITY = 'live.webrtc.entity'; -export const CONF_LIVE_WEBRTC_URL = 'live.webrtc.url'; + `${CONF_EVENT_VIEWER}.controls.thumbnails.size` as const; +export const CONF_LIVE = 'live' as const; +export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE = + `${CONF_LIVE}.controls.next_previous.style` as const; +export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE = + `${CONF_LIVE}.controls.next_previous.size` as const; +export const CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA = + `${CONF_LIVE}.controls.thumbnails.media` as const; export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE = - 'live.controls.thumbnails.mode'; + `${CONF_LIVE}.controls.thumbnails.mode` as const; export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE = - 'live.controls.thumbnails.size'; - export const CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA = - 'live.controls.thumbnails.media'; + `${CONF_LIVE}.controls.thumbnails.size` as const; +export const CONF_LIVE_DRAGGABLE = `${CONF_LIVE}.draggable` as const; +export const CONF_LIVE_JSMPEG = `${CONF_LIVE}.jsmpeg` as const; +export const CONF_LIVE_LAZY_LOAD = `${CONF_LIVE}.lazy_load` as const; +export const CONF_LIVE_PRELOAD = `${CONF_LIVE}.preload` as const; +export const CONF_LIVE_WEBRTC = `${CONF_LIVE}.webrtc` as const; +export const CONF_LIVE_WEBRTC_ENTITY = `${CONF_LIVE_WEBRTC}.entity` as const; +export const CONF_LIVE_WEBRTC_URL = `${CONF_LIVE_WEBRTC}.url` as const; -export const CONF_IMAGE_SRC = 'image.src'; +export const CONF_IMAGE = 'image' as const; +export const CONF_IMAGE_SRC = `${CONF_IMAGE}.src` as const; -export const CONF_MENU_BUTTONS_FRIGATE = 'menu.buttons.frigate'; -export const CONF_MENU_BUTTONS_FRIGATE_UI = 'menu.buttons.frigate_ui'; -export const CONF_MENU_BUTTONS_FRIGATE_FULLSCREEN = 'menu.buttons.fullscreen'; -export const CONF_MENU_BUTTONS_FRIGATE_DOWNLOAD = 'menu.buttons.download'; -export const CONF_MENU_BUTTONS_LIVE = 'menu.buttons.live'; -export const CONF_MENU_BUTTONS_CLIPS = 'menu.buttons.clips'; -export const CONF_MENU_BUTTONS_SNAPSHOTS = 'menu.buttons.snapshots'; -export const CONF_MENU_BUTTONS_IMAGE = 'menu.buttons.image'; -export const CONF_MENU_BUTTON_SIZE = 'menu.button_size'; -export const CONF_MENU_MODE = 'menu.mode'; +export const CONF_MENU = 'menu' as const; +export const CONF_MENU_BUTTONS_FRIGATE = `${CONF_MENU}.buttons.frigate` as const; +export const CONF_MENU_BUTTONS_FRIGATE_UI = `${CONF_MENU}.buttons.frigate_ui` as const; +export const CONF_MENU_BUTTONS_FRIGATE_FULLSCREEN = + `${CONF_MENU}.buttons.fullscreen` as const; +export const CONF_MENU_BUTTONS_FRIGATE_DOWNLOAD = + `${CONF_MENU}.buttons.download` as const; +export const CONF_MENU_BUTTONS_LIVE = `${CONF_MENU}.buttons.live` as const; +export const CONF_MENU_BUTTONS_CLIPS = `${CONF_MENU}.buttons.clips` as const; +export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const; +export const CONF_MENU_BUTTONS_IMAGE = `${CONF_MENU}.buttons.image` as const; +export const CONF_MENU_BUTTON_SIZE = `${CONF_MENU}.button_size` as const; +export const CONF_MENU_MODE = `${CONF_MENU}.mode` as const; -export const CONF_DIMENSIONS_ASPECT_RATIO = 'dimensions.aspect_ratio'; -export const CONF_DIMENSIONS_ASPECT_RATIO_MODE = 'dimensions.aspect_ratio_mode'; +export const CONF_DIMENSIONS = 'dimensions' as const; +export const CONF_DIMENSIONS_ASPECT_RATIO = `${CONF_DIMENSIONS}.aspect_ratio` as const; +export const CONF_DIMENSIONS_ASPECT_RATIO_MODE = + `${CONF_DIMENSIONS}.aspect_ratio_mode` as const; + +export const CONF_OVERRIDES = 'overrides' as const; \ No newline at end of file diff --git a/src/editor.ts b/src/editor.ts index 7683c523..63c0d2a8 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -5,20 +5,26 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { HomeAssistant, LovelaceCardEditor, fireEvent } from 'custom-card-helpers'; import { localize } from './localize/localize.js'; -import { frigateCardConfigDefaults, RawFrigateCardConfig } from './types.js'; +import { + frigateCardConfigDefaults, + RawFrigateCardConfig, + RawFrigateCardConfigArray, +} from './types.js'; -import frigate_card_editor_style from './scss/editor.scss'; import { - copyConfig, - deleteConfigValue, - getConfigValue, - isConfigUpgradeable, - setConfigValue, - trimConfig, - upgradeConfig, -} from './config-mgmt.js'; -import { - CONF_CAMERA_ENTITY, + CONF_CAMERAS, + CONF_CAMERAS_ARRAY_CAMERA_ENTITY, + CONF_CAMERAS_ARRAY_CAMERA_NAME, + CONF_CAMERAS_ARRAY_CLIENT_ID, + CONF_CAMERAS_ARRAY_ICON, + CONF_CAMERAS_ARRAY_ID, + CONF_CAMERAS_ARRAY_LABEL, + CONF_CAMERAS_ARRAY_LIVE_PROVIDER, + CONF_CAMERAS_ARRAY_TITLE, + CONF_CAMERAS_ARRAY_URL, + CONF_CAMERAS_ARRAY_WEBRTC_ENTITY, + CONF_CAMERAS_ARRAY_WEBRTC_URL, + CONF_CAMERAS_ARRAY_ZONE, CONF_DIMENSIONS_ASPECT_RATIO, CONF_DIMENSIONS_ASPECT_RATIO_MODE, CONF_EVENT_VIEWER_AUTOPLAY_CLIP, @@ -28,19 +34,15 @@ import { CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE, CONF_EVENT_VIEWER_DRAGGABLE, CONF_EVENT_VIEWER_LAZY_LOAD, - CONF_FRIGATE_CAMERA_NAME, - CONF_FRIGATE_CLIENT_ID, - CONF_FRIGATE_LABEL, - CONF_FRIGATE_URL, - CONF_FRIGATE_ZONE, CONF_IMAGE_SRC, + CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, + CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA, CONF_LIVE_CONTROLS_THUMBNAILS_MODE, CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, + CONF_LIVE_DRAGGABLE, + CONF_LIVE_LAZY_LOAD, CONF_LIVE_PRELOAD, - CONF_LIVE_PROVIDER, - CONF_LIVE_WEBRTC_ENTITY, - CONF_LIVE_WEBRTC_URL, CONF_MENU_BUTTONS_CLIPS, CONF_MENU_BUTTONS_FRIGATE, CONF_MENU_BUTTONS_FRIGATE_DOWNLOAD, @@ -55,6 +57,18 @@ import { CONF_VIEW_TIMEOUT, CONF_VIEW_UPDATE_FORCE, } from './const.js'; +import { arrayMove, getEntityTitle, prettifyFrigateName } from './common.js'; +import { + copyConfig, + deleteConfigValue, + getArrayConfigPath, + getConfigValue, + isConfigUpgradeable, + setConfigValue, + upgradeConfig, +} from './config-mgmt.js'; + +import frigate_card_editor_style from './scss/editor.scss'; interface EditorOptionsSet { icon: string; @@ -66,29 +80,27 @@ interface EditorOptions { [setName: string]: EditorOptionsSet; } -interface EditorOptionTarget { +interface ConfigValueTarget { configValue: string; checked?: boolean; value?: string; } +interface EditorCameraTarget { + cameraIndex: number; +} + interface EditorOptionSetTarget { optionSetName: string; } const options: EditorOptions = { - basic: { - icon: 'cog', - name: localize('editor.basic'), - secondary: localize('editor.basic_secondary'), + cameras: { + icon: 'video', + name: localize('editor.cameras'), + secondary: localize('editor.cameras_secondary'), show: true, }, - frigate: { - icon: 'alpha-f-box', - name: localize('editor.frigate'), - secondary: localize('editor.frigate_secondary'), - show: false, - }, view: { icon: 'eye', name: localize('editor.view'), @@ -135,6 +147,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor protected _initialized = false; protected _configUpgradeable = false; + @property({ attribute: false }) + protected _expandedCameraIndex: number | null = null; + public setConfig(config: RawFrigateCardConfig): void { // Note: This does not use Zod to parse the configuration, so it may be // partially or completely invalid. It's more useful to have a partially @@ -190,6 +205,20 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor `; } + /** + * Get a localized help label for a given config path. + * @param configPath The config path. + * @returns A localized label. + */ + protected _getLabel(configPath: string): string { + // Strip out array indices from the path. + const path = configPath + .split('.') + .filter((e) => !e.match(/^\[[0-9]+\]$/)) + .join('.'); + return localize(`config.${path}`); + } + /** * Render a dropdown menu. * @param configPath The configuration path to set/read. @@ -207,7 +236,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor return html` @@ -225,6 +254,201 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor `; } + /** + * Render a camera header. + * @param cameraIndex The index of the camera to edit/add. + * @param cameraConfig The configuration of the camera in question. + * @param addNewCamera Whether or not this is a header to add a new camera. + * @returns A rendered template. + */ + protected _renderCameraHeader( + cameraIndex: number, + cameraConfig?: RawFrigateCardConfig, + addNewCamera?: boolean, + ): TemplateResult { + return html` +
+ + + ${addNewCamera + ? html` + [${localize('editor.add_new_camera')}...] + ` + : // Attempt to render a recognizable name for the camera, + // starting with the most likely to be useful and working our + // ways towards the least useful. + html` + ${cameraConfig?.title || + cameraConfig?.id || + [ + cameraConfig?.camera_entity + ? getEntityTitle(this.hass, String(cameraConfig.camera_entity)) + : '', + cameraConfig?.client_id, + cameraConfig?.camera_name + ? prettifyFrigateName(String(cameraConfig.camera_name)) + : '', + cameraConfig?.label + ? prettifyFrigateName(String(cameraConfig.label)) + : '', + cameraConfig?.zone + ? prettifyFrigateName(String(cameraConfig.zone)) + : '', + ] + .filter(Boolean) + .join(' / ') || + localize('editor.camera') + ' #' + cameraIndex} + `} + +
+ `; + } + + /** + * Render a camera section. + * @param cameras The full array of cameras. + * @param cameraIndex The index (in the array) to render. + * @param cameraEntities The full list of camera entities. + * @param addNewCamera Whether or not this is a section to add a new non-existent camera. + * @returns A rendered template. + */ + protected _renderCamera( + cameras: RawFrigateCardConfigArray, + cameraIndex: number, + cameraEntities: string[], + addNewCamera?: boolean, + ): TemplateResult | void { + const liveProviders = { + '': '', + auto: localize('config.cameras.live_providers.auto'), + frigate: localize('config.cameras.live_providers.frigate'), + 'frigate-jsmpeg': localize('config.cameras.live_providers.frigate-jsmpeg'), + webrtc: localize('config.cameras.live_providers.webrtc'), + } as const; + + // Make a new config and update the editor with changes on it, + const modifyConfig = (func: (config: RawFrigateCardConfig) => boolean): void => { + if (this._config) { + const newConfig = copyConfig(this._config); + if (func(newConfig)) { + this._updateConfig(newConfig); + } + } + }; + + return html` + ${this._renderCameraHeader(cameraIndex, cameras[cameraIndex], addNewCamera)} + ${this._expandedCameraIndex === cameraIndex + ? html`
+
+ + !addNewCamera && + modifyConfig((config: RawFrigateCardConfig): boolean => { + if (Array.isArray(config.cameras) && cameraIndex > 0) { + arrayMove(config.cameras, cameraIndex, cameraIndex - 1); + this._expandedCameraIndex = cameraIndex - 1; + return true; + } + return false; + })} + > + + + = this._config.cameras.length - 1} + @click=${() => + !addNewCamera && + modifyConfig((config: RawFrigateCardConfig): boolean => { + if ( + Array.isArray(config.cameras) && + cameraIndex < config.cameras.length - 1 + ) { + arrayMove(config.cameras, cameraIndex, cameraIndex + 1); + this._expandedCameraIndex = cameraIndex + 1; + return true; + } + return false; + })} + > + + + { + modifyConfig((config: RawFrigateCardConfig): boolean => { + if (Array.isArray(config.cameras)) { + config.cameras.splice(cameraIndex, 1); + this._expandedCameraIndex = null; + return true; + } + return false; + }); + }} + > + + +
+ ${this._renderDropdown( + getArrayConfigPath(CONF_CAMERAS_ARRAY_CAMERA_ENTITY, cameraIndex), + cameraEntities, + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_CAMERA_NAME, cameraIndex), + )} + ${this._renderDropdown( + getArrayConfigPath(CONF_CAMERAS_ARRAY_LIVE_PROVIDER, cameraIndex), + liveProviders)} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_URL, cameraIndex), + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_LABEL, cameraIndex), + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_ZONE, cameraIndex), + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_CLIENT_ID, cameraIndex), + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_TITLE, cameraIndex), + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_ICON, cameraIndex), + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_ID, cameraIndex), + )} + ${this._renderDropdown( + getArrayConfigPath(CONF_CAMERAS_ARRAY_WEBRTC_ENTITY, cameraIndex), + cameraEntities, + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_WEBRTC_URL, cameraIndex), + )} +
` + : ``} + `; + } + /** * Render a string input field. * @param configPath The configuration path to set/read. @@ -239,12 +463,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor return; } return html` `; } @@ -263,7 +487,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor if (!this._config) { return; } - return html` + return html` `; } + protected _updateConfig(config: RawFrigateCardConfig): void { + this._config = config; + fireEvent(this, 'config-changed', { config: this._config }); + } + protected render(): TemplateResult | void { if (!this.hass || !this._helpers || !this._config) { return html``; @@ -312,13 +541,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor below: localize('config.menu.modes.below'), }; - const liveProviders = { - '': '', - frigate: localize('config.live.providers.frigate'), - 'frigate-jsmpeg': localize('config.live.providers.frigate-jsmpeg'), - webrtc: localize('config.live.providers.webrtc'), - }; - const eventViewerNextPreviousControlStyles = { '': '', thumbnails: localize( @@ -328,6 +550,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor none: localize('config.event_viewer.controls.next_previous.styles.none'), }; + const liveNextPreviousControlStyles = { + '': '', + chevrons: localize('config.live.controls.next_previous.styles.chevrons'), + icons: localize('config.live.controls.next_previous.styles.icons'), + none: localize('config.live.controls.next_previous.styles.none'), + }; + const aspectRatioModes = { '': '', dynamic: localize('config.dimensions.aspect_ratio_modes.dynamic'), @@ -353,6 +582,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor const getShowButtonLabel = (configPath: string) => localize('editor.show_button') + ': ' + localize(`config.${configPath}`); + const cameras = (getConfigValue(this._config, CONF_CAMERAS) || + []) as RawFrigateCardConfigArray; + return html` ${this._configUpgradeable ? html`
@@ -365,10 +597,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor if (this._config) { const upgradedConfig = copyConfig(this._config); upgradeConfig(upgradedConfig); - this._config = upgradedConfig; - - fireEvent(this, 'config-changed', { config: this._config }); - this.requestUpdate(); + this._updateConfig(upgradedConfig); } }} > @@ -378,25 +607,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
` : html``}
- ${this._renderOptionSetHeader('basic')} - ${options.basic.show - ? html` -
- ${this._renderDropdown(CONF_CAMERA_ENTITY, cameraEntities)} -
- ` - : ''} - ${this._renderOptionSetHeader('frigate')} - ${options.frigate.show - ? html` -
- ${this._renderStringInput(CONF_FRIGATE_CAMERA_NAME)} - ${this._renderStringInput(CONF_FRIGATE_URL)} - ${this._renderStringInput(CONF_FRIGATE_LABEL)} - ${this._renderStringInput(CONF_FRIGATE_ZONE)} - ${this._renderStringInput(CONF_FRIGATE_CLIENT_ID)} -
- ` + ${this._renderOptionSetHeader('cameras')} + ${options.cameras.show + ? html`
+ ${cameras.map((_, index) => + this._renderCamera(cameras, index, cameraEntities), + )} + ${this._renderCamera(cameras, cameras.length, cameraEntities, true)} +
` : ''} ${this._renderOptionSetHeader('view')} ${options.view.show @@ -404,9 +622,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderDropdown(CONF_VIEW_DEFAULT, viewModes)} ${this._renderStringInput(CONF_VIEW_TIMEOUT, '[0-9]')} - ${this._renderSwitch( - CONF_VIEW_UPDATE_FORCE, - defaults.view.update_force)} + ${this._renderSwitch(CONF_VIEW_UPDATE_FORCE, defaults.view.update_force)}
` : ''} @@ -464,9 +680,19 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ? html`
${this._renderSwitch(CONF_LIVE_PRELOAD, defaults.live.preload)} - ${this._renderDropdown(CONF_LIVE_PROVIDER, liveProviders)} - ${this._renderDropdown(CONF_LIVE_WEBRTC_ENTITY, cameraEntities)} - ${this._renderStringInput(CONF_LIVE_WEBRTC_URL)} + ${this._renderSwitch( + CONF_LIVE_DRAGGABLE, + defaults.live.draggable, + )} + ${this._renderSwitch( + CONF_LIVE_LAZY_LOAD, + defaults.live.lazy_load, + )} + ${this._renderDropdown( + CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, + liveNextPreviousControlStyles, + )} + ${this._renderStringInput(CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE)} ${this._renderDropdown( CONF_LIVE_CONTROLS_THUMBNAILS_MODE, thumbnailModes, @@ -541,6 +767,19 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor this._helpers = await (window as any).loadCardHelpers(); } + /** + * Display/hide a camera section. + * @param ev The event triggering the change. + */ + protected _toggleCameraHandler(ev: { target: EditorCameraTarget | null }): void { + if (ev && ev.target) { + this._expandedCameraIndex = + this._expandedCameraIndex == ev.target.cameraIndex + ? null + : ev.target.cameraIndex; + } + } + /** * Handle a toggled set of options. * @param ev The event triggering the change. @@ -573,7 +812,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor * @param ev Event triggering the change. */ protected _valueChangedHandler(ev: { - target: (EditorOptionTarget & HTMLElement) | null; + target: (ConfigValueTarget & HTMLElement) | null; }): void { const target = ev.target; if (!this._config || !this.hass || !target) { @@ -591,22 +830,17 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor return; } + if (getConfigValue(this._config, key) === value) { + return; + } + const newConfig = copyConfig(this._config); if (value === '' || typeof value === 'undefined') { - // Don't delete empty properties that are from a dropdown menu. An empty - // property in that context may just be a user-entered value that is not - // in the valid choices in the dropdown. This probably won't end well for - // the user anyway, but having the whole property deleted the moment they - // press a key is very jarring. - if (target.tagName != 'PAPER-DROPDOWN-MENU') { - deleteConfigValue(newConfig, key); - } + deleteConfigValue(newConfig, key); } else { setConfigValue(newConfig, key, value); } - trimConfig(newConfig); - this._config = newConfig; - fireEvent(this, 'config-changed', { config: this._config }); + this._updateConfig(newConfig); } /** diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index f5270e82..f9f7b294 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -9,12 +9,26 @@ "no_clip": "No recent clip" }, "config": { - "camera_entity": "Camera Entity", - "frigate": { - "camera_name": "Frigate camera name (Optional, autodetected from entity)", + "cameras": { + "camera_entity": "Camera Entity", + "camera_name": "Frigate camera name (Autodetected from entity)", "client_id": "Frigate client id (For >1 Frigate server)", + "id": "Unique id for this camera in this card", "label": "Frigate label/object filter", - "url": "Frigate server URL", + "frigate_url": "Frigate server URL", + "title": "Title for this camera (Autodetected from entity)", + "icon": "Icon for this camera (Autodetected from entity)", + "live_provider": "Live view provider for this camera", + "live_providers": { + "auto": "Automatic", + "frigate": "Frigate", + "frigate-jsmpeg": "Frigate JSMpeg", + "webrtc": "WebRTC" + }, + "webrtc": { + "entity": "WebRTC Camera Entity (Not a Frigate camera)", + "url": "WebRTC Camera URL" + }, "zone": "Frigate zone" }, "view": { @@ -31,13 +45,13 @@ "update_force": "Force card updates (ignore media playing / interaction)" }, "event_viewer": { - "autoplay_clip": "Autoplay most recent clip (In 'clip' view)", - "draggable": "Event viewer can be dragged/swiped", - "lazy_load": "Lazily load event media", + "autoplay_clip": "Autoplay clips", + "draggable": "Event Viewer can be dragged/swiped", + "lazy_load": "Event Viewer media is lazily loaded in carousel", "controls": { "next_previous": { - "style": "Event Viewer Next & Previous Control style", - "size": "Event Viewer Next & Previous Control size (e.g. '48px')", + "style": "Event Viewer next & previous control style", + "size": "Event Viewer next & previous control size (e.g. '48px')", "styles": { "thumbnails": "Thumbnails", "chevrons": "Chevrons", @@ -56,18 +70,19 @@ } }, "live": { - "preload": "Preload live view", - "provider": "Live view provider", - "providers": { - "frigate": "Frigate", - "frigate-jsmpeg": "Frigate JSMpeg", - "webrtc": "WebRTC" - }, - "webrtc": { - "entity": "WebRTC Camera Entity (Not a Frigate camera)", - "url": "WebRTC Camera URL" - }, + "preload": "Preload live view in the background", + "draggable": "Live cameras view can be dragged/swiped", + "lazy_load": "Live cameras are lazily loaded in carousel", "controls": { + "next_previous": { + "style": "Live view next & previous control style", + "size": "Live view next & previous control size (e.g. '48px')", + "styles": { + "chevrons": "Chevrons", + "icons": "Icons", + "none": "None" + } + }, "thumbnails": { "mode": "Live thumbnails mode", "size": "Live thumbnails size (e.g. '100px')", @@ -87,7 +102,8 @@ "frigate": "Frigate menu / Default view", "frigate_ui": "Frigate user Interface", "fullscreen": "Fullscreen", - "download": "Download event media" + "download": "Download event media", + "cameras": "Select camera" }, "mode": "Menu mode", "modes": { @@ -120,10 +136,8 @@ } }, "editor": { - "basic": "Basic", - "basic_secondary": "Options for most users", - "frigate": "Frigate", - "frigate_secondary": "Frigate server options", + "cameras": "Cameras", + "cameras_secondary": "What cameras to render on this card", "view": "View", "view_secondary": "What the card should show and how to show it", "menu": "Menu", @@ -138,7 +152,12 @@ "dimensions_secondary": "Dimensions & shape options", "show_button": "Show button", "upgrade": "Upgrade", - "upgrade_available": "An automatic card configuration upgrade is available" + "upgrade_available": "An automatic card configuration upgrade is available", + "delete": "Delete", + "move_up": "Move up", + "move_down": "Move down", + "add_new_camera": "Add new camera", + "camera": "Camera" }, "error": { "empty_response": "Received empty response from Home Assistant for request", @@ -151,12 +170,16 @@ "invalid_configuration": "Invalid configuration", "invalid_configuration_no_hint": "No location hint available (bad or missing type?)", "upgrade_available": "An automated card configuration upgrade is available, please visit the visual card editor", - "missing_webrtc": "WebRTC component not found", - "no_frigate_camera_name": "Cannot autodetect Frigate camera name, you need to either set camera_entity and / or frigate.camera_name", + "webrtc_missing": "WebRTC component not found", + "webrtc_reported_error": "WebRTC component reported an error", + "no_cameras": "No valid cameras found, you must configure at least one camera entry", + "no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually", + "duplicate_camera_id": "Duplicate Frigate camera id for the following camera, use the 'id' parameter to uniquely identify cameras", "could_not_render_elements": "Could not render picture elements", "invalid_elements_config": "Invalid picture elements configuration", "jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path", "jsmpeg_no_player": "Could not start JSMPEG player", + "no_camera_name": "Could not determine Frigate camera name for camera, please specify either 'camera_entity' or 'camera_name' for the following camera", "download_no_media": "No media to download", "download_no_event_id": "Could not extract Frigate event id from media", "download_sign_failed": "Could not sign media URL for download" diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 6aec018a..83408a43 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -48,10 +48,6 @@ customElements.whenDefined('ha-camera-stream').then(() => { ? html` { - // TODO: This block can be removed a safe distance from HA 2021.11. - if (typeof this._elementResized != 'undefined') { - this._elementResized(); - } dispatchMediaShowEvent(this, e); }} .src=${typeof this._connected == 'undefined' || this._connected diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index 4e8cbce5..13648fec 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -10,6 +10,7 @@ // ==================================================================== import { TemplateResult, css, html } from 'lit'; +import { Ref, createRef, ref } from 'lit/directives/ref'; import { customElement } from 'lit/decorators.js'; import { @@ -22,6 +23,17 @@ customElements.whenDefined('ha-hls-player').then(() => { @customElement('frigate-card-ha-hls-player') // eslint-disable-next-line @typescript-eslint/no-unused-vars class FrigateCardHaHlsPlayer extends customElements.get('ha-hls-player') { + protected _videoRef: Ref = createRef(); + + /** + * Play the video. + */ + public play(): void { + if (this._videoRef.value) { + this._videoRef.value.play(); + } + } + // ===================================================================================== // Minor modifications from: // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts @@ -29,15 +41,12 @@ customElements.whenDefined('ha-hls-player').then(() => { protected render(): TemplateResult { return html`