Merge branch 'dermotduffy:main' into main
This commit is contained in:
@@ -33,6 +33,16 @@ Provide details about what browser (and version) you are seeing the issue in. An
|
|||||||
Explain what the issue is, and how things should look/behave. If possible provide a screenshot with a description.
|
Explain what the issue is, and how things should look/behave. If possible provide a screenshot with a description.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
**Frigate card diagnostic information**
|
||||||
|
|
||||||
|
<!--
|
||||||
|
On the card, hold down the Frigate menu button for a few seconds then paste the diagnostics below:
|
||||||
|
-->
|
||||||
|
|
||||||
|
```json
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
**Javascript errors shown in the web inspector (if applicable):**
|
**Javascript errors shown in the web inspector (if applicable):**
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ updates:
|
|||||||
directory: /
|
directory: /
|
||||||
schedule:
|
schedule:
|
||||||
interval: daily
|
interval: daily
|
||||||
|
target-branch: "dev"
|
||||||
|
|||||||
@@ -64,3 +64,6 @@
|
|||||||
- name: wontfix
|
- name: wontfix
|
||||||
description: This will not be worked on
|
description: This will not be worked on
|
||||||
color: ffffff
|
color: ffffff
|
||||||
|
- name: votes needed
|
||||||
|
description: Vote for this feature to encourage prioritization!
|
||||||
|
color: e2ed82
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- release-*
|
- dev
|
||||||
pull_request:
|
pull_request:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "17 6 * * *"
|
- cron: "17 6 * * *"
|
||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Setup Node and Yarn
|
- name: Setup Node and Yarn
|
||||||
uses: volta-cli/action@v3
|
uses: volta-cli/action@v4
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install --immutable
|
run: yarn install --immutable
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Setup Node and Yarn
|
- name: Setup Node and Yarn
|
||||||
uses: volta-cli/action@v3
|
uses: volta-cli/action@v4
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install --immutable
|
run: yarn install --immutable
|
||||||
|
|||||||
@@ -70,16 +70,17 @@ lovelace:
|
|||||||
|
|
||||||
### Advanced Users: Manual Installation
|
### Advanced Users: Manual Installation
|
||||||
|
|
||||||
**Note:** This is very rarely needed unless you are developing the card. Please consider HACS (above)!
|
**Note:** This is very rarely needed -- please consider HACS (above)!
|
||||||
|
|
||||||
* Download the `frigate-hass-card.js` attachment of the desired [release](https://github.com/dermotduffy/frigate-hass-card/releases) to a location accessible by Home Assistant.
|
* Download the `frigate-hass-card.zip` attachment of the desired [release](https://github.com/dermotduffy/frigate-hass-card/releases) to a location accessible by Home Assistant. Note that the release will have a series of `.js` files (for HACS users) **and** a `frigate-hass-card.zip` for the convenience of manual installers.
|
||||||
* Add the location as a Lovelace resource via the UI, or via [YAML configuration](https://www.home-assistant.io/lovelace/dashboards/#resources)) such as:
|
* Unzip the file and move the contents of the `dist/` folder to any subfolder name you'd like, e.g. `frigate-card` is used in the below example.
|
||||||
|
* Add the location as a Lovelace resource via the UI, or via [YAML configuration](https://www.home-assistant.io/lovelace/dashboards/#resources) such as:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
lovelace:
|
lovelace:
|
||||||
mode: yaml
|
mode: yaml
|
||||||
resources:
|
resources:
|
||||||
- url: /local/frigate-hass-card.js
|
- url: /local/frigate-card/frigate-hass-card.js
|
||||||
type: module
|
type: module
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -317,7 +318,7 @@ cameras:
|
|||||||
|
|
||||||
#### Camera IDs: Referring to cameras in card configuration
|
#### Camera IDs: Referring 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_card.entity` or `frigate.camera_name`} parameters for that camera -- in that order of precedence. These ids may be used in conditions, dependencies or custom actions to refer to a given camera unambiguously. |
|
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_card.entity` or `frigate.camera_name`} parameters for that camera -- in that order of precedence. These ids may be used in conditions, dependencies or custom actions to refer to a given camera unambiguously.
|
||||||
|
|
||||||
#### Example
|
#### Example
|
||||||
|
|
||||||
@@ -405,6 +406,8 @@ See the [fully expanded menu configuration example](#config-expanded-menu) for h
|
|||||||
| `button_size` | 40 | :white_check_mark: | The size of the menu buttons in pixels. Must be >= `20`.|
|
| `button_size` | 40 | :white_check_mark: | The size of the menu buttons in pixels. Must be >= `20`.|
|
||||||
| `buttons` | | :white_check_mark: | Whether to show or hide built-in buttons. See below. |
|
| `buttons` | | :white_check_mark: | Whether to show or hide built-in buttons. See below. |
|
||||||
|
|
||||||
|
<a name="menu-buttons"></a>
|
||||||
|
|
||||||
#### Menu Options: Buttons
|
#### Menu Options: Buttons
|
||||||
|
|
||||||
All configuration is under:
|
All configuration is under:
|
||||||
@@ -432,6 +435,7 @@ menu:
|
|||||||
| `expand` | :white_check_mark: | The `expand` menu button: expand the card into a popup/dialog. |
|
| `expand` | :white_check_mark: | The `expand` menu button: expand the card into a popup/dialog. |
|
||||||
| `timeline` | :white_check_mark: | The `timeline` menu button: show the event timeline. |
|
| `timeline` | :white_check_mark: | The `timeline` menu button: show the event timeline. |
|
||||||
| `media_player` | :white_check_mark: | The `media_player` menu button: sends the visible media to a remote media player. Supports Frigate clips, snapshots and live camera (only for cameras that specify a `camera_entity` and only using the default HA stream (equivalent to the `ha` live provider). `jsmpeg` or `webrtc-card` are not supported, although live can still be played as long as `camera_entity` is specified. In the player list, a `tap` will send the media to the player, a `hold` will stop the media on the player. |
|
| `media_player` | :white_check_mark: | The `media_player` menu button: sends the visible media to a remote media player. Supports Frigate clips, snapshots and live camera (only for cameras that specify a `camera_entity` and only using the default HA stream (equivalent to the `ha` live provider). `jsmpeg` or `webrtc-card` are not supported, although live can still be played as long as `camera_entity` is specified. In the player list, a `tap` will send the media to the player, a `hold` will stop the media on the player. |
|
||||||
|
| `microphone` | :white_check_mark: | The `microphone` button allows usage of 2-way audio in certain configurations. See [Using 2-way audio](#using-2-way-audio). |
|
||||||
|
|
||||||
##### Configuration on each button
|
##### Configuration on each button
|
||||||
|
|
||||||
@@ -462,11 +466,26 @@ See the [fully expanded live configuration example](#config-expanded-live) for h
|
|||||||
| `lazy_load` | `true` | :heavy_multiplication_x: | Whether or not to lazily load cameras 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. |
|
| `lazy_load` | `true` | :heavy_multiplication_x: | Whether or not to lazily load cameras 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. |
|
||||||
| `lazy_unload` | `never` | :heavy_multiplication_x: | When to lazily **un**load lazyily-loaded cameras. `never` will never lazily-unload, `unselected` will lazy-unload a camera when it is unselected in the carousel, `hidden` will lazy-unload all cameras when the browser/tab becomes hidden or `all` on any opportunity to lazily unload (i.e. either case). This will cause a reloading delay on revisiting that camera in the carousel but will save the streaming network resources that are otherwise consumed. This option has no effect if `lazy_load` is false. Some live providers (e.g. `webrtc-card`) implement their own lazy unloading independently which may occur regardless of the value of this setting.|
|
| `lazy_unload` | `never` | :heavy_multiplication_x: | When to lazily **un**load lazyily-loaded cameras. `never` will never lazily-unload, `unselected` will lazy-unload a camera when it is unselected in the carousel, `hidden` will lazy-unload all cameras when the browser/tab becomes hidden or `all` on any opportunity to lazily unload (i.e. either case). This will cause a reloading delay on revisiting that camera in the carousel but will save the streaming network resources that are otherwise consumed. This option has no effect if `lazy_load` is false. Some live providers (e.g. `webrtc-card`) implement their own lazy unloading independently which may occur regardless of the value of this setting.|
|
||||||
| `draggable` | `true` | :heavy_multiplication_x: | Whether or not the live carousel can be dragged left or right, via touch/swipe and mouse dragging. |
|
| `draggable` | `true` | :heavy_multiplication_x: | Whether or not the live carousel can be dragged left or right, via touch/swipe and mouse dragging. |
|
||||||
|
| `zoomable` | `true` | :white_check_mark: | Whether or not the live carousel can be zoomed and panned, via touch/pinch and mouse scroll wheel with `ctrl` held. |
|
||||||
| `transition_effect` | `slide` | :heavy_multiplication_x: | Effect to apply as a transition between live cameras. Accepted values: `slide` or `none`. |
|
| `transition_effect` | `slide` | :heavy_multiplication_x: | Effect to apply as a transition between live cameras. Accepted values: `slide` or `none`. |
|
||||||
| `show_image_during_load` | `true` | :white_check_mark: | If `true`, during the initial stream load, the `image` live provider will be shown instead of the loading video stream. This still image will auto-refresh and is replaced with the live stream once loaded. |
|
| `show_image_during_load` | `true` | :white_check_mark: | If `true`, during the initial stream load, the `image` live provider will be shown instead of the loading video stream. This still image will auto-refresh and is replaced with the live stream once loaded. |
|
||||||
| `actions` | | :white_check_mark: | Actions to use for the `live` view. See [actions](#actions) 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. |
|
| `controls` | | :white_check_mark: | Configuration for the `live` view controls. See below. |
|
||||||
| `layout` | | :white_check_mark: | See [media layout](#media-layout) below.|
|
| `layout` | | :white_check_mark: | See [media layout](#media-layout) below.|
|
||||||
|
| `microphone` | | :white_check_mark: | See [microphone](#microphone) below.|
|
||||||
|
|
||||||
|
#### Live Controls
|
||||||
|
|
||||||
|
All configuration is under:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
live:
|
||||||
|
controls:
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Overridable | Description |
|
||||||
|
| - | - | - | - |
|
||||||
|
| `builtin` | `true` | :white_check_mark: | Whether to show the built in (browser) video controls on live video. |
|
||||||
|
|
||||||
#### Live Controls: Thumbnails
|
#### Live Controls: Thumbnails
|
||||||
|
|
||||||
@@ -541,6 +560,24 @@ live:
|
|||||||
| `mode` | `popup-bottom-right` | :white_check_mark: | How to display the live camera title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . |
|
| `mode` | `popup-bottom-right` | :white_check_mark: | How to display the live camera title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . |
|
||||||
| `duration_seconds` | `2` | :white_check_mark: | The number of seconds to display the title popup. `0` implies forever.|
|
| `duration_seconds` | `2` | :white_check_mark: | The number of seconds to display the title popup. `0` implies forever.|
|
||||||
|
|
||||||
|
<a name="microphone"></a>
|
||||||
|
|
||||||
|
#### Live: Microphone
|
||||||
|
|
||||||
|
All configuration is under:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
live:
|
||||||
|
microphone:
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Overridable | Description |
|
||||||
|
| - | - | - | - |
|
||||||
|
| `always_connected` | `false` | :white_check_mark: | Whether or not to keep the microphone stream continually connected while the card is running, or only when microphone is used (default). In the latter case there'll be a connection reset when the microphone is first used -- using this option can avoid that reset.|
|
||||||
|
| `disconnect_seconds` | `60` | :white_check_mark: | The number of seconds after microphone usage to disconnect the microphone from the stream. `0` implies never. Not relevant if `always_connected` is `true`.|
|
||||||
|
|
||||||
|
See [Using 2-way audio](#using-2-way-audio) for more information about the very particular requirements that must be followed for 2-way audio to work.
|
||||||
|
|
||||||
### Media Viewer Options
|
### Media Viewer Options
|
||||||
|
|
||||||
The `media_viewer` is used for viewing all `clip`, `snapshot` or recording media, in a media carousel.
|
The `media_viewer` is used for viewing all `clip`, `snapshot` or recording media, in a media carousel.
|
||||||
@@ -561,12 +598,26 @@ See the [fully expanded Media viewer configuration example](#config-expanded-med
|
|||||||
| `auto_unmute` | `never` | :heavy_multiplication_x: | Whether to automatically unmute events. `never` will never automatically unmute, `selected` will automatically unmute when an event is selected in the carousel, `visible` will automatically unmute when the browser/tab becomes visible or `all` on any opportunity to automatically unmute (i.e. either case). Note that some browsers will not allow automated unmute until the user has interacted with the page in some way -- if the user has not then the browser may pause the media instead.|
|
| `auto_unmute` | `never` | :heavy_multiplication_x: | Whether to automatically unmute events. `never` will never automatically unmute, `selected` will automatically unmute when an event is selected in the carousel, `visible` will automatically unmute when the browser/tab becomes visible or `all` on any opportunity to automatically unmute (i.e. either case). Note that some browsers will not allow automated unmute until the user has interacted with the page in some way -- if the user has not then the browser may pause the media instead.|
|
||||||
| `lazy_load` | `true` | :heavy_multiplication_x: | Whether or not to lazily load media in the Media 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. |
|
| `lazy_load` | `true` | :heavy_multiplication_x: | Whether or not to lazily load media in the Media 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 Media viewer carousel can be dragged left or right, via touch/swipe and mouse dragging. |
|
| `draggable` | `true` | :heavy_multiplication_x: | Whether or not the Media viewer carousel can be dragged left or right, via touch/swipe and mouse dragging. |
|
||||||
|
| `zoomable` | `true` | :heavy_multiplication_x: | Whether or not the Media Viewer can be zoomed and panned, via touch/pinch and mouse scroll wheel with `ctrl` held. |
|
||||||
| `snapshot_click_plays_clip` | `true` | :heavy_multiplication_x: | Whether clicking on a snapshot in the media viewer should play a related clip. |
|
| `snapshot_click_plays_clip` | `true` | :heavy_multiplication_x: | Whether clicking on a snapshot in the media viewer should play a related clip. |
|
||||||
| `transition_effect` | `slide` | :heavy_multiplication_x: | Effect to apply as a transition between event media. Accepted values: `slide` or `none`. |
|
| `transition_effect` | `slide` | :heavy_multiplication_x: | Effect to apply as a transition between event media. Accepted values: `slide` or `none`. |
|
||||||
| `controls` | | :heavy_multiplication_x: | Configuration for the Media viewer controls. See below. |
|
| `controls` | | :heavy_multiplication_x: | Configuration for the Media viewer controls. See below. |
|
||||||
| `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `media_viewer` (e.g. `clip`, `snapshot`). See [actions](#actions) below.|
|
| `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `media_viewer` (e.g. `clip`, `snapshot`). See [actions](#actions) below.|
|
||||||
| `layout` | | :white_check_mark: | See [media layout](#media-layout) below.|
|
| `layout` | | :white_check_mark: | See [media layout](#media-layout) below.|
|
||||||
|
|
||||||
|
#### Media Viewer Controls
|
||||||
|
|
||||||
|
All configuration is under:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
media_viewer:
|
||||||
|
controls:
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Overridable | Description |
|
||||||
|
| - | - | - | - |
|
||||||
|
| `builtin` | `true` | :white_check_mark: | Whether to show the built in (browser) video controls on media viewer video. |
|
||||||
|
|
||||||
#### Media Viewer Controls: Next / Previous
|
#### Media Viewer Controls: Next / Previous
|
||||||
|
|
||||||
All configuration is under:
|
All configuration is under:
|
||||||
@@ -703,6 +754,7 @@ See the [fully expanded image configuration example](#config-expanded-image) for
|
|||||||
| `mode` | `url` | :white_check_mark: | Mode of the the `image` [view](#views). Value must be one of `url` (to fetch an arbitrary image URL), `camera` (to show a still of the currently selected camera using either `camera_entity` or `webrtc_card.entity` in that order of precedence), or `screensaver` (to show an [embedded stock Frigate card logo](https://github.com/dermotduffy/frigate-hass-card/blob/main/src/images/frigate-bird-in-sky.jpg)). In either `url` or `camera` mode, the `screensaver` content is used as a fallback if a URL is not specified or cannot be derived. |
|
| `mode` | `url` | :white_check_mark: | Mode of the the `image` [view](#views). Value must be one of `url` (to fetch an arbitrary image URL), `camera` (to show a still of the currently selected camera using either `camera_entity` or `webrtc_card.entity` in that order of precedence), or `screensaver` (to show an [embedded stock Frigate card logo](https://github.com/dermotduffy/frigate-hass-card/blob/main/src/images/frigate-bird-in-sky.jpg)). In either `url` or `camera` mode, the `screensaver` content is used as a fallback if a URL is not specified or cannot be derived. |
|
||||||
| `url` | | :white_check_mark: | A static image URL to be used when the `mode` is set to `url` or when a temporary image is required (e.g. may appear momentarily prior to load of a camera snapshot in the `camera` mode). Note that a `_t=[timestsamp]` query parameter will be automatically added to all URLs such that the image will not be cached by the browser.|
|
| `url` | | :white_check_mark: | A static image URL to be used when the `mode` is set to `url` or when a temporary image is required (e.g. may appear momentarily prior to load of a camera snapshot in the `camera` mode). Note that a `_t=[timestsamp]` query parameter will be automatically added to all URLs such that the image will not be cached by the browser.|
|
||||||
| `refresh_seconds` | 0 | :white_check_mark: | The image will be refreshed at least every `refresh_seconds` (it may refresh more frequently, e.g. whenever Home Assistant updates its camera security token). `0` implies no refreshing. |
|
| `refresh_seconds` | 0 | :white_check_mark: | The image will be refreshed at least every `refresh_seconds` (it may refresh more frequently, e.g. whenever Home Assistant updates its camera security token). `0` implies no refreshing. |
|
||||||
|
| `zoomable` | `true` | :white_check_mark: | Whether or not the image can be zoomed and panned, via touch/pinch and mouse scroll wheel with `ctrl` held. |
|
||||||
| `actions` | | :white_check_mark: | Actions to use for the `image` view. See [actions](#actions) below.|
|
| `actions` | | :white_check_mark: | Actions to use for the `image` view. See [actions](#actions) below.|
|
||||||
|
|
||||||
**Note**: When `mode` is set to `camera` this is effectively providing the same image as the `image` live provider would show in the live camera carousel.
|
**Note**: When `mode` is set to `camera` this is effectively providing the same image as the `image` live provider would show in the live camera carousel.
|
||||||
@@ -921,6 +973,25 @@ item, that has both of the following parameters set:
|
|||||||
| `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). |
|
| `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. |
|
| `overrides` | | :heavy_multiplication_x: |Configuration overrides to be applied. Any configuration parameter described in this documentation as 'Overridable' is supported. |
|
||||||
|
|
||||||
|
<a name="automation-options"></a>
|
||||||
|
|
||||||
|
### Automation Options
|
||||||
|
|
||||||
|
All configuration is a list under:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
automations:
|
||||||
|
- [conditions:]
|
||||||
|
[actions:]
|
||||||
|
[actions_not:]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Overridable | Description |
|
||||||
|
| - | - | - | - |
|
||||||
|
| `conditions` | | :heavy_multiplication_x: | A set of conditions that will trigger the automation. See [Frigate Card Conditions](#frigate-card-conditions). |
|
||||||
|
| `actions` | | :heavy_multiplication_x: | An optional list of actions that will be run when the conditions evaluate `true`. Actions can be [stock Home Assistant actions](https://www.home-assistant.io/dashboards/actions/) or [Frigate card actions](#frigate-card-actions).|
|
||||||
|
| `actions_not` | | :heavy_multiplication_x: | An optional list of actions that will be run when the conditions evaluate `false`. Actions can be [stock Home Assistant actions](https://www.home-assistant.io/dashboards/actions/) or [Frigate card actions](#frigate-card-actions).|
|
||||||
|
|
||||||
<a name="media-layout"></a>
|
<a name="media-layout"></a>
|
||||||
|
|
||||||
### Media Layout
|
### Media Layout
|
||||||
@@ -1023,6 +1094,44 @@ See [the WebRTC Card live configuration](#webrtc-live-configuration) above, and
|
|||||||
documentation](https://github.com/AlexxIT/WebRTC#configuration) for full
|
documentation](https://github.com/AlexxIT/WebRTC#configuration) for full
|
||||||
configuration options that can be used here.
|
configuration options that can be used here.
|
||||||
|
|
||||||
|
<a name="using-2-way-audio"></a>
|
||||||
|
|
||||||
|
## Using 2-Way Audio
|
||||||
|
|
||||||
|
This card supports 2-way audio (e.g. transmitting audio from a microphone to a suitably equipped camera). Requirements for 2-way audio to work:
|
||||||
|
|
||||||
|
Environmental requirements:
|
||||||
|
* Must have a camera that supports audio out (otherwise what's the point!)
|
||||||
|
* Camera must be supported by `go2rtc` for 2-way audio (see [supported cameras](https://github.com/AlexxIT/go2rtc#two-way-audio)).
|
||||||
|
* Must be accessing your Home Assistant instance over `https`. The browser will enforce this.
|
||||||
|
|
||||||
|
Card requirements:
|
||||||
|
* Only Frigate cameras are supported.
|
||||||
|
* Only the `go2rtc` live provider is supported.
|
||||||
|
* Only the `webrtc` mode supports 2-way audio:
|
||||||
|
```yaml
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.front_door
|
||||||
|
live_provider: go2rtc
|
||||||
|
go2rtc:
|
||||||
|
modes:
|
||||||
|
- webrtc
|
||||||
|
```
|
||||||
|
* Must have microphone menu button enabled:
|
||||||
|
```yaml
|
||||||
|
menu:
|
||||||
|
buttons:
|
||||||
|
microphone:
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
* The camera will always load without the microphone connected.
|
||||||
|
* To speak, hold-down the microphone menu button.
|
||||||
|
* On first press, this will reset the `webrtc` connection to include 2-way audio (unless the `always_connected` microphone option is set to `true`).
|
||||||
|
* Thereafter hold the microphone button down to unmute/speak, let go to mute.
|
||||||
|
* The video will automatically reset to remove the microphone after the number of seconds specified by `disconnect_seconds` in the `microphone` configuration have elapsed since the last mute/unmute press.
|
||||||
|
|
||||||
<a name="frigate-card-conditions"></a>
|
<a name="frigate-card-conditions"></a>
|
||||||
|
|
||||||
## Frigate Card Conditions
|
## Frigate Card Conditions
|
||||||
@@ -1173,6 +1282,8 @@ Parameters for the `custom:frigate-card-ptz` element:
|
|||||||
| `data_left`, `data_right`, `data_up`, `data_down`, `data_zoom_in`, `data_zoom_out`, `data_home` | Shorthand for a `tap_action` that calls the `service` with the data provided in this argument. Internally, this is just translated into the longer-form `actions_[button]`. If both `actions_X` and `data_X` are specified, `actions_X` takes priority. This is compatible with [AlexxIT's WebRTC Card PTZ configuration](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples). |
|
| `data_left`, `data_right`, `data_up`, `data_down`, `data_zoom_in`, `data_zoom_out`, `data_home` | Shorthand for a `tap_action` that calls the `service` with the data provided in this argument. Internally, this is just translated into the longer-form `actions_[button]`. If both `actions_X` and `data_X` are specified, `actions_X` takes priority. This is compatible with [AlexxIT's WebRTC Card PTZ configuration](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples). |
|
||||||
| `service` | | An optional Home Assistant service to call when the `data_` parameters are used. |
|
| `service` | | An optional Home Assistant service to call when the `data_` parameters are used. |
|
||||||
|
|
||||||
|
<a name="frigate-card-actions"></a>
|
||||||
|
|
||||||
### Special Actions
|
### Special Actions
|
||||||
|
|
||||||
#### `custom:frigate-card-action`
|
#### `custom:frigate-card-action`
|
||||||
@@ -1180,7 +1291,7 @@ Parameters for the `custom:frigate-card-ptz` element:
|
|||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| - | - |
|
| - | - |
|
||||||
| `action` | Must be `custom:frigate-card-action`. |
|
| `action` | Must be `custom:frigate-card-action`. |
|
||||||
| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_select`, `expand`.|
|
| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_on`, `live_substream_off`, `live_substream_select`, `expand`, `microphone_mute`, `microphone_unmute`, `mute`, `unmute`, `play`, `pause`|
|
||||||
|
|
||||||
<a name="custom-actions"></a>
|
<a name="custom-actions"></a>
|
||||||
|
|
||||||
@@ -1198,6 +1309,9 @@ Parameters for the `custom:frigate-card-ptz` element:
|
|||||||
|`media_player`| Perform a media player action. Takes a `media_player` parameter with the entity ID of the media_player on which to perform the action, and a `media_player_action` parameter which should be either `play` or `stop` to play or stop the media in question. |
|
|`media_player`| Perform a media player action. Takes a `media_player` parameter with the entity ID of the media_player on which to perform the action, and a `media_player_action` parameter which should be either `play` or `stop` to play or stop the media in question. |
|
||||||
|`live_substream_select`| Perform a media player action. Takes a `camera` parameter with the [camera ID](#camera-ids) of the substream camera. |
|
|`live_substream_select`| Perform a media player action. Takes a `camera` parameter with the [camera ID](#camera-ids) of the substream camera. |
|
||||||
|`expand`| Expand the card into a dialog/popup. |
|
|`expand`| Expand the card into a dialog/popup. |
|
||||||
|
|`microphone_mute`, `microphone_unmute`| Mute or unmute the microphone. See [Using 2-way audio](#using-2-way-audio). |
|
||||||
|
|`mute`, `unmute`| Mute or unmute the loaded media. |
|
||||||
|
|`play`, `pause`| Play or pause the loaded media. |
|
||||||
|
|
||||||
<a name="views"></a>
|
<a name="views"></a>
|
||||||
|
|
||||||
@@ -1458,6 +1572,18 @@ Pan around a large camera view to only show part of the video feed in the card a
|
|||||||
|
|
||||||
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/ribbon-timeline.png" alt="Ribbon Timeline" width="400px">
|
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/ribbon-timeline.png" alt="Ribbon Timeline" width="400px">
|
||||||
|
|
||||||
|
### In-menu media / mute control
|
||||||
|
|
||||||
|
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/dev/images/native-media-control.png" alt="In-Menu Media Control" width="400px">
|
||||||
|
|
||||||
|
### 2-way Audio Support
|
||||||
|
|
||||||
|
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/dev/images/microphone.gif" alt="2-way Audio" width="400px">
|
||||||
|
|
||||||
|
### Zoom Support
|
||||||
|
|
||||||
|
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/dev/images/zoom.gif" alt="Zoom Support" width="400px">
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
### Illustrative Expanded Configuration Reference
|
### Illustrative Expanded Configuration Reference
|
||||||
@@ -1744,6 +1870,22 @@ menu:
|
|||||||
enabled: false
|
enabled: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:cast
|
icon: mdi:cast
|
||||||
|
microphone:
|
||||||
|
priority: 50
|
||||||
|
enabled: false
|
||||||
|
alignment: matching
|
||||||
|
icon: mdi:microphone
|
||||||
|
type: momentary
|
||||||
|
mute:
|
||||||
|
priority: 50
|
||||||
|
enabled: false
|
||||||
|
alignment: matching
|
||||||
|
icon: mdi:volume-off
|
||||||
|
play:
|
||||||
|
priority: 50
|
||||||
|
enabled: false
|
||||||
|
alignment: matching
|
||||||
|
icon: mdi:play
|
||||||
button_size: 40
|
button_size: 40
|
||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
@@ -1765,8 +1907,10 @@ live:
|
|||||||
lazy_load: true
|
lazy_load: true
|
||||||
lazy_unload: never
|
lazy_unload: never
|
||||||
draggable: true
|
draggable: true
|
||||||
|
zoomable: true
|
||||||
transition_effect: slide
|
transition_effect: slide
|
||||||
controls:
|
controls:
|
||||||
|
builtin: true
|
||||||
next_previous:
|
next_previous:
|
||||||
style: chevrons
|
style: chevrons
|
||||||
size: 48
|
size: 48
|
||||||
@@ -1793,6 +1937,9 @@ live:
|
|||||||
position:
|
position:
|
||||||
x: 50
|
x: 50
|
||||||
y: 50
|
y: 50
|
||||||
|
microphone:
|
||||||
|
always_connected: false
|
||||||
|
disconnect_seconds: 60
|
||||||
actions:
|
actions:
|
||||||
entity: light.office_main_lights
|
entity: light.office_main_lights
|
||||||
tap_action:
|
tap_action:
|
||||||
@@ -1824,9 +1971,11 @@ media_viewer:
|
|||||||
auto_unmute: never
|
auto_unmute: never
|
||||||
lazy_load: true
|
lazy_load: true
|
||||||
draggable: true
|
draggable: true
|
||||||
|
zoomable: true
|
||||||
snapshot_click_plays_clip: true
|
snapshot_click_plays_clip: true
|
||||||
transition_effect: slide
|
transition_effect: slide
|
||||||
controls:
|
controls:
|
||||||
|
builtin: true
|
||||||
next_previous:
|
next_previous:
|
||||||
size: 48
|
size: 48
|
||||||
style: thumbnails
|
style: thumbnails
|
||||||
@@ -1911,6 +2060,7 @@ Reference: [Image Options](#image-options).
|
|||||||
image:
|
image:
|
||||||
mode: url
|
mode: url
|
||||||
refresh_seconds: 0
|
refresh_seconds: 0
|
||||||
|
zoomable: true
|
||||||
layout:
|
layout:
|
||||||
fit: contain
|
fit: contain
|
||||||
position:
|
position:
|
||||||
@@ -2570,6 +2720,24 @@ performance:
|
|||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Expand: Automation section</summary>
|
||||||
|
|
||||||
|
Reference: [Automation Options](#automation-options).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
automations:
|
||||||
|
- conditions:
|
||||||
|
fullscreen: true
|
||||||
|
actions:
|
||||||
|
- action: custom:frigate-card-action
|
||||||
|
frigate_card_action: live_substream_on
|
||||||
|
actions_not:
|
||||||
|
- action: custom:frigate-card-action
|
||||||
|
frigate_card_action: live_substream_off
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Expand: Other options</summary>
|
<summary>Expand: Other options</summary>
|
||||||
|
|
||||||
@@ -3460,6 +3628,32 @@ overrides:
|
|||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### Using menu-based video controls instead of browser builtin controls
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Expand: Using menu-based video controls</summary>
|
||||||
|
|
||||||
|
Disable the stock video controls and add menu button equivalents.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: custom:frigate-card
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.back_yard
|
||||||
|
live:
|
||||||
|
controls:
|
||||||
|
builtin: false
|
||||||
|
media_viewer:
|
||||||
|
controls:
|
||||||
|
builtin: false
|
||||||
|
menu:
|
||||||
|
buttons:
|
||||||
|
play:
|
||||||
|
enabled: true
|
||||||
|
mute:
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
### Automatically trigger "fullscreen" mode
|
### Automatically trigger "fullscreen" mode
|
||||||
|
|
||||||
The card cannot automatically natively trigger fullscreen mode without the user
|
The card cannot automatically natively trigger fullscreen mode without the user
|
||||||
@@ -3551,6 +3745,31 @@ https://ha.mydomain.org/lovelace-test/0?frigate-card-action:main:clips
|
|||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
### Automation actions
|
||||||
|
|
||||||
|
The card can automatically execute actions when certain conditions are met.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Expand: Automatically selecting a high-definition substream in fullscreen mode</summary>
|
||||||
|
|
||||||
|
This example will automatically turn on the first configured substream when the
|
||||||
|
card is put in fullscreen mode, and turn off the substream when exiting
|
||||||
|
fullscreen mode.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
automations:
|
||||||
|
- conditions:
|
||||||
|
fullscreen: true
|
||||||
|
actions:
|
||||||
|
- action: custom:frigate-card-action
|
||||||
|
frigate_card_action: live_substream_on
|
||||||
|
actions_not:
|
||||||
|
- action: custom:frigate-card-action
|
||||||
|
frigate_card_action: live_substream_off
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
<a name="media-layout-examples"></a>
|
<a name="media-layout-examples"></a>
|
||||||
|
|
||||||
## Card Refreshes
|
## Card Refreshes
|
||||||
@@ -3652,6 +3871,9 @@ To send an action to a named Frigate card on the dashboard:
|
|||||||
| `live` | :white_check_mark: | |
|
| `live` | :white_check_mark: | |
|
||||||
| `media_player`| :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/frigate-hass-card/issues) if you need this. |
|
| `media_player`| :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/frigate-hass-card/issues) if you need this. |
|
||||||
| `menu_toggle` | :white_check_mark: | |
|
| `menu_toggle` | :white_check_mark: | |
|
||||||
|
| `microphone_mute`, `microphone_unmute`| :heavy_multiplication_x: | |
|
||||||
|
| `mute`, `unmute` | :heavy_multiplication_x: | |
|
||||||
|
| `play`, `pause` | :heavy_multiplication_x: | |
|
||||||
| `recording` | :white_check_mark: | |
|
| `recording` | :white_check_mark: | |
|
||||||
| `recordings` | :white_check_mark: | |
|
| `recordings` | :white_check_mark: | |
|
||||||
| `snapshot` | :white_check_mark: | |
|
| `snapshot` | :white_check_mark: | |
|
||||||
@@ -3718,6 +3940,24 @@ live:
|
|||||||
mode: none
|
mode: none
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Microphone / 2-way audio doesn't work
|
||||||
|
|
||||||
|
There are many requirements for 2-way audio to work. See [Using 2-way
|
||||||
|
audio](#using-2-way-audio) for more information about these. If your microphone
|
||||||
|
still does not work and you believe you meet all the requirements try
|
||||||
|
eliminating the card from the picture by going directly to the `go2rtc` UI,
|
||||||
|
navigating to `links` for your given stream, then to `webrtc.html` with a
|
||||||
|
microphone. If this does not work correctly with 2-way audio then your issue is
|
||||||
|
with `go2rtc` not with the card. In this case, you could file an issue in [that
|
||||||
|
repo](https://github.com/AlexxIT/go2rtc/issues) with debugging information as
|
||||||
|
appropriate.
|
||||||
|
|
||||||
|
### Microphone menu button does not appear
|
||||||
|
|
||||||
|
The microphone menu button will only appear if both enabled (see [Menu Button Options](#menu-buttons))
|
||||||
|
and if the media that is currently loaded supports 2-way audio. See
|
||||||
|
[Using 2-way audio](#using-2-way-audio) for more information about the requirements that must be followed.
|
||||||
|
|
||||||
### Static image URL with credentials doesn't load
|
### Static image URL with credentials doesn't load
|
||||||
|
|
||||||
Your browser will not allow a page/script (like this card) to pass credentials to a cross-origin (different host) image URL for security reasons. There is no way around this unless you could also control the webserver that is serving the image to specifically allow `crossorigin` requests (which is typically not the case for an image served from a camera, for example). The stock Home Assistant Picture Glance card has the same limitation, for the same reasons.
|
Your browser will not allow a page/script (like this card) to pass credentials to a cross-origin (different host) image URL for security reasons. There is no way around this unless you could also control the webserver that is serving the image to specifically allow `crossorigin` requests (which is typically not the case for an image served from a camera, for example). The stock Home Assistant Picture Glance card has the same limitation, for the same reasons.
|
||||||
@@ -3811,6 +4051,12 @@ Try resetting the app frontend cache:
|
|||||||
|
|
||||||
* `Configuration -> Companion App -> Debugging -> Reset frontend cache`
|
* `Configuration -> Companion App -> Debugging -> Reset frontend cache`
|
||||||
|
|
||||||
|
### Chrome does not update card version after upgrade
|
||||||
|
|
||||||
|
When upgrading the card it's recommended to reset the frontend cache. Sometimes clearing site data in Chrome settings isn't enough.
|
||||||
|
|
||||||
|
* Press F12 to display `Dev Console` in Chrome then right click on the refresh icon and select `Empty Cache and Hard Reload`
|
||||||
|
|
||||||
### Casting to a remote media player does not work
|
### Casting to a remote media player does not work
|
||||||
|
|
||||||
This could be for any number of reasons. Chromecast devices can be quite picky on network, DNS and certificate issues, as well as audio and video codecs. Check your Home Assistant log as there may be more information in there.
|
This could be for any number of reasons. Chromecast devices can be quite picky on network, DNS and certificate issues, as well as audio and video codecs. Check your Home Assistant log as there may be more information in there.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 219 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "frigate-hass-card",
|
"name": "frigate-hass-card",
|
||||||
"version": "5.0.0",
|
"version": "5.1.1",
|
||||||
"description": "Frigate Lovelace Card for Home Assistant",
|
"description": "Frigate Lovelace Card for Home Assistant",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"frigate",
|
"frigate",
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cycjimmy/jsmpeg-player": "^6.0.4",
|
"@cycjimmy/jsmpeg-player": "^6.0.4",
|
||||||
|
"@dermotduffy/panzoom": "^4.5.1",
|
||||||
"@egjs/hammerjs": "^2.0.17",
|
"@egjs/hammerjs": "^2.0.17",
|
||||||
"@graphiteds/core": "^1.9.6",
|
"@graphiteds/core": "^1.9.6",
|
||||||
"@lit-labs/scoped-registry-mixin": "^1.0.1",
|
"@lit-labs/scoped-registry-mixin": "^1.0.1",
|
||||||
|
|||||||
@@ -78,14 +78,10 @@ class ActionHandler extends HTMLElement implements ActionHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const start = (): void => {
|
const start = (): void => {
|
||||||
const options = element.actionHandlerOptions;
|
this.held = false;
|
||||||
|
this.timer = window.setTimeout(() => {
|
||||||
if (options?.hasHold) {
|
this.held = true;
|
||||||
this.held = false;
|
}, this.holdTime);
|
||||||
this.timer = window.setTimeout(() => {
|
|
||||||
this.held = true;
|
|
||||||
}, this.holdTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
fireEvent(element, 'action', { action: 'start_tap' });
|
fireEvent(element, 'action', { action: 'start_tap' });
|
||||||
};
|
};
|
||||||
@@ -102,14 +98,13 @@ class ActionHandler extends HTMLElement implements ActionHandler {
|
|||||||
// This action handler by default relies on synthetic click events for
|
// This action handler by default relies on synthetic click events for
|
||||||
// touch devices, in order to ensure that embedded cards (e.g. WebRTC)
|
// touch devices, in order to ensure that embedded cards (e.g. WebRTC)
|
||||||
// can use stock click handlers. The exception is for hold events.
|
// can use stock click handlers. The exception is for hold events.
|
||||||
!(options?.hasHold && this.held)
|
!this.held
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options?.hasHold) {
|
|
||||||
clearTimeout(this.timer);
|
clearTimeout(this.timer);
|
||||||
this.timer = undefined;
|
this.timer = undefined;
|
||||||
}
|
|
||||||
|
|
||||||
fireEvent(element, 'action', { action: 'end_tap' });
|
fireEvent(element, 'action', { action: 'end_tap' });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import { AutomationActions, FrigateCardError } from './types.js';
|
||||||
|
import { ConditionController } from './conditions.js';
|
||||||
|
import { Automation, Automations } from './types.js';
|
||||||
|
import { frigateCardHandleAction } from './utils/action.js';
|
||||||
|
import { localize } from './localize/localize.js';
|
||||||
|
|
||||||
|
const MAX_NESTED_AUTOMATION_EXECUTIONS = 10;
|
||||||
|
|
||||||
|
export class AutomationsControllerError extends FrigateCardError {}
|
||||||
|
|
||||||
|
export class AutomationsController {
|
||||||
|
protected _automations: Automations;
|
||||||
|
protected _priorEvaluations: Map<Automation, boolean> = new Map();
|
||||||
|
|
||||||
|
// A counter to avoid infinite loops, increases every time actions are run,
|
||||||
|
// decreases every time actions are complete.
|
||||||
|
protected _nestedAutomationExecutions = 0;
|
||||||
|
|
||||||
|
constructor(automations: Automations) {
|
||||||
|
this._automations = automations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public execute(
|
||||||
|
element: HTMLElement,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
conditionController: ConditionController,
|
||||||
|
): void {
|
||||||
|
const actionsToRun: AutomationActions[] = [];
|
||||||
|
for (const automation of this._automations ?? []) {
|
||||||
|
const shouldExecute = conditionController.evaluateCondition(automation.conditions);
|
||||||
|
const actions = shouldExecute ? automation.actions : automation.actions_not;
|
||||||
|
const priorEvaluation = this._priorEvaluations.get(automation);
|
||||||
|
this._priorEvaluations.set(automation, shouldExecute);
|
||||||
|
if (shouldExecute !== priorEvaluation && actions) {
|
||||||
|
actionsToRun.push(actions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
++this._nestedAutomationExecutions;
|
||||||
|
if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) {
|
||||||
|
throw new AutomationsControllerError(localize('error.too_many_automations'));
|
||||||
|
}
|
||||||
|
|
||||||
|
actionsToRun.forEach((actions) => {
|
||||||
|
frigateCardHandleAction(element, hass, {}, actions);
|
||||||
|
});
|
||||||
|
--this._nestedAutomationExecutions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,21 @@ export class CachedValueController<T> implements ReactiveController {
|
|||||||
protected _host: ReactiveControllerHost;
|
protected _host: ReactiveControllerHost;
|
||||||
protected _timerSeconds: number;
|
protected _timerSeconds: number;
|
||||||
protected _callback: () => T;
|
protected _callback: () => T;
|
||||||
|
protected _timerStartCallback?: () => void;
|
||||||
|
protected _timerStopCallback?: () => void;
|
||||||
protected _timerID?: number;
|
protected _timerID?: number;
|
||||||
|
|
||||||
constructor(host: ReactiveControllerHost, timerSeconds: number, callback: () => T) {
|
constructor(
|
||||||
|
host: ReactiveControllerHost,
|
||||||
|
timerSeconds: number,
|
||||||
|
callback: () => T,
|
||||||
|
timerStartCallback?: () => void,
|
||||||
|
timerStopCallback?: () => void,
|
||||||
|
) {
|
||||||
this._timerSeconds = timerSeconds;
|
this._timerSeconds = timerSeconds;
|
||||||
this._callback = callback;
|
this._callback = callback;
|
||||||
|
this._timerStartCallback = timerStartCallback;
|
||||||
|
this._timerStopCallback = timerStopCallback;
|
||||||
(this._host = host).addController(this);
|
(this._host = host).addController(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +58,7 @@ export class CachedValueController<T> implements ReactiveController {
|
|||||||
public stopTimer(): void {
|
public stopTimer(): void {
|
||||||
if (this._timerID !== undefined) {
|
if (this._timerID !== undefined) {
|
||||||
window.clearInterval(this._timerID);
|
window.clearInterval(this._timerID);
|
||||||
|
this._timerStopCallback?.();
|
||||||
}
|
}
|
||||||
this._timerID = undefined;
|
this._timerID = undefined;
|
||||||
}
|
}
|
||||||
@@ -59,6 +70,7 @@ export class CachedValueController<T> implements ReactiveController {
|
|||||||
this.stopTimer();
|
this.stopTimer();
|
||||||
|
|
||||||
if (this._timerSeconds > 0) {
|
if (this._timerSeconds > 0) {
|
||||||
|
this._timerStartCallback?.();
|
||||||
this._timerID = window.setInterval(() => {
|
this._timerID = window.setInterval(() => {
|
||||||
this.updateValue();
|
this.updateValue();
|
||||||
this._host.requestUpdate();
|
this._host.requestUpdate();
|
||||||
@@ -66,6 +78,10 @@ export class CachedValueController<T> implements ReactiveController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hasTimer(): boolean {
|
||||||
|
return !!this._timerID;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host has connected to the cache.
|
* Host has connected to the cache.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cach
|
|||||||
import { CameraManagerEngine } from './engine';
|
import { CameraManagerEngine } from './engine';
|
||||||
import { CameraInitializationError } from './error';
|
import { CameraInitializationError } from './error';
|
||||||
import { Engine } from './types';
|
import { Engine } from './types';
|
||||||
|
import { getCameraEntityFromConfig } from './util';
|
||||||
|
|
||||||
export class CameraManagerEngineFactory {
|
export class CameraManagerEngineFactory {
|
||||||
protected _entityRegistryManager: EntityRegistryManager;
|
protected _entityRegistryManager: EntityRegistryManager;
|
||||||
@@ -63,16 +64,22 @@ export class CameraManagerEngineFactory {
|
|||||||
engine = Engine.Frigate;
|
engine = Engine.Frigate;
|
||||||
} else if (cameraConfig.engine === 'motioneye') {
|
} else if (cameraConfig.engine === 'motioneye') {
|
||||||
engine = Engine.MotionEye;
|
engine = Engine.MotionEye;
|
||||||
|
} else if (cameraConfig.engine === 'generic') {
|
||||||
|
engine = Engine.Generic;
|
||||||
} else if (cameraConfig.engine === 'auto') {
|
} else if (cameraConfig.engine === 'auto') {
|
||||||
const cameraEntity = cameraConfig.camera_entity;
|
const cameraEntity = getCameraEntityFromConfig(cameraConfig);
|
||||||
|
|
||||||
if (cameraEntity) {
|
if (cameraEntity) {
|
||||||
let entity: Entity | null;
|
let entity: Entity | null;
|
||||||
try {
|
try {
|
||||||
entity = await this._entityRegistryManager.getEntity(hass, cameraEntity);
|
entity = await this._entityRegistryManager.getEntity(hass, cameraEntity);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Throw a slightly friendlier exception (as a typo in the entity is
|
// If the camera is not in the registry, but is in the HA states it is
|
||||||
// likely to be a common failure mode).
|
// assumed to be a generic camera.
|
||||||
|
if (hass.states[cameraEntity]) {
|
||||||
|
return Engine.Generic;
|
||||||
|
}
|
||||||
|
// Otherwise, it's probably a typo so throw an exception.
|
||||||
throw new CameraInitializationError(
|
throw new CameraInitializationError(
|
||||||
localize('error.no_camera_entity'),
|
localize('error.no_camera_entity'),
|
||||||
cameraConfig,
|
cameraConfig,
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import uniq from 'lodash-es/uniq';
|
|||||||
import format from 'date-fns/format';
|
import format from 'date-fns/format';
|
||||||
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||||
import frigateLogo from './assets/frigate-logo-dark.svg';
|
import frigateLogo from './assets/frigate-logo-dark.svg';
|
||||||
|
import { getCameraEntityFromConfig } from '../util';
|
||||||
|
|
||||||
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||||
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||||
@@ -153,12 +154,13 @@ export class FrigateCameraManagerEngine
|
|||||||
cameraConfig.triggers.motion || cameraConfig.triggers.occupancy;
|
cameraConfig.triggers.motion || cameraConfig.triggers.occupancy;
|
||||||
|
|
||||||
let entity: Entity | null = null;
|
let entity: Entity | null = null;
|
||||||
|
const cameraEntity = getCameraEntityFromConfig(cameraConfig);
|
||||||
|
|
||||||
// Entity information is required if the Frigate camera name is missing, or
|
// Entity information is required if the Frigate camera name is missing, or
|
||||||
// if the entity requires automatic resolution of motion/occupancy sensors.
|
// if the entity requires automatic resolution of motion/occupancy sensors.
|
||||||
if (cameraConfig.camera_entity && (!hasCameraName || hasAutoTriggers)) {
|
if (cameraEntity && (!hasCameraName || hasAutoTriggers)) {
|
||||||
try {
|
try {
|
||||||
entity = await entityRegistryManager.getEntity(hass, cameraConfig.camera_entity);
|
entity = await entityRegistryManager.getEntity(hass, cameraEntity);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new CameraInitializationError(
|
throw new CameraInitializationError(
|
||||||
localize('error.no_camera_entity'),
|
localize('error.no_camera_entity'),
|
||||||
@@ -989,9 +991,7 @@ export class FrigateCameraManagerEngine
|
|||||||
|
|
||||||
const countSegments = () =>
|
const countSegments = () =>
|
||||||
sum(
|
sum(
|
||||||
cameraIDs.map(
|
cameraIDs.map((cameraID) => this._recordingSegmentsCache.getSize(cameraID) ?? 0),
|
||||||
(cameraID) => this._recordingSegmentsCache.getSize(cameraID) ?? 0,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const segmentsStart = countSegments();
|
const segmentsStart = countSegments();
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const eventSchema = z.object({
|
|||||||
label: z.string(),
|
label: z.string(),
|
||||||
sub_label: z.string().nullable(),
|
sub_label: z.string().nullable(),
|
||||||
start_time: z.number(),
|
start_time: z.number(),
|
||||||
top_score: z.number(),
|
top_score: z.number().nullable(),
|
||||||
zones: z.string().array(),
|
zones: z.string().array(),
|
||||||
retain_indefinitely: z.boolean().optional(),
|
retain_indefinitely: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ export const getEventTitle = (event: FrigateEvent): string => {
|
|||||||
? event.end_time - event.start_time
|
? event.end_time - event.start_time
|
||||||
: Date.now() / 1000 - event.start_time,
|
: Date.now() / 1000 - event.start_time,
|
||||||
);
|
);
|
||||||
|
const score = event.top_score !== null ? ` ${Math.round(event.top_score * 100)}%` : '';
|
||||||
|
|
||||||
return `${formatDateAndTime(
|
return `${formatDateAndTime(
|
||||||
utcToZonedTime(event.start_time * 1000, localTimezone),
|
utcToZonedTime(event.start_time * 1000, localTimezone),
|
||||||
)} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round(
|
)} [${durationSeconds}s, ${prettifyTitle(event.label)}${score}]`;
|
||||||
event.top_score * 100,
|
|
||||||
)}%]`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getRecordingTitle = (
|
export const getRecordingTitle = (
|
||||||
@@ -93,5 +93,5 @@ export const getRecordingID = (
|
|||||||
// can be dedup'd from this id.
|
// can be dedup'd from this id.
|
||||||
return `${cameraConfig.frigate?.client_id ?? ''}/${
|
return `${cameraConfig.frigate?.client_id ?? ''}/${
|
||||||
cameraConfig.frigate.camera_name ?? ''
|
cameraConfig.frigate.camera_name ?? ''
|
||||||
}/${recording.startTime.getTime()}/${recording.endTime.getTime()}}`;
|
}/${recording.startTime.getTime()}/${recording.endTime.getTime()}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DateRange } from './range';
|
|||||||
import orderBy from 'lodash-es/orderBy';
|
import orderBy from 'lodash-es/orderBy';
|
||||||
import uniqBy from 'lodash-es/uniqBy';
|
import uniqBy from 'lodash-es/uniqBy';
|
||||||
import { ViewMedia } from '../view/media';
|
import { ViewMedia } from '../view/media';
|
||||||
|
import { CameraConfig } from '../types';
|
||||||
|
|
||||||
export const convertRangeToCacheFriendlyTimes = (
|
export const convertRangeToCacheFriendlyTimes = (
|
||||||
range: DateRange,
|
range: DateRange,
|
||||||
@@ -53,3 +54,7 @@ export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => {
|
|||||||
'asc',
|
'asc',
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCameraEntityFromConfig = (cameraConfig: CameraConfig): string | null => {
|
||||||
|
return cameraConfig.camera_entity ?? cameraConfig.webrtc_card?.entity ?? null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
import {
|
|
||||||
FrigateCardCondition,
|
|
||||||
FrigateCardConfig,
|
|
||||||
frigateConditionalSchema,
|
|
||||||
OverrideConfigurationKey,
|
|
||||||
RawFrigateCardConfig,
|
|
||||||
} from './types';
|
|
||||||
import { HassEntities } from 'home-assistant-js-websocket';
|
|
||||||
import merge from 'lodash-es/merge';
|
|
||||||
import { copyConfig } from './config-mgmt';
|
|
||||||
|
|
||||||
export interface ConditionState {
|
|
||||||
view?: string;
|
|
||||||
fullscreen?: boolean;
|
|
||||||
expand?: boolean;
|
|
||||||
camera?: string;
|
|
||||||
state?: HassEntities;
|
|
||||||
media_loaded?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ConditionStateRequestEvent extends Event {
|
|
||||||
public conditionState?: ConditionState;
|
|
||||||
}
|
|
||||||
|
|
||||||
function evaluateCondition(
|
|
||||||
condition?: Readonly<FrigateCardCondition>,
|
|
||||||
state?: Readonly<ConditionState>,
|
|
||||||
): boolean {
|
|
||||||
if (!state) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
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?.expand !== undefined) {
|
|
||||||
result &&=
|
|
||||||
state.expand !== undefined && condition.expand == state.expand;
|
|
||||||
}
|
|
||||||
if (condition?.camera?.length) {
|
|
||||||
result &&= !!state.camera && condition.camera.includes(state.camera);
|
|
||||||
}
|
|
||||||
if (condition?.state?.length) {
|
|
||||||
for (const stateTest of condition?.state) {
|
|
||||||
result &&=
|
|
||||||
!!state.state &&
|
|
||||||
((!stateTest.state && !stateTest.state_not) ||
|
|
||||||
(stateTest.entity in state.state &&
|
|
||||||
(!stateTest.state ||
|
|
||||||
state.state[stateTest.entity].state === stateTest.state) &&
|
|
||||||
(!stateTest.state_not ||
|
|
||||||
state.state[stateTest.entity].state !== stateTest.state_not)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (condition?.media_loaded !== undefined) {
|
|
||||||
result &&=
|
|
||||||
state.media_loaded !== undefined && condition.media_loaded == state.media_loaded;
|
|
||||||
}
|
|
||||||
if (condition?.media_query) {
|
|
||||||
result &&= window.matchMedia(condition.media_query).matches;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluate whether a frigateCardCondition is met using an event to fetch state.
|
|
||||||
* @returns A boolean indicating whether the condition is met.
|
|
||||||
*/
|
|
||||||
export function fetchStateAndEvaluateCondition(
|
|
||||||
element: HTMLElement,
|
|
||||||
condition?: FrigateCardCondition,
|
|
||||||
): boolean {
|
|
||||||
if (!condition) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateEvent = new ConditionStateRequestEvent(
|
|
||||||
`frigate-card:condition-state-request`,
|
|
||||||
{
|
|
||||||
bubbles: true,
|
|
||||||
composed: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/* Special note on what's going on here:
|
|
||||||
*
|
|
||||||
* Some parts of the card (e.g. <frigate-card-elements>) may have arbitrary
|
|
||||||
* complexity and layers (that this card doesn't control) between that master
|
|
||||||
* element and the element that needs to evaluate the condition. In these
|
|
||||||
* cases there's no clean way to pass state from the rest of card down
|
|
||||||
* through these layers. Instead, an event is dispatched as a "request for
|
|
||||||
* state" (StateRequestEvent) upwards which is caught by the outer card
|
|
||||||
* and state added to the event object. Because event propagation is handled
|
|
||||||
* synchronously, the state will be added to the event before the flow
|
|
||||||
* proceeds.
|
|
||||||
*/
|
|
||||||
element.dispatchEvent(stateEvent);
|
|
||||||
return evaluateCondition(condition, stateEvent.conditionState);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function conditionStateRequestHandler(
|
|
||||||
ev: ConditionStateRequestEvent,
|
|
||||||
conditionState?: ConditionState,
|
|
||||||
): void {
|
|
||||||
ev.conditionState = conditionState;
|
|
||||||
}
|
|
||||||
|
|
||||||
type RawOverrides = {
|
|
||||||
conditions: FrigateCardCondition;
|
|
||||||
overrides: RawFrigateCardConfig;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
export function getOverriddenConfig(
|
|
||||||
config: Readonly<RawFrigateCardConfig>,
|
|
||||||
overrides: Readonly<RawOverrides> | undefined,
|
|
||||||
conditionState?: Readonly<ConditionState>,
|
|
||||||
): RawFrigateCardConfig {
|
|
||||||
const output = copyConfig(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<RawOverrides> | undefined,
|
|
||||||
key: OverrideConfigurationKey,
|
|
||||||
): RawOverrides {
|
|
||||||
return (
|
|
||||||
overrides
|
|
||||||
?.filter((o) => key in o.overrides)
|
|
||||||
.map((o) => ({
|
|
||||||
conditions: o.conditions,
|
|
||||||
overrides: o.overrides[key] as RawFrigateCardConfig,
|
|
||||||
})) ?? []
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CardConditionManager {
|
|
||||||
// Whether or not to include HA state in ConditionState. Doing so increases
|
|
||||||
// CPU usage as HA state is pumped out very fast, so this is only enabled if
|
|
||||||
// the configuration needs to consume it.
|
|
||||||
protected _hasHAStateConditions = false;
|
|
||||||
|
|
||||||
protected _callback: () => void;
|
|
||||||
protected _mediaQueries: MediaQueryList[] = [];
|
|
||||||
protected _boundTriggerChange = this._triggerChange.bind(this);
|
|
||||||
|
|
||||||
constructor(config: FrigateCardConfig, callback: () => void) {
|
|
||||||
this._initConditions(config);
|
|
||||||
this._callback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy the object.
|
|
||||||
*/
|
|
||||||
public destroy(): void {
|
|
||||||
this._mediaQueries.forEach((mql) =>
|
|
||||||
mql.removeEventListener('change', this._boundTriggerChange),
|
|
||||||
);
|
|
||||||
this._mediaQueries = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if the conditions have state conditions.
|
|
||||||
*/
|
|
||||||
get hasHAStateConditions(): boolean {
|
|
||||||
return this._hasHAStateConditions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger the callback.
|
|
||||||
* @param _ Ignored parameter.
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
protected _triggerChange(_: MediaQueryListEvent): void {
|
|
||||||
this._callback();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Init the conditions.
|
|
||||||
* @param config The card configuration.
|
|
||||||
*/
|
|
||||||
protected _initConditions(config: FrigateCardConfig): void {
|
|
||||||
const getAllConditions = (config: FrigateCardConfig): FrigateCardCondition[] => {
|
|
||||||
const conditions: FrigateCardCondition[] = [];
|
|
||||||
config.overrides?.forEach((override) => conditions.push(override.conditions));
|
|
||||||
|
|
||||||
// Element conditions can be arbitrarily nested underneath conditionals and
|
|
||||||
// custom elements that this card may not known. Here we recursively parse
|
|
||||||
// down the elements tree, parsing as we go to find valid conditions.
|
|
||||||
const getElementsConditions = (data: unknown): void => {
|
|
||||||
const parseResult = frigateConditionalSchema.safeParse(data);
|
|
||||||
if (parseResult.success) {
|
|
||||||
conditions.push(parseResult.data.conditions);
|
|
||||||
parseResult.data.elements?.forEach(getElementsConditions);
|
|
||||||
} else if (data && typeof data === 'object') {
|
|
||||||
Object.keys(data).forEach((key) => getElementsConditions(data[key]));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
config.elements?.forEach(getElementsConditions);
|
|
||||||
return conditions;
|
|
||||||
};
|
|
||||||
|
|
||||||
const conditions = getAllConditions(config);
|
|
||||||
this._hasHAStateConditions = conditions.some(
|
|
||||||
(condition) => !!condition.state?.length,
|
|
||||||
);
|
|
||||||
conditions.forEach((condition) => {
|
|
||||||
if (condition.media_query) {
|
|
||||||
const mql = window.matchMedia(condition.media_query);
|
|
||||||
mql.addEventListener('change', this._boundTriggerChange);
|
|
||||||
this._mediaQueries.push(mql);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+323
-111
@@ -21,23 +21,23 @@ import 'web-dialog';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import pkg from '../package.json';
|
import pkg from '../package.json';
|
||||||
import { actionHandler } from './action-handler-directive.js';
|
import { actionHandler } from './action-handler-directive.js';
|
||||||
|
import { AutomationsController } from './automations';
|
||||||
import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js';
|
import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js';
|
||||||
import { CameraManager } from './camera-manager/manager.js';
|
import { CameraManager } from './camera-manager/manager.js';
|
||||||
import {
|
|
||||||
CardConditionManager,
|
|
||||||
ConditionState,
|
|
||||||
conditionStateRequestHandler,
|
|
||||||
getOverriddenConfig,
|
|
||||||
} from './card-condition.js';
|
|
||||||
import './components/elements.js';
|
import './components/elements.js';
|
||||||
import { FrigateCardElements } from './components/elements.js';
|
import { FrigateCardElements } from './components/elements.js';
|
||||||
import './components/menu.js';
|
import './components/menu.js';
|
||||||
import { FrigateCardMenu, FRIGATE_BUTTON_MENU_ICON } from './components/menu.js';
|
import { FRIGATE_BUTTON_MENU_ICON, FrigateCardMenu } from './components/menu.js';
|
||||||
import './components/message.js';
|
import './components/message.js';
|
||||||
import { renderMessage, renderProgressIndicator } from './components/message.js';
|
import { renderMessage, renderProgressIndicator } from './components/message.js';
|
||||||
import './components/thumbnail-carousel.js';
|
import './components/thumbnail-carousel.js';
|
||||||
import './components/views.js';
|
import './components/views.js';
|
||||||
import { FrigateCardViews } from './components/views.js';
|
import { FrigateCardViews } from './components/views.js';
|
||||||
|
import {
|
||||||
|
ConditionController,
|
||||||
|
ConditionEvaluateRequestEvent,
|
||||||
|
getOverriddenConfig,
|
||||||
|
} from './conditions.js';
|
||||||
import { isConfigUpgradeable } from './config-mgmt.js';
|
import { isConfigUpgradeable } from './config-mgmt.js';
|
||||||
import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, REPO_URL } from './const.js';
|
import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, REPO_URL } from './const.js';
|
||||||
import { getLanguage, loadLanguages, localize } from './localize/localize.js';
|
import { getLanguage, loadLanguages, localize } from './localize/localize.js';
|
||||||
@@ -45,17 +45,17 @@ import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance
|
|||||||
import cardStyle from './scss/card.scss';
|
import cardStyle from './scss/card.scss';
|
||||||
import {
|
import {
|
||||||
Actions,
|
Actions,
|
||||||
ActionType,
|
ActionsConfig,
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
CardWideConfig,
|
CardWideConfig,
|
||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
|
FRIGATE_CARD_VIEW_DEFAULT,
|
||||||
|
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||||
FrigateCardConfig,
|
FrigateCardConfig,
|
||||||
frigateCardConfigSchema,
|
frigateCardConfigSchema,
|
||||||
FrigateCardCustomAction,
|
FrigateCardCustomAction,
|
||||||
FrigateCardError,
|
FrigateCardError,
|
||||||
FrigateCardView,
|
FrigateCardView,
|
||||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
|
||||||
FRIGATE_CARD_VIEW_DEFAULT,
|
|
||||||
MediaLoadedInfo,
|
MediaLoadedInfo,
|
||||||
MenuButton,
|
MenuButton,
|
||||||
Message,
|
Message,
|
||||||
@@ -65,7 +65,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
convertActionToFrigateCardCustomAction,
|
convertActionToFrigateCardCustomAction,
|
||||||
createFrigateCardCustomAction,
|
createFrigateCardCustomAction,
|
||||||
frigateCardHandleAction,
|
frigateCardHandleActionConfig,
|
||||||
frigateCardHasAction,
|
frigateCardHasAction,
|
||||||
getActionConfigGivenAction,
|
getActionConfigGivenAction,
|
||||||
} from './utils/action.js';
|
} from './utils/action.js';
|
||||||
@@ -90,7 +90,14 @@ import { ResolvedMediaCache } from './utils/ha/resolved-media.js';
|
|||||||
import { supportsFeature } from './utils/ha/update.js';
|
import { supportsFeature } from './utils/ha/update.js';
|
||||||
import { FrigateCardInitializer } from './utils/initializer.js';
|
import { FrigateCardInitializer } from './utils/initializer.js';
|
||||||
import { isValidMediaLoadedInfo } from './utils/media-info.js';
|
import { isValidMediaLoadedInfo } from './utils/media-info.js';
|
||||||
|
import { MicrophoneController } from './utils/microphone';
|
||||||
import { getActionsFromQueryString } from './utils/querystring.js';
|
import { getActionsFromQueryString } from './utils/querystring.js';
|
||||||
|
import {
|
||||||
|
createViewWithNextStream,
|
||||||
|
createViewWithoutSubstream,
|
||||||
|
createViewWithSelectedSubstream,
|
||||||
|
hasSubstream,
|
||||||
|
} from './utils/substream';
|
||||||
import { View } from './view/view.js';
|
import { View } from './view/view.js';
|
||||||
|
|
||||||
/** A note on media callbacks:
|
/** A note on media callbacks:
|
||||||
@@ -112,11 +119,11 @@ import { View } from './view/view.js';
|
|||||||
|
|
||||||
/** A note on action/menu/ll-custom events:
|
/** A note on action/menu/ll-custom events:
|
||||||
*
|
*
|
||||||
* The card supports actions being configured in a number of places (e.g. tap on an
|
* The card supports actions being configured in a number of places (e.g. tap on
|
||||||
* element, double_tap on a menu item, hold on the live view). These actions are
|
* an element, double_tap on a menu item, hold on the live view). These actions
|
||||||
* handled frigateCardHandleAction(). For Frigate-card specific actions,
|
* are handled by frigateCardHandleActionConfig(). For Frigate-card specific
|
||||||
* frigateCardHandleAction() call will result in an ll-custom DOM event being
|
* actions, the frigateCardHandleActionConfig() call will result in an ll-custom
|
||||||
* fired, which needs to be caught at the card level to handle.
|
* DOM event being fired, which needs to be caught at the card level to handle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* eslint no-console: 0 */
|
/* eslint no-console: 0 */
|
||||||
@@ -145,6 +152,7 @@ enum InitializationAspect {
|
|||||||
SIDE_LOAD_ELEMENTS = 'side-load-elements',
|
SIDE_LOAD_ELEMENTS = 'side-load-elements',
|
||||||
MEDIA_PLAYERS = 'media-players',
|
MEDIA_PLAYERS = 'media-players',
|
||||||
CAMERAS = 'cameras',
|
CAMERAS = 'cameras',
|
||||||
|
MICROPHONE = 'microphone',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,7 +187,9 @@ class FrigateCard extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
protected _expand?: boolean = false;
|
protected _expand?: boolean = false;
|
||||||
|
|
||||||
protected _conditionState?: ConditionState;
|
protected _microphoneController?: MicrophoneController;
|
||||||
|
protected _conditionController?: ConditionController;
|
||||||
|
protected _automationsController?: AutomationsController;
|
||||||
|
|
||||||
protected _refMenu: Ref<FrigateCardMenu> = createRef();
|
protected _refMenu: Ref<FrigateCardMenu> = createRef();
|
||||||
protected _refMain: Ref<HTMLElement> = createRef();
|
protected _refMain: Ref<HTMLElement> = createRef();
|
||||||
@@ -213,12 +223,12 @@ class FrigateCard extends LitElement {
|
|||||||
// The mouse handler may be called continually, throttle it to at most once
|
// The mouse handler may be called continually, throttle it to at most once
|
||||||
// per second for performance reasons.
|
// per second for performance reasons.
|
||||||
protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000);
|
protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000);
|
||||||
|
protected _boundCardActionEventHandler = this._cardActionEventHandler.bind(this);
|
||||||
|
protected _boundFullscreenHandler = this._fullscreenHandler.bind(this);
|
||||||
|
|
||||||
protected _triggers: Map<string, Date> = new Map();
|
protected _triggers: Map<string, Date> = new Map();
|
||||||
protected _untriggerTimerID: number | null = null;
|
protected _untriggerTimerID: number | null = null;
|
||||||
|
|
||||||
protected _conditionManager: CardConditionManager | null = null;
|
|
||||||
|
|
||||||
protected _mediaPlayers?: string[];
|
protected _mediaPlayers?: string[];
|
||||||
|
|
||||||
protected _initializer = new FrigateCardInitializer();
|
protected _initializer = new FrigateCardInitializer();
|
||||||
@@ -249,9 +259,8 @@ class FrigateCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._conditionManager?.hasHAStateConditions) {
|
if (this._conditionController?.hasHAStateConditions) {
|
||||||
// HA entity state is part of the condition state.
|
this._conditionController.setState({ state: this._hass.states });
|
||||||
this._generateConditionState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dark mode may depend on HASS.
|
// Dark mode may depend on HASS.
|
||||||
@@ -290,36 +299,30 @@ class FrigateCard extends LitElement {
|
|||||||
} as unknown as FrigateCardConfig;
|
} as unknown as FrigateCardConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected _requestUpdateForComponentsThatUseConditions(): void {
|
||||||
* Generate the state used to evaluate conditions.
|
// Update the components that need to know about condition changes. Trigger
|
||||||
*/
|
// updates directly on them to them to avoid the performance hit of a entire
|
||||||
protected _generateConditionState(): void {
|
// card re-render (esp. when using card-mod).
|
||||||
this._conditionState = {
|
|
||||||
view: this._view?.view,
|
|
||||||
fullscreen: screenfull.isEnabled && screenfull.isFullscreen,
|
|
||||||
expand: this._expand,
|
|
||||||
camera: this._view?.camera,
|
|
||||||
media_loaded: !!this._currentMediaLoadedInfo,
|
|
||||||
...(this._conditionManager?.hasHAStateConditions && {
|
|
||||||
state: this._hass?.states,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update the components that need the new condition state. Passed directly
|
|
||||||
// to them to avoid the performance hit of a entire card re-render (esp.
|
|
||||||
// when using card-mod).
|
|
||||||
// https://github.com/dermotduffy/frigate-hass-card/issues/678
|
// https://github.com/dermotduffy/frigate-hass-card/issues/678
|
||||||
if (this._refViews.value) {
|
if (this._refViews.value) {
|
||||||
this._refViews.value.conditionState = this._conditionState;
|
this._refViews.value.conditionControllerEpoch =
|
||||||
|
this._conditionController?.getEpoch();
|
||||||
}
|
}
|
||||||
if (this._refElements.value) {
|
if (this._refElements.value) {
|
||||||
this._refElements.value.conditionState = this._conditionState;
|
this._refElements.value.conditionControllerEpoch =
|
||||||
|
this._conditionController?.getEpoch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _overrideConfig(): void {
|
||||||
|
if (!this._conditionController) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const overriddenConfig = getOverriddenConfig(
|
const overriddenConfig = getOverriddenConfig(
|
||||||
|
this._conditionController,
|
||||||
this._config,
|
this._config,
|
||||||
this._config.overrides,
|
this._config.overrides,
|
||||||
this._conditionState,
|
|
||||||
) as FrigateCardConfig;
|
) as FrigateCardConfig;
|
||||||
|
|
||||||
// Save on Lit re-rendering costs by only updating the configuration if it
|
// Save on Lit re-rendering costs by only updating the configuration if it
|
||||||
@@ -341,7 +344,13 @@ class FrigateCard extends LitElement {
|
|||||||
* Get the style of emphasized menu items.
|
* Get the style of emphasized menu items.
|
||||||
* @returns A StyleInfo.
|
* @returns A StyleInfo.
|
||||||
*/
|
*/
|
||||||
protected _getEmphasizedStyle(): StyleInfo {
|
protected _getEmphasizedStyle(critical?: boolean): StyleInfo {
|
||||||
|
if (critical) {
|
||||||
|
return {
|
||||||
|
animation: 'pulse 3s infinite',
|
||||||
|
color: 'var(--error-color, white)',
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
color: 'var(--primary-color, white)',
|
color: 'var(--primary-color, white)',
|
||||||
};
|
};
|
||||||
@@ -475,12 +484,9 @@ class FrigateCard extends LitElement {
|
|||||||
title: localize('config.menu.buttons.substreams'),
|
title: localize('config.menu.buttons.substreams'),
|
||||||
...this._getConfig().menu.buttons.substreams,
|
...this._getConfig().menu.buttons.substreams,
|
||||||
type: 'custom:frigate-card-menu-icon',
|
type: 'custom:frigate-card-menu-icon',
|
||||||
tap_action: createFrigateCardCustomAction('live_substream_select', {
|
tap_action: createFrigateCardCustomAction(
|
||||||
camera:
|
hasSubstream(this._view) ? 'live_substream_off' : 'live_substream_on',
|
||||||
override === undefined || override === dependencies[0]
|
) as FrigateCardCustomAction,
|
||||||
? dependencies[1]
|
|
||||||
: dependencies[0],
|
|
||||||
}) as FrigateCardCustomAction,
|
|
||||||
});
|
});
|
||||||
} else if (dependencies.length > 2) {
|
} else if (dependencies.length > 2) {
|
||||||
const menuItems = Array.from(dependencies, (cameraID) => {
|
const menuItems = Array.from(dependencies, (cameraID) => {
|
||||||
@@ -613,6 +619,40 @@ class FrigateCard extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this._microphoneController &&
|
||||||
|
this._currentMediaLoadedInfo?.capabilities?.supports2WayAudio
|
||||||
|
) {
|
||||||
|
const muted = this._microphoneController.isMuted();
|
||||||
|
const buttonType = this._getConfig().menu.buttons.microphone.type;
|
||||||
|
buttons.push({
|
||||||
|
icon: this._microphoneController.isForbidden()
|
||||||
|
? 'mdi:microphone-message-off'
|
||||||
|
: muted
|
||||||
|
? 'mdi:microphone-off'
|
||||||
|
: 'mdi:microphone',
|
||||||
|
...this._getConfig().menu.buttons.microphone,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.microphone'),
|
||||||
|
style: muted ? {} : this._getEmphasizedStyle(true),
|
||||||
|
...(buttonType === 'momentary' && {
|
||||||
|
start_tap_action: createFrigateCardCustomAction(
|
||||||
|
'microphone_unmute',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
end_tap_action: createFrigateCardCustomAction(
|
||||||
|
'microphone_mute',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
}),
|
||||||
|
...(buttonType === 'toggle' && {
|
||||||
|
tap_action: createFrigateCardCustomAction(
|
||||||
|
this._microphoneController.isMuted()
|
||||||
|
? 'microphone_unmute'
|
||||||
|
: 'microphone_mute',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (screenfull.isEnabled && !this._isBeingCasted()) {
|
if (screenfull.isEnabled && !this._isBeingCasted()) {
|
||||||
buttons.push({
|
buttons.push({
|
||||||
icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen',
|
icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen',
|
||||||
@@ -674,6 +714,34 @@ class FrigateCard extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._currentMediaLoadedInfo && this._currentMediaLoadedInfo.player) {
|
||||||
|
if (this._currentMediaLoadedInfo.capabilities?.supportsPause) {
|
||||||
|
const paused = this._currentMediaLoadedInfo.player.isPaused();
|
||||||
|
buttons.push({
|
||||||
|
icon: paused ? 'mdi:play' : 'mdi:pause',
|
||||||
|
...this._getConfig().menu.buttons.play,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.play'),
|
||||||
|
tap_action: createFrigateCardCustomAction(
|
||||||
|
paused ? 'play' : 'pause',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._currentMediaLoadedInfo.capabilities?.hasAudio) {
|
||||||
|
const muted = this._currentMediaLoadedInfo.player.isMuted();
|
||||||
|
buttons.push({
|
||||||
|
icon: muted ? 'mdi:volume-off' : 'mdi:volume-high',
|
||||||
|
...this._getConfig().menu.buttons.mute,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.mute'),
|
||||||
|
tap_action: createFrigateCardCustomAction(
|
||||||
|
muted ? 'unmute' : 'mute',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({
|
const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({
|
||||||
style: this._getStyleFromActions(button),
|
style: this._getStyleFromActions(button),
|
||||||
...button,
|
...button,
|
||||||
@@ -827,18 +895,52 @@ class FrigateCard extends LitElement {
|
|||||||
this._view = undefined;
|
this._view = undefined;
|
||||||
this._message = null;
|
this._message = null;
|
||||||
|
|
||||||
this._conditionManager?.destroy();
|
this._setupConditionController();
|
||||||
this._conditionManager = new CardConditionManager(
|
|
||||||
config,
|
|
||||||
this._generateConditionState.bind(this),
|
|
||||||
);
|
|
||||||
|
|
||||||
this._generateConditionState();
|
this._automationsController = new AutomationsController(this._config.automations);
|
||||||
this._setLightOrDarkMode();
|
this._setLightOrDarkMode();
|
||||||
this._setPropertiesForMinMaxHeight();
|
this._setPropertiesForMinMaxHeight();
|
||||||
this._untrigger();
|
this._untrigger();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _setupConditionController(): void {
|
||||||
|
this._conditionController?.destroy();
|
||||||
|
this._conditionController = new ConditionController(this._config);
|
||||||
|
this._conditionController.addStateListener(this._overrideConfig.bind(this));
|
||||||
|
this._conditionController.addStateListener(
|
||||||
|
this._requestUpdateForComponentsThatUseConditions.bind(this),
|
||||||
|
);
|
||||||
|
this._conditionController.addStateListener(this._executeAutomations.bind(this));
|
||||||
|
|
||||||
|
this._conditionController.setState({
|
||||||
|
view: undefined,
|
||||||
|
fullscreen: this._isInFullscreen(),
|
||||||
|
expand: this._expand,
|
||||||
|
camera: undefined,
|
||||||
|
...(this._hass &&
|
||||||
|
this._conditionController?.hasHAStateConditions && {
|
||||||
|
state: this._hass.states,
|
||||||
|
}),
|
||||||
|
media_loaded: !!this._currentMediaLoadedInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _executeAutomations(): void {
|
||||||
|
// Never execute automations if there's an error (as our automation loop
|
||||||
|
// avoidance -- which shows as an error -- does not work).
|
||||||
|
if (this._message?.type !== 'error' && this._hass && this._conditionController) {
|
||||||
|
try {
|
||||||
|
this._automationsController?.execute(
|
||||||
|
this,
|
||||||
|
this._hass,
|
||||||
|
this._conditionController,
|
||||||
|
);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
this._handleThrownError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card the card config, prioritizing the overriden config if present.
|
* Card the card config, prioritizing the overriden config if present.
|
||||||
* @returns A FrigateCardConfig.
|
* @returns A FrigateCardConfig.
|
||||||
@@ -860,7 +962,10 @@ class FrigateCard extends LitElement {
|
|||||||
View.adoptFromViewIfAppropriate(view, this._view);
|
View.adoptFromViewIfAppropriate(view, this._view);
|
||||||
|
|
||||||
this._view = view;
|
this._view = view;
|
||||||
this._generateConditionState();
|
this._conditionController?.setState({
|
||||||
|
view: this._view.view,
|
||||||
|
camera: this._view.camera,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (args?.resetMessage ?? true) {
|
if (args?.resetMessage ?? true) {
|
||||||
@@ -944,11 +1049,30 @@ class FrigateCard extends LitElement {
|
|||||||
setPerformanceCSSStyles(this, this._cardWideConfig?.performance);
|
setPerformanceCSSStyles(this, this._cardWideConfig?.performance);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._initializeBackground();
|
|
||||||
|
|
||||||
if (changedProps.has('_view')) {
|
if (changedProps.has('_view')) {
|
||||||
this._setPropertiesForExpandedMode();
|
this._setPropertiesForExpandedMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oldConfig: FrigateCardConfig | undefined =
|
||||||
|
changedProps.get('_overriddenConfig') ?? changedProps.get('_config');
|
||||||
|
const newConfig = this._getConfig();
|
||||||
|
if (
|
||||||
|
(!this._microphoneController ||
|
||||||
|
changedProps.has('_overriddenConfig') ||
|
||||||
|
changedProps.has('_config')) &&
|
||||||
|
oldConfig?.live.microphone.disconnect_seconds !==
|
||||||
|
newConfig.live.microphone.disconnect_seconds
|
||||||
|
) {
|
||||||
|
const config = this._getConfig();
|
||||||
|
this._microphoneController = new MicrophoneController(
|
||||||
|
config.live.microphone.always_connected
|
||||||
|
? undefined
|
||||||
|
: config.live.microphone.disconnect_seconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be called after the microphoneController is created.
|
||||||
|
this._initializeBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _setPropertiesForMinMaxHeight(): void {
|
protected _setPropertiesForMinMaxHeight(): void {
|
||||||
@@ -1124,6 +1248,10 @@ class FrigateCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async _initializeMicrophone(): Promise<void> {
|
||||||
|
await this._microphoneController?.connect();
|
||||||
|
}
|
||||||
|
|
||||||
protected async _initializeMediaPlayers(hass: HomeAssistant): Promise<void> {
|
protected async _initializeMediaPlayers(hass: HomeAssistant): Promise<void> {
|
||||||
const isValidMediaPlayer = (entityID: string): boolean => {
|
const isValidMediaPlayer = (entityID: string): boolean => {
|
||||||
if (entityID.startsWith('media_player.')) {
|
if (entityID.startsWith('media_player.')) {
|
||||||
@@ -1219,19 +1347,33 @@ class FrigateCard extends LitElement {
|
|||||||
protected _initializeBackground(): void {
|
protected _initializeBackground(): void {
|
||||||
const hass = this._hass;
|
const hass = this._hass;
|
||||||
const config = this._getConfig();
|
const config = this._getConfig();
|
||||||
const needMediaPlayers = config.menu.buttons.media_player.enabled;
|
if (!hass || !config) {
|
||||||
if (!hass || !config || !needMediaPlayers) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this._initializer.isInitialized(InitializationAspect.MEDIA_PLAYERS)) {
|
if (
|
||||||
|
this._initializer.isInitializedMultiple([
|
||||||
|
...(config.menu.buttons.media_player.enabled
|
||||||
|
? [InitializationAspect.MEDIA_PLAYERS]
|
||||||
|
: []),
|
||||||
|
...(config.live.microphone.always_connected
|
||||||
|
? [InitializationAspect.MICROPHONE]
|
||||||
|
: []),
|
||||||
|
])
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._initializer
|
this._initializer
|
||||||
.initializeMultipleIfNecessary({
|
.initializeMultipleIfNecessary({
|
||||||
[InitializationAspect.MEDIA_PLAYERS]: async () =>
|
...(config.menu.buttons.media_player.enabled && {
|
||||||
await this._initializeMediaPlayers(hass),
|
[InitializationAspect.MEDIA_PLAYERS]: async () =>
|
||||||
|
await this._initializeMediaPlayers(hass),
|
||||||
|
}),
|
||||||
|
...(config.live.microphone.always_connected && {
|
||||||
|
[InitializationAspect.MICROPHONE]: async () =>
|
||||||
|
await this._initializeMicrophone(),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.then((initialized) => {
|
.then((initialized) => {
|
||||||
if (initialized) {
|
if (initialized) {
|
||||||
@@ -1389,15 +1531,21 @@ class FrigateCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _cardActionEventHandler(ev: CustomEvent<ActionType>): void {
|
protected _cardActionEventHandler(ev: Event): void {
|
||||||
const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail);
|
// The event may not actually be a CustomEvent object, but may still have a
|
||||||
if (frigateCardAction) {
|
// detail field (see:
|
||||||
this._cardActionHandler(frigateCardAction);
|
// https://github.com/custom-cards/custom-card-helpers/blob/master/src/fire-event.ts#L70
|
||||||
|
// )
|
||||||
|
if ('detail' in ev) {
|
||||||
|
const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail);
|
||||||
|
if (frigateCardAction) {
|
||||||
|
this._cardActionHandler(frigateCardAction);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _cardActionHandler(frigateCardAction: FrigateCardCustomAction): void {
|
protected _cardActionHandler(frigateCardAction: FrigateCardCustomAction): void {
|
||||||
if (!this._view) {
|
if (!this._view || !this._cameraManager) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1444,7 +1592,7 @@ class FrigateCard extends LitElement {
|
|||||||
this._setExpand(!this._expand);
|
this._setExpand(!this._expand);
|
||||||
break;
|
break;
|
||||||
case 'fullscreen':
|
case 'fullscreen':
|
||||||
this._toggleFullscreen();
|
screenfull.toggle(this);
|
||||||
break;
|
break;
|
||||||
case 'menu_toggle':
|
case 'menu_toggle':
|
||||||
// This is a rare code path: this would only be used if someone has a
|
// This is a rare code path: this would only be used if someone has a
|
||||||
@@ -1469,16 +1617,24 @@ class FrigateCard extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'live_substream_select':
|
case 'live_substream_select': {
|
||||||
const overrides: Map<string, string> =
|
const view = createViewWithSelectedSubstream(
|
||||||
this._view.context?.live?.overrides ?? new Map();
|
this._view,
|
||||||
overrides.set(this._view.camera, frigateCardAction.camera);
|
frigateCardAction.camera,
|
||||||
this._changeView({
|
);
|
||||||
view: this._view.clone().mergeInContext({
|
view && this._changeView({ view: view });
|
||||||
live: { overrides: overrides },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
case 'live_substream_off': {
|
||||||
|
const view = createViewWithoutSubstream(this._view);
|
||||||
|
view && this._changeView({ view: view });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'live_substream_on': {
|
||||||
|
const view = createViewWithNextStream(this._cameraManager, this._view);
|
||||||
|
view && this._changeView({ view: view });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'media_player':
|
case 'media_player':
|
||||||
this._mediaPlayerAction(
|
this._mediaPlayerAction(
|
||||||
frigateCardAction.media_player,
|
frigateCardAction.media_player,
|
||||||
@@ -1488,6 +1644,43 @@ class FrigateCard extends LitElement {
|
|||||||
case 'diagnostics':
|
case 'diagnostics':
|
||||||
this._diagnostics();
|
this._diagnostics();
|
||||||
break;
|
break;
|
||||||
|
case 'microphone_mute':
|
||||||
|
this._microphoneController?.mute();
|
||||||
|
this.requestUpdate();
|
||||||
|
break;
|
||||||
|
case 'microphone_unmute':
|
||||||
|
if (
|
||||||
|
!this._microphoneController?.isConnected() &&
|
||||||
|
!this._microphoneController?.isForbidden()
|
||||||
|
) {
|
||||||
|
// The connect() call is async and make take an arbitrary amount of
|
||||||
|
// time for the user to grant access to their microphone. With a
|
||||||
|
// momentary microphone button the mute call (on mouse release) may
|
||||||
|
// arrive before the connection is even granted, so we unmute first
|
||||||
|
// before the connection is made, so the mute call on release will not
|
||||||
|
// be 'overwritten' incorrectly.
|
||||||
|
this._microphoneController?.unmute();
|
||||||
|
|
||||||
|
// Must requestUpdate to show the correct microphone state in the
|
||||||
|
// menu.
|
||||||
|
this._initializeMicrophone().then(() => this.requestUpdate());
|
||||||
|
} else if (this._microphoneController?.isConnected()) {
|
||||||
|
this._microphoneController.unmute();
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'mute':
|
||||||
|
this._currentMediaLoadedInfo?.player?.mute();
|
||||||
|
break;
|
||||||
|
case 'unmute':
|
||||||
|
this._currentMediaLoadedInfo?.player?.unmute();
|
||||||
|
break;
|
||||||
|
case 'play':
|
||||||
|
this._currentMediaLoadedInfo?.player?.play();
|
||||||
|
break;
|
||||||
|
case 'pause':
|
||||||
|
this._currentMediaLoadedInfo?.player?.pause();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(`Frigate card received unknown card action: ${action}`);
|
console.warn(`Frigate card received unknown card action: ${action}`);
|
||||||
}
|
}
|
||||||
@@ -1592,23 +1785,26 @@ class FrigateCard extends LitElement {
|
|||||||
* Handle an action called on an element.
|
* Handle an action called on an element.
|
||||||
* @param ev The actionHandler event.
|
* @param ev The actionHandler event.
|
||||||
*/
|
*/
|
||||||
protected _actionHandler(ev: CustomEvent, config?: Actions): void {
|
protected _actionHandler(ev: CustomEvent, config?: ActionsConfig): void {
|
||||||
const interaction = ev.detail.action;
|
const interaction = ev.detail.action;
|
||||||
const node: HTMLElement | null = ev.currentTarget as HTMLElement | null;
|
const node: HTMLElement | null = ev.currentTarget as HTMLElement | null;
|
||||||
|
const actionConfig = getActionConfigGivenAction(interaction, config);
|
||||||
if (
|
if (
|
||||||
|
this._hass &&
|
||||||
config &&
|
config &&
|
||||||
node &&
|
node &&
|
||||||
interaction &&
|
interaction &&
|
||||||
// Don't call frigateCardHandleAction() unless there is explicitly an
|
// Don't call frigateCardHandleActionConfig() unless there is explicitly an
|
||||||
// action defined (as it uses a default that is unhelpful for views that
|
// action defined (as it uses a default that is unhelpful for views that
|
||||||
// have default tap/click actions).
|
// have default tap/click actions).
|
||||||
getActionConfigGivenAction(interaction, config)
|
actionConfig
|
||||||
) {
|
) {
|
||||||
frigateCardHandleAction(
|
frigateCardHandleActionConfig(
|
||||||
node,
|
node,
|
||||||
this._hass as HomeAssistant,
|
this._hass,
|
||||||
config,
|
config,
|
||||||
ev.detail.action,
|
ev.detail.action,
|
||||||
|
actionConfig,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1764,8 +1960,10 @@ class FrigateCard extends LitElement {
|
|||||||
|
|
||||||
this._setPropertiesForExpandedMode();
|
this._setPropertiesForExpandedMode();
|
||||||
|
|
||||||
// An update may be required to draw elements.
|
this._conditionController?.setState({
|
||||||
this._generateConditionState();
|
media_loaded: !!this._currentMediaLoadedInfo,
|
||||||
|
});
|
||||||
|
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1796,18 +1994,7 @@ class FrigateCard extends LitElement {
|
|||||||
*/
|
*/
|
||||||
protected _mediaUnloadedHandler(): void {
|
protected _mediaUnloadedHandler(): void {
|
||||||
this._currentMediaLoadedInfo = null;
|
this._currentMediaLoadedInfo = null;
|
||||||
this._generateConditionState();
|
this._conditionController?.setState({ media_loaded: false });
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handler called when fullscreen is toggled.
|
|
||||||
*/
|
|
||||||
protected _fullscreenHandler(): void {
|
|
||||||
this._generateConditionState();
|
|
||||||
// Re-render after a change to fullscreen mode to take advantage of
|
|
||||||
// the expanded screen real-estate (vs staying in aspect-ratio locked
|
|
||||||
// modes).
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1816,9 +2003,10 @@ class FrigateCard extends LitElement {
|
|||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
if (screenfull.isEnabled) {
|
if (screenfull.isEnabled) {
|
||||||
screenfull.on('change', this._fullscreenHandler.bind(this));
|
screenfull.on('change', this._boundFullscreenHandler);
|
||||||
}
|
}
|
||||||
this.addEventListener('mousemove', this._boundMouseHandler);
|
this.addEventListener('mousemove', this._boundMouseHandler);
|
||||||
|
this.addEventListener('ll-custom', this._boundCardActionEventHandler);
|
||||||
this._panel = isCardInPanel(this);
|
this._panel = isCardInPanel(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1830,9 +2018,10 @@ class FrigateCard extends LitElement {
|
|||||||
this._mediaUnloadedHandler();
|
this._mediaUnloadedHandler();
|
||||||
|
|
||||||
if (screenfull.isEnabled) {
|
if (screenfull.isEnabled) {
|
||||||
screenfull.off('change', this._fullscreenHandler.bind(this));
|
screenfull.off('change', this._boundFullscreenHandler);
|
||||||
}
|
}
|
||||||
this.removeEventListener('mousemove', this._boundMouseHandler);
|
this.removeEventListener('mousemove', this._boundMouseHandler);
|
||||||
|
this.removeEventListener('ll-custom', this._boundCardActionEventHandler);
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1919,22 +2108,36 @@ class FrigateCard extends LitElement {
|
|||||||
return { ...this._getConfig().view.actions, ...specificActions };
|
return { ...this._getConfig().view.actions, ...specificActions };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _isInFullscreen(): boolean {
|
||||||
|
return screenfull.isEnabled && screenfull.isFullscreen;
|
||||||
|
}
|
||||||
|
|
||||||
protected _setExpand(expand: boolean): void {
|
protected _setExpand(expand: boolean): void {
|
||||||
if (screenfull.isEnabled && screenfull.isFullscreen) {
|
if (expand && this._isInFullscreen()) {
|
||||||
// Fullscreen and expanded mode are mutually exclusive.
|
// Fullscreen and expanded mode are mutually exclusive.
|
||||||
screenfull.exit();
|
screenfull.exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
this._expand = expand;
|
this._expand = expand;
|
||||||
this._generateConditionState();
|
this._conditionController?.setState({
|
||||||
|
expand: this._expand,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _toggleFullscreen(): void {
|
protected _fullscreenHandler(): void {
|
||||||
if (screenfull.isEnabled) {
|
if (this._isInFullscreen()) {
|
||||||
// Fullscreen and expanded mode are mutually exclusive.
|
|
||||||
this._expand = false;
|
this._expand = false;
|
||||||
screenfull.toggle(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._conditionController?.setState({
|
||||||
|
fullscreen: this._isInFullscreen(),
|
||||||
|
expand: this._expand,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-render after a change to fullscreen mode to take advantage of
|
||||||
|
// the expanded screen real-estate (vs staying in aspect-ratio locked
|
||||||
|
// modes).
|
||||||
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _renderInDialogIfNecessary(contents: TemplateResult): TemplateResult | void {
|
protected _renderInDialogIfNecessary(contents: TemplateResult): TemplateResult | void {
|
||||||
@@ -1995,12 +2198,20 @@ class FrigateCard extends LitElement {
|
|||||||
class="${classMap(cardClasses)}"
|
class="${classMap(cardClasses)}"
|
||||||
style="${styleMap(cardStyle)}"
|
style="${styleMap(cardStyle)}"
|
||||||
@action=${(ev: CustomEvent) => this._actionHandler(ev, actions)}
|
@action=${(ev: CustomEvent) => this._actionHandler(ev, actions)}
|
||||||
@ll-custom=${this._cardActionEventHandler.bind(this)}
|
|
||||||
@frigate-card:message=${this._messageHandler.bind(this)}
|
@frigate-card:message=${this._messageHandler.bind(this)}
|
||||||
@frigate-card:view:change=${this._changeViewHandler.bind(this)}
|
@frigate-card:view:change=${this._changeViewHandler.bind(this)}
|
||||||
@frigate-card:view:change-context=${this._addViewContextHandler.bind(this)}
|
@frigate-card:view:change-context=${this._addViewContextHandler.bind(this)}
|
||||||
@frigate-card:media:loaded=${this._mediaLoadedHandler.bind(this)}
|
@frigate-card:media:loaded=${this._mediaLoadedHandler.bind(this)}
|
||||||
@frigate-card:media:unloaded=${this._mediaUnloadedHandler.bind(this)}
|
@frigate-card:media:unloaded=${this._mediaUnloadedHandler.bind(this)}
|
||||||
|
@frigate-card:media:volumechange=${
|
||||||
|
() => this.requestUpdate() /* Refresh mute menu button */
|
||||||
|
}
|
||||||
|
@frigate-card:media:play=${
|
||||||
|
() => this.requestUpdate() /* Refresh play/pause menu button */
|
||||||
|
}
|
||||||
|
@frigate-card:media:pause=${
|
||||||
|
() => this.requestUpdate() /* Refresh play/pause menu button */
|
||||||
|
}
|
||||||
@frigate-card:render=${() => this.requestUpdate()}
|
@frigate-card:render=${() => this.requestUpdate()}
|
||||||
>
|
>
|
||||||
${renderMenuAbove ? this._renderMenu() : ''}
|
${renderMenuAbove ? this._renderMenu() : ''}
|
||||||
@@ -2018,8 +2229,9 @@ class FrigateCard extends LitElement {
|
|||||||
.resolvedMediaCache=${this._resolvedMediaCache}
|
.resolvedMediaCache=${this._resolvedMediaCache}
|
||||||
.config=${this._getConfig()}
|
.config=${this._getConfig()}
|
||||||
.nonOverriddenConfig=${this._config}
|
.nonOverriddenConfig=${this._config}
|
||||||
.conditionState=${this._conditionState}
|
.conditionControllerEpoch=${this._conditionController?.getEpoch()}
|
||||||
.hide=${!!this._message}
|
.hide=${!!this._message}
|
||||||
|
.microphoneStream=${this._microphoneController?.getStream()}
|
||||||
></frigate-card-views>`}
|
></frigate-card-views>`}
|
||||||
${
|
${
|
||||||
// Keep message rendering to last to show messages that may have been
|
// Keep message rendering to last to show messages that may have been
|
||||||
@@ -2035,15 +2247,15 @@ class FrigateCard extends LitElement {
|
|||||||
${ref(this._refElements)}
|
${ref(this._refElements)}
|
||||||
.hass=${this._hass}
|
.hass=${this._hass}
|
||||||
.elements=${this._getConfig().elements}
|
.elements=${this._getConfig().elements}
|
||||||
.conditionState=${this._conditionState}
|
.conditionControllerEpoch=${this._conditionController?.getEpoch()}
|
||||||
@frigate-card:menu-add=${(e) => {
|
@frigate-card:menu-add=${(e) => {
|
||||||
this._addDynamicMenuButton(e.detail);
|
this._addDynamicMenuButton(e.detail);
|
||||||
}}
|
}}
|
||||||
@frigate-card:menu-remove=${(e) => {
|
@frigate-card:menu-remove=${(e) => {
|
||||||
this._removeDynamicMenuButton(e.detail);
|
this._removeDynamicMenuButton(e.detail);
|
||||||
}}
|
}}
|
||||||
@frigate-card:condition-state-request=${(ev) => {
|
@frigate-card:condition:evaluate=${(ev: ConditionEvaluateRequestEvent) => {
|
||||||
conditionStateRequestHandler(ev, this._conditionState);
|
ev.evaluation = this._conditionController?.evaluateCondition(ev.condition);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
</frigate-card-elements>`
|
</frigate-card-elements>`
|
||||||
|
|||||||
+11
-22
@@ -8,7 +8,6 @@ import {
|
|||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { ConditionState, fetchStateAndEvaluateCondition } from '../card-condition.js';
|
|
||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
import elementsStyle from '../scss/elements.scss';
|
import elementsStyle from '../scss/elements.scss';
|
||||||
import ptzStyle from '../scss/elements-ptz.scss';
|
import ptzStyle from '../scss/elements-ptz.scss';
|
||||||
@@ -34,6 +33,7 @@ import {
|
|||||||
getActionConfigGivenAction,
|
getActionConfigGivenAction,
|
||||||
} from '../utils/action.js';
|
} from '../utils/action.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
|
import { ConditionControllerEpoch, evaluateConditionViaEvent } from '../conditions.js';
|
||||||
|
|
||||||
/* A note on picture element rendering:
|
/* A note on picture element rendering:
|
||||||
*
|
*
|
||||||
@@ -81,11 +81,11 @@ export class FrigateCardElementsCore extends LitElement {
|
|||||||
public elements: PictureElements;
|
public elements: PictureElements;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Need to ensure card re-renders when conditionState changes, hence having it
|
* Need to ensure card re-renders when conditions change, hence having it as a
|
||||||
* as a property even though it is not currently directly used by this class.
|
* property even though it is not currently directly used by this class.
|
||||||
*/
|
*/
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public conditionState?: ConditionState;
|
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||||
|
|
||||||
protected _root: HuiConditionalElement | null = null;
|
protected _root: HuiConditionalElement | null = null;
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ export class FrigateCardElements extends LitElement {
|
|||||||
public hass?: HomeAssistant;
|
public hass?: HomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public conditionState?: ConditionState;
|
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public elements: PictureElements;
|
public elements: PictureElements;
|
||||||
@@ -210,9 +210,6 @@ export class FrigateCardElements extends LitElement {
|
|||||||
path[0].addEventListener('frigate-card:menu-remove', this._boundMenuRemoveHandler);
|
path[0].addEventListener('frigate-card:menu-remove', this._boundMenuRemoveHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Connected callback.
|
|
||||||
*/
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
@@ -221,30 +218,20 @@ export class FrigateCardElements extends LitElement {
|
|||||||
this.addEventListener('frigate-card:menu-add', this._menuAddHandler);
|
this.addEventListener('frigate-card:menu-add', this._menuAddHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnected callback.
|
|
||||||
*/
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
this.removeEventListener('frigate-card:menu-add', this._menuAddHandler);
|
this.removeEventListener('frigate-card:menu-add', this._menuAddHandler);
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the template.
|
|
||||||
* @returns A rendered template.
|
|
||||||
*/
|
|
||||||
protected render(): TemplateResult {
|
protected render(): TemplateResult {
|
||||||
return html` <frigate-card-elements-core
|
return html`<frigate-card-elements-core
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.conditionState=${this.conditionState}
|
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||||
.elements=${this.elements}
|
.elements=${this.elements}
|
||||||
>
|
>
|
||||||
</frigate-card-elements-core>`;
|
</frigate-card-elements-core>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(elementsStyle);
|
return unsafeCSS(elementsStyle);
|
||||||
}
|
}
|
||||||
@@ -259,7 +246,9 @@ export class FrigateCardElements extends LitElement {
|
|||||||
export class FrigateCardElementsConditional extends LitElement {
|
export class FrigateCardElementsConditional extends LitElement {
|
||||||
protected _config?: FrigateConditional;
|
protected _config?: FrigateConditional;
|
||||||
|
|
||||||
// Every set of hass is treated as a reason to re-evaluate. Given that this
|
// A note on hass as an update mechanism:
|
||||||
|
//
|
||||||
|
// Every set of hass is treated as a reason to re-evaluate. Given that this
|
||||||
// node may be buried down the DOM (as a descendent of non-Frigate card
|
// node may be buried down the DOM (as a descendent of non-Frigate card
|
||||||
// elements), the hass object is used as the (only) trigger for condition
|
// elements), the hass object is used as the (only) trigger for condition
|
||||||
// re-fetch even if hass itself has not changed.
|
// re-fetch even if hass itself has not changed.
|
||||||
@@ -298,7 +287,7 @@ export class FrigateCardElementsConditional extends LitElement {
|
|||||||
* Render the card.
|
* Render the card.
|
||||||
*/
|
*/
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (fetchStateAndEvaluateCondition(this, this._config.conditions)) {
|
if (evaluateConditionViaEvent(this, this._config.conditions)) {
|
||||||
return html` <frigate-card-elements-core
|
return html` <frigate-card-elements-core
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.elements=${this._config.elements}
|
.elements=${this._config.elements}
|
||||||
|
|||||||
+67
-9
@@ -6,32 +6,39 @@ import {
|
|||||||
LitElement,
|
LitElement,
|
||||||
PropertyValues,
|
PropertyValues,
|
||||||
TemplateResult,
|
TemplateResult,
|
||||||
unsafeCSS,
|
unsafeCSS
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { live } from 'lit/directives/live.js';
|
import { live } from 'lit/directives/live.js';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
|
import isEqual from 'lodash-es/isEqual';
|
||||||
import { CachedValueController } from '../cached-value-controller.js';
|
import { CachedValueController } from '../cached-value-controller.js';
|
||||||
import defaultImage from '../images/frigate-bird-in-sky.jpg';
|
import defaultImage from '../images/frigate-bird-in-sky.jpg';
|
||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
import imageStyle from '../scss/image.scss';
|
import imageStyle from '../scss/image.scss';
|
||||||
import { CameraConfig, ImageViewConfig, MediaLoadedInfo } from '../types.js';
|
import {
|
||||||
|
CameraConfig,
|
||||||
|
FrigateCardMediaPlayer,
|
||||||
|
ImageViewConfig,
|
||||||
|
MediaLoadedInfo
|
||||||
|
} from '../types.js';
|
||||||
|
import { contentsChanged } from '../utils/basic.js';
|
||||||
import { isHassDifferent } from '../utils/ha';
|
import { isHassDifferent } from '../utils/ha';
|
||||||
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
|
||||||
import {
|
import {
|
||||||
createMediaLoadedInfo,
|
createMediaLoadedInfo,
|
||||||
dispatchExistingMediaLoadedInfoAsEvent,
|
dispatchExistingMediaLoadedInfoAsEvent,
|
||||||
|
dispatchMediaPauseEvent,
|
||||||
|
dispatchMediaPlayEvent
|
||||||
} from '../utils/media-info.js';
|
} from '../utils/media-info.js';
|
||||||
|
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
||||||
import { View } from '../view/view.js';
|
import { View } from '../view/view.js';
|
||||||
import { dispatchErrorMessageEvent } from './message.js';
|
import { dispatchErrorMessageEvent } from './message.js';
|
||||||
import { contentsChanged } from '../utils/basic.js';
|
|
||||||
import isEqual from 'lodash-es/isEqual';
|
|
||||||
|
|
||||||
// See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py .
|
// See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py .
|
||||||
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
|
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
@customElement('frigate-card-image')
|
@customElement('frigate-card-image')
|
||||||
export class FrigateCardImage extends LitElement {
|
export class FrigateCardImage extends LitElement implements FrigateCardMediaPlayer {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public hass?: HomeAssistant;
|
public hass?: HomeAssistant;
|
||||||
|
|
||||||
@@ -54,6 +61,40 @@ export class FrigateCardImage extends LitElement {
|
|||||||
|
|
||||||
protected _mediaLoadedInfo: MediaLoadedInfo | null = null;
|
protected _mediaLoadedInfo: MediaLoadedInfo | null = null;
|
||||||
|
|
||||||
|
public async play(): Promise<void> {
|
||||||
|
this._cachedValueController?.startTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async pause(): Promise<void> {
|
||||||
|
this._cachedValueController?.stopTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async mute(): Promise<void> {
|
||||||
|
// Not implemented.
|
||||||
|
}
|
||||||
|
|
||||||
|
public async unmute(): Promise<void> {
|
||||||
|
// Not implemented.
|
||||||
|
}
|
||||||
|
|
||||||
|
public isMuted(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public async seek(_seconds: number): Promise<void> {
|
||||||
|
// Not implemented.
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public async setControls(_controls: boolean): Promise<void> {
|
||||||
|
// Not implemented.
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPaused(): boolean {
|
||||||
|
return !this._cachedValueController?.hasTimer() ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the camera entity for the current camera configuration.
|
* Get the camera entity for the current camera configuration.
|
||||||
* @returns The entity or undefined if no camera entity is available.
|
* @returns The entity or undefined if no camera entity is available.
|
||||||
@@ -108,9 +149,15 @@ export class FrigateCardImage extends LitElement {
|
|||||||
this,
|
this,
|
||||||
this.imageConfig.refresh_seconds,
|
this.imageConfig.refresh_seconds,
|
||||||
this._getImageSource.bind(this),
|
this._getImageSource.bind(this),
|
||||||
|
() => dispatchMediaPlayEvent(this),
|
||||||
|
() => dispatchMediaPauseEvent(this),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
updateElementStyleFromMediaLayoutConfig(this, this.imageConfig?.layout);
|
updateElementStyleFromMediaLayoutConfig(this, this.imageConfig?.layout);
|
||||||
|
|
||||||
|
if (changedProps.has('imageConfig') && this.imageConfig?.zoomable) {
|
||||||
|
import('./zoomer.js');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the camera or view changed, immediately discard the old value (view to
|
// If the camera or view changed, immediately discard the old value (view to
|
||||||
@@ -231,16 +278,27 @@ export class FrigateCardImage extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
||||||
|
return this.imageConfig?.zoomable
|
||||||
|
? html` <frigate-card-zoomer> ${template} </frigate-card-zoomer>`
|
||||||
|
: template;
|
||||||
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
const src = this._cachedValueController?.value;
|
const src = this._cachedValueController?.value;
|
||||||
// Note the use of live() below to ensure the update will restore the image
|
// Note the use of live() below to ensure the update will restore the image
|
||||||
// src if it's been changed via _forceSafeImage().
|
// src if it's been changed via _forceSafeImage().
|
||||||
return src
|
return src
|
||||||
? html` <img
|
? this._useZoomIfRequired(html` <img
|
||||||
${ref(this._refImage)}
|
${ref(this._refImage)}
|
||||||
src=${live(src)}
|
src=${live(src)}
|
||||||
@load=${(ev: Event) => {
|
@load=${(ev: Event) => {
|
||||||
const mediaLoadedInfo = createMediaLoadedInfo(ev);
|
const mediaLoadedInfo = createMediaLoadedInfo(ev, {
|
||||||
|
player: this,
|
||||||
|
capabilities: {
|
||||||
|
supportsPause: !!this.imageConfig?.refresh_seconds,
|
||||||
|
},
|
||||||
|
});
|
||||||
// Avoid the media being reported as repeatedly loading unless the
|
// Avoid the media being reported as repeatedly loading unless the
|
||||||
// media info changes.
|
// media info changes.
|
||||||
if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) {
|
if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) {
|
||||||
@@ -263,7 +321,7 @@ export class FrigateCardImage extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>`
|
/>`)
|
||||||
: html``;
|
: html``;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# go2rtc Player
|
||||||
|
|
||||||
|
This is a modified version of the go2rtc example video player code, modified for
|
||||||
|
use in the Frigate card. In order to make future maintenance easier,
|
||||||
|
modifications have been kept to a minimum.
|
||||||
|
|
||||||
|
## Original Example Code
|
||||||
|
|
||||||
|
**Link**: https://github.com/AlexxIT/go2rtc/tree/master/www
|
||||||
|
|
||||||
|
**Description**: Example video player imported from go2rtc.
|
||||||
|
|
||||||
|
**Copyright**: [Alexey Khit](https://github.com/AlexxIT)
|
||||||
|
|
||||||
|
**License**: [MIT](https://github.com/AlexxIT/go2rtc/blob/master/LICENSE)
|
||||||
+15
-1
@@ -12,11 +12,25 @@ export class VideoRTC extends HTMLElement {
|
|||||||
video: HTMLVideoElement;
|
video: HTMLVideoElement;
|
||||||
ws: WebSocket | null;
|
ws: WebSocket | null;
|
||||||
wsURL: string;
|
wsURL: string;
|
||||||
pc: RTCPeerConnection;
|
pc: RTCPeerConnection | null;
|
||||||
connectTS: number;
|
connectTS: number;
|
||||||
mseCodecs: string;
|
mseCodecs: string;
|
||||||
|
|
||||||
src: string | URL;
|
src: string | URL;
|
||||||
|
|
||||||
oninit(): void;
|
oninit(): void;
|
||||||
|
send(value: unknown): void;
|
||||||
|
onpcvideo(ev: Event): void;
|
||||||
|
onconnect(): void;
|
||||||
|
ondisconnect(): void;
|
||||||
|
onclose(): void;
|
||||||
|
onopen(): void;
|
||||||
|
onwebrtc(): void;
|
||||||
|
onmessage: Record<string, (msg: { type: string; value: string }) => void>;
|
||||||
|
|
||||||
|
// Custom methods/members.
|
||||||
|
controls: boolean;
|
||||||
|
containingPlayer: FrigateCardMediaPlayer | null;
|
||||||
|
microphoneStream: MediaStream | null;
|
||||||
|
reconnect();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
import { mayHaveAudio } from '../../../utils/audio';
|
||||||
|
import {
|
||||||
|
hideMediaControlsTemporarily,
|
||||||
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
|
} from '../../../utils/media';
|
||||||
|
import {
|
||||||
|
dispatchMediaLoadedEvent,
|
||||||
|
dispatchMediaPauseEvent,
|
||||||
|
dispatchMediaPlayEvent,
|
||||||
|
dispatchMediaVolumeChangeEvent,
|
||||||
|
} from '../../../utils/media-info';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video player for go2rtc streaming application.
|
||||||
|
*
|
||||||
|
* All modern web technologies are supported in almost any browser except Apple Safari.
|
||||||
|
*
|
||||||
|
* Support:
|
||||||
|
* - RTCPeerConnection for Safari iOS 11.0+
|
||||||
|
* - IntersectionObserver for Safari iOS 12.2+
|
||||||
|
*
|
||||||
|
* Doesn't support:
|
||||||
|
* - MediaSource for Safari iOS all
|
||||||
|
* - Customized built-in elements (extends HTMLVideoElement) because all Safari
|
||||||
|
* - Public class fields because old Safari (before 14.0)
|
||||||
|
* - Autoplay for Safari
|
||||||
|
*/
|
||||||
|
export class VideoRTC extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.DISCONNECT_TIMEOUT = 5000;
|
||||||
|
this.RECONNECT_TIMEOUT = 30000;
|
||||||
|
|
||||||
|
this.CODECS = [
|
||||||
|
'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
|
||||||
|
'avc1.64002A', // H.264 high 4.2 (Chromecast 3rd Gen)
|
||||||
|
'avc1.640033', // H.264 high 5.1 (Chromecast with Google TV)
|
||||||
|
'hvc1.1.6.L153.B0', // H.265 main 5.1 (Chromecast Ultra)
|
||||||
|
'mp4a.40.2', // AAC LC
|
||||||
|
'mp4a.40.5', // AAC HE
|
||||||
|
'flac', // FLAC (PCM compatible)
|
||||||
|
'opus', // OPUS Chrome, Firefox
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [config] Supported modes (webrtc, mse, mp4, mjpeg).
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
this.mode = 'webrtc,mse,mp4,mjpeg';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [config] Run stream when not displayed on the screen. Default `false`.
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
this.background = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [config] Run stream only when player in the viewport. Stop when user scroll out player.
|
||||||
|
* Value is percentage of visibility from `0` (not visible) to `1` (full visible).
|
||||||
|
* Default `0` - disable;
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
this.visibilityThreshold = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [config] Run stream only when browser page on the screen. Stop when user change browser
|
||||||
|
* tab or minimise browser windows.
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
this.visibilityCheck = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [config] WebRTC configuration
|
||||||
|
* @type {RTCConfiguration}
|
||||||
|
*/
|
||||||
|
this.pcConfig = {
|
||||||
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||||
|
sdpSemantics: 'unified-plan', // important for Chromecast 1
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
this.wsState = WebSocket.CLOSED;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [info] WebRTC connection state.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
this.pcState = WebSocket.CLOSED;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {HTMLVideoElement}
|
||||||
|
*/
|
||||||
|
this.video = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {WebSocket}
|
||||||
|
*/
|
||||||
|
this.ws = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {string|URL}
|
||||||
|
*/
|
||||||
|
this.wsURL = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {RTCPeerConnection}
|
||||||
|
*/
|
||||||
|
this.pc = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
this.connectTS = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
this.mseCodecs = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [internal] Disconnect TimeoutID.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
this.disconnectTID = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [internal] Reconnect TimeoutID.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
this.reconnectTID = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [internal] Handler for receiving Binary from WebSocket.
|
||||||
|
* @type {Function}
|
||||||
|
*/
|
||||||
|
this.ondata = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [internal] Handlers list for receiving JSON from WebSocket
|
||||||
|
* @type {Object.<string,Function>}}
|
||||||
|
*/
|
||||||
|
this.onmessage = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A microphone stream to attach to a WebRTC connection.
|
||||||
|
* @type {MediaStream}}
|
||||||
|
*/
|
||||||
|
this.microphoneStream = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reference to a containing FrigateCardMediaPlayer object.
|
||||||
|
* @type {FrigateCardMediaPlayer}}
|
||||||
|
*/
|
||||||
|
this.containingPlayer = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show or hide video controls.
|
||||||
|
* @type {boolean}}
|
||||||
|
*/
|
||||||
|
this.controls = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect the stream.
|
||||||
|
*/
|
||||||
|
reconnect() {
|
||||||
|
if (this.wsState !== WebSocket.CLOSED) {
|
||||||
|
// The websocket has onclose handlers, so don't call onconnect directly,
|
||||||
|
// wait until the eventhandlers are finished..
|
||||||
|
this.ws?.addEventListener('close', () => this.onconnect());
|
||||||
|
this.ondisconnect();
|
||||||
|
} else {
|
||||||
|
// Still call ondisconnect() as there may be an RTC connection to
|
||||||
|
// terminate even if the websocket is closed.
|
||||||
|
this.ondisconnect();
|
||||||
|
this.onconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set video source (WebSocket URL). Support relative path.
|
||||||
|
* @param {string|URL} value
|
||||||
|
*/
|
||||||
|
set src(value) {
|
||||||
|
if (typeof value !== 'string') value = value.toString();
|
||||||
|
if (value.startsWith('http')) {
|
||||||
|
value = 'ws' + value.substring(4);
|
||||||
|
} else if (value.startsWith('/')) {
|
||||||
|
value = 'ws' + location.origin.substring(4) + value;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.wsURL = value;
|
||||||
|
|
||||||
|
this.onconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play video. Support automute when autoplay blocked.
|
||||||
|
* https://developer.chrome.com/blog/autoplay/
|
||||||
|
*/
|
||||||
|
play() {
|
||||||
|
// Frigate card controls playing at a higher level.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send message to server via WebSocket
|
||||||
|
* @param {Object} value
|
||||||
|
*/
|
||||||
|
send(value) {
|
||||||
|
if (this.ws) this.ws.send(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
codecs(type) {
|
||||||
|
const test =
|
||||||
|
type === 'mse'
|
||||||
|
? (codec) => MediaSource.isTypeSupported(`video/mp4; codecs="${codec}"`)
|
||||||
|
: (codec) => this.video.canPlayType(`video/mp4; codecs="${codec}"`);
|
||||||
|
return this.CODECS.filter(test).join();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `CustomElement`. Invoked each time the custom element is appended into a
|
||||||
|
* document-connected element.
|
||||||
|
*/
|
||||||
|
connectedCallback() {
|
||||||
|
if (this.disconnectTID) {
|
||||||
|
clearTimeout(this.disconnectTID);
|
||||||
|
this.disconnectTID = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// because video autopause on disconnected from DOM
|
||||||
|
if (this.video) {
|
||||||
|
const seek = this.video.seekable;
|
||||||
|
if (seek.length > 0) {
|
||||||
|
this.video.currentTime = seek.end(seek.length - 1);
|
||||||
|
}
|
||||||
|
this.play();
|
||||||
|
} else {
|
||||||
|
this.oninit();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `CustomElement`. Invoked each time the custom element is disconnected from the
|
||||||
|
* document's DOM.
|
||||||
|
*/
|
||||||
|
disconnectedCallback() {
|
||||||
|
if (this.background || this.disconnectTID) return;
|
||||||
|
if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
|
||||||
|
|
||||||
|
this.disconnectTID = setTimeout(() => {
|
||||||
|
if (this.reconnectTID) {
|
||||||
|
clearTimeout(this.reconnectTID);
|
||||||
|
this.reconnectTID = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.disconnectTID = 0;
|
||||||
|
|
||||||
|
this.ondisconnect();
|
||||||
|
}, this.DISCONNECT_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates child DOM elements. Called automatically once on `connectedCallback`.
|
||||||
|
*/
|
||||||
|
oninit() {
|
||||||
|
this.video = document.createElement('video');
|
||||||
|
this.video.controls = this.controls;
|
||||||
|
this.video.playsInline = true;
|
||||||
|
this.video.preload = 'auto';
|
||||||
|
|
||||||
|
this.video.style.display = 'block'; // fix bottom margin 4px
|
||||||
|
this.video.style.width = '100%';
|
||||||
|
this.video.style.height = '100%';
|
||||||
|
|
||||||
|
this.appendChild(this.video);
|
||||||
|
|
||||||
|
if (this.background) return;
|
||||||
|
|
||||||
|
if ('hidden' in document && this.visibilityCheck) {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
this.disconnectedCallback();
|
||||||
|
} else if (this.isConnected) {
|
||||||
|
this.connectedCallback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('IntersectionObserver' in window && this.visibilityThreshold) {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry.isIntersecting) {
|
||||||
|
this.disconnectedCallback();
|
||||||
|
} else if (this.isConnected) {
|
||||||
|
this.connectedCallback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: this.visibilityThreshold },
|
||||||
|
);
|
||||||
|
observer.observe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.video.onloadeddata = () => {
|
||||||
|
if (this.controls) {
|
||||||
|
hideMediaControlsTemporarily(this.video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
|
||||||
|
}
|
||||||
|
dispatchMediaLoadedEvent(this, this.video, {
|
||||||
|
player: this.containingPlayer,
|
||||||
|
capabilities: {
|
||||||
|
// 2-way audio is only supported on WebRTC connections. The state of
|
||||||
|
// `this.microphoneStream` is not taken into account here since
|
||||||
|
// that can be created after the fact -- this is purely saying that
|
||||||
|
// were a microphone stream available it could be used usefully.
|
||||||
|
supports2WayAudio: !!this.pc,
|
||||||
|
supportsPause: true,
|
||||||
|
hasAudio: mayHaveAudio(this.video),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
this.video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this);
|
||||||
|
this.video.onplay = () => dispatchMediaPlayEvent(this);
|
||||||
|
this.video.onpause = () => dispatchMediaPauseEvent(this);
|
||||||
|
// Always started muted. Media may be unmuted in accordance with user
|
||||||
|
// configuration.
|
||||||
|
this.video.muted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to WebSocket. Called automatically on `connectedCallback`.
|
||||||
|
* @return {boolean} true if the connection has started.
|
||||||
|
*/
|
||||||
|
onconnect() {
|
||||||
|
if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false;
|
||||||
|
|
||||||
|
// CLOSED or CONNECTING => CONNECTING
|
||||||
|
this.wsState = WebSocket.CONNECTING;
|
||||||
|
|
||||||
|
this.connectTS = Date.now();
|
||||||
|
|
||||||
|
this.ws = new WebSocket(this.wsURL);
|
||||||
|
this.ws.binaryType = 'arraybuffer';
|
||||||
|
this.ws.addEventListener('open', (ev) => this.onopen(ev));
|
||||||
|
this.ws.addEventListener('close', (ev) => this.onclose(ev));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ondisconnect() {
|
||||||
|
this.wsState = WebSocket.CLOSED;
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pcState = WebSocket.CLOSED;
|
||||||
|
if (this.pc) {
|
||||||
|
this.pc.close();
|
||||||
|
this.pc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Array.<string>} of modes (mse, webrtc, etc.)
|
||||||
|
*/
|
||||||
|
onopen() {
|
||||||
|
// CONNECTING => OPEN
|
||||||
|
this.wsState = WebSocket.OPEN;
|
||||||
|
|
||||||
|
this.ws.addEventListener('message', (ev) => {
|
||||||
|
if (typeof ev.data === 'string') {
|
||||||
|
const msg = JSON.parse(ev.data);
|
||||||
|
for (const mode in this.onmessage) {
|
||||||
|
this.onmessage[mode](msg);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.ondata(ev.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ondata = null;
|
||||||
|
this.onmessage = {};
|
||||||
|
|
||||||
|
const modes = [];
|
||||||
|
|
||||||
|
if (this.mode.indexOf('mse') >= 0 && 'MediaSource' in window) {
|
||||||
|
// iPhone
|
||||||
|
modes.push('mse');
|
||||||
|
this.onmse();
|
||||||
|
} else if (this.mode.indexOf('mp4') >= 0) {
|
||||||
|
modes.push('mp4');
|
||||||
|
this.onmp4();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) {
|
||||||
|
// macOS Desktop app
|
||||||
|
modes.push('webrtc');
|
||||||
|
this.onwebrtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mode.indexOf('mjpeg') >= 0) {
|
||||||
|
if (modes.length) {
|
||||||
|
this.onmessage['mjpeg'] = (msg) => {
|
||||||
|
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
|
||||||
|
this.onmjpeg();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
modes.push('mjpeg');
|
||||||
|
this.onmjpeg();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return modes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {boolean} true if reconnection has started.
|
||||||
|
*/
|
||||||
|
onclose() {
|
||||||
|
if (this.wsState === WebSocket.CLOSED) return false;
|
||||||
|
|
||||||
|
// CONNECTING, OPEN => CONNECTING
|
||||||
|
this.wsState = WebSocket.CONNECTING;
|
||||||
|
this.ws = null;
|
||||||
|
|
||||||
|
// reconnect no more than once every X seconds
|
||||||
|
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
|
||||||
|
|
||||||
|
this.reconnectTID = setTimeout(() => {
|
||||||
|
this.reconnectTID = 0;
|
||||||
|
this.onconnect();
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onmse() {
|
||||||
|
const ms = new MediaSource();
|
||||||
|
ms.addEventListener(
|
||||||
|
'sourceopen',
|
||||||
|
() => {
|
||||||
|
URL.revokeObjectURL(this.video.src);
|
||||||
|
this.send({ type: 'mse', value: this.codecs('mse') });
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
this.video.src = URL.createObjectURL(ms);
|
||||||
|
this.video.srcObject = null;
|
||||||
|
this.play();
|
||||||
|
|
||||||
|
this.mseCodecs = '';
|
||||||
|
|
||||||
|
this.onmessage['mse'] = (msg) => {
|
||||||
|
if (msg.type !== 'mse') return;
|
||||||
|
|
||||||
|
this.mseCodecs = msg.value;
|
||||||
|
|
||||||
|
const sb = ms.addSourceBuffer(msg.value);
|
||||||
|
sb.mode = 'segments'; // segments or sequence
|
||||||
|
sb.addEventListener('updateend', () => {
|
||||||
|
if (sb.updating) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (bufLen > 0) {
|
||||||
|
const data = buf.slice(0, bufLen);
|
||||||
|
bufLen = 0;
|
||||||
|
sb.appendBuffer(data);
|
||||||
|
} else if (sb.buffered && sb.buffered.length) {
|
||||||
|
const end = sb.buffered.end(sb.buffered.length - 1) - 15;
|
||||||
|
const start = sb.buffered.start(0);
|
||||||
|
if (end > start) {
|
||||||
|
sb.remove(start, end);
|
||||||
|
ms.setLiveSeekableRange(end, end + 15);
|
||||||
|
}
|
||||||
|
// console.debug("VideoRTC.buffered", start, end);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// console.debug(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const buf = new Uint8Array(2 * 1024 * 1024);
|
||||||
|
let bufLen = 0;
|
||||||
|
|
||||||
|
this.ondata = (data) => {
|
||||||
|
if (sb.updating || bufLen > 0) {
|
||||||
|
const b = new Uint8Array(data);
|
||||||
|
buf.set(b, bufLen);
|
||||||
|
bufLen += b.byteLength;
|
||||||
|
// console.debug("VideoRTC.buffer", b.byteLength, bufLen);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
sb.appendBuffer(data);
|
||||||
|
} catch (e) {
|
||||||
|
// console.debug(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onwebrtc() {
|
||||||
|
const pc = new RTCPeerConnection(this.pcConfig);
|
||||||
|
|
||||||
|
const video2 = document.createElement('video');
|
||||||
|
video2.addEventListener('loadeddata', (ev) => this.onpcvideo(ev), { once: true });
|
||||||
|
|
||||||
|
pc.addEventListener('icecandidate', (ev) => {
|
||||||
|
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
|
||||||
|
this.send({ type: 'webrtc/candidate', value: candidate });
|
||||||
|
});
|
||||||
|
|
||||||
|
pc.addEventListener('track', (ev) => {
|
||||||
|
// when stream already init
|
||||||
|
if (video2.srcObject !== null) return;
|
||||||
|
|
||||||
|
// when audio track not exist in Chrome
|
||||||
|
if (ev.streams.length === 0) return;
|
||||||
|
|
||||||
|
// when audio track not exist in Firefox
|
||||||
|
if (ev.streams[0].id[0] === '{') return;
|
||||||
|
|
||||||
|
// Filter out tracks that are not video related.
|
||||||
|
if (ev.track.kind !== 'video') return;
|
||||||
|
|
||||||
|
video2.srcObject = ev.streams[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
pc.addEventListener('connectionstatechange', () => {
|
||||||
|
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
|
||||||
|
pc.close(); // stop next events
|
||||||
|
|
||||||
|
this.pcState = WebSocket.CLOSED;
|
||||||
|
this.pc = null;
|
||||||
|
|
||||||
|
this.onconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.onmessage['webrtc'] = (msg) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'webrtc/candidate':
|
||||||
|
pc.addIceCandidate({
|
||||||
|
candidate: msg.value,
|
||||||
|
sdpMid: '0',
|
||||||
|
}).catch(() => console.debug);
|
||||||
|
break;
|
||||||
|
case 'webrtc/answer':
|
||||||
|
pc.setRemoteDescription({
|
||||||
|
type: 'answer',
|
||||||
|
sdp: msg.value,
|
||||||
|
}).catch(() => console.debug);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
if (msg.value.indexOf('webrtc/offer') < 0) return;
|
||||||
|
pc.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Safari doesn't support "offerToReceiveVideo"
|
||||||
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||||
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||||
|
|
||||||
|
// Must add microphone tracks prior to making the offer.
|
||||||
|
this.microphoneStream?.getTracks().forEach((track) => {
|
||||||
|
pc.addTransceiver(track, { direction: 'sendonly' });
|
||||||
|
});
|
||||||
|
|
||||||
|
pc.createOffer().then((offer) => {
|
||||||
|
pc.setLocalDescription(offer).then(() => {
|
||||||
|
this.send({ type: 'webrtc/offer', value: offer.sdp });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pcState = WebSocket.CONNECTING;
|
||||||
|
this.pc = pc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ev {Event}
|
||||||
|
*/
|
||||||
|
onpcvideo(ev) {
|
||||||
|
if (!this.pc) return;
|
||||||
|
|
||||||
|
/** @type {HTMLVideoElement} */
|
||||||
|
const video2 = ev.target;
|
||||||
|
const state = this.pc.connectionState;
|
||||||
|
|
||||||
|
// Firefox doesn't support pc.connectionState
|
||||||
|
if (state === 'connected' || state === 'connecting' || !state) {
|
||||||
|
// Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
|
||||||
|
let rtcPriority = 0,
|
||||||
|
msePriority = 0;
|
||||||
|
|
||||||
|
/** @type {MediaStream} */
|
||||||
|
const ms = video2.srcObject;
|
||||||
|
if (ms.getVideoTracks().length > 0) rtcPriority += 0x220;
|
||||||
|
if (ms.getAudioTracks().length > 0) rtcPriority += 0x102;
|
||||||
|
|
||||||
|
if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230;
|
||||||
|
if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210;
|
||||||
|
if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101;
|
||||||
|
|
||||||
|
if (rtcPriority >= msePriority) {
|
||||||
|
this.video.srcObject = ms;
|
||||||
|
this.play();
|
||||||
|
|
||||||
|
this.pcState = WebSocket.OPEN;
|
||||||
|
|
||||||
|
this.wsState = WebSocket.CLOSED;
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
} else {
|
||||||
|
this.pcState = WebSocket.CLOSED;
|
||||||
|
this.pc.close();
|
||||||
|
this.pc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
video2.srcObject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onmjpeg() {
|
||||||
|
let receivedFirstFrame = false;
|
||||||
|
|
||||||
|
this.ondata = (data) => {
|
||||||
|
this.video.controls = false;
|
||||||
|
this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data);
|
||||||
|
|
||||||
|
if (!receivedFirstFrame) {
|
||||||
|
receivedFirstFrame = true;
|
||||||
|
dispatchMediaLoadedEvent(this, this.video, {
|
||||||
|
player: this.containingPlayer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.send({ type: 'mjpeg' });
|
||||||
|
}
|
||||||
|
|
||||||
|
onmp4() {
|
||||||
|
/** @type {HTMLCanvasElement} **/
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
/** @type {CanvasRenderingContext2D} */
|
||||||
|
let context;
|
||||||
|
|
||||||
|
/** @type {HTMLVideoElement} */
|
||||||
|
const video2 = document.createElement('video');
|
||||||
|
video2.autoplay = true;
|
||||||
|
video2.playsInline = true;
|
||||||
|
video2.muted = true;
|
||||||
|
|
||||||
|
video2.addEventListener('loadeddata', (ev) => {
|
||||||
|
if (!context) {
|
||||||
|
canvas.width = video2.videoWidth;
|
||||||
|
canvas.height = video2.videoHeight;
|
||||||
|
context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
dispatchMediaLoadedEvent(this, video2, {
|
||||||
|
player: this.containingPlayer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
this.video.controls = false;
|
||||||
|
this.video.poster = canvas.toDataURL('image/jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ondata = (data) => {
|
||||||
|
video2.src = 'data:video/mp4;base64,' + VideoRTC.btoa(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.send({ type: 'mp4', value: this.codecs('mp4') });
|
||||||
|
}
|
||||||
|
|
||||||
|
static btoa(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
const len = bytes.byteLength;
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return window.btoa(binary);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,23 +7,21 @@ import {
|
|||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { CameraEndpoints } from '../../camera-manager/types.js';
|
||||||
|
import { localize } from '../../localize/localize';
|
||||||
import liveMSEStyle from '../../scss/live-go2rtc.scss';
|
import liveMSEStyle from '../../scss/live-go2rtc.scss';
|
||||||
import {
|
import {
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
FrigateCardMediaPlayer,
|
FrigateCardMediaPlayer,
|
||||||
|
MicrophoneConfig,
|
||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
import '../image.js';
|
|
||||||
import {
|
|
||||||
hideMediaControlsTemporarily,
|
|
||||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
|
||||||
} from '../../utils/media';
|
|
||||||
import { dispatchMediaLoadedEvent } from '../../utils/media-info';
|
|
||||||
import { localize } from '../../localize/localize';
|
|
||||||
import { dispatchErrorMessageEvent } from '../message';
|
|
||||||
import { VideoRTC } from '../../external/go2rtc/video-rtc';
|
|
||||||
import { CameraEndpoints } from '../../camera-manager/types.js';
|
|
||||||
import { getEndpointAddressOrDispatchError } from '../../utils/endpoint';
|
import { getEndpointAddressOrDispatchError } from '../../utils/endpoint';
|
||||||
|
import '../image.js';
|
||||||
|
import { dispatchErrorMessageEvent } from '../message';
|
||||||
|
import { VideoRTC } from './go2rtc/video-rtc';
|
||||||
|
|
||||||
|
customElements.define('frigate-card-live-go2rtc-player', VideoRTC);
|
||||||
|
|
||||||
// Note (2023-02-18): Depending on the behavior of the player / browser is
|
// Note (2023-02-18): Depending on the behavior of the player / browser is
|
||||||
// possible this URL will need to be re-signed in order to avoid HA spamming
|
// possible this URL will need to be re-signed in order to avoid HA spamming
|
||||||
@@ -32,32 +30,6 @@ import { getEndpointAddressOrDispatchError } from '../../utils/endpoint';
|
|||||||
// provider).
|
// provider).
|
||||||
const GO2RTC_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
|
const GO2RTC_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
|
||||||
|
|
||||||
@customElement('frigate-card-live-go2rtc-player')
|
|
||||||
class FrigateCardGo2RTCPlayer extends VideoRTC {
|
|
||||||
public play(): void {
|
|
||||||
// Let Frigate card control auto playing.
|
|
||||||
}
|
|
||||||
|
|
||||||
public oninit(): void {
|
|
||||||
super.oninit();
|
|
||||||
|
|
||||||
if (this.video) {
|
|
||||||
const onloadeddata = this.video.onloadeddata;
|
|
||||||
this.video.onloadeddata = (e) => {
|
|
||||||
if (onloadeddata) {
|
|
||||||
onloadeddata.call(this.video, e);
|
|
||||||
}
|
|
||||||
hideMediaControlsTemporarily(this.video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
|
|
||||||
dispatchMediaLoadedEvent(this, this.video);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Always started muted. Media may be unmuted in accordance with user
|
|
||||||
// configuration.
|
|
||||||
this.video.muted = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@customElement('frigate-card-live-go2rtc')
|
@customElement('frigate-card-live-go2rtc')
|
||||||
export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPlayer {
|
export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPlayer {
|
||||||
// Not an reactive property to avoid resetting the video.
|
// Not an reactive property to avoid resetting the video.
|
||||||
@@ -69,7 +41,16 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraEndpoints?: CameraEndpoints;
|
public cameraEndpoints?: CameraEndpoints;
|
||||||
|
|
||||||
protected _player?: FrigateCardGo2RTCPlayer;
|
@property({ attribute: false })
|
||||||
|
public microphoneStream?: MediaStream;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public microphoneConfig?: MicrophoneConfig;
|
||||||
|
|
||||||
|
@property({ attribute: true, type: Boolean })
|
||||||
|
public controls = false;
|
||||||
|
|
||||||
|
protected _player?: VideoRTC;
|
||||||
|
|
||||||
public async play(): Promise<void> {
|
public async play(): Promise<void> {
|
||||||
return this._player?.video?.play();
|
return this._player?.video?.play();
|
||||||
@@ -101,6 +82,16 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setControls(controls?: boolean): Promise<void> {
|
||||||
|
if (this._player?.video) {
|
||||||
|
this._player.video.controls = controls ?? this.controls;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPaused(): boolean {
|
||||||
|
return this._player?.video.paused ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
this._player = undefined;
|
this._player = undefined;
|
||||||
}
|
}
|
||||||
@@ -135,9 +126,12 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._player = new FrigateCardGo2RTCPlayer();
|
this._player = new VideoRTC();
|
||||||
|
this._player.containingPlayer = this;
|
||||||
|
this._player.microphoneStream = this.microphoneStream ?? null;
|
||||||
this._player.src = address;
|
this._player.src = address;
|
||||||
this._player.visibilityCheck = false;
|
this._player.visibilityCheck = false;
|
||||||
|
this._player.controls = this.controls;
|
||||||
|
|
||||||
if (this.cameraConfig?.go2rtc?.modes && this.cameraConfig.go2rtc.modes.length) {
|
if (this.cameraConfig?.go2rtc?.modes && this.cameraConfig.go2rtc.modes.length) {
|
||||||
this._player.mode = this.cameraConfig.go2rtc.modes.join(',');
|
this._player.mode = this.cameraConfig.go2rtc.modes.join(',');
|
||||||
@@ -150,6 +144,20 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
|||||||
if (!this._player || changedProps.has('cameraEndpoints')) {
|
if (!this._player || changedProps.has('cameraEndpoints')) {
|
||||||
this._createPlayer();
|
this._createPlayer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (changedProps.has('controls') && this._player) {
|
||||||
|
this._player.controls = this.controls;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._player && changedProps.has('microphoneStream')) {
|
||||||
|
if (this._player?.microphoneStream !== this.microphoneStream) {
|
||||||
|
this._player.microphoneStream = this.microphoneStream ?? null;
|
||||||
|
|
||||||
|
// Need to force a reconnect if the microphone stream changes since
|
||||||
|
// WebRTC cannot introduce a new stream after the offer is already made.
|
||||||
|
this._player.reconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraConfig?: CameraConfig;
|
public cameraConfig?: CameraConfig;
|
||||||
|
|
||||||
|
@property({ attribute: true, type: Boolean })
|
||||||
|
public controls = true;
|
||||||
|
|
||||||
protected _playerRef: Ref<Element & FrigateCardMediaPlayer> = createRef();
|
protected _playerRef: Ref<Element & FrigateCardMediaPlayer> = createRef();
|
||||||
|
|
||||||
public async play(): Promise<void> {
|
public async play(): Promise<void> {
|
||||||
@@ -43,6 +46,14 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
|
|||||||
this._playerRef.value?.seek(seconds);
|
this._playerRef.value?.seek(seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setControls(controls?: boolean): Promise<void> {
|
||||||
|
this._playerRef.value?.setControls(controls ?? this.controls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPaused(): boolean {
|
||||||
|
return this._playerRef.value?.isPaused() ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.hass) {
|
if (!this.hass) {
|
||||||
return;
|
return;
|
||||||
@@ -57,7 +68,7 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
|
|||||||
${ref(this._playerRef)}
|
${ref(this._playerRef)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.stateObj=${stateObj}
|
.stateObj=${stateObj}
|
||||||
.controls=${true}
|
.controls=${this.controls}
|
||||||
.muted=${true}
|
.muted=${true}
|
||||||
>
|
>
|
||||||
</frigate-card-ha-camera-stream>`;
|
</frigate-card-ha-camera-stream>`;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
import liveImageStyle from '../../scss/live-image.scss';
|
import liveImageStyle from '../../scss/live-image.scss';
|
||||||
import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js';
|
import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js';
|
||||||
import { getStateObjOrDispatchError } from './live.js';
|
|
||||||
import '../image.js';
|
import '../image.js';
|
||||||
|
import { getStateObjOrDispatchError } from './live.js';
|
||||||
|
|
||||||
@customElement('frigate-card-live-image')
|
@customElement('frigate-card-live-image')
|
||||||
export class FrigateCardLiveImage extends LitElement implements FrigateCardMediaPlayer {
|
export class FrigateCardLiveImage extends LitElement implements FrigateCardMediaPlayer {
|
||||||
@@ -14,32 +15,38 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraConfig?: CameraConfig;
|
public cameraConfig?: CameraConfig;
|
||||||
|
|
||||||
@state()
|
protected _refImage: Ref<Element & FrigateCardMediaPlayer> = createRef();
|
||||||
protected _playing = true;
|
|
||||||
|
|
||||||
public async play(): Promise<void> {
|
public async play(): Promise<void> {
|
||||||
this._playing = true;
|
await this._refImage.value?.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async pause(): Promise<void> {
|
public async pause(): Promise<void> {
|
||||||
this._playing = false;
|
await this._refImage.value?.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async mute(): Promise<void> {
|
public async mute(): Promise<void> {
|
||||||
// Not implemented.
|
await this._refImage.value?.mute();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unmute(): Promise<void> {
|
public async unmute(): Promise<void> {
|
||||||
// Not implemented.
|
await this._refImage.value?.unmute();
|
||||||
}
|
}
|
||||||
|
|
||||||
public isMuted(): boolean {
|
public isMuted(): boolean {
|
||||||
return true;
|
return !!this._refImage.value?.isMuted();
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
public async seek(seconds: number): Promise<void> {
|
||||||
public async seek(_seconds: number): Promise<void> {
|
await this._refImage.value?.seek(seconds);
|
||||||
// Not implemented.
|
}
|
||||||
|
|
||||||
|
public async setControls(controls?: boolean): Promise<void> {
|
||||||
|
await this._refImage.value?.setControls(controls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPaused(): boolean {
|
||||||
|
return this._refImage.value?.isPaused() ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
@@ -50,12 +57,14 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
|||||||
getStateObjOrDispatchError(this, this.hass, this.cameraConfig);
|
getStateObjOrDispatchError(this, this.hass, this.cameraConfig);
|
||||||
|
|
||||||
return html` <frigate-card-image
|
return html` <frigate-card-image
|
||||||
|
${ref(this._refImage)}
|
||||||
.imageConfig=${{
|
.imageConfig=${{
|
||||||
mode: this.cameraConfig.image.url ? ('url' as const) : ('camera' as const),
|
mode: this.cameraConfig.image.url ? ('url' as const) : ('camera' as const),
|
||||||
refresh_seconds: this._playing ? this.cameraConfig.image.refresh_seconds : 0,
|
refresh_seconds: this.cameraConfig.image.refresh_seconds,
|
||||||
url: this.cameraConfig.image.url,
|
url: this.cameraConfig.image.url,
|
||||||
// Don't need to pass layout options as FrigateCardLiveProvider has
|
|
||||||
// already taken care of this for us.
|
// The live provider will take care of zoom and layout options.
|
||||||
|
zoomable: false,
|
||||||
}}
|
}}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraConfig=${this.cameraConfig}
|
.cameraConfig=${this.cameraConfig}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import JSMpeg from '@cycjimmy/jsmpeg-player';
|
|||||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { until } from 'lit/directives/until.js';
|
import { until } from 'lit/directives/until.js';
|
||||||
|
import { CameraEndpoints } from '../../camera-manager/types.js';
|
||||||
import { renderProgressIndicator } from '../../components/message.js';
|
import { renderProgressIndicator } from '../../components/message.js';
|
||||||
import { localize } from '../../localize/localize.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
import liveJSMPEGStyle from '../../scss/live-jsmpeg.scss';
|
import liveJSMPEGStyle from '../../scss/live-jsmpeg.scss';
|
||||||
@@ -11,10 +12,13 @@ import {
|
|||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
FrigateCardMediaPlayer,
|
FrigateCardMediaPlayer,
|
||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
import { dispatchMediaLoadedEvent } from '../../utils/media-info.js';
|
|
||||||
import { dispatchErrorMessageEvent } from '../message.js';
|
|
||||||
import { CameraEndpoints } from '../../camera-manager/types.js';
|
|
||||||
import { getEndpointAddressOrDispatchError } from '../../utils/endpoint.js';
|
import { getEndpointAddressOrDispatchError } from '../../utils/endpoint.js';
|
||||||
|
import {
|
||||||
|
dispatchMediaLoadedEvent,
|
||||||
|
dispatchMediaPauseEvent,
|
||||||
|
dispatchMediaPlayEvent,
|
||||||
|
} from '../../utils/media-info.js';
|
||||||
|
import { dispatchErrorMessageEvent } from '../message.js';
|
||||||
|
|
||||||
// Number of seconds a signed URL is valid for.
|
// Number of seconds a signed URL is valid for.
|
||||||
const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
|
const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
|
||||||
@@ -70,13 +74,22 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
|
|||||||
// JSMPEG does not support seeking.
|
// JSMPEG does not support seeking.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public async setControls(_controls: boolean): Promise<void> {
|
||||||
|
// Not implemented.
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPaused(): boolean {
|
||||||
|
return this._jsmpegVideoPlayer?.player?.paused ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a JSMPEG player.
|
* Create a JSMPEG player.
|
||||||
* @param url The URL for the player to connect to.
|
* @param url The URL for the player to connect to.
|
||||||
* @returns A JSMPEG player.
|
* @returns A JSMPEG player.
|
||||||
*/
|
*/
|
||||||
protected async _createJSMPEGPlayer(url: string): Promise<JSMpeg.VideoElement> {
|
protected async _createJSMPEGPlayer(url: string): Promise<JSMpeg.VideoElement> {
|
||||||
return new Promise<JSMpeg.VideoElement>((resolve) => {
|
this._jsmpegVideoPlayer = await new Promise<JSMpeg.VideoElement>((resolve) => {
|
||||||
let videoDecoded = false;
|
let videoDecoded = false;
|
||||||
const player = new JSMpeg.VideoElement(
|
const player = new JSMpeg.VideoElement(
|
||||||
this,
|
this,
|
||||||
@@ -106,13 +119,26 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
|
|||||||
// ignore any subsequent calls.
|
// ignore any subsequent calls.
|
||||||
if (!videoDecoded && this._jsmpegCanvasElement) {
|
if (!videoDecoded && this._jsmpegCanvasElement) {
|
||||||
videoDecoded = true;
|
videoDecoded = true;
|
||||||
dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement);
|
|
||||||
resolve(player);
|
resolve(player);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onPlay: () => dispatchMediaPlayEvent(this),
|
||||||
|
onPause: () => dispatchMediaPauseEvent(this),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The media loaded event must be dispatched after the player is assigned to
|
||||||
|
// `this._jsmpegVideoPlayer`, since the load call may (will!) result in
|
||||||
|
// calls back to the player to check for pause status for menu buttons.
|
||||||
|
if (this._jsmpegCanvasElement) {
|
||||||
|
dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement, {
|
||||||
|
player: this,
|
||||||
|
capabilities: {
|
||||||
|
supportsPause: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,7 +212,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(address);
|
await this._createJSMPEGPlayer(address);
|
||||||
this._refreshPlayerTimerID = window.setTimeout(() => {
|
this._refreshPlayerTimerID = window.setTimeout(() => {
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
|
}, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import {
|
|||||||
FrigateCardError,
|
FrigateCardError,
|
||||||
FrigateCardMediaPlayer,
|
FrigateCardMediaPlayer,
|
||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
import { dispatchMediaLoadedEvent } from '../../utils/media-info.js';
|
import {
|
||||||
|
dispatchMediaLoadedEvent,
|
||||||
|
dispatchMediaPauseEvent,
|
||||||
|
dispatchMediaPlayEvent,
|
||||||
|
dispatchMediaVolumeChangeEvent,
|
||||||
|
} from '../../utils/media-info.js';
|
||||||
import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js';
|
import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js';
|
||||||
import { renderTask } from '../../utils/task.js';
|
import { renderTask } from '../../utils/task.js';
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +23,7 @@ import {
|
|||||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
} from '../../utils/media.js';
|
} from '../../utils/media.js';
|
||||||
import { CameraEndpoints } from '../../camera-manager/types.js';
|
import { CameraEndpoints } from '../../camera-manager/types.js';
|
||||||
|
import { mayHaveAudio } from '../../utils/audio.js';
|
||||||
|
|
||||||
// Create a wrapper for AlexxIT's WebRTC card
|
// Create a wrapper for AlexxIT's WebRTC card
|
||||||
// - https://github.com/AlexxIT/WebRTC
|
// - https://github.com/AlexxIT/WebRTC
|
||||||
@@ -35,6 +41,9 @@ export class FrigateCardLiveWebRTCCard
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
|
@property({ attribute: true, type: Boolean })
|
||||||
|
public controls = false;
|
||||||
|
|
||||||
protected hass?: HomeAssistant;
|
protected hass?: HomeAssistant;
|
||||||
|
|
||||||
// A task to await the load of the WebRTC component.
|
// A task to await the load of the WebRTC component.
|
||||||
@@ -73,6 +82,17 @@ export class FrigateCardLiveWebRTCCard
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setControls(controls?: boolean): Promise<void> {
|
||||||
|
const player = this._getPlayer();
|
||||||
|
if (player) {
|
||||||
|
player.controls = controls ?? this.controls;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPaused(): boolean {
|
||||||
|
return this._getPlayer()?.paused ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
@@ -171,15 +191,22 @@ export class FrigateCardLiveWebRTCCard
|
|||||||
this.updateComplete.then(() => {
|
this.updateComplete.then(() => {
|
||||||
const video = this._getPlayer();
|
const video = this._getPlayer();
|
||||||
if (video) {
|
if (video) {
|
||||||
const onloadeddata = video.onloadeddata;
|
video.onloadeddata = () => {
|
||||||
|
if (this.controls) {
|
||||||
video.onloadeddata = (e) => {
|
hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
|
||||||
if (onloadeddata) {
|
|
||||||
onloadeddata.call(video, e);
|
|
||||||
}
|
}
|
||||||
hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
|
dispatchMediaLoadedEvent(this, video, {
|
||||||
dispatchMediaLoadedEvent(this, video);
|
player: this,
|
||||||
|
capabilities: {
|
||||||
|
supportsPause: true,
|
||||||
|
hasAudio: mayHaveAudio(video),
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
video.onplay = () => dispatchMediaPlayEvent(this);
|
||||||
|
video.onpause = () => dispatchMediaPauseEvent(this);
|
||||||
|
video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this);
|
||||||
|
video.controls = this.controls;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-43
@@ -1,5 +1,7 @@
|
|||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import { EmblaOptionsType } from 'embla-carousel';
|
import { EmblaOptionsType } from 'embla-carousel';
|
||||||
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
||||||
|
import { HassEntity } from 'home-assistant-js-websocket';
|
||||||
import {
|
import {
|
||||||
CSSResultGroup,
|
CSSResultGroup,
|
||||||
html,
|
html,
|
||||||
@@ -9,14 +11,17 @@ import {
|
|||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import { guard } from 'lit/directives/guard.js';
|
import { guard } from 'lit/directives/guard.js';
|
||||||
import { keyed } from 'lit/directives/keyed.js';
|
import { keyed } from 'lit/directives/keyed.js';
|
||||||
import { ConditionState, getOverriddenConfig } from '../../card-condition.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
|
import { CameraManager } from '../../camera-manager/manager.js';
|
||||||
|
import { CameraEndpoints } from '../../camera-manager/types.js';
|
||||||
|
import { ConditionControllerEpoch, getOverriddenConfig } from '../../conditions.js';
|
||||||
import { localize } from '../../localize/localize.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
import liveStyle from '../../scss/live.scss';
|
|
||||||
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
||||||
import liveProviderStyle from '../../scss/live-provider.scss';
|
import liveProviderStyle from '../../scss/live-provider.scss';
|
||||||
|
import liveStyle from '../../scss/live.scss';
|
||||||
import {
|
import {
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
CardWideConfig,
|
CardWideConfig,
|
||||||
@@ -36,26 +41,21 @@ import {
|
|||||||
dispatchExistingMediaLoadedInfoAsEvent,
|
dispatchExistingMediaLoadedInfoAsEvent,
|
||||||
dispatchMediaUnloadedEvent,
|
dispatchMediaUnloadedEvent,
|
||||||
} from '../../utils/media-info.js';
|
} from '../../utils/media-info.js';
|
||||||
|
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
|
||||||
|
import { playMediaMutingIfNecessary } from '../../utils/media.js';
|
||||||
import { dispatchViewContextChangeEvent, View } from '../../view/view.js';
|
import { dispatchViewContextChangeEvent, View } from '../../view/view.js';
|
||||||
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
|
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
|
||||||
import { Lazyload } from './../embla-plugins/lazyload.js';
|
|
||||||
import {
|
import {
|
||||||
FrigateCardMediaCarousel,
|
FrigateCardMediaCarousel,
|
||||||
wrapMediaLoadedEventForCarousel,
|
wrapMediaLoadedEventForCarousel,
|
||||||
wrapMediaUnloadedEventForCarousel,
|
wrapMediaUnloadedEventForCarousel,
|
||||||
} from '../media-carousel.js';
|
} from '../media-carousel.js';
|
||||||
|
import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../message.js';
|
||||||
import '../next-prev-control.js';
|
import '../next-prev-control.js';
|
||||||
import '../title-control.js';
|
|
||||||
import '../surround.js';
|
import '../surround.js';
|
||||||
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
|
import '../title-control.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
|
||||||
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
|
import { Lazyload } from './../embla-plugins/lazyload.js';
|
||||||
import { CameraManager } from '../../camera-manager/manager.js';
|
|
||||||
import { HomeAssistant } from 'custom-card-helpers';
|
|
||||||
import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js';
|
|
||||||
import { HassEntity } from 'home-assistant-js-websocket';
|
|
||||||
import { CameraEndpoints } from '../../camera-manager/types.js';
|
|
||||||
import { playMediaMutingIfNecessary } from '../../utils/media.js';
|
|
||||||
|
|
||||||
interface LiveViewContext {
|
interface LiveViewContext {
|
||||||
// A cameraID override (used for dependencies/substreams to force a different
|
// A cameraID override (used for dependencies/substreams to force a different
|
||||||
@@ -117,7 +117,7 @@ export const getStateObjOrDispatchError = (
|
|||||||
@customElement('frigate-card-live')
|
@customElement('frigate-card-live')
|
||||||
export class FrigateCardLive extends LitElement {
|
export class FrigateCardLive extends LitElement {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public conditionState?: ConditionState;
|
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
@@ -137,6 +137,9 @@ export class FrigateCardLive extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public microphoneStream?: MediaStream;
|
||||||
|
|
||||||
// Whether or not the live view is currently in the background (i.e. preloaded
|
// Whether or not the live view is currently in the background (i.e. preloaded
|
||||||
// but not visible)
|
// but not visible)
|
||||||
@state()
|
@state()
|
||||||
@@ -246,10 +249,11 @@ export class FrigateCardLive extends LitElement {
|
|||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.liveConfig=${this.liveConfig}
|
.liveConfig=${this.liveConfig}
|
||||||
.inBackground=${this._inBackground}
|
.inBackground=${this._inBackground}
|
||||||
.conditionState=${this.conditionState}
|
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||||
.liveOverrides=${this.liveOverrides}
|
.liveOverrides=${this.liveOverrides}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
|
.microphoneStream=${this.microphoneStream}
|
||||||
@frigate-card:message=${(ev: CustomEvent<Message>) => {
|
@frigate-card:message=${(ev: CustomEvent<Message>) => {
|
||||||
this._renderKey++;
|
this._renderKey++;
|
||||||
this._messageReceivedPostRender = true;
|
this._messageReceivedPostRender = true;
|
||||||
@@ -306,7 +310,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
public inBackground?: boolean;
|
public inBackground?: boolean;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public conditionState?: ConditionState;
|
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
@@ -314,6 +318,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public microphoneStream?: MediaStream;
|
||||||
|
|
||||||
// Index between camera name and slide number.
|
// Index between camera name and slide number.
|
||||||
protected _cameraToSlide: Record<string, number> = {};
|
protected _cameraToSlide: Record<string, number> = {};
|
||||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
||||||
@@ -517,21 +524,23 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
slideIndex: number,
|
slideIndex: number,
|
||||||
): TemplateResult | void {
|
): TemplateResult | void {
|
||||||
if (!this.liveConfig || !this.hass || !this.cameraManager) {
|
if (
|
||||||
|
!this.liveConfig ||
|
||||||
|
!this.hass ||
|
||||||
|
!this.cameraManager ||
|
||||||
|
!this.conditionControllerEpoch
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The conditionState object contains the currently live camera, which (in
|
// The condition controller object contains the currently live camera, which
|
||||||
// the carousel for example) is not necessarily the live camera this
|
// (in the carousel for example) is not necessarily the live camera *this*
|
||||||
// <frigate-card-live-provider> is rendering right now.
|
// <frigate-card-live-provider> is rendering right now, so we provide a
|
||||||
const conditionState = {
|
// stateOverride to evaluate the condition in that context.
|
||||||
...this.conditionState,
|
|
||||||
camera: cameraID,
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = getOverriddenConfig(
|
const config = getOverriddenConfig(
|
||||||
|
this.conditionControllerEpoch.controller,
|
||||||
this.liveConfig,
|
this.liveConfig,
|
||||||
this.liveOverrides,
|
this.liveOverrides,
|
||||||
conditionState,
|
{ camera: cameraID },
|
||||||
) as LiveConfig;
|
) as LiveConfig;
|
||||||
|
|
||||||
const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID);
|
const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID);
|
||||||
@@ -540,6 +549,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
<div class="embla__slide">
|
<div class="embla__slide">
|
||||||
<frigate-card-live-provider
|
<frigate-card-live-provider
|
||||||
?disabled=${this.liveConfig.lazy_load}
|
?disabled=${this.liveConfig.lazy_load}
|
||||||
|
.microphoneStream=${this.view?.camera === cameraID
|
||||||
|
? this.microphoneStream
|
||||||
|
: undefined}
|
||||||
.cameraConfig=${cameraConfig}
|
.cameraConfig=${cameraConfig}
|
||||||
.cameraEndpoints=${guard(
|
.cameraEndpoints=${guard(
|
||||||
[this.cameraManager, cameraID],
|
[this.cameraManager, cameraID],
|
||||||
@@ -594,12 +606,6 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = getOverriddenConfig(
|
|
||||||
this.liveConfig,
|
|
||||||
this.liveOverrides,
|
|
||||||
this.conditionState,
|
|
||||||
) as LiveConfig;
|
|
||||||
|
|
||||||
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
|
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
|
||||||
|
|
||||||
const overrideCameraID = (cameraID: string): string => {
|
const overrideCameraID = (cameraID: string): string => {
|
||||||
@@ -642,7 +648,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
|
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
|
||||||
: ''}"
|
: ''}"
|
||||||
.logo="${cameraMetadataCurrent?.engineLogo}"
|
.logo="${cameraMetadataCurrent?.engineLogo}"
|
||||||
.titlePopupConfig=${config.controls.title}
|
.titlePopupConfig=${this.liveConfig.controls.title}
|
||||||
.selected=${this._getSelectedCameraIndex()}
|
.selected=${this._getSelectedCameraIndex()}
|
||||||
transitionEffect=${this._getTransitionEffect()}
|
transitionEffect=${this._getTransitionEffect()}
|
||||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
||||||
@@ -655,7 +661,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
slot="previous"
|
slot="previous"
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.direction=${'previous'}
|
.direction=${'previous'}
|
||||||
.controlConfig=${config.controls.next_previous}
|
.controlConfig=${this.liveConfig.controls.next_previous}
|
||||||
.label=${cameraMetadataPrevious?.title ?? ''}
|
.label=${cameraMetadataPrevious?.title ?? ''}
|
||||||
.icon=${cameraMetadataPrevious?.icon}
|
.icon=${cameraMetadataPrevious?.icon}
|
||||||
?disabled=${prevID === null}
|
?disabled=${prevID === null}
|
||||||
@@ -670,7 +676,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
slot="next"
|
slot="next"
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.direction=${'next'}
|
.direction=${'next'}
|
||||||
.controlConfig=${config.controls.next_previous}
|
.controlConfig=${this.liveConfig.controls.next_previous}
|
||||||
.label=${cameraMetadataNext?.title ?? ''}
|
.label=${cameraMetadataNext?.title ?? ''}
|
||||||
.icon=${cameraMetadataNext?.icon}
|
.icon=${cameraMetadataNext?.icon}
|
||||||
?disabled=${nextID === null}
|
?disabled=${nextID === null}
|
||||||
@@ -721,6 +727,9 @@ export class FrigateCardLiveProvider
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public microphoneStream?: MediaStream;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
protected _isVideoMediaLoaded = false;
|
protected _isVideoMediaLoaded = false;
|
||||||
|
|
||||||
@@ -772,6 +781,16 @@ export class FrigateCardLiveProvider
|
|||||||
this._refProvider.value?.seek(seconds);
|
this._refProvider.value?.seek(seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setControls(controls?: boolean): Promise<void> {
|
||||||
|
await this.updateComplete;
|
||||||
|
await this._refProvider.value?.updateComplete;
|
||||||
|
this._refProvider.value?.setControls(controls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPaused(): boolean {
|
||||||
|
return this._refProvider.value?.isPaused() ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the fully resolved live provider.
|
* Get the fully resolved live provider.
|
||||||
* @returns A live provider (that is not 'auto').
|
* @returns A live provider (that is not 'auto').
|
||||||
@@ -839,6 +858,9 @@ export class FrigateCardLiveProvider
|
|||||||
if (this.liveConfig?.show_image_during_load) {
|
if (this.liveConfig?.show_image_during_load) {
|
||||||
this._importPromises.push(import('./live-image.js'));
|
this._importPromises.push(import('./live-image.js'));
|
||||||
}
|
}
|
||||||
|
if (this.liveConfig?.zoomable) {
|
||||||
|
this._importPromises.push(import('./../zoomer.js'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (changedProps.has('cameraConfig')) {
|
if (changedProps.has('cameraConfig')) {
|
||||||
const provider = this._getResolvedProvider();
|
const provider = this._getResolvedProvider();
|
||||||
@@ -865,6 +887,17 @@ export class FrigateCardLiveProvider
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
||||||
|
return this.liveConfig?.zoomable
|
||||||
|
? html` <frigate-card-zoomer
|
||||||
|
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
|
||||||
|
@frigate-card:zoom:unzoomed=${() => this.setControls()}
|
||||||
|
>
|
||||||
|
${template}
|
||||||
|
</frigate-card-zoomer>`
|
||||||
|
: template;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master render method.
|
* Master render method.
|
||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
@@ -885,9 +918,9 @@ export class FrigateCardLiveProvider
|
|||||||
hidden: showImageDuringLoading,
|
hidden: showImageDuringLoading,
|
||||||
};
|
};
|
||||||
|
|
||||||
return html`
|
return this._useZoomIfRequired(html`
|
||||||
${showImageDuringLoading || provider === 'image'
|
${showImageDuringLoading || provider === 'image'
|
||||||
? html`<frigate-card-live-image
|
? html` <frigate-card-live-image
|
||||||
${ref(this._refProvider)}
|
${ref(this._refProvider)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraConfig=${this.cameraConfig}
|
.cameraConfig=${this.cameraConfig}
|
||||||
@@ -910,6 +943,7 @@ export class FrigateCardLiveProvider
|
|||||||
class=${classMap(providerClasses)}
|
class=${classMap(providerClasses)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraConfig=${this.cameraConfig}
|
.cameraConfig=${this.cameraConfig}
|
||||||
|
?controls=${this.liveConfig.controls.builtin}
|
||||||
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
||||||
>
|
>
|
||||||
</frigate-card-live-ha>`
|
</frigate-card-live-ha>`
|
||||||
@@ -920,6 +954,9 @@ export class FrigateCardLiveProvider
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraConfig=${this.cameraConfig}
|
.cameraConfig=${this.cameraConfig}
|
||||||
.cameraEndpoints=${this.cameraEndpoints}
|
.cameraEndpoints=${this.cameraEndpoints}
|
||||||
|
.microphoneStream=${this.microphoneStream}
|
||||||
|
.microphoneConfig=${this.liveConfig.microphone}
|
||||||
|
?controls=${this.liveConfig.controls.builtin}
|
||||||
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
||||||
>
|
>
|
||||||
</frigate-card-live-webrtc-card>`
|
</frigate-card-live-webrtc-card>`
|
||||||
@@ -931,6 +968,7 @@ export class FrigateCardLiveProvider
|
|||||||
.cameraConfig=${this.cameraConfig}
|
.cameraConfig=${this.cameraConfig}
|
||||||
.cameraEndpoints=${this.cameraEndpoints}
|
.cameraEndpoints=${this.cameraEndpoints}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
|
?controls=${this.liveConfig.controls.builtin}
|
||||||
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
||||||
>
|
>
|
||||||
</frigate-card-live-webrtc-card>`
|
</frigate-card-live-webrtc-card>`
|
||||||
@@ -946,12 +984,9 @@ export class FrigateCardLiveProvider
|
|||||||
>
|
>
|
||||||
</frigate-card-live-jsmpeg>`
|
</frigate-card-live-jsmpeg>`
|
||||||
: html``}
|
: html``}
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(liveProviderStyle);
|
return unsafeCSS(liveProviderStyle);
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-74
@@ -9,8 +9,12 @@ import {
|
|||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { guard } from 'lit/directives/guard.js';
|
||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
|
import { CameraManager } from '../camera-manager/manager.js';
|
||||||
import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js';
|
import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js';
|
||||||
|
import { localize } from '../localize/localize.js';
|
||||||
|
import '../patches/ha-hls-player';
|
||||||
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
|
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
|
||||||
import viewerProviderStyle from '../scss/viewer-provider.scss';
|
import viewerProviderStyle from '../scss/viewer-provider.scss';
|
||||||
import viewerStyle from '../scss/viewer.scss';
|
import viewerStyle from '../scss/viewer.scss';
|
||||||
@@ -24,39 +28,41 @@ import {
|
|||||||
ViewerConfig,
|
ViewerConfig,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||||
|
import { mayHaveAudio } from '../utils/audio.js';
|
||||||
import { contentsChanged, errorToConsole } from '../utils/basic.js';
|
import { contentsChanged, errorToConsole } from '../utils/basic.js';
|
||||||
|
import { canonicalizeHAURL } from '../utils/ha/index.js';
|
||||||
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
||||||
import { View } from '../view/view.js';
|
import {
|
||||||
|
dispatchMediaLoadedEvent,
|
||||||
|
dispatchMediaPauseEvent,
|
||||||
|
dispatchMediaPlayEvent,
|
||||||
|
dispatchMediaVolumeChangeEvent,
|
||||||
|
} from '../utils/media-info.js';
|
||||||
|
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
||||||
|
import {
|
||||||
|
changeViewToRecentEventsForCameraAndDependents,
|
||||||
|
changeViewToRecentRecordingForCameraAndDependents,
|
||||||
|
} from '../utils/media-to-view.js';
|
||||||
|
import {
|
||||||
|
hideMediaControlsTemporarily,
|
||||||
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
|
playMediaMutingIfNecessary,
|
||||||
|
} from '../utils/media.js';
|
||||||
|
import { ViewMediaClassifier } from '../view/media-classifier';
|
||||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||||
|
import { MediaQueriesResults } from '../view/media-queries-results.js';
|
||||||
|
import { VideoContentType, ViewMedia } from '../view/media.js';
|
||||||
|
import { View } from '../view/view.js';
|
||||||
|
import type { CarouselSelect } from './carousel.js';
|
||||||
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
||||||
import { Lazyload } from './embla-plugins/lazyload.js';
|
import { Lazyload } from './embla-plugins/lazyload.js';
|
||||||
import {
|
import {
|
||||||
FrigateCardMediaCarousel,
|
FrigateCardMediaCarousel,
|
||||||
wrapMediaLoadedEventForCarousel,
|
wrapMediaLoadedEventForCarousel,
|
||||||
} from './media-carousel.js';
|
} from './media-carousel.js';
|
||||||
import type { CarouselSelect } from './carousel.js';
|
|
||||||
import './next-prev-control.js';
|
import './next-prev-control.js';
|
||||||
import './title-control.js';
|
|
||||||
import '../patches/ha-hls-player';
|
|
||||||
import './surround.js';
|
import './surround.js';
|
||||||
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
import './title-control.js';
|
||||||
import { CameraManager } from '../camera-manager/manager.js';
|
|
||||||
import {
|
|
||||||
changeViewToRecentEventsForCameraAndDependents,
|
|
||||||
changeViewToRecentRecordingForCameraAndDependents,
|
|
||||||
} from '../utils/media-to-view.js';
|
|
||||||
import { VideoContentType, ViewMedia } from '../view/media.js';
|
|
||||||
import { ViewMediaClassifier } from '../view/media-classifier';
|
|
||||||
import { guard } from 'lit/directives/guard.js';
|
|
||||||
import { localize } from '../localize/localize.js';
|
|
||||||
import { MediaQueriesResults } from '../view/media-queries-results.js';
|
|
||||||
import { canonicalizeHAURL } from '../utils/ha/index.js';
|
|
||||||
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
|
|
||||||
import { playMediaMutingIfNecessary } from '../utils/media.js';
|
|
||||||
import {
|
|
||||||
hideMediaControlsTemporarily,
|
|
||||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
|
||||||
} from '../utils/media.js';
|
|
||||||
|
|
||||||
export interface MediaViewerViewContext {
|
export interface MediaViewerViewContext {
|
||||||
seek?: Date;
|
seek?: Date;
|
||||||
@@ -602,6 +608,23 @@ export class FrigateCardViewerProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setControls(controls?: boolean): Promise<void> {
|
||||||
|
if (this._refFrigateCardMediaPlayer.value) {
|
||||||
|
return this._refFrigateCardMediaPlayer.value.setControls(controls);
|
||||||
|
} else if (this._refVideoProvider.value) {
|
||||||
|
this._refVideoProvider.value.controls = controls ?? this.viewerConfig?.controls.builtin ?? true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPaused(): boolean {
|
||||||
|
if (this._refFrigateCardMediaPlayer.value) {
|
||||||
|
return this._refFrigateCardMediaPlayer.value.isPaused();
|
||||||
|
} else if (this._refVideoProvider.value) {
|
||||||
|
return this._refVideoProvider.value.paused;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch a clip view that matches the current (snapshot) query.
|
* Dispatch a clip view that matches the current (snapshot) query.
|
||||||
*/
|
*/
|
||||||
@@ -674,6 +697,21 @@ export class FrigateCardViewerProvider
|
|||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) {
|
||||||
|
import('./zoomer.js');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
||||||
|
return this.viewerConfig?.zoomable
|
||||||
|
? html` <frigate-card-zoomer
|
||||||
|
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
|
||||||
|
@frigate-card:zoom:unzoomed=${() => this.setControls()}
|
||||||
|
>
|
||||||
|
${template}
|
||||||
|
</frigate-card-zoomer>`
|
||||||
|
: template;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
@@ -693,61 +731,73 @@ export class FrigateCardViewerProvider
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return ViewMediaClassifier.isVideo(this.media)
|
return this._useZoomIfRequired(html`
|
||||||
? this.media.getVideoContentType() === VideoContentType.HLS
|
${ViewMediaClassifier.isVideo(this.media)
|
||||||
? html`<frigate-card-ha-hls-player
|
? this.media.getVideoContentType() === VideoContentType.HLS
|
||||||
${ref(this._refFrigateCardMediaPlayer)}
|
? html`<frigate-card-ha-hls-player
|
||||||
allow-exoplayer
|
${ref(this._refFrigateCardMediaPlayer)}
|
||||||
aria-label="${this.media.getTitle() ?? ''}"
|
allow-exoplayer
|
||||||
?autoplay=${false}
|
|
||||||
controls
|
|
||||||
muted
|
|
||||||
playsinline
|
|
||||||
title="${this.media.getTitle() ?? ''}"
|
|
||||||
url=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
|
|
||||||
.hass=${this.hass}
|
|
||||||
>
|
|
||||||
</frigate-card-ha-hls-player>`
|
|
||||||
: html`
|
|
||||||
<video
|
|
||||||
${ref(this._refVideoProvider)}
|
|
||||||
aria-label="${this.media.getTitle() ?? ''}"
|
aria-label="${this.media.getTitle() ?? ''}"
|
||||||
title="${this.media.getTitle() ?? ''}"
|
|
||||||
muted
|
|
||||||
controls
|
|
||||||
playsinline
|
|
||||||
?autoplay=${false}
|
?autoplay=${false}
|
||||||
@loadedmetadata=${(ev: Event) => {
|
controls
|
||||||
if (ev.target) {
|
muted
|
||||||
hideMediaControlsTemporarily(
|
playsinline
|
||||||
ev.target as HTMLVideoElement,
|
title="${this.media.getTitle() ?? ''}"
|
||||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
url=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
|
||||||
);
|
.hass=${this.hass}
|
||||||
}
|
?controls=${this.viewerConfig.controls.builtin}
|
||||||
}}
|
|
||||||
@loadeddata=${(ev: Event) => {
|
|
||||||
dispatchMediaLoadedEvent(this, ev);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<source
|
</frigate-card-ha-hls-player>`
|
||||||
src=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
|
: html`
|
||||||
type="video/mp4"
|
<video
|
||||||
/>
|
${ref(this._refVideoProvider)}
|
||||||
</video>
|
aria-label="${this.media.getTitle() ?? ''}"
|
||||||
`
|
title="${this.media.getTitle() ?? ''}"
|
||||||
: html`<img
|
muted
|
||||||
aria-label="${this.media.getTitle() ?? ''}"
|
playsinline
|
||||||
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
|
?autoplay=${false}
|
||||||
title="${this.media.getTitle() ?? ''}"
|
?controls=${this.viewerConfig.controls.builtin}
|
||||||
@click=${() => {
|
@loadedmetadata=${(ev: Event) => {
|
||||||
if (this.viewerConfig?.snapshot_click_plays_clip) {
|
if (ev.target && !!this.viewerConfig?.controls.builtin) {
|
||||||
this._dispatchRelatedClipView();
|
hideMediaControlsTemporarily(
|
||||||
}
|
ev.target as HTMLVideoElement,
|
||||||
}}
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
@load=${(e: Event) => {
|
);
|
||||||
dispatchMediaLoadedEvent(this, e);
|
}
|
||||||
}}
|
}}
|
||||||
/>`;
|
@loadeddata=${(ev: Event) => {
|
||||||
|
dispatchMediaLoadedEvent(this, ev, {
|
||||||
|
player: this,
|
||||||
|
capabilities: {
|
||||||
|
supportsPause: true,
|
||||||
|
hasAudio: mayHaveAudio(ev.target as HTMLVideoElement),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
|
||||||
|
@play=${() => dispatchMediaPlayEvent(this)}
|
||||||
|
@pause=${() => dispatchMediaPauseEvent(this)}
|
||||||
|
>
|
||||||
|
<source
|
||||||
|
src=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
|
||||||
|
type="video/mp4"
|
||||||
|
/>
|
||||||
|
</video>
|
||||||
|
`
|
||||||
|
: html`<img
|
||||||
|
aria-label="${this.media.getTitle() ?? ''}"
|
||||||
|
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
|
||||||
|
title="${this.media.getTitle() ?? ''}"
|
||||||
|
@click=${() => {
|
||||||
|
if (this.viewerConfig?.snapshot_click_plays_clip) {
|
||||||
|
this._dispatchRelatedClipView();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
@load=${(ev: Event) => {
|
||||||
|
dispatchMediaLoadedEvent(this, ev, { player: this });
|
||||||
|
}}
|
||||||
|
/>`}
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
|
|||||||
+16
-14
@@ -1,15 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
CSSResultGroup,
|
CSSResultGroup,
|
||||||
html,
|
html,
|
||||||
LitElement,
|
LitElement,
|
||||||
PropertyValues,
|
PropertyValues,
|
||||||
TemplateResult,
|
TemplateResult,
|
||||||
unsafeCSS
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import { CameraManager } from '../camera-manager/manager.js';
|
import { CameraManager } from '../camera-manager/manager.js';
|
||||||
import { ConditionState, getOverridesByKey } from '../card-condition';
|
import { ConditionControllerEpoch, getOverridesByKey } from '../conditions';
|
||||||
import viewsStyle from '../scss/views.scss';
|
import viewsStyle from '../scss/views.scss';
|
||||||
import { CardWideConfig, ExtendedHomeAssistant, FrigateCardConfig } from '../types.js';
|
import { CardWideConfig, ExtendedHomeAssistant, FrigateCardConfig } from '../types.js';
|
||||||
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
||||||
@@ -40,14 +40,14 @@ export class FrigateCardViews extends LitElement {
|
|||||||
public resolvedMediaCache?: ResolvedMediaCache;
|
public resolvedMediaCache?: ResolvedMediaCache;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public conditionState?: ConditionState;
|
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public cameras?: ConditionState;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public hide?: boolean;
|
public hide?: boolean;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public microphoneStream?: MediaStream;
|
||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('view') || changedProps.has('config')) {
|
if (changedProps.has('view') || changedProps.has('config')) {
|
||||||
if (this.view?.is('live') || this._shouldLivePreload()) {
|
if (this.view?.is('live') || this._shouldLivePreload()) {
|
||||||
@@ -72,7 +72,7 @@ export class FrigateCardViews extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
protected shouldUpdate(_: PropertyValues): boolean {
|
protected shouldUpdate(_: PropertyValues): boolean {
|
||||||
// Future: Updates to `hass` and `conditionState` here will be frequent.
|
// Future: Updates to `hass` and `conditionState` here will be frequent.
|
||||||
@@ -150,6 +150,7 @@ export class FrigateCardViews extends LitElement {
|
|||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraConfig=${cameraConfig}
|
.cameraConfig=${cameraConfig}
|
||||||
|
.supportZoom=${true}
|
||||||
>
|
>
|
||||||
</frigate-card-image>`
|
</frigate-card-image>`
|
||||||
: ``}
|
: ``}
|
||||||
@@ -199,10 +200,11 @@ export class FrigateCardViews extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.liveConfig=${this.nonOverriddenConfig.live}
|
.liveConfig=${this.nonOverriddenConfig.live}
|
||||||
.conditionState=${this.conditionState}
|
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||||
.liveOverrides=${getOverridesByKey(this.config.overrides, 'live')}
|
.liveOverrides=${getOverridesByKey('live', this.config.overrides)}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
|
.microphoneStream=${this.microphoneStream}
|
||||||
class="${classMap(liveClasses)}"
|
class="${classMap(liveClasses)}"
|
||||||
>
|
>
|
||||||
</frigate-card-live>
|
</frigate-card-live>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
css,
|
||||||
|
CSSResultGroup,
|
||||||
|
html,
|
||||||
|
LitElement,
|
||||||
|
PropertyValues,
|
||||||
|
TemplateResult,
|
||||||
|
} from 'lit';
|
||||||
|
import { customElement, state } from 'lit/decorators.js';
|
||||||
|
import { setOrRemoveAttribute } from '../utils/basic.js';
|
||||||
|
import { Zoom } from '../utils/zoom/zoom.js';
|
||||||
|
|
||||||
|
@customElement('frigate-card-zoomer')
|
||||||
|
export class FrigateCardZoomer extends LitElement {
|
||||||
|
protected _zoom = new Zoom(this);
|
||||||
|
|
||||||
|
@state()
|
||||||
|
protected _zoomed = false;
|
||||||
|
|
||||||
|
protected _zoomHandler = () => (this._zoomed = true);
|
||||||
|
protected _unzoomHandler = () => (this._zoomed = false);
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.addEventListener('frigate-card:zoom:zoomed', this._zoomHandler);
|
||||||
|
this.addEventListener('frigate-card:zoom:unzoomed', this._unzoomHandler);
|
||||||
|
this._zoom.activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
this._zoom.deactivate();
|
||||||
|
this.removeEventListener('frigate-card:zoom:zoomed', this._zoomHandler);
|
||||||
|
this.removeEventListener('frigate-card:zoom:unzoomed', this._unzoomHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
|
if (changedProps.has('_zoomed')) {
|
||||||
|
setOrRemoveAttribute(this, this._zoomed, 'zoomed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
return html` <slot></slot> `;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return css`
|
||||||
|
:host {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
:host([zoomed]) {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'frigate-card-zoomer': FrigateCardZoomer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { HassEntities } from 'home-assistant-js-websocket';
|
||||||
|
import merge from 'lodash-es/merge';
|
||||||
|
import { copyConfig } from './config-mgmt';
|
||||||
|
import {
|
||||||
|
FrigateCardCondition,
|
||||||
|
FrigateCardConfig,
|
||||||
|
frigateConditionalSchema,
|
||||||
|
OverrideConfigurationKey,
|
||||||
|
RawFrigateCardConfig,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface ConditionState {
|
||||||
|
view?: string;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
expand?: boolean;
|
||||||
|
camera?: string;
|
||||||
|
state?: HassEntities;
|
||||||
|
media_loaded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConditionEvaluateRequestEvent extends Event {
|
||||||
|
public condition: FrigateCardCondition;
|
||||||
|
public evaluation?: boolean;
|
||||||
|
|
||||||
|
constructor(condition: FrigateCardCondition, eventInitDict?: EventInit) {
|
||||||
|
super('frigate-card:condition:evaluate', eventInitDict);
|
||||||
|
this.condition = condition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate whether a frigateCardCondition is met using an event to evaluate.
|
||||||
|
* @returns A boolean indicating whether the condition is met.
|
||||||
|
*/
|
||||||
|
export function evaluateConditionViaEvent(
|
||||||
|
element: HTMLElement,
|
||||||
|
condition?: FrigateCardCondition,
|
||||||
|
): boolean {
|
||||||
|
if (!condition) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluateEvent = new ConditionEvaluateRequestEvent(condition, {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Special note on what's going on here:
|
||||||
|
*
|
||||||
|
* Some parts of the card (e.g. <frigate-card-elements>) may have arbitrary
|
||||||
|
* complexity and layers (that this card doesn't control) between that master
|
||||||
|
* element and the element that needs to evaluate the condition. In these
|
||||||
|
* cases there's no clean way to pass state from the rest of card down through
|
||||||
|
* these layers. Instead, an event is dispatched as a "request for evaluation"
|
||||||
|
* (ConditionEvaluateRequestEvent) upwards which is caught by the outer card
|
||||||
|
* and the evaluation result is added to the event object. Because event
|
||||||
|
* propagation is handled synchronously, the result will be added to the event
|
||||||
|
* before the flow proceeds.
|
||||||
|
*/
|
||||||
|
element.dispatchEvent(evaluateEvent);
|
||||||
|
return evaluateEvent.evaluation ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawOverrides = {
|
||||||
|
conditions: FrigateCardCondition;
|
||||||
|
overrides: RawFrigateCardConfig;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export function getOverriddenConfig(
|
||||||
|
controller: Readonly<ConditionController>,
|
||||||
|
config: Readonly<RawFrigateCardConfig>,
|
||||||
|
configOverrides?: Readonly<RawOverrides>,
|
||||||
|
stateOverrides?: Partial<ConditionState>,
|
||||||
|
): RawFrigateCardConfig {
|
||||||
|
const output = copyConfig(config);
|
||||||
|
let overridden = false;
|
||||||
|
if (configOverrides) {
|
||||||
|
for (const override of configOverrides) {
|
||||||
|
if (controller.evaluateCondition(override.conditions, stateOverrides)) {
|
||||||
|
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(
|
||||||
|
key: OverrideConfigurationKey,
|
||||||
|
overrides?: Readonly<RawOverrides>,
|
||||||
|
): RawOverrides {
|
||||||
|
return (
|
||||||
|
overrides
|
||||||
|
?.filter((o) => key in o.overrides)
|
||||||
|
.map((o) => ({
|
||||||
|
conditions: o.conditions,
|
||||||
|
overrides: o.overrides[key] as RawFrigateCardConfig,
|
||||||
|
})) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tiny wrapper interface to allow the same controller to be passed around
|
||||||
|
// immutably within objects that will not be equal (===). Every state change
|
||||||
|
// generates a new epoch. This is used for Lit rendering to ensure changes to
|
||||||
|
// condition state are recognized as changes even though the controller is the
|
||||||
|
// same.
|
||||||
|
export interface ConditionControllerEpoch {
|
||||||
|
controller: Readonly<ConditionController>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConditionController {
|
||||||
|
protected _state: ConditionState = {};
|
||||||
|
protected _epoch: ConditionControllerEpoch = this._createEpoch();
|
||||||
|
protected _stateListeners: (() => void)[] = [];
|
||||||
|
|
||||||
|
// Whether or not to include HA state in ConditionState. Doing so increases
|
||||||
|
// CPU usage as HA state is pumped out very fast, so this is only enabled if
|
||||||
|
// the configuration needs to consume it.
|
||||||
|
protected _hasHAStateConditions = false;
|
||||||
|
protected _mediaQueries: MediaQueryList[] = [];
|
||||||
|
protected _mediaQueryTrigger = () => this._triggerChange();
|
||||||
|
|
||||||
|
constructor(config?: FrigateCardConfig) {
|
||||||
|
if (config) {
|
||||||
|
this._initConditions(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public addStateListener(callback: () => void): void {
|
||||||
|
this._stateListeners.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeStateListener(callback: () => void): void {
|
||||||
|
this._stateListeners = this._stateListeners.filter(
|
||||||
|
(listener) => listener != callback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this._mediaQueries.forEach((mql) =>
|
||||||
|
mql.removeEventListener('change', this._mediaQueryTrigger),
|
||||||
|
);
|
||||||
|
this._mediaQueries = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public setState(state: Partial<ConditionState>): void {
|
||||||
|
this._state = {
|
||||||
|
...this._state,
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
this._triggerChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasHAStateConditions(): boolean {
|
||||||
|
return this._hasHAStateConditions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEpoch(): ConditionControllerEpoch {
|
||||||
|
return this._epoch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public evaluateCondition(
|
||||||
|
condition: Readonly<FrigateCardCondition>,
|
||||||
|
stateOverrides?: Partial<ConditionState>,
|
||||||
|
): boolean {
|
||||||
|
const state = {
|
||||||
|
...this._state,
|
||||||
|
...stateOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
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.expand !== undefined) {
|
||||||
|
result &&= state.expand !== undefined && condition.expand == state.expand;
|
||||||
|
}
|
||||||
|
if (condition.camera?.length) {
|
||||||
|
result &&= !!state.camera && condition.camera.includes(state.camera);
|
||||||
|
}
|
||||||
|
if (condition.state?.length) {
|
||||||
|
for (const stateTest of condition.state) {
|
||||||
|
result &&=
|
||||||
|
!!state.state &&
|
||||||
|
((!stateTest.state && !stateTest.state_not) ||
|
||||||
|
(stateTest.entity in state.state &&
|
||||||
|
(!stateTest.state ||
|
||||||
|
state.state[stateTest.entity].state === stateTest.state) &&
|
||||||
|
(!stateTest.state_not ||
|
||||||
|
state.state[stateTest.entity].state !== stateTest.state_not)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (condition.media_loaded !== undefined) {
|
||||||
|
result &&=
|
||||||
|
state.media_loaded !== undefined && condition.media_loaded == state.media_loaded;
|
||||||
|
}
|
||||||
|
if (condition.media_query) {
|
||||||
|
result &&= window.matchMedia(condition.media_query).matches;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _createEpoch(): ConditionControllerEpoch {
|
||||||
|
return { controller: this };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _triggerChange(): void {
|
||||||
|
this._epoch = this._createEpoch();
|
||||||
|
this._stateListeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _initConditions(config: FrigateCardConfig): void {
|
||||||
|
const getAllConditions = (config: FrigateCardConfig): FrigateCardCondition[] => {
|
||||||
|
const conditions: FrigateCardCondition[] = [];
|
||||||
|
config.overrides?.forEach((override) => conditions.push(override.conditions));
|
||||||
|
|
||||||
|
// Element conditions can be arbitrarily nested underneath conditionals and
|
||||||
|
// custom elements that this card may not known. Here we recursively parse
|
||||||
|
// down the elements tree, parsing as we go to find valid conditions.
|
||||||
|
const getElementsConditions = (data: unknown): void => {
|
||||||
|
const parseResult = frigateConditionalSchema.safeParse(data);
|
||||||
|
if (parseResult.success) {
|
||||||
|
conditions.push(parseResult.data.conditions);
|
||||||
|
parseResult.data.elements?.forEach(getElementsConditions);
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
Object.keys(data).forEach((key) => getElementsConditions(data[key]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
config.elements?.forEach(getElementsConditions);
|
||||||
|
return conditions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const conditions = getAllConditions(config);
|
||||||
|
this._hasHAStateConditions = conditions.some(
|
||||||
|
(condition) => !!condition.state?.length,
|
||||||
|
);
|
||||||
|
conditions.forEach((condition) => {
|
||||||
|
if (condition.media_query) {
|
||||||
|
const mql = window.matchMedia(condition.media_query);
|
||||||
|
mql.addEventListener('change', this._mediaQueryTrigger);
|
||||||
|
this._mediaQueries.push(mql);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,6 +104,8 @@ export const CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP =
|
|||||||
`${CONF_MEDIA_VIEWER}.snapshot_click_plays_clip` as const;
|
`${CONF_MEDIA_VIEWER}.snapshot_click_plays_clip` as const;
|
||||||
export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT =
|
export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT =
|
||||||
`${CONF_MEDIA_VIEWER}.transition_effect` as const;
|
`${CONF_MEDIA_VIEWER}.transition_effect` as const;
|
||||||
|
export const CONF_MEDIA_VIEWER_CONTROLS_BUILTIN =
|
||||||
|
`${CONF_MEDIA_VIEWER}.controls.builtin` as const;
|
||||||
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE =
|
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE =
|
||||||
`${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const;
|
`${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const;
|
||||||
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE =
|
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE =
|
||||||
@@ -132,6 +134,7 @@ export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE =
|
|||||||
`${CONF_MEDIA_VIEWER}.controls.timeline.style` as const;
|
`${CONF_MEDIA_VIEWER}.controls.timeline.style` as const;
|
||||||
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS =
|
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS =
|
||||||
`${CONF_MEDIA_VIEWER}.controls.timeline.window_seconds` as const;
|
`${CONF_MEDIA_VIEWER}.controls.timeline.window_seconds` as const;
|
||||||
|
export const CONF_MEDIA_VIEWER_ZOOMABLE = `${CONF_MEDIA_VIEWER}.zoomable` as const;
|
||||||
|
|
||||||
export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE =
|
export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE =
|
||||||
`${CONF_MEDIA_VIEWER}.controls.title.mode` as const;
|
`${CONF_MEDIA_VIEWER}.controls.title.mode` as const;
|
||||||
@@ -148,6 +151,7 @@ export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const;
|
|||||||
export const CONF_LIVE_AUTO_PAUSE = `${CONF_LIVE}.auto_pause` as const;
|
export const CONF_LIVE_AUTO_PAUSE = `${CONF_LIVE}.auto_pause` as const;
|
||||||
export const CONF_LIVE_AUTO_MUTE = `${CONF_LIVE}.auto_mute` as const;
|
export const CONF_LIVE_AUTO_MUTE = `${CONF_LIVE}.auto_mute` as const;
|
||||||
export const CONF_LIVE_AUTO_UNMUTE = `${CONF_LIVE}.auto_unmute` as const;
|
export const CONF_LIVE_AUTO_UNMUTE = `${CONF_LIVE}.auto_unmute` as const;
|
||||||
|
export const CONF_LIVE_CONTROLS_BUILTIN = `${CONF_LIVE}.controls.builtin` as const;
|
||||||
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE =
|
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE =
|
||||||
`${CONF_LIVE}.controls.next_previous.style` as const;
|
`${CONF_LIVE}.controls.next_previous.style` as const;
|
||||||
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE =
|
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE =
|
||||||
@@ -191,6 +195,11 @@ export const CONF_LIVE_PRELOAD = `${CONF_LIVE}.preload` as const;
|
|||||||
export const CONF_LIVE_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const;
|
export const CONF_LIVE_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const;
|
||||||
export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD =
|
export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD =
|
||||||
`${CONF_LIVE}.show_image_during_load` as const;
|
`${CONF_LIVE}.show_image_during_load` as const;
|
||||||
|
export const CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS =
|
||||||
|
`${CONF_LIVE}.microphone.disconnect_seconds` as const;
|
||||||
|
export const CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED =
|
||||||
|
`${CONF_LIVE}.microphone.always_connected` as const;
|
||||||
|
export const CONF_LIVE_ZOOMABLE = `${CONF_LIVE}.zoomable` as const;
|
||||||
|
|
||||||
const CONF_IMAGE = 'image' as const;
|
const CONF_IMAGE = 'image' as const;
|
||||||
export const CONF_IMAGE_LAYOUT_FIT = `${CONF_IMAGE}.layout.fit` as const;
|
export const CONF_IMAGE_LAYOUT_FIT = `${CONF_IMAGE}.layout.fit` as const;
|
||||||
@@ -199,6 +208,7 @@ export const CONF_IMAGE_LAYOUT_POSITION_Y = `${CONF_IMAGE}.layout.position.y` as
|
|||||||
export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const;
|
export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const;
|
||||||
export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const;
|
export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const;
|
||||||
export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const;
|
export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const;
|
||||||
|
export const CONF_IMAGE_ZOOMABLE = `${CONF_IMAGE}.zoomable` as const;
|
||||||
|
|
||||||
const CONF_TIMELINE = 'timeline' as const;
|
const CONF_TIMELINE = 'timeline' as const;
|
||||||
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
|
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
|
||||||
|
|||||||
+87
-23
@@ -2,6 +2,11 @@ import { fireEvent, HomeAssistant, LovelaceCardEditor } from 'custom-card-helper
|
|||||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
|
import { FRIGATE_ICON_SVG_PATH } from './camera-manager/frigate/icon.js';
|
||||||
|
import {
|
||||||
|
MOTIONEYE_ICON_SVG_PATH,
|
||||||
|
MOTIONEYE_ICON_SVG_VIEWBOX,
|
||||||
|
} from './camera-manager/motioneye/icon.js';
|
||||||
import {
|
import {
|
||||||
copyConfig,
|
copyConfig,
|
||||||
deleteConfigValue,
|
deleteConfigValue,
|
||||||
@@ -12,6 +17,7 @@ import {
|
|||||||
upgradeConfig,
|
upgradeConfig,
|
||||||
} from './config-mgmt.js';
|
} from './config-mgmt.js';
|
||||||
import {
|
import {
|
||||||
|
CONF_CAMERAS,
|
||||||
CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
|
CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
|
||||||
CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS,
|
CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS,
|
||||||
CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS,
|
CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS,
|
||||||
@@ -39,19 +45,22 @@ import {
|
|||||||
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
|
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
|
||||||
CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
|
CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
|
||||||
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
|
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
|
||||||
CONF_CAMERAS,
|
|
||||||
CONF_DIMENSIONS_ASPECT_RATIO_MODE,
|
|
||||||
CONF_DIMENSIONS_ASPECT_RATIO,
|
CONF_DIMENSIONS_ASPECT_RATIO,
|
||||||
|
CONF_DIMENSIONS_ASPECT_RATIO_MODE,
|
||||||
|
CONF_DIMENSIONS_MAX_HEIGHT,
|
||||||
|
CONF_DIMENSIONS_MIN_HEIGHT,
|
||||||
CONF_IMAGE_LAYOUT_FIT,
|
CONF_IMAGE_LAYOUT_FIT,
|
||||||
CONF_IMAGE_LAYOUT_POSITION_X,
|
CONF_IMAGE_LAYOUT_POSITION_X,
|
||||||
CONF_IMAGE_LAYOUT_POSITION_Y,
|
CONF_IMAGE_LAYOUT_POSITION_Y,
|
||||||
CONF_IMAGE_MODE,
|
CONF_IMAGE_MODE,
|
||||||
CONF_IMAGE_REFRESH_SECONDS,
|
CONF_IMAGE_REFRESH_SECONDS,
|
||||||
CONF_IMAGE_URL,
|
CONF_IMAGE_URL,
|
||||||
|
CONF_IMAGE_ZOOMABLE,
|
||||||
CONF_LIVE_AUTO_MUTE,
|
CONF_LIVE_AUTO_MUTE,
|
||||||
CONF_LIVE_AUTO_PAUSE,
|
CONF_LIVE_AUTO_PAUSE,
|
||||||
CONF_LIVE_AUTO_PLAY,
|
CONF_LIVE_AUTO_PLAY,
|
||||||
CONF_LIVE_AUTO_UNMUTE,
|
CONF_LIVE_AUTO_UNMUTE,
|
||||||
|
CONF_LIVE_CONTROLS_BUILTIN,
|
||||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
|
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
|
||||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
|
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||||
CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA,
|
CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA,
|
||||||
@@ -65,6 +74,7 @@ import {
|
|||||||
CONF_LIVE_CONTROLS_TIMELINE_MEDIA,
|
CONF_LIVE_CONTROLS_TIMELINE_MEDIA,
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_MODE,
|
CONF_LIVE_CONTROLS_TIMELINE_MODE,
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||||
|
CONF_LIVE_CONTROLS_TIMELINE_STYLE,
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||||
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
|
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
|
||||||
CONF_LIVE_CONTROLS_TITLE_MODE,
|
CONF_LIVE_CONTROLS_TITLE_MODE,
|
||||||
@@ -74,9 +84,12 @@ import {
|
|||||||
CONF_LIVE_LAYOUT_POSITION_Y,
|
CONF_LIVE_LAYOUT_POSITION_Y,
|
||||||
CONF_LIVE_LAZY_LOAD,
|
CONF_LIVE_LAZY_LOAD,
|
||||||
CONF_LIVE_LAZY_UNLOAD,
|
CONF_LIVE_LAZY_UNLOAD,
|
||||||
|
CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
|
||||||
|
CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS,
|
||||||
CONF_LIVE_PRELOAD,
|
CONF_LIVE_PRELOAD,
|
||||||
CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
|
CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
|
||||||
CONF_LIVE_TRANSITION_EFFECT,
|
CONF_LIVE_TRANSITION_EFFECT,
|
||||||
|
CONF_LIVE_ZOOMABLE,
|
||||||
CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE,
|
CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE,
|
||||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||||
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||||
@@ -87,6 +100,7 @@ import {
|
|||||||
CONF_MEDIA_VIEWER_AUTO_PAUSE,
|
CONF_MEDIA_VIEWER_AUTO_PAUSE,
|
||||||
CONF_MEDIA_VIEWER_AUTO_PLAY,
|
CONF_MEDIA_VIEWER_AUTO_PLAY,
|
||||||
CONF_MEDIA_VIEWER_AUTO_UNMUTE,
|
CONF_MEDIA_VIEWER_AUTO_UNMUTE,
|
||||||
|
CONF_MEDIA_VIEWER_CONTROLS_BUILTIN,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
|
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
|
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
|
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
|
||||||
@@ -99,6 +113,7 @@ import {
|
|||||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA,
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||||
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
|
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
|
CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
|
||||||
@@ -109,6 +124,7 @@ import {
|
|||||||
CONF_MEDIA_VIEWER_LAZY_LOAD,
|
CONF_MEDIA_VIEWER_LAZY_LOAD,
|
||||||
CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP,
|
CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP,
|
||||||
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
|
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
|
||||||
|
CONF_MEDIA_VIEWER_ZOOMABLE,
|
||||||
CONF_MENU_ALIGNMENT,
|
CONF_MENU_ALIGNMENT,
|
||||||
CONF_MENU_BUTTON_SIZE,
|
CONF_MENU_BUTTON_SIZE,
|
||||||
CONF_MENU_BUTTONS,
|
CONF_MENU_BUTTONS,
|
||||||
@@ -128,27 +144,24 @@ import {
|
|||||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
|
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
|
||||||
CONF_TIMELINE_MEDIA,
|
CONF_TIMELINE_MEDIA,
|
||||||
CONF_TIMELINE_SHOW_RECORDINGS,
|
CONF_TIMELINE_SHOW_RECORDINGS,
|
||||||
|
CONF_TIMELINE_STYLE,
|
||||||
CONF_TIMELINE_WINDOW_SECONDS,
|
CONF_TIMELINE_WINDOW_SECONDS,
|
||||||
CONF_VIEW_CAMERA_SELECT,
|
CONF_VIEW_CAMERA_SELECT,
|
||||||
CONF_VIEW_DARK_MODE,
|
CONF_VIEW_DARK_MODE,
|
||||||
CONF_VIEW_DEFAULT,
|
CONF_VIEW_DEFAULT,
|
||||||
|
CONF_VIEW_SCAN,
|
||||||
CONF_VIEW_SCAN_ENABLED,
|
CONF_VIEW_SCAN_ENABLED,
|
||||||
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS,
|
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS,
|
||||||
CONF_VIEW_SCAN_UNTRIGGER_RESET,
|
CONF_VIEW_SCAN_UNTRIGGER_RESET,
|
||||||
CONF_VIEW_SCAN_UNTRIGGER_SECONDS,
|
CONF_VIEW_SCAN_UNTRIGGER_SECONDS,
|
||||||
CONF_VIEW_SCAN,
|
|
||||||
CONF_VIEW_TIMEOUT_SECONDS,
|
CONF_VIEW_TIMEOUT_SECONDS,
|
||||||
CONF_VIEW_UPDATE_CYCLE_CAMERA,
|
CONF_VIEW_UPDATE_CYCLE_CAMERA,
|
||||||
CONF_VIEW_UPDATE_FORCE,
|
CONF_VIEW_UPDATE_FORCE,
|
||||||
CONF_VIEW_UPDATE_SECONDS,
|
CONF_VIEW_UPDATE_SECONDS,
|
||||||
MEDIA_CHUNK_SIZE_MAX,
|
MEDIA_CHUNK_SIZE_MAX,
|
||||||
CONF_DIMENSIONS_MAX_HEIGHT,
|
|
||||||
CONF_DIMENSIONS_MIN_HEIGHT,
|
|
||||||
CONF_TIMELINE_STYLE,
|
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_STYLE,
|
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
|
|
||||||
} from './const.js';
|
} from './const.js';
|
||||||
import { localize } from './localize/localize.js';
|
import { localize } from './localize/localize.js';
|
||||||
|
import { setLowPerformanceProfile } from './performance.js';
|
||||||
import frigate_card_editor_style from './scss/editor.scss';
|
import frigate_card_editor_style from './scss/editor.scss';
|
||||||
import {
|
import {
|
||||||
BUTTON_SIZE_MIN,
|
BUTTON_SIZE_MIN,
|
||||||
@@ -162,29 +175,23 @@ import {
|
|||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { arrayMove, prettifyTitle } from './utils/basic.js';
|
import { arrayMove, prettifyTitle } from './utils/basic.js';
|
||||||
import { getCameraID } from './utils/camera.js';
|
import { getCameraID } from './utils/camera.js';
|
||||||
import { FRIGATE_ICON_SVG_PATH } from './camera-manager/frigate/icon.js';
|
|
||||||
import {
|
import {
|
||||||
getEntitiesFromHASS,
|
getEntitiesFromHASS,
|
||||||
getEntityTitle,
|
getEntityTitle,
|
||||||
sideLoadHomeAssistantElements,
|
sideLoadHomeAssistantElements,
|
||||||
} from './utils/ha';
|
} from './utils/ha';
|
||||||
import { setLowPerformanceProfile } from './performance.js';
|
|
||||||
import {
|
|
||||||
MOTIONEYE_ICON_SVG_PATH,
|
|
||||||
MOTIONEYE_ICON_SVG_VIEWBOX,
|
|
||||||
} from './camera-manager/motioneye/icon.js';
|
|
||||||
|
|
||||||
const MENU_BUTTONS = 'buttons';
|
const MENU_BUTTONS = 'buttons';
|
||||||
const MENU_CAMERAS = 'cameras';
|
const MENU_CAMERAS = 'cameras';
|
||||||
const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies';
|
const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies';
|
||||||
|
const MENU_CAMERAS_ENGINE = 'cameras.engine';
|
||||||
const MENU_CAMERAS_FRIGATE = 'cameras.frigate';
|
const MENU_CAMERAS_FRIGATE = 'cameras.frigate';
|
||||||
const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc';
|
const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc';
|
||||||
const MENU_CAMERAS_IMAGE = 'cameras.image';
|
const MENU_CAMERAS_IMAGE = 'cameras.image';
|
||||||
|
const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider';
|
||||||
const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye';
|
const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye';
|
||||||
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
|
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
|
||||||
const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
|
const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
|
||||||
const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider';
|
|
||||||
const MENU_CAMERAS_ENGINE = 'cameras.engine';
|
|
||||||
const MENU_IMAGE_LAYOUT = 'image.layout';
|
const MENU_IMAGE_LAYOUT = 'image.layout';
|
||||||
const MENU_LIVE_CONTROLS = 'live.controls';
|
const MENU_LIVE_CONTROLS = 'live.controls';
|
||||||
const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous';
|
const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous';
|
||||||
@@ -192,18 +199,19 @@ const MENU_LIVE_CONTROLS_THUMBNAILS = 'live.controls.thumbnails';
|
|||||||
const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline';
|
const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline';
|
||||||
const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title';
|
const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title';
|
||||||
const MENU_LIVE_LAYOUT = 'live.layout';
|
const MENU_LIVE_LAYOUT = 'live.layout';
|
||||||
const MENU_MEDIA_GALLERY_CONTROLS_THUMBNAILS = 'media_gallery.controls.thumbnails';
|
const MENU_LIVE_MICROPHONE = 'live.microphone';
|
||||||
const MENU_MEDIA_GALLERY_CONTROLS_FILTER = 'media_gallery.controls.filter';
|
const MENU_MEDIA_GALLERY_CONTROLS_FILTER = 'media_gallery.controls.filter';
|
||||||
|
const MENU_MEDIA_GALLERY_CONTROLS_THUMBNAILS = 'media_gallery.controls.thumbnails';
|
||||||
const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls';
|
const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls';
|
||||||
const MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS = 'media_viewer.controls.next_previous';
|
const MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS = 'media_viewer.controls.next_previous';
|
||||||
const MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS = 'media_viewer.controls.thumbnails';
|
const MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS = 'media_viewer.controls.thumbnails';
|
||||||
const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline';
|
const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline';
|
||||||
const MENU_MEDIA_VIEWER_CONTROLS_TITLE = 'media_viewer.controls.title';
|
const MENU_MEDIA_VIEWER_CONTROLS_TITLE = 'media_viewer.controls.title';
|
||||||
const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout';
|
const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout';
|
||||||
const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails';
|
|
||||||
const MENU_OPTIONS = 'options';
|
const MENU_OPTIONS = 'options';
|
||||||
const MENU_PERFORMANCE_FEATURES = 'performance.features';
|
const MENU_PERFORMANCE_FEATURES = 'performance.features';
|
||||||
const MENU_PERFORMANCE_STYLE = 'performance.style';
|
const MENU_PERFORMANCE_STYLE = 'performance.style';
|
||||||
|
const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails';
|
||||||
const MENU_VIEW_SCAN = 'scan';
|
const MENU_VIEW_SCAN = 'scan';
|
||||||
|
|
||||||
interface EditorOptionsSet {
|
interface EditorOptionsSet {
|
||||||
@@ -531,6 +539,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
{ value: 'mjpeg', label: localize('config.cameras.go2rtc.modes.mjpeg') },
|
{ value: 'mjpeg', label: localize('config.cameras.go2rtc.modes.mjpeg') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected _microphoneButtonTypes: EditorSelectOption[] = [
|
||||||
|
{ value: '', label: '' },
|
||||||
|
{ value: 'momentary', label: localize('config.menu.buttons.types.momentary') },
|
||||||
|
{ value: 'toggle', label: localize('config.menu.buttons.types.toggle') },
|
||||||
|
];
|
||||||
|
|
||||||
public setConfig(config: RawFrigateCardConfig): void {
|
public setConfig(config: RawFrigateCardConfig): void {
|
||||||
// Note: This does not use Zod to parse the configuration, so it may be
|
// 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
|
// partially or completely invalid. It's more useful to have a partially
|
||||||
@@ -836,7 +850,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
* @param button The name of the button.
|
* @param button The name of the button.
|
||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
*/
|
*/
|
||||||
protected _renderMenuButton(button: string): TemplateResult {
|
protected _renderMenuButton(
|
||||||
|
button: string,
|
||||||
|
additionalOptions?: TemplateResult,
|
||||||
|
): TemplateResult {
|
||||||
const menuButtonAlignments: EditorSelectOption[] = [
|
const menuButtonAlignments: EditorSelectOption[] = [
|
||||||
{ value: '', label: '' },
|
{ value: '', label: '' },
|
||||||
{ value: 'matching', label: localize('config.menu.buttons.alignments.matching') },
|
{ value: 'matching', label: localize('config.menu.buttons.alignments.matching') },
|
||||||
@@ -887,6 +904,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
${this._renderIconSelector(`${CONF_MENU_BUTTONS}.${button}.icon`, {
|
${this._renderIconSelector(`${CONF_MENU_BUTTONS}.${button}.icon`, {
|
||||||
label: localize('config.menu.buttons.icon'),
|
label: localize('config.menu.buttons.icon'),
|
||||||
})}
|
})}
|
||||||
|
${additionalOptions}
|
||||||
</div>`
|
</div>`
|
||||||
: ''}
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -994,10 +1012,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
configPathShowRecordings: string,
|
configPathShowRecordings: string,
|
||||||
defaultShowRecordings: boolean,
|
defaultShowRecordings: boolean,
|
||||||
): TemplateResult {
|
): TemplateResult {
|
||||||
return html`
|
return html` ${this._renderOptionSelector(
|
||||||
${this._renderOptionSelector(configPathStyle, this._timelineStyleTypes, {
|
configPathStyle,
|
||||||
label: localize(`config.common.${CONF_TIMELINE_STYLE}`),
|
this._timelineStyleTypes,
|
||||||
})}
|
{
|
||||||
|
label: localize(`config.common.${CONF_TIMELINE_STYLE}`),
|
||||||
|
},
|
||||||
|
)}
|
||||||
${this._renderNumberInput(configPathWindowSeconds, {
|
${this._renderNumberInput(configPathWindowSeconds, {
|
||||||
label: localize(`config.common.${CONF_TIMELINE_WINDOW_SECONDS}`),
|
label: localize(`config.common.${CONF_TIMELINE_WINDOW_SECONDS}`),
|
||||||
})}
|
})}
|
||||||
@@ -1755,6 +1776,16 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
${this._renderMenuButton('expand') /* */}
|
${this._renderMenuButton('expand') /* */}
|
||||||
${this._renderMenuButton('timeline')}
|
${this._renderMenuButton('timeline')}
|
||||||
${this._renderMenuButton('media_player')}
|
${this._renderMenuButton('media_player')}
|
||||||
|
${this._renderMenuButton(
|
||||||
|
'microphone',
|
||||||
|
html`${this._renderOptionSelector(
|
||||||
|
`${CONF_MENU_BUTTONS}.microphone.type`,
|
||||||
|
this._microphoneButtonTypes,
|
||||||
|
{ label: localize('config.menu.buttons.type') },
|
||||||
|
)}`,
|
||||||
|
)}
|
||||||
|
${this._renderMenuButton('play') /* */}
|
||||||
|
${this._renderMenuButton('mute')}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -1764,6 +1795,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
<div class="values">
|
<div class="values">
|
||||||
${this._renderSwitch(CONF_LIVE_PRELOAD, this._defaults.live.preload)}
|
${this._renderSwitch(CONF_LIVE_PRELOAD, this._defaults.live.preload)}
|
||||||
${this._renderSwitch(CONF_LIVE_DRAGGABLE, this._defaults.live.draggable)}
|
${this._renderSwitch(CONF_LIVE_DRAGGABLE, this._defaults.live.draggable)}
|
||||||
|
${this._renderSwitch(CONF_LIVE_ZOOMABLE, this._defaults.live.zoomable)}
|
||||||
${this._renderSwitch(CONF_LIVE_LAZY_LOAD, this._defaults.live.lazy_load)}
|
${this._renderSwitch(CONF_LIVE_LAZY_LOAD, this._defaults.live.lazy_load)}
|
||||||
${this._renderOptionSelector(
|
${this._renderOptionSelector(
|
||||||
CONF_LIVE_LAZY_UNLOAD,
|
CONF_LIVE_LAZY_UNLOAD,
|
||||||
@@ -1799,6 +1831,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
'config.live.controls.editor_label',
|
'config.live.controls.editor_label',
|
||||||
{ name: 'mdi:gamepad' },
|
{ name: 'mdi:gamepad' },
|
||||||
html`
|
html`
|
||||||
|
${this._renderSwitch(
|
||||||
|
CONF_LIVE_CONTROLS_BUILTIN,
|
||||||
|
this._defaults.live.controls.builtin,
|
||||||
|
{
|
||||||
|
label: localize('config.common.controls.builtin'),
|
||||||
|
},
|
||||||
|
)}
|
||||||
${this._renderNextPreviousControls(
|
${this._renderNextPreviousControls(
|
||||||
MENU_LIVE_CONTROLS_NEXT_PREVIOUS,
|
MENU_LIVE_CONTROLS_NEXT_PREVIOUS,
|
||||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
|
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||||
@@ -1844,6 +1883,19 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
CONF_LIVE_LAYOUT_POSITION_X,
|
CONF_LIVE_LAYOUT_POSITION_X,
|
||||||
CONF_LIVE_LAYOUT_POSITION_Y,
|
CONF_LIVE_LAYOUT_POSITION_Y,
|
||||||
)}
|
)}
|
||||||
|
${this._putInSubmenu(
|
||||||
|
MENU_LIVE_MICROPHONE,
|
||||||
|
true,
|
||||||
|
'config.live.microphone.editor_label',
|
||||||
|
{ name: 'mdi:microphone' },
|
||||||
|
html`
|
||||||
|
${this._renderNumberInput(CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS)}
|
||||||
|
${this._renderSwitch(
|
||||||
|
CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
|
||||||
|
this._defaults.live.microphone.always_connected,
|
||||||
|
)}
|
||||||
|
`,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -1888,6 +1940,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
CONF_MEDIA_VIEWER_DRAGGABLE,
|
CONF_MEDIA_VIEWER_DRAGGABLE,
|
||||||
this._defaults.media_viewer.draggable,
|
this._defaults.media_viewer.draggable,
|
||||||
)}
|
)}
|
||||||
|
${this._renderSwitch(
|
||||||
|
CONF_MEDIA_VIEWER_ZOOMABLE,
|
||||||
|
this._defaults.media_viewer.zoomable,
|
||||||
|
)}
|
||||||
${this._renderSwitch(
|
${this._renderSwitch(
|
||||||
CONF_MEDIA_VIEWER_LAZY_LOAD,
|
CONF_MEDIA_VIEWER_LAZY_LOAD,
|
||||||
this._defaults.media_viewer.lazy_load,
|
this._defaults.media_viewer.lazy_load,
|
||||||
@@ -1906,6 +1962,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
'config.media_viewer.controls.editor_label',
|
'config.media_viewer.controls.editor_label',
|
||||||
{ name: 'mdi:gamepad' },
|
{ name: 'mdi:gamepad' },
|
||||||
html`
|
html`
|
||||||
|
${this._renderSwitch(
|
||||||
|
CONF_MEDIA_VIEWER_CONTROLS_BUILTIN,
|
||||||
|
this._defaults.media_viewer.controls.builtin,
|
||||||
|
{
|
||||||
|
label: localize('config.common.controls.builtin'),
|
||||||
|
},
|
||||||
|
)}
|
||||||
${this._renderNextPreviousControls(
|
${this._renderNextPreviousControls(
|
||||||
MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS,
|
MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
|
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||||
@@ -1958,6 +2021,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
${this._renderOptionSelector(CONF_IMAGE_MODE, this._imageModes)}
|
${this._renderOptionSelector(CONF_IMAGE_MODE, this._imageModes)}
|
||||||
${this._renderStringInput(CONF_IMAGE_URL)}
|
${this._renderStringInput(CONF_IMAGE_URL)}
|
||||||
${this._renderNumberInput(CONF_IMAGE_REFRESH_SECONDS)}
|
${this._renderNumberInput(CONF_IMAGE_REFRESH_SECONDS)}
|
||||||
|
${this._renderSwitch(CONF_IMAGE_ZOOMABLE, this._defaults.image.zoomable)}
|
||||||
${this._renderMediaLayout(
|
${this._renderMediaLayout(
|
||||||
MENU_IMAGE_LAYOUT,
|
MENU_IMAGE_LAYOUT,
|
||||||
'config.image.layout',
|
'config.image.layout',
|
||||||
|
|||||||
Vendored
-9
@@ -1,9 +0,0 @@
|
|||||||
# go2rtc Player
|
|
||||||
|
|
||||||
**Link**: https://github.com/AlexxIT/go2rtc/tree/master/www
|
|
||||||
|
|
||||||
**Description**: A video player imported from go2rtc.
|
|
||||||
|
|
||||||
**Copyright**: [Alexey Khit](https://github.com/AlexxIT)
|
|
||||||
|
|
||||||
**License**: [MIT](https://github.com/AlexxIT/go2rtc/blob/master/LICENSE)
|
|
||||||
Vendored
-597
@@ -1,597 +0,0 @@
|
|||||||
/**
|
|
||||||
* Video player for go2rtc streaming application.
|
|
||||||
*
|
|
||||||
* All modern web technologies are supported in almost any browser except Apple Safari.
|
|
||||||
*
|
|
||||||
* Support:
|
|
||||||
* - RTCPeerConnection for Safari iOS 11.0+
|
|
||||||
* - IntersectionObserver for Safari iOS 12.2+
|
|
||||||
*
|
|
||||||
* Doesn't support:
|
|
||||||
* - MediaSource for Safari iOS all
|
|
||||||
* - Customized built-in elements (extends HTMLVideoElement) because all Safari
|
|
||||||
* - Public class fields because old Safari (before 14.0)
|
|
||||||
* - Autoplay for Safari
|
|
||||||
*/
|
|
||||||
export class VideoRTC extends HTMLElement {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.DISCONNECT_TIMEOUT = 5000;
|
|
||||||
this.RECONNECT_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
this.CODECS = [
|
|
||||||
"avc1.640029", // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
|
|
||||||
"avc1.64002A", // H.264 high 4.2 (Chromecast 3rd Gen)
|
|
||||||
"avc1.640033", // H.264 high 5.1 (Chromecast with Google TV)
|
|
||||||
"hvc1.1.6.L153.B0", // H.265 main 5.1 (Chromecast Ultra)
|
|
||||||
"mp4a.40.2", // AAC LC
|
|
||||||
"mp4a.40.5", // AAC HE
|
|
||||||
"opus", // OPUS Chrome
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [config] Supported modes (webrtc, mse, mp4, mjpeg).
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
this.mode = "webrtc,mse,mp4,mjpeg";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [config] Run stream when not displayed on the screen. Default `false`.
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
this.background = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [config] Run stream only when player in the viewport. Stop when user scroll out player.
|
|
||||||
* Value is percentage of visibility from `0` (not visible) to `1` (full visible).
|
|
||||||
* Default `0` - disable;
|
|
||||||
* @type {number}
|
|
||||||
*/
|
|
||||||
this.visibilityThreshold = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [config] Run stream only when browser page on the screen. Stop when user change browser
|
|
||||||
* tab or minimise browser windows.
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
this.visibilityCheck = true;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [config] WebRTC configuration
|
|
||||||
* @type {RTCConfiguration}
|
|
||||||
*/
|
|
||||||
this.pcConfig = {
|
|
||||||
iceServers: [{urls: 'stun:stun.l.google.com:19302'}],
|
|
||||||
sdpSemantics: 'unified-plan', // important for Chromecast 1
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED
|
|
||||||
* @type {number}
|
|
||||||
*/
|
|
||||||
this.wsState = WebSocket.CLOSED;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [info] WebRTC connection state.
|
|
||||||
* @type {number}
|
|
||||||
*/
|
|
||||||
this.pcState = WebSocket.CLOSED;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {HTMLVideoElement}
|
|
||||||
*/
|
|
||||||
this.video = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {WebSocket}
|
|
||||||
*/
|
|
||||||
this.ws = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {string|URL}
|
|
||||||
*/
|
|
||||||
this.wsURL = "";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {RTCPeerConnection}
|
|
||||||
*/
|
|
||||||
this.pc = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {number}
|
|
||||||
*/
|
|
||||||
this.connectTS = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
this.mseCodecs = "";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [internal] Disconnect TimeoutID.
|
|
||||||
* @type {number}
|
|
||||||
*/
|
|
||||||
this.disconnectTID = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [internal] Reconnect TimeoutID.
|
|
||||||
* @type {number}
|
|
||||||
*/
|
|
||||||
this.reconnectTID = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [internal] Handler for receiving Binary from WebSocket.
|
|
||||||
* @type {Function}
|
|
||||||
*/
|
|
||||||
this.ondata = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [internal] Handlers list for receiving JSON from WebSocket
|
|
||||||
* @type {Object.<string,Function>}}
|
|
||||||
*/
|
|
||||||
this.onmessage = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set video source (WebSocket URL). Support relative path.
|
|
||||||
* @param {string|URL} value
|
|
||||||
*/
|
|
||||||
set src(value) {
|
|
||||||
if (typeof value !== "string") value = value.toString();
|
|
||||||
if (value.startsWith("http")) {
|
|
||||||
value = "ws" + value.substring(4);
|
|
||||||
} else if (value.startsWith("/")) {
|
|
||||||
value = "ws" + location.origin.substring(4) + value;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.wsURL = value;
|
|
||||||
|
|
||||||
this.onconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play video. Support automute when autoplay blocked.
|
|
||||||
* https://developer.chrome.com/blog/autoplay/
|
|
||||||
*/
|
|
||||||
play() {
|
|
||||||
this.video.play().catch(er => {
|
|
||||||
if (er.name === "NotAllowedError" && !this.video.muted) {
|
|
||||||
this.video.muted = true;
|
|
||||||
this.video.play().catch(() => console.debug);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send message to server via WebSocket
|
|
||||||
* @param {Object} value
|
|
||||||
*/
|
|
||||||
send(value) {
|
|
||||||
if (this.ws) this.ws.send(JSON.stringify(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
codecs(type) {
|
|
||||||
const test = type === "mse"
|
|
||||||
? codec => MediaSource.isTypeSupported(`video/mp4; codecs="${codec}"`)
|
|
||||||
: codec => this.video.canPlayType(`video/mp4; codecs="${codec}"`);
|
|
||||||
return this.CODECS.filter(test).join();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `CustomElement`. Invoked each time the custom element is appended into a
|
|
||||||
* document-connected element.
|
|
||||||
*/
|
|
||||||
connectedCallback() {
|
|
||||||
if (this.disconnectTID) {
|
|
||||||
clearTimeout(this.disconnectTID);
|
|
||||||
this.disconnectTID = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// because video autopause on disconnected from DOM
|
|
||||||
if (this.video) {
|
|
||||||
const seek = this.video.seekable;
|
|
||||||
if (seek.length > 0) {
|
|
||||||
this.video.currentTime = seek.end(seek.length - 1);
|
|
||||||
}
|
|
||||||
this.play();
|
|
||||||
} else {
|
|
||||||
this.oninit();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.onconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `CustomElement`. Invoked each time the custom element is disconnected from the
|
|
||||||
* document's DOM.
|
|
||||||
*/
|
|
||||||
disconnectedCallback() {
|
|
||||||
if (this.background || this.disconnectTID) return;
|
|
||||||
if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
|
|
||||||
|
|
||||||
this.disconnectTID = setTimeout(() => {
|
|
||||||
if (this.reconnectTID) {
|
|
||||||
clearTimeout(this.reconnectTID);
|
|
||||||
this.reconnectTID = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.disconnectTID = 0;
|
|
||||||
|
|
||||||
this.ondisconnect();
|
|
||||||
}, this.DISCONNECT_TIMEOUT);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates child DOM elements. Called automatically once on `connectedCallback`.
|
|
||||||
*/
|
|
||||||
oninit() {
|
|
||||||
this.video = document.createElement("video");
|
|
||||||
this.video.controls = true;
|
|
||||||
this.video.playsInline = true;
|
|
||||||
this.video.preload = "auto";
|
|
||||||
|
|
||||||
this.video.style.display = "block"; // fix bottom margin 4px
|
|
||||||
this.video.style.width = "100%";
|
|
||||||
this.video.style.height = "100%"
|
|
||||||
|
|
||||||
this.appendChild(this.video);
|
|
||||||
|
|
||||||
if (this.background) return;
|
|
||||||
|
|
||||||
if ("hidden" in document && this.visibilityCheck) {
|
|
||||||
document.addEventListener("visibilitychange", () => {
|
|
||||||
if (document.hidden) {
|
|
||||||
this.disconnectedCallback();
|
|
||||||
} else if (this.isConnected) {
|
|
||||||
this.connectedCallback();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("IntersectionObserver" in window && this.visibilityThreshold) {
|
|
||||||
const observer = new IntersectionObserver(entries => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
if (!entry.isIntersecting) {
|
|
||||||
this.disconnectedCallback();
|
|
||||||
} else if (this.isConnected) {
|
|
||||||
this.connectedCallback();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, {threshold: this.visibilityThreshold});
|
|
||||||
observer.observe(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to WebSocket. Called automatically on `connectedCallback`.
|
|
||||||
* @return {boolean} true if the connection has started.
|
|
||||||
*/
|
|
||||||
onconnect() {
|
|
||||||
if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false;
|
|
||||||
|
|
||||||
// CLOSED or CONNECTING => CONNECTING
|
|
||||||
this.wsState = WebSocket.CONNECTING;
|
|
||||||
|
|
||||||
this.connectTS = Date.now();
|
|
||||||
|
|
||||||
this.ws = new WebSocket(this.wsURL);
|
|
||||||
this.ws.binaryType = "arraybuffer";
|
|
||||||
this.ws.addEventListener("open", ev => this.onopen(ev));
|
|
||||||
this.ws.addEventListener("close", ev => this.onclose(ev));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ondisconnect() {
|
|
||||||
this.wsState = WebSocket.CLOSED;
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.close();
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pcState = WebSocket.CLOSED;
|
|
||||||
if (this.pc) {
|
|
||||||
this.pc.close();
|
|
||||||
this.pc = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {Array.<string>} of modes (mse, webrtc, etc.)
|
|
||||||
*/
|
|
||||||
onopen() {
|
|
||||||
// CONNECTING => OPEN
|
|
||||||
this.wsState = WebSocket.OPEN;
|
|
||||||
|
|
||||||
this.ws.addEventListener("message", ev => {
|
|
||||||
if (typeof ev.data === "string") {
|
|
||||||
const msg = JSON.parse(ev.data);
|
|
||||||
for (const mode in this.onmessage) {
|
|
||||||
this.onmessage[mode](msg);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.ondata(ev.data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.ondata = null;
|
|
||||||
this.onmessage = {};
|
|
||||||
|
|
||||||
const modes = [];
|
|
||||||
|
|
||||||
if (this.mode.indexOf("mse") >= 0 && "MediaSource" in window) { // iPhone
|
|
||||||
modes.push("mse");
|
|
||||||
this.onmse();
|
|
||||||
} else if (this.mode.indexOf("mp4") >= 0) {
|
|
||||||
modes.push("mp4");
|
|
||||||
this.onmp4();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.mode.indexOf("webrtc") >= 0 && "RTCPeerConnection" in window) { // macOS Desktop app
|
|
||||||
modes.push("webrtc");
|
|
||||||
this.onwebrtc();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.mode.indexOf("mjpeg") >= 0) {
|
|
||||||
if (modes.length) {
|
|
||||||
this.onmessage["mjpeg"] = msg => {
|
|
||||||
if (msg.type !== "error" || msg.value.indexOf(modes[0]) !== 0) return;
|
|
||||||
this.onmjpeg();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
modes.push("mjpeg");
|
|
||||||
this.onmjpeg();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return modes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return {boolean} true if reconnection has started.
|
|
||||||
*/
|
|
||||||
onclose() {
|
|
||||||
if (this.wsState === WebSocket.CLOSED) return false;
|
|
||||||
|
|
||||||
// CONNECTING, OPEN => CONNECTING
|
|
||||||
this.wsState = WebSocket.CONNECTING;
|
|
||||||
this.ws = null;
|
|
||||||
|
|
||||||
// reconnect no more than once every X seconds
|
|
||||||
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
|
|
||||||
|
|
||||||
this.reconnectTID = setTimeout(() => {
|
|
||||||
this.reconnectTID = 0;
|
|
||||||
this.onconnect();
|
|
||||||
}, delay);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
onmse() {
|
|
||||||
const ms = new MediaSource();
|
|
||||||
ms.addEventListener("sourceopen", () => {
|
|
||||||
URL.revokeObjectURL(this.video.src);
|
|
||||||
this.send({type: "mse", value: this.codecs("mse")});
|
|
||||||
}, {once: true});
|
|
||||||
|
|
||||||
this.video.src = URL.createObjectURL(ms);
|
|
||||||
this.video.srcObject = null;
|
|
||||||
this.play();
|
|
||||||
|
|
||||||
this.mseCodecs = "";
|
|
||||||
|
|
||||||
this.onmessage["mse"] = msg => {
|
|
||||||
if (msg.type !== "mse") return;
|
|
||||||
|
|
||||||
this.mseCodecs = msg.value;
|
|
||||||
|
|
||||||
const sb = ms.addSourceBuffer(msg.value);
|
|
||||||
sb.mode = "segments"; // segments or sequence
|
|
||||||
sb.addEventListener("updateend", () => {
|
|
||||||
if (sb.updating) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (bufLen > 0) {
|
|
||||||
const data = buf.slice(0, bufLen);
|
|
||||||
bufLen = 0;
|
|
||||||
sb.appendBuffer(data);
|
|
||||||
} else if (sb.buffered && sb.buffered.length) {
|
|
||||||
const end = sb.buffered.end(sb.buffered.length - 1) - 15;
|
|
||||||
const start = sb.buffered.start(0);
|
|
||||||
if (end > start) {
|
|
||||||
sb.remove(start, end);
|
|
||||||
ms.setLiveSeekableRange(end, end + 15);
|
|
||||||
}
|
|
||||||
// console.debug("VideoRTC.buffered", start, end);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// console.debug(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const buf = new Uint8Array(2 * 1024 * 1024);
|
|
||||||
let bufLen = 0;
|
|
||||||
|
|
||||||
this.ondata = data => {
|
|
||||||
if (sb.updating || bufLen > 0) {
|
|
||||||
const b = new Uint8Array(data);
|
|
||||||
buf.set(b, bufLen);
|
|
||||||
bufLen += b.byteLength;
|
|
||||||
// console.debug("VideoRTC.buffer", b.byteLength, bufLen);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
sb.appendBuffer(data);
|
|
||||||
} catch (e) {
|
|
||||||
// console.debug(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onwebrtc() {
|
|
||||||
const pc = new RTCPeerConnection(this.pcConfig);
|
|
||||||
|
|
||||||
/** @type {HTMLVideoElement} */
|
|
||||||
const video2 = document.createElement("video");
|
|
||||||
video2.addEventListener("loadeddata", ev => this.onpcvideo(ev), {once: true});
|
|
||||||
|
|
||||||
pc.addEventListener("icecandidate", ev => {
|
|
||||||
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : "";
|
|
||||||
this.send({type: "webrtc/candidate", value: candidate});
|
|
||||||
});
|
|
||||||
|
|
||||||
pc.addEventListener("track", ev => {
|
|
||||||
// when stream already init
|
|
||||||
if (video2.srcObject !== null) return;
|
|
||||||
|
|
||||||
// when audio track not exist in Chrome
|
|
||||||
if (ev.streams.length === 0) return;
|
|
||||||
|
|
||||||
// when audio track not exist in Firefox
|
|
||||||
if (ev.streams[0].id[0] === '{') return;
|
|
||||||
|
|
||||||
video2.srcObject = ev.streams[0];
|
|
||||||
});
|
|
||||||
|
|
||||||
pc.addEventListener("connectionstatechange", () => {
|
|
||||||
if (pc.connectionState === "failed" || pc.connectionState === "disconnected") {
|
|
||||||
pc.close(); // stop next events
|
|
||||||
|
|
||||||
this.pcState = WebSocket.CLOSED;
|
|
||||||
this.pc = null;
|
|
||||||
|
|
||||||
this.onconnect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.onmessage["webrtc"] = msg => {
|
|
||||||
switch (msg.type) {
|
|
||||||
case "webrtc/candidate":
|
|
||||||
pc.addIceCandidate({
|
|
||||||
candidate: msg.value,
|
|
||||||
sdpMid: "0"
|
|
||||||
}).catch(() => console.debug);
|
|
||||||
break;
|
|
||||||
case "webrtc/answer":
|
|
||||||
pc.setRemoteDescription({
|
|
||||||
type: "answer",
|
|
||||||
sdp: msg.value
|
|
||||||
}).catch(() => console.debug);
|
|
||||||
break;
|
|
||||||
case "error":
|
|
||||||
if (msg.value.indexOf("webrtc/offer") < 0) return;
|
|
||||||
pc.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Safari doesn't support "offerToReceiveVideo"
|
|
||||||
pc.addTransceiver("video", {direction: "recvonly"});
|
|
||||||
pc.addTransceiver("audio", {direction: "recvonly"});
|
|
||||||
|
|
||||||
pc.createOffer().then(offer => {
|
|
||||||
pc.setLocalDescription(offer).then(() => {
|
|
||||||
this.send({type: "webrtc/offer", value: offer.sdp});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.pcState = WebSocket.CONNECTING;
|
|
||||||
this.pc = pc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param ev {Event}
|
|
||||||
*/
|
|
||||||
onpcvideo(ev) {
|
|
||||||
if (!this.pc) return;
|
|
||||||
|
|
||||||
/** @type {HTMLVideoElement} */
|
|
||||||
const video2 = ev.target;
|
|
||||||
const state = this.pc.connectionState;
|
|
||||||
|
|
||||||
// Firefox doesn't support pc.connectionState
|
|
||||||
if (state === "connected" || state === "connecting" || !state) {
|
|
||||||
// Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
|
|
||||||
let rtcPriority = 0, msePriority = 0;
|
|
||||||
|
|
||||||
/** @type {MediaStream} */
|
|
||||||
const ms = video2.srcObject;
|
|
||||||
if (ms.getVideoTracks().length > 0) rtcPriority += 0x220;
|
|
||||||
if (ms.getAudioTracks().length > 0) rtcPriority += 0x102;
|
|
||||||
|
|
||||||
if (this.mseCodecs.indexOf("hvc1.") >= 0) msePriority += 0x230;
|
|
||||||
if (this.mseCodecs.indexOf("avc1.") >= 0) msePriority += 0x210;
|
|
||||||
if (this.mseCodecs.indexOf("mp4a.") >= 0) msePriority += 0x101;
|
|
||||||
|
|
||||||
if (rtcPriority >= msePriority) {
|
|
||||||
this.video.srcObject = ms;
|
|
||||||
this.play();
|
|
||||||
|
|
||||||
this.pcState = WebSocket.OPEN;
|
|
||||||
|
|
||||||
this.wsState = WebSocket.CLOSED;
|
|
||||||
this.ws.close();
|
|
||||||
this.ws = null;
|
|
||||||
} else {
|
|
||||||
this.pcState = WebSocket.CLOSED;
|
|
||||||
this.pc.close();
|
|
||||||
this.pc = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
video2.srcObject = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
onmjpeg() {
|
|
||||||
this.ondata = data => {
|
|
||||||
this.video.controls = false;
|
|
||||||
this.video.poster = "data:image/jpeg;base64," + VideoRTC.btoa(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.send({type: "mjpeg"});
|
|
||||||
}
|
|
||||||
|
|
||||||
onmp4() {
|
|
||||||
/** @type {HTMLCanvasElement} **/
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
/** @type {CanvasRenderingContext2D} */
|
|
||||||
let context;
|
|
||||||
|
|
||||||
/** @type {HTMLVideoElement} */
|
|
||||||
const video2 = document.createElement("video");
|
|
||||||
video2.autoplay = true;
|
|
||||||
video2.playsInline = true;
|
|
||||||
video2.muted = true;
|
|
||||||
|
|
||||||
video2.addEventListener("loadeddata", ev => {
|
|
||||||
if (!context) {
|
|
||||||
canvas.width = video2.videoWidth;
|
|
||||||
canvas.height = video2.videoHeight;
|
|
||||||
context = canvas.getContext('2d');
|
|
||||||
}
|
|
||||||
|
|
||||||
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
this.video.controls = false;
|
|
||||||
this.video.poster = canvas.toDataURL("image/jpeg");
|
|
||||||
});
|
|
||||||
|
|
||||||
this.ondata = data => {
|
|
||||||
video2.src = "data:video/mp4;base64," + VideoRTC.btoa(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.send({type: "mp4", value: this.codecs("mp4")});
|
|
||||||
}
|
|
||||||
|
|
||||||
static btoa(buffer) {
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
const len = bytes.byteLength;
|
|
||||||
let binary = "";
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
binary += String.fromCharCode(bytes[i]);
|
|
||||||
}
|
|
||||||
return window.btoa(binary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -84,6 +84,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"controls": {
|
"controls": {
|
||||||
|
"builtin": "Built-in video controls",
|
||||||
"filter": {
|
"filter": {
|
||||||
"editor_label": "Media Filter",
|
"editor_label": "Media Filter",
|
||||||
"mode": "Filter mode",
|
"mode": "Filter mode",
|
||||||
@@ -204,7 +205,8 @@
|
|||||||
"url": "Arbitrary image specified by URL"
|
"url": "Arbitrary image specified by URL"
|
||||||
},
|
},
|
||||||
"refresh_seconds": "Number of seconds after which to refresh (0=never)",
|
"refresh_seconds": "Number of seconds after which to refresh (0=never)",
|
||||||
"url": "Static image URL for image view"
|
"url": "Static image URL for image view",
|
||||||
|
"zoomable": "Image can be zoomed/panned"
|
||||||
},
|
},
|
||||||
"live": {
|
"live": {
|
||||||
"auto_mute": "Automatically mute live cameras",
|
"auto_mute": "Automatically mute live cameras",
|
||||||
@@ -218,9 +220,16 @@
|
|||||||
"layout": "Live Layout",
|
"layout": "Live Layout",
|
||||||
"lazy_load": "Live cameras are lazily loaded",
|
"lazy_load": "Live cameras are lazily loaded",
|
||||||
"lazy_unload": "Live cameras are lazily unloaded",
|
"lazy_unload": "Live cameras are lazily unloaded",
|
||||||
|
"microphone": {
|
||||||
|
"always_connected": "Always keep the microphone connected",
|
||||||
|
"disconnect_seconds": "Seconds after which to disconnect microphone (0=never)",
|
||||||
|
"editor_label": "Microphone",
|
||||||
|
"enabled": "Microphone enabled"
|
||||||
|
},
|
||||||
"preload": "Preload live view in the background",
|
"preload": "Preload live view in the background",
|
||||||
"show_image_during_load": "Show still image while the live stream is loading",
|
"show_image_during_load": "Show still image while the live stream is loading",
|
||||||
"transition_effect": "Live camera transition effect"
|
"transition_effect": "Live camera transition effect",
|
||||||
|
"zoomable": "Live cameras can be zoomed/panned"
|
||||||
},
|
},
|
||||||
"media_viewer": {
|
"media_viewer": {
|
||||||
"auto_mute": "Automatically mute media",
|
"auto_mute": "Automatically mute media",
|
||||||
@@ -238,7 +247,8 @@
|
|||||||
"transition_effects": {
|
"transition_effects": {
|
||||||
"none": "No transition",
|
"none": "No transition",
|
||||||
"slide": "Slide transition"
|
"slide": "Slide transition"
|
||||||
}
|
},
|
||||||
|
"zoomable": "Media Viewer can be zoomed/panned"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"alignment": "Menu alignment",
|
"alignment": "Menu alignment",
|
||||||
@@ -267,11 +277,19 @@
|
|||||||
"image": "Image",
|
"image": "Image",
|
||||||
"live": "Live",
|
"live": "Live",
|
||||||
"media_player": "Send to media player",
|
"media_player": "Send to media player",
|
||||||
|
"microphone": "Microphone",
|
||||||
|
"mute": "Mute / Unmute",
|
||||||
|
"play": "Play / Pause",
|
||||||
"priority": "Priority",
|
"priority": "Priority",
|
||||||
"recordings": "Recordings",
|
"recordings": "Recordings",
|
||||||
"snapshots": "Snapshots",
|
"snapshots": "Snapshots",
|
||||||
"substreams": "Substream(s)",
|
"substreams": "Substream(s)",
|
||||||
"timeline": "Timeline"
|
"timeline": "Timeline",
|
||||||
|
"type": "Button type",
|
||||||
|
"types": {
|
||||||
|
"momentary": "Momentary",
|
||||||
|
"toggle": "Toggle"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"position": "Menu position",
|
"position": "Menu position",
|
||||||
"positions": {
|
"positions": {
|
||||||
@@ -417,6 +435,7 @@
|
|||||||
"no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera",
|
"no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera",
|
||||||
"reconnecting": "Reconnecting",
|
"reconnecting": "Reconnecting",
|
||||||
"timeline_no_cameras": "No Frigate cameras to show in timeline",
|
"timeline_no_cameras": "No Frigate cameras to show in timeline",
|
||||||
|
"too_many_automations": "Too many nested automation calls, please check your configuration for loops",
|
||||||
"troubleshooting": "Check troubleshooting",
|
"troubleshooting": "Check troubleshooting",
|
||||||
"unknown": "Unknown error",
|
"unknown": "Unknown error",
|
||||||
"upgrade_available": "An automated card configuration upgrade is available, please visit the visual card editor",
|
"upgrade_available": "An automated card configuration upgrade is available, please visit the visual card editor",
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"controls": {
|
"controls": {
|
||||||
|
"builtin": "",
|
||||||
"filter": {
|
"filter": {
|
||||||
"editor_label": "Filtro multimediale",
|
"editor_label": "Filtro multimediale",
|
||||||
"mode": "Modalità filtro",
|
"mode": "Modalità filtro",
|
||||||
@@ -204,7 +205,8 @@
|
|||||||
"url": "Immagine arbitraria specificata dall'URL"
|
"url": "Immagine arbitraria specificata dall'URL"
|
||||||
},
|
},
|
||||||
"refresh_seconds": "Numero di secondi dopo i quali aggiornare (0 = mai)",
|
"refresh_seconds": "Numero di secondi dopo i quali aggiornare (0 = mai)",
|
||||||
"url": "URL di immagine statica per la vista dell'immagine"
|
"url": "URL di immagine statica per la vista dell'immagine",
|
||||||
|
"zoomable": ""
|
||||||
},
|
},
|
||||||
"live": {
|
"live": {
|
||||||
"auto_mute": "Muta automaticamente le telecamere in diretta",
|
"auto_mute": "Muta automaticamente le telecamere in diretta",
|
||||||
@@ -218,9 +220,16 @@
|
|||||||
"layout": "Disposizione dal vivo",
|
"layout": "Disposizione dal vivo",
|
||||||
"lazy_load": "Le telecamere dal vivo sono pigramente cariche",
|
"lazy_load": "Le telecamere dal vivo sono pigramente cariche",
|
||||||
"lazy_unload": "Le telecamere dal vivo sono pigramente non caricate",
|
"lazy_unload": "Le telecamere dal vivo sono pigramente non caricate",
|
||||||
|
"microphone": {
|
||||||
|
"always_connected": "",
|
||||||
|
"disconnect_seconds": "",
|
||||||
|
"editor_label": "",
|
||||||
|
"enabled": ""
|
||||||
|
},
|
||||||
"preload": "Precarica Live View in background",
|
"preload": "Precarica Live View in background",
|
||||||
"show_image_during_load": "Mostra un'immagine fissa durante il caricamento del live streaming",
|
"show_image_during_load": "Mostra un'immagine fissa durante il caricamento del live streaming",
|
||||||
"transition_effect": "Effetto di transizione della telecamera dal vivo"
|
"transition_effect": "Effetto di transizione della telecamera dal vivo",
|
||||||
|
"zoomable": ""
|
||||||
},
|
},
|
||||||
"media_viewer": {
|
"media_viewer": {
|
||||||
"auto_mute": "Muta automaticamente i media",
|
"auto_mute": "Muta automaticamente i media",
|
||||||
@@ -237,7 +246,8 @@
|
|||||||
"transition_effects": {
|
"transition_effects": {
|
||||||
"none": "Nessuna transizione",
|
"none": "Nessuna transizione",
|
||||||
"slide": "Transizione diapositiva"
|
"slide": "Transizione diapositiva"
|
||||||
}
|
},
|
||||||
|
"zoomable": ""
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"alignment": "Allineamento dei menu",
|
"alignment": "Allineamento dei menu",
|
||||||
@@ -266,10 +276,17 @@
|
|||||||
"image": "Immagine",
|
"image": "Immagine",
|
||||||
"live": "Abitare",
|
"live": "Abitare",
|
||||||
"media_player": "Invia a Media Player",
|
"media_player": "Invia a Media Player",
|
||||||
|
"mute": "",
|
||||||
|
"play": "",
|
||||||
"priority": "Priorità",
|
"priority": "Priorità",
|
||||||
"snapshots": "Istantanee",
|
"snapshots": "Istantanee",
|
||||||
"substreams": "Flusso/i secondario/i",
|
"substreams": "Flusso/i secondario/i",
|
||||||
"timeline": "Timeline"
|
"timeline": "Timeline",
|
||||||
|
"type": "",
|
||||||
|
"types": {
|
||||||
|
"momentary": "",
|
||||||
|
"toggle": ""
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"position": "Posizione del menu",
|
"position": "Posizione del menu",
|
||||||
"positions": {
|
"positions": {
|
||||||
@@ -411,6 +428,7 @@
|
|||||||
"reconnecting": "Riconnessione",
|
"reconnecting": "Riconnessione",
|
||||||
"timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline",
|
"timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline",
|
||||||
"troubleshooting": "Controllare la risoluzione dei problemi",
|
"troubleshooting": "Controllare la risoluzione dei problemi",
|
||||||
|
"too_many_automations": "",
|
||||||
"unknown": "Errore sconosciuto",
|
"unknown": "Errore sconosciuto",
|
||||||
"upgrade_available": "È disponibile un aggiornamento di configurazione della scheda automatizzato, visitare l'editor di schede visive",
|
"upgrade_available": "È disponibile un aggiornamento di configurazione della scheda automatizzato, visitare l'editor di schede visive",
|
||||||
"webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore",
|
"webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore",
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"controls": {
|
"controls": {
|
||||||
|
"builtin": "",
|
||||||
"filter": {
|
"filter": {
|
||||||
"editor_label": "Filtro de Mídia",
|
"editor_label": "Filtro de Mídia",
|
||||||
"mode": "Modo do filtro",
|
"mode": "Modo do filtro",
|
||||||
@@ -204,7 +205,8 @@
|
|||||||
"url": "Imagem arbitrária especificada por URL"
|
"url": "Imagem arbitrária especificada por URL"
|
||||||
},
|
},
|
||||||
"refresh_seconds": "Número de segundos após o qual atualizar (0 = nunca)",
|
"refresh_seconds": "Número de segundos após o qual atualizar (0 = nunca)",
|
||||||
"url": "Imagem arbitrária especificada por URL"
|
"url": "Imagem arbitrária especificada por URL",
|
||||||
|
"zoomable": ""
|
||||||
},
|
},
|
||||||
"live": {
|
"live": {
|
||||||
"auto_mute": "Silenciar câmeras ao vivo automaticamente",
|
"auto_mute": "Silenciar câmeras ao vivo automaticamente",
|
||||||
@@ -218,9 +220,16 @@
|
|||||||
"layout": "Layout dinâmico",
|
"layout": "Layout dinâmico",
|
||||||
"lazy_load": "As câmeras ao vivo são carregadas lentamente",
|
"lazy_load": "As câmeras ao vivo são carregadas lentamente",
|
||||||
"lazy_unload": "As câmeras ao vivo são descarregadas preguiçosamente",
|
"lazy_unload": "As câmeras ao vivo são descarregadas preguiçosamente",
|
||||||
|
"microphone": {
|
||||||
|
"always_connected": "",
|
||||||
|
"disconnect_seconds": "",
|
||||||
|
"editor_label": "",
|
||||||
|
"enabled": ""
|
||||||
|
},
|
||||||
"preload": "Pré-carregar a visualização ao vivo em segundo plano",
|
"preload": "Pré-carregar a visualização ao vivo em segundo plano",
|
||||||
"show_image_during_load": "Mostrar imagem estática enquanto a transmissão ao vivo está carregando",
|
"show_image_during_load": "Mostrar imagem estática enquanto a transmissão ao vivo está carregando",
|
||||||
"transition_effect": "Efeito de transição de câmera ao vivo"
|
"transition_effect": "Efeito de transição de câmera ao vivo",
|
||||||
|
"zoomable": ""
|
||||||
},
|
},
|
||||||
"media_viewer": {
|
"media_viewer": {
|
||||||
"auto_mute": "Silenciar mídia automaticamente",
|
"auto_mute": "Silenciar mídia automaticamente",
|
||||||
@@ -238,7 +247,8 @@
|
|||||||
"transition_effects": {
|
"transition_effects": {
|
||||||
"none": "Sem transição",
|
"none": "Sem transição",
|
||||||
"slide": "Transição de slides"
|
"slide": "Transição de slides"
|
||||||
}
|
},
|
||||||
|
"zoomable": ""
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"alignment": "Alinhamento do menu",
|
"alignment": "Alinhamento do menu",
|
||||||
@@ -267,11 +277,18 @@
|
|||||||
"image": "Imagem",
|
"image": "Imagem",
|
||||||
"live": "Ao vivo",
|
"live": "Ao vivo",
|
||||||
"media_player": "Enviar para o reprodutor de mídia",
|
"media_player": "Enviar para o reprodutor de mídia",
|
||||||
|
"mute": "",
|
||||||
|
"play": "",
|
||||||
"priority": "Prioridade",
|
"priority": "Prioridade",
|
||||||
"recordings": "Gravações",
|
"recordings": "Gravações",
|
||||||
"snapshots": "Instantâneos",
|
"snapshots": "Instantâneos",
|
||||||
"substreams": "Substream(s)",
|
"substreams": "Substream(s)",
|
||||||
"timeline": "Linha do tempo"
|
"timeline": "Linha do tempo",
|
||||||
|
"type": "",
|
||||||
|
"types": {
|
||||||
|
"momentary": "",
|
||||||
|
"toggle": ""
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"position": "Posição do menu",
|
"position": "Posição do menu",
|
||||||
"positions": {
|
"positions": {
|
||||||
@@ -417,6 +434,7 @@
|
|||||||
"no_visible_cameras": "Nenhuma câmera visível encontrada, você deve configurar pelo menos uma câmera não oculta",
|
"no_visible_cameras": "Nenhuma câmera visível encontrada, você deve configurar pelo menos uma câmera não oculta",
|
||||||
"reconnecting": "Reconectando",
|
"reconnecting": "Reconectando",
|
||||||
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
|
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
|
||||||
|
"too_many_automations": "",
|
||||||
"troubleshooting": "Verifique a solução de problemas",
|
"troubleshooting": "Verifique a solução de problemas",
|
||||||
"unknown": "Erro desconhecido",
|
"unknown": "Erro desconhecido",
|
||||||
"upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
|
"upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
|
||||||
|
|||||||
@@ -0,0 +1,488 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"frigate_card": "Cartão Frigate",
|
||||||
|
"frigate_card_description": "Um cartão da Lovelace para usar com Frigate",
|
||||||
|
"live": "Ao Vivo",
|
||||||
|
"no_media": "Sem média",
|
||||||
|
"recordings": "Gravações",
|
||||||
|
"version": "Versão"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"cameras": {
|
||||||
|
"camera_entity": "Entidade da Câmera",
|
||||||
|
"dependencies": {
|
||||||
|
"all_cameras": "Mostrar eventos para todas as câmeras nesta câmera",
|
||||||
|
"cameras": "Mostrar eventos para câmeras específicas nesta câmera",
|
||||||
|
"editor_label": "Opções de dependência"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"editor_label": "Editor de etiquetas"
|
||||||
|
},
|
||||||
|
"frigate": {
|
||||||
|
"camera_name": "Nome da câmera do Frigate (detectado automaticamente pela entidade)",
|
||||||
|
"client_id": "ID do cliente do Frigate (para >1 servidor Frigate)",
|
||||||
|
"editor_label": "Opções do Frigate",
|
||||||
|
"labels": "Etiquetas",
|
||||||
|
"url": "URL do servidor Frigate",
|
||||||
|
"zones": "Zonas"
|
||||||
|
},
|
||||||
|
"go2rtc": {
|
||||||
|
"editor_label": "Editor de etiquetas",
|
||||||
|
"modes": {
|
||||||
|
"editor_label": "Editor de etiquetas",
|
||||||
|
"mjpeg": "Mjpeg",
|
||||||
|
"mp4": "Mp4",
|
||||||
|
"mse": "Mse",
|
||||||
|
"webrtc": "Webrtc"
|
||||||
|
},
|
||||||
|
"stream": "Stream"
|
||||||
|
},
|
||||||
|
"hide": "Esconder",
|
||||||
|
"icon": "Ícone para esta câmera (detectado automaticamente pela entidade)",
|
||||||
|
"id": "ID exclusivo para esta câmera nesse cartão",
|
||||||
|
"image": {
|
||||||
|
"editor_label": "Editor etiquetas",
|
||||||
|
"refresh_seconds": "Atualizar em segundos",
|
||||||
|
"url": "Link"
|
||||||
|
},
|
||||||
|
"live_provider": "Fonte de visualização ao vivo para esta câmera",
|
||||||
|
"live_provider_options": {
|
||||||
|
"editor_label": "Editor de etiquetas"
|
||||||
|
},
|
||||||
|
"live_providers": {
|
||||||
|
"auto": "Automatico",
|
||||||
|
"go2rtc": "Go2rtc",
|
||||||
|
"ha": "Ha",
|
||||||
|
"image": "Imagem",
|
||||||
|
"jsmpeg": "JSMpeg",
|
||||||
|
"webrtc-card": "Cartão WebRTC (de @AlexxIT)"
|
||||||
|
},
|
||||||
|
"motioneye": {
|
||||||
|
"editor_label": "Directoria pre-definido",
|
||||||
|
"images": {
|
||||||
|
"directory_pattern": "Directoria pre-definido",
|
||||||
|
"file_pattern": "Ficheiro pre-definido"
|
||||||
|
},
|
||||||
|
"movies": {
|
||||||
|
"directory_pattern": "Directoria pre-definida",
|
||||||
|
"file_pattern": "Ficheiro pre-definido"
|
||||||
|
},
|
||||||
|
"url": "Link"
|
||||||
|
},
|
||||||
|
"title": "Título para esta câmera (detectado automaticamente pela entidade)",
|
||||||
|
"triggers": {
|
||||||
|
"editor_label": "Opções de activação",
|
||||||
|
"entities": "Activar a partir de outras entidades",
|
||||||
|
"motion": "Activar detectando automaticamente o sensor de movimento",
|
||||||
|
"occupancy": "Activar detectando automaticamente o sensor de ocupação"
|
||||||
|
},
|
||||||
|
"webrtc_card": {
|
||||||
|
"editor_label": "Opções do cartão WebRTC",
|
||||||
|
"entity": "Entidade de câmera de cartão WebRTC (não é uma câmera Frigate)",
|
||||||
|
"url": "URL da câmera do cartão WebRTC"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"controls": {
|
||||||
|
"builtin": "",
|
||||||
|
"filter": {
|
||||||
|
"editor_label": "Editor de titulos",
|
||||||
|
"mode": "Modo",
|
||||||
|
"modes": {
|
||||||
|
"left": "Esquerda",
|
||||||
|
"none": "Nenhum",
|
||||||
|
"right": "Direita"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"next_previous": {
|
||||||
|
"editor_label": "Editor de titulos",
|
||||||
|
"size": "Tamanho de controle próximo e anterior",
|
||||||
|
"style": "Estilo do controle próximo e anterior",
|
||||||
|
"styles": {
|
||||||
|
"chevrons": "Setas",
|
||||||
|
"icons": "Ícones",
|
||||||
|
"none": "Nenhum",
|
||||||
|
"thumbnails": "Miniaturas"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thumbnails": {
|
||||||
|
"editor_label": "Editor de titulos",
|
||||||
|
"media": "Mostrar miniaturas de clipes ou snapshots",
|
||||||
|
"medias": {
|
||||||
|
"clips": "Miniaturas de clipes",
|
||||||
|
"snapshots": "Miniaturas de Snapshots"
|
||||||
|
},
|
||||||
|
"mode": "Modos",
|
||||||
|
"modes": {
|
||||||
|
"above": "Miniaturas acima da mídia",
|
||||||
|
"below": "Miniaturas abaixo da mídia",
|
||||||
|
"left": "Miniaturas em uma gaveta à esquerda",
|
||||||
|
"none": "Sem miniaturas",
|
||||||
|
"right": "Miniaturas em uma gaveta à direita"
|
||||||
|
},
|
||||||
|
"show_details": "Mostrar detalhes",
|
||||||
|
"show_download_control": "Mostrar o botão de download",
|
||||||
|
"show_favorite_control": "Mostrar o botão de favorito nas miniaturas",
|
||||||
|
"show_timeline_control": "Mostrar a linha do tempo nas miniaturas",
|
||||||
|
"size": "Tamanho das miniaturas em pixels"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"editor_label": "Controles de linha do tempo",
|
||||||
|
"mode": "Modo",
|
||||||
|
"modes": {
|
||||||
|
"above": "Por cima",
|
||||||
|
"below": "Abaixo",
|
||||||
|
"none": "Nenhum"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"duration_seconds": "Segundos para exibir o pop-up (0 = para sempre)",
|
||||||
|
"editor_label": "Editor de titulos",
|
||||||
|
"mode": "Modo de exibição de título de mídia",
|
||||||
|
"modes": {
|
||||||
|
"none": "Sem exibição de título",
|
||||||
|
"popup-bottom-left": "Pop-up no canto inferior esquerdo",
|
||||||
|
"popup-bottom-right": "Pop-up no canto inferior direito",
|
||||||
|
"popup-top-left": "Pop-up no canto superior esquerdo",
|
||||||
|
"popup-top-right": "Pop-up no canto superior direito"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"fit": "Fit",
|
||||||
|
"fits": {
|
||||||
|
"contain": "Conter",
|
||||||
|
"cover": "Tapar",
|
||||||
|
"fill": "Preencher"
|
||||||
|
},
|
||||||
|
"position": {
|
||||||
|
"x": "Percentagem da localização horizontal",
|
||||||
|
"y": "Percentagem da localização vertical"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"media_action_conditions": {
|
||||||
|
"all": "Todas as oportunidades",
|
||||||
|
"hidden": "Ao ocultar o navegador/aba",
|
||||||
|
"never": "Nunca",
|
||||||
|
"selected": "Ao selecionar",
|
||||||
|
"unselected": "Ao desselecionar",
|
||||||
|
"visible": "Ao mostrar o navegador/aba"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"clustering_threshold": "A contagem de eventos nos quais eles são agrupados (0 = sem agrupamento)",
|
||||||
|
"media": "A mídia que a linha do tempo exibe",
|
||||||
|
"medias": {
|
||||||
|
"all": "Todos os tipos de mídia",
|
||||||
|
"clips": "Clipes",
|
||||||
|
"snapshots": "Instantâneos"
|
||||||
|
},
|
||||||
|
"show_recordings": "Mostrar gravações",
|
||||||
|
"window_seconds": "A duração padrão da visualização da linha do tempo em segundos"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dimensions": {
|
||||||
|
"aspect_ratio": "Proporção padrão (e.g. '16:9')",
|
||||||
|
"aspect_ratio_mode": "Modo de proporção",
|
||||||
|
"aspect_ratio_modes": {
|
||||||
|
"dynamic": "A proporção se ajusta à mídia",
|
||||||
|
"static": "Proporção estática",
|
||||||
|
"unconstrained": "Proporção irrestrita"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"layout": "Layout",
|
||||||
|
"mode": "Modo de visualização de imagem",
|
||||||
|
"modes": {
|
||||||
|
"camera": "Instantâneo da câmera do Home Assistant, da entidade de câmera",
|
||||||
|
"screensaver": "Logo Frigate embutido",
|
||||||
|
"url": "Imagem arbitrária especificada por URL"
|
||||||
|
},
|
||||||
|
"refresh_seconds": "Número de segundos após o qual atualizar (0 = nunca)",
|
||||||
|
"url": "Imagem arbitrária especificada por URL",
|
||||||
|
"zoomable": ""
|
||||||
|
},
|
||||||
|
"live": {
|
||||||
|
"auto_mute": "Silenciar câmeras ao vivo automaticamente",
|
||||||
|
"auto_pause": "Parar câmeras ao vivo automaticamente",
|
||||||
|
"auto_play": "Reproduzir câmeras ao vivo automaticamente",
|
||||||
|
"auto_unmute": "Ativar automaticamente o som das câmeras ao vivo",
|
||||||
|
"controls": {
|
||||||
|
"editor_label": "Controles da visualização ao vivo"
|
||||||
|
},
|
||||||
|
"draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada",
|
||||||
|
"layout": "layout",
|
||||||
|
"lazy_load": "As câmeras ao vivo são carregadas lentamente",
|
||||||
|
"lazy_unload": "As câmeras ao vivo são descarregadas preguiçosamente",
|
||||||
|
"microphone": {
|
||||||
|
"always_connected": "",
|
||||||
|
"disconnect_seconds": "",
|
||||||
|
"editor_label": "",
|
||||||
|
"enabled": ""
|
||||||
|
},
|
||||||
|
"preload": "Pré-carregar a visualização ao vivo em segundo plano",
|
||||||
|
"show_image_during_load": "Mostar imagem durante o carregamento",
|
||||||
|
"transition_effect": "Efeito de transição de câmera ao vivo",
|
||||||
|
"zoomable": ""
|
||||||
|
},
|
||||||
|
"media_viewer": {
|
||||||
|
"auto_mute": "Silenciar mídia automaticamente",
|
||||||
|
"auto_pause": "Parar mídia automaticamente",
|
||||||
|
"auto_play": "Reproduzir mídia automaticamente",
|
||||||
|
"auto_unmute": "Ativar mídia automaticamente",
|
||||||
|
"controls": {
|
||||||
|
"editor_label": "Controles do visualizador de mídia"
|
||||||
|
},
|
||||||
|
"draggable": "Visualizador de eventos pode ser arrastado/deslizado",
|
||||||
|
"layout": "Layout",
|
||||||
|
"lazy_load": "A mídia do Visualizador de eventos é carregada lentamente no carrossel",
|
||||||
|
"transition_effect": "Efeito de transição do Visualizador de eventos",
|
||||||
|
"transition_effects": {
|
||||||
|
"none": "Sem transição",
|
||||||
|
"slide": "Transição de slides"
|
||||||
|
},
|
||||||
|
"zoomable": ""
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"alignment": "Alinhamento do menu",
|
||||||
|
"alignments": {
|
||||||
|
"bottom": "Alinhado à parte inferior",
|
||||||
|
"left": "Alinhado à esquerda",
|
||||||
|
"right": "Alinhado à direita",
|
||||||
|
"top": "Alinhado ao topo"
|
||||||
|
},
|
||||||
|
"button_size": "Tamanho do botão de menu (e.g. '40px')",
|
||||||
|
"buttons": {
|
||||||
|
"alignment": "Alinhamento do botão",
|
||||||
|
"alignments": {
|
||||||
|
"matching": "Mesmo alinhamento do menu",
|
||||||
|
"opposing": "Opor-se ao alinhamento do menu"
|
||||||
|
},
|
||||||
|
"camera_ui": "Camera",
|
||||||
|
"cameras": "Selecionar câmera",
|
||||||
|
"clips": "Clipes",
|
||||||
|
"download": "Descarregar mídia do evento",
|
||||||
|
"enabled": "Botão ativado",
|
||||||
|
"expand": "Expandir",
|
||||||
|
"frigate": "Frigate menu / Visualização padrão",
|
||||||
|
"fullscreen": "Tela cheia",
|
||||||
|
"icon": "Ícone",
|
||||||
|
"image": "Imagem",
|
||||||
|
"live": "Ao vivo",
|
||||||
|
"media_player": "Enviar para o reprodutor de mídia",
|
||||||
|
"mute": "",
|
||||||
|
"play": "",
|
||||||
|
"priority": "Prioridade",
|
||||||
|
"snapshots": "Instantâneos",
|
||||||
|
"substreams": "substreams",
|
||||||
|
"timeline": "Linha do tempo",
|
||||||
|
"type": "",
|
||||||
|
"types": {
|
||||||
|
"momentary": "",
|
||||||
|
"toggle": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": "Posição do menu",
|
||||||
|
"positions": {
|
||||||
|
"bottom": "Posicionado na parte inferior",
|
||||||
|
"left": "Posicionado à esquerda",
|
||||||
|
"right": "Posicionado à direita",
|
||||||
|
"top": "Posicionado no topo"
|
||||||
|
},
|
||||||
|
"style": "Estilo do menu",
|
||||||
|
"styles": {
|
||||||
|
"hidden": "Menu oculto",
|
||||||
|
"hover": "Menu suspenso",
|
||||||
|
"none": "Sem menu",
|
||||||
|
"outside": "Menu externo",
|
||||||
|
"overlay": "Menu sobreposto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"info": "Esta configuração do cartão especificou manualmente as substituições configuradas que podem substituir os valores mostrados no editor visual, consulte o editor de código para visualizar/modificar essas substituições"
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"features": {
|
||||||
|
"animated_progress_indicator": "Animação na barra de progresso",
|
||||||
|
"editor_label": "Editor de etiquetas",
|
||||||
|
"media_chunk_size": "Tamanho do ficheiro"
|
||||||
|
},
|
||||||
|
"profile": "Perfil",
|
||||||
|
"profiles": {
|
||||||
|
"high": "Alto",
|
||||||
|
"low": "Baixo"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"border_radius": "Tamanho do bordo",
|
||||||
|
"box_shadow": "Caixa de Fundo",
|
||||||
|
"editor_label": "Editor de etiquetas"
|
||||||
|
},
|
||||||
|
"warning": "Avisos"
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"camera_select": "Visualização de câmeras recém-selecionadas",
|
||||||
|
"dark_mode": "Modo escuro",
|
||||||
|
"dark_modes": {
|
||||||
|
"auto": "Automático",
|
||||||
|
"off": "Desligado",
|
||||||
|
"on": "Ligado"
|
||||||
|
},
|
||||||
|
"default": "Visualização padrão",
|
||||||
|
"scan": {
|
||||||
|
"enabled": "Modo scan ativado",
|
||||||
|
"scan_mode": "Modo scan",
|
||||||
|
"show_trigger_status": "Exibir estado do gatilho",
|
||||||
|
"untrigger_reset": "Redefinir a visualização para o padrão após desacionar",
|
||||||
|
"untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar"
|
||||||
|
},
|
||||||
|
"timeout_seconds": "Redefinir para a visualização padrão X segundos após a ação do usuário (0 = nunca)",
|
||||||
|
"update_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada",
|
||||||
|
"update_force": "Forçar atualizações do cartão (ignore a interação do Utilizador)",
|
||||||
|
"update_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)",
|
||||||
|
"views": {
|
||||||
|
"clip": "Clipe mais recente",
|
||||||
|
"clips": "Galeria de clipes",
|
||||||
|
"current": "Visualização atual",
|
||||||
|
"image": "Imagem estática",
|
||||||
|
"live": "Visualização ao vivo",
|
||||||
|
"snapshot": "Snapshot mais recente",
|
||||||
|
"snapshots": "Galeria de Snapshots",
|
||||||
|
"timeline": "Visualização da linha do tempo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"add_new_camera": "Adicionar nova câmera",
|
||||||
|
"button": "Botão",
|
||||||
|
"camera": "Câmera",
|
||||||
|
"cameras": "Câmeras",
|
||||||
|
"cameras_secondary": "Câmeras para renderizar neste cartão",
|
||||||
|
"delete": "Excluir",
|
||||||
|
"dimensions": "Dimensões",
|
||||||
|
"dimensions_secondary": "Dimensões e opções de forma",
|
||||||
|
"image": "Imagem",
|
||||||
|
"image_secondary": "Opções de visualização de imagem estática",
|
||||||
|
"live": "Ao vivo",
|
||||||
|
"live_secondary": "Opções de visualização da câmera ao vivo",
|
||||||
|
"media_gallery": "Galeria",
|
||||||
|
"media_gallery_secondary": "Galeria Secundaria",
|
||||||
|
"media_viewer": "Visualizador de eventos",
|
||||||
|
"media_viewer_secondary": "Opções do visualizador de Snapshots e clipes",
|
||||||
|
"menu": "Menu",
|
||||||
|
"menu_secondary": "Opções de aparência do menu",
|
||||||
|
"move_down": "Descer",
|
||||||
|
"move_up": "Subir",
|
||||||
|
"overrides": "As substituições estão ativas",
|
||||||
|
"overrides_secondary": "Substituições de configuração dinâmica detectadas",
|
||||||
|
"timeline": "Linha do tempo",
|
||||||
|
"timeline_secondary": "Opções do evento da linha do tempo",
|
||||||
|
"upgrade": "Actualização",
|
||||||
|
"upgrade_available": "Está disponível uma atualização automática do cartão",
|
||||||
|
"view": "Visualizar",
|
||||||
|
"view_secondary": "O que deve ser mostrado neste cartão"
|
||||||
|
},
|
||||||
|
"elements": {
|
||||||
|
"ptz": {
|
||||||
|
"down": "Baixo",
|
||||||
|
"home": "Origem",
|
||||||
|
"left": "Esquerda",
|
||||||
|
"right": "Direira",
|
||||||
|
"up": "Cima",
|
||||||
|
"zoom_in": "Ampliar",
|
||||||
|
"zoom_out": "Reduzir"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"could_not_render_elements": "Não foi possível renderizar os elementos da imagem",
|
||||||
|
"could_not_resolve": "Não foi possível resolver o URL de mídia",
|
||||||
|
"diagnostics": "Diagnósticos do cartão. Reveja as informações confidenciais antes de partilhar",
|
||||||
|
"download_no_media": "Nenhuma mídia para download",
|
||||||
|
"download_sign_failed": "Não foi possível assinar o URL de mídia para download",
|
||||||
|
"duplicate_camera_id": "Duplique o ID da câmera Frigate para a câmera a seguir, use o parâmetro 'id' para identificar exclusivamente as câmeras",
|
||||||
|
"empty_response": "Sem resposta do Home Assistant para a solicitação",
|
||||||
|
"failed_response": "Falha ao receber resposta do Home Assistant para solicitação",
|
||||||
|
"failed_retain": "Não foi possível reter o evento",
|
||||||
|
"failed_sign": "Não foi possível assinar a URL do Home Assistant",
|
||||||
|
"image_load_error": "A imagem não pôde ser carregada",
|
||||||
|
"invalid_configuration": "Configuração inválida",
|
||||||
|
"invalid_configuration_no_hint": "Nenhuma dica de local disponível (tipo incorreto ou ausente?)",
|
||||||
|
"invalid_elements_config": "Configuração de elementos de imagem inválida",
|
||||||
|
"invalid_response": "Resposta inválida recebida do Home Assistant para a solicitação",
|
||||||
|
"jsmpeg_no_player": "Não foi possível iniciar o player JSMPEG",
|
||||||
|
"live_camera_no_endpoint": "Nenhuma câmera ao vivo",
|
||||||
|
"live_camera_not_found": "Nenhuma câmera ao vivo não foi encontrada",
|
||||||
|
"live_camera_unavailable": "Câmera ao vivo indisponivel",
|
||||||
|
"no_camera_engine": "Não existe câmera",
|
||||||
|
"no_camera_entity": "Não existe uma entidade câmera",
|
||||||
|
"no_camera_entity_for_triggers": "Não existe camera para a acção",
|
||||||
|
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
|
||||||
|
"no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir",
|
||||||
|
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este serviço ativo",
|
||||||
|
"no_visible_cameras": "Sem camaras visiveis",
|
||||||
|
"reconnecting": "A voltar a ligar",
|
||||||
|
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
|
||||||
|
"troubleshooting": "Verifique a solução de problemas",
|
||||||
|
"too_many_automations": "",
|
||||||
|
"unknown": "Erro desconhecido",
|
||||||
|
"upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
|
||||||
|
"webrtc_card_reported_error": "O cartão WebRTC relatou um erro",
|
||||||
|
"webrtc_card_waiting": "Aguardar o cartão WebRTC carregar ..."
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"camera": "Camera",
|
||||||
|
"duration": "Duração",
|
||||||
|
"in_progress": "Em andamento",
|
||||||
|
"score": "Pontuação",
|
||||||
|
"seek": "Procurar",
|
||||||
|
"start": "Início",
|
||||||
|
"what": "O quê",
|
||||||
|
"where": "Onde"
|
||||||
|
},
|
||||||
|
"media_filter": {
|
||||||
|
"all": "Todos",
|
||||||
|
"camera": "Camera",
|
||||||
|
"favorite": "Favoritos",
|
||||||
|
"media_type": "Tipos de media",
|
||||||
|
"media_types": {
|
||||||
|
"clips": "Clips",
|
||||||
|
"recordings": "Gravações",
|
||||||
|
"snapshots": "Imagens"
|
||||||
|
},
|
||||||
|
"not_favorite": "Não favorito",
|
||||||
|
"select_camera": "Seleciona a camara",
|
||||||
|
"select_favorite": "Seleciona o favorito",
|
||||||
|
"select_media_type": "Seleciona o tipo de media",
|
||||||
|
"select_what": "Seleciona",
|
||||||
|
"select_when": "Seleciona quando",
|
||||||
|
"select_where": "Seleciona onde",
|
||||||
|
"what": "O que",
|
||||||
|
"when": "Quando",
|
||||||
|
"whens": {
|
||||||
|
"past_month": "O mes passado",
|
||||||
|
"past_week": "A semana passada",
|
||||||
|
"today": "Hoje",
|
||||||
|
"yesterday": "Ontem"
|
||||||
|
},
|
||||||
|
"where": "Onde"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"camera": "Camera",
|
||||||
|
"duration": "Duração",
|
||||||
|
"events": "Eventos",
|
||||||
|
"in_progress": "Em andamento",
|
||||||
|
"seek": "Procurar",
|
||||||
|
"start": "Começar"
|
||||||
|
},
|
||||||
|
"thumbnail": {
|
||||||
|
"no_thumbnail": "Nenhuma miniatura disponível",
|
||||||
|
"retain_indefinitely": "Evento será retido por tempo indeterminado",
|
||||||
|
"timeline": "Ver evento na linha do tempo"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"pan_behavior": {
|
||||||
|
"pan": "Pan",
|
||||||
|
"seek": "Pan seeks across all media",
|
||||||
|
"seek-in-media": "Pan seeks within selected media item only"
|
||||||
|
},
|
||||||
|
"select_date": "Selecionar a data"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,8 @@ export const loadLanguages = async (hass: HomeAssistant): Promise<void> => {
|
|||||||
const lang = getLanguage(hass);
|
const lang = getLanguage(hass);
|
||||||
if (lang === 'it') {
|
if (lang === 'it') {
|
||||||
languages[lang] = await import('./languages/it.json');
|
languages[lang] = await import('./languages/it.json');
|
||||||
|
} else if (lang === 'pt') {
|
||||||
|
languages[lang] = await import('./languages/pt-PT.json');
|
||||||
} else if (lang === 'pt_BR') {
|
} else if (lang === 'pt_BR') {
|
||||||
languages[lang] = await import('./languages/pt-BR.json');
|
languages[lang] = await import('./languages/pt-BR.json');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,16 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
this._player?.seek(seconds);
|
this._player?.seek(seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setControls(controls?: boolean): Promise<void> {
|
||||||
|
if (this._player) {
|
||||||
|
this._player.setControls(controls ?? this.controls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPaused(): boolean {
|
||||||
|
return this._player?.isPaused() ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master render method.
|
* Master render method.
|
||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
@@ -84,7 +94,7 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
return html`
|
return html`
|
||||||
<img
|
<img
|
||||||
@load=${(ev: Event) => {
|
@load=${(ev: Event) => {
|
||||||
dispatchMediaLoadedEvent(this, ev);
|
dispatchMediaLoadedEvent(this, ev, { player: this });
|
||||||
}}
|
}}
|
||||||
.src=${typeof this._connected == 'undefined' || this._connected
|
.src=${typeof this._connected == 'undefined' || this._connected
|
||||||
? computeMJPEGStreamUrl(this.stateObj)
|
? computeMJPEGStreamUrl(this.stateObj)
|
||||||
|
|||||||
@@ -9,17 +9,23 @@
|
|||||||
// available as compilation time.
|
// available as compilation time.
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
|
|
||||||
import { css, CSSResultGroup, html, unsafeCSS, TemplateResult } from 'lit';
|
import { CSSResultGroup, TemplateResult, css, html, unsafeCSS } from 'lit';
|
||||||
import { customElement } from 'lit/decorators.js';
|
import { customElement } from 'lit/decorators.js';
|
||||||
import { query } from 'lit/decorators/query.js';
|
import { query } from 'lit/decorators/query.js';
|
||||||
import { dispatchErrorMessageEvent } from '../components/message.js';
|
import { dispatchErrorMessageEvent } from '../components/message.js';
|
||||||
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
|
|
||||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
||||||
import {
|
|
||||||
hideMediaControlsTemporarily,
|
|
||||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
|
||||||
} from '../utils/media.js';
|
|
||||||
import { FrigateCardMediaPlayer } from '../types.js';
|
import { FrigateCardMediaPlayer } from '../types.js';
|
||||||
|
import { mayHaveAudio } from '../utils/audio.js';
|
||||||
|
import {
|
||||||
|
dispatchMediaLoadedEvent,
|
||||||
|
dispatchMediaPauseEvent,
|
||||||
|
dispatchMediaPlayEvent,
|
||||||
|
dispatchMediaVolumeChangeEvent,
|
||||||
|
} from '../utils/media-info.js';
|
||||||
|
import {
|
||||||
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
|
hideMediaControlsTemporarily,
|
||||||
|
} from '../utils/media.js';
|
||||||
|
|
||||||
customElements.whenDefined('ha-hls-player').then(() => {
|
customElements.whenDefined('ha-hls-player').then(() => {
|
||||||
@customElement('frigate-card-ha-hls-player')
|
@customElement('frigate-card-ha-hls-player')
|
||||||
@@ -67,6 +73,16 @@ customElements.whenDefined('ha-hls-player').then(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setControls(controls?: boolean): Promise<void> {
|
||||||
|
if (this._video) {
|
||||||
|
this._video.controls = controls ?? this.controls;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPaused(): boolean {
|
||||||
|
return this._video?.paused ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================================
|
// =====================================================================================
|
||||||
// Minor modifications from:
|
// Minor modifications from:
|
||||||
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
|
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
|
||||||
@@ -88,11 +104,25 @@ customElements.whenDefined('ha-hls-player').then(() => {
|
|||||||
?playsinline=${this.playsInline}
|
?playsinline=${this.playsInline}
|
||||||
?controls=${this.controls}
|
?controls=${this.controls}
|
||||||
@loadedmetadata=${() => {
|
@loadedmetadata=${() => {
|
||||||
hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
|
if (this.controls) {
|
||||||
|
hideMediaControlsTemporarily(
|
||||||
|
this._video,
|
||||||
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
@loadeddata=${(e) => {
|
@loadeddata=${(ev) => {
|
||||||
dispatchMediaLoadedEvent(this, e);
|
dispatchMediaLoadedEvent(this, ev, {
|
||||||
|
player: this,
|
||||||
|
capabilities: {
|
||||||
|
supportsPause: true,
|
||||||
|
hasAudio: mayHaveAudio(this._video),
|
||||||
|
},
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
|
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
|
||||||
|
@play=${() => dispatchMediaPlayEvent(this)}
|
||||||
|
@pause=${() => dispatchMediaPauseEvent(this)}
|
||||||
></video>
|
></video>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,16 @@ import { query } from 'lit/decorators/query.js';
|
|||||||
import { dispatchErrorMessageEvent } from '../components/message.js';
|
import { dispatchErrorMessageEvent } from '../components/message.js';
|
||||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
||||||
import { FrigateCardMediaPlayer } from '../types.js';
|
import { FrigateCardMediaPlayer } from '../types.js';
|
||||||
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
|
import { mayHaveAudio } from '../utils/audio.js';
|
||||||
|
import {
|
||||||
|
dispatchMediaLoadedEvent,
|
||||||
|
dispatchMediaPauseEvent,
|
||||||
|
dispatchMediaPlayEvent,
|
||||||
|
dispatchMediaVolumeChangeEvent,
|
||||||
|
} from '../utils/media-info.js';
|
||||||
import {
|
import {
|
||||||
hideMediaControlsTemporarily,
|
hideMediaControlsTemporarily,
|
||||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
} from '../utils/media.js';
|
} from '../utils/media.js';
|
||||||
|
|
||||||
customElements.whenDefined('ha-web-rtc-player').then(() => {
|
customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||||
@@ -66,6 +72,16 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setControls(controls?: boolean): Promise<void> {
|
||||||
|
if (this._video) {
|
||||||
|
this._video.controls = controls ?? this.controls;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPaused(): boolean {
|
||||||
|
return this._video?.paused ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================================
|
// =====================================================================================
|
||||||
// Minor modifications from:
|
// Minor modifications from:
|
||||||
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
|
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
|
||||||
@@ -84,11 +100,25 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
|
|||||||
?playsinline=${this.playsInline}
|
?playsinline=${this.playsInline}
|
||||||
?controls=${this.controls}
|
?controls=${this.controls}
|
||||||
@loadedmetadata=${() => {
|
@loadedmetadata=${() => {
|
||||||
hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
|
if (this.controls) {
|
||||||
|
hideMediaControlsTemporarily(
|
||||||
|
this._video,
|
||||||
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
@loadeddata=${(e) => {
|
@loadeddata=${(ev) => {
|
||||||
dispatchMediaLoadedEvent(this, e);
|
dispatchMediaLoadedEvent(this, ev, {
|
||||||
|
player: this,
|
||||||
|
capabilities: {
|
||||||
|
supportsPause: true,
|
||||||
|
hasAudio: mayHaveAudio(this._video),
|
||||||
|
},
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
|
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
|
||||||
|
@play=${() => dispatchMediaPlayEvent(this)}
|
||||||
|
@pause=${() => dispatchMediaPauseEvent(this)}
|
||||||
></video>
|
></video>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ ha-icon-button.button {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
opacity: 0;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 100;
|
||||||
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-2
@@ -219,11 +219,19 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
|
|||||||
'image',
|
'image',
|
||||||
'live',
|
'live',
|
||||||
'menu_toggle',
|
'menu_toggle',
|
||||||
|
'mute',
|
||||||
|
'live_substream_on',
|
||||||
|
'live_substream_off',
|
||||||
|
'microphone_mute',
|
||||||
|
'microphone_unmute',
|
||||||
|
'play',
|
||||||
|
'pause',
|
||||||
'recording',
|
'recording',
|
||||||
'recordings',
|
'recordings',
|
||||||
'snapshot',
|
'snapshot',
|
||||||
'snapshots',
|
'snapshots',
|
||||||
'timeline',
|
'timeline',
|
||||||
|
'unmute',
|
||||||
] as const;
|
] as const;
|
||||||
const FRIGATE_CARD_ACTIONS = [
|
const FRIGATE_CARD_ACTIONS = [
|
||||||
...FRIGATE_CARD_GENERAL_ACTIONS,
|
...FRIGATE_CARD_GENERAL_ACTIONS,
|
||||||
@@ -261,7 +269,7 @@ export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSche
|
|||||||
|
|
||||||
// Cannot use discriminatedUnion since frigateCardCustomActionSchema uses a
|
// Cannot use discriminatedUnion since frigateCardCustomActionSchema uses a
|
||||||
// transform on the discriminated union key.
|
// transform on the discriminated union key.
|
||||||
const actionSchema = z.union([
|
export const actionSchema = z.union([
|
||||||
toggleActionSchema,
|
toggleActionSchema,
|
||||||
callServiceActionSchema,
|
callServiceActionSchema,
|
||||||
navigateActionSchema,
|
navigateActionSchema,
|
||||||
@@ -409,6 +417,22 @@ const imageBaseConfigSchema = z.object({
|
|||||||
* Live provider options
|
* Live provider options
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const microphoneConfigDefault = {
|
||||||
|
always_connected: false,
|
||||||
|
disconnect_seconds: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
const microphoneConfigSchema = z
|
||||||
|
.object({
|
||||||
|
always_connected: z.boolean().default(microphoneConfigDefault.always_connected),
|
||||||
|
disconnect_seconds: z
|
||||||
|
.number()
|
||||||
|
.min(0)
|
||||||
|
.default(microphoneConfigDefault.disconnect_seconds),
|
||||||
|
})
|
||||||
|
.default(microphoneConfigDefault);
|
||||||
|
export type MicrophoneConfig = z.infer<typeof microphoneConfigSchema>;
|
||||||
|
|
||||||
const go2rtcConfigSchema = z.object({
|
const go2rtcConfigSchema = z.object({
|
||||||
modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(),
|
modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(),
|
||||||
stream: z.string().optional(),
|
stream: z.string().optional(),
|
||||||
@@ -616,7 +640,7 @@ export type MenuSubmenuSelect = z.infer<typeof menuSubmenuSelectSchema>;
|
|||||||
|
|
||||||
export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu | MenuSubmenuSelect;
|
export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu | MenuSubmenuSelect;
|
||||||
|
|
||||||
const frigateCardConditionSchema = z.object({
|
export const frigateCardConditionSchema = z.object({
|
||||||
view: z.string().array().optional(),
|
view: z.string().array().optional(),
|
||||||
fullscreen: z.boolean().optional(),
|
fullscreen: z.boolean().optional(),
|
||||||
expand: z.boolean().optional(),
|
expand: z.boolean().optional(),
|
||||||
@@ -765,12 +789,14 @@ const viewConfigSchema = z
|
|||||||
const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const;
|
const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const;
|
||||||
const imageConfigDefault = {
|
const imageConfigDefault = {
|
||||||
mode: 'url' as const,
|
mode: 'url' as const,
|
||||||
|
zoomable: true,
|
||||||
...imageBaseConfigDefault,
|
...imageBaseConfigDefault,
|
||||||
};
|
};
|
||||||
const imageConfigSchema = imageBaseConfigSchema
|
const imageConfigSchema = imageBaseConfigSchema
|
||||||
.extend({
|
.extend({
|
||||||
mode: z.enum(IMAGE_MODES).default(imageConfigDefault.mode),
|
mode: z.enum(IMAGE_MODES).default(imageConfigDefault.mode),
|
||||||
layout: mediaLayoutConfigSchema.optional(),
|
layout: mediaLayoutConfigSchema.optional(),
|
||||||
|
zoomable: z.boolean().default(imageConfigDefault.zoomable),
|
||||||
})
|
})
|
||||||
.merge(actionsSchema)
|
.merge(actionsSchema)
|
||||||
.default(imageConfigDefault);
|
.default(imageConfigDefault);
|
||||||
@@ -918,9 +944,11 @@ const liveConfigDefault = {
|
|||||||
lazy_load: true,
|
lazy_load: true,
|
||||||
lazy_unload: 'never' as const,
|
lazy_unload: 'never' as const,
|
||||||
draggable: true,
|
draggable: true,
|
||||||
|
zoomable: true,
|
||||||
transition_effect: 'slide' as const,
|
transition_effect: 'slide' as const,
|
||||||
show_image_during_load: true,
|
show_image_during_load: true,
|
||||||
controls: {
|
controls: {
|
||||||
|
builtin: true,
|
||||||
next_previous: {
|
next_previous: {
|
||||||
size: 48,
|
size: 48,
|
||||||
style: 'chevrons' as const,
|
style: 'chevrons' as const,
|
||||||
@@ -932,6 +960,9 @@ const liveConfigDefault = {
|
|||||||
duration_seconds: 2,
|
duration_seconds: 2,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
microphone: {
|
||||||
|
...microphoneConfigDefault,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const livethumbnailsControlSchema = thumbnailsControlSchema.extend({
|
const livethumbnailsControlSchema = thumbnailsControlSchema.extend({
|
||||||
@@ -944,6 +975,7 @@ const liveOverridableConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
controls: z
|
controls: z
|
||||||
.object({
|
.object({
|
||||||
|
builtin: z.boolean().default(liveConfigDefault.controls.builtin),
|
||||||
next_previous: nextPreviousControlConfigSchema
|
next_previous: nextPreviousControlConfigSchema
|
||||||
.extend({
|
.extend({
|
||||||
// Live cannot show thumbnails, remove that option.
|
// Live cannot show thumbnails, remove that option.
|
||||||
@@ -975,6 +1007,8 @@ const liveOverridableConfigSchema = z
|
|||||||
.boolean()
|
.boolean()
|
||||||
.default(liveConfigDefault.show_image_during_load),
|
.default(liveConfigDefault.show_image_during_load),
|
||||||
layout: mediaLayoutConfigSchema.optional(),
|
layout: mediaLayoutConfigSchema.optional(),
|
||||||
|
microphone: microphoneConfigSchema.default(liveConfigDefault.microphone),
|
||||||
|
zoomable: z.boolean().default(liveConfigDefault.zoomable),
|
||||||
})
|
})
|
||||||
.merge(actionsSchema);
|
.merge(actionsSchema);
|
||||||
|
|
||||||
@@ -1037,6 +1071,12 @@ const menuConfigDefault = {
|
|||||||
fullscreen: visibleButtonDefault,
|
fullscreen: visibleButtonDefault,
|
||||||
expand: hiddenButtonDefault,
|
expand: hiddenButtonDefault,
|
||||||
media_player: visibleButtonDefault,
|
media_player: visibleButtonDefault,
|
||||||
|
microphone: {
|
||||||
|
...hiddenButtonDefault,
|
||||||
|
type: 'momentary' as const,
|
||||||
|
},
|
||||||
|
mute: hiddenButtonDefault,
|
||||||
|
play: hiddenButtonDefault,
|
||||||
recordings: hiddenButtonDefault,
|
recordings: hiddenButtonDefault,
|
||||||
},
|
},
|
||||||
button_size: 40,
|
button_size: 40,
|
||||||
@@ -1073,7 +1113,16 @@ const menuConfigSchema = z
|
|||||||
media_player: visibleButtonSchema.default(
|
media_player: visibleButtonSchema.default(
|
||||||
menuConfigDefault.buttons.media_player,
|
menuConfigDefault.buttons.media_player,
|
||||||
),
|
),
|
||||||
|
microphone: hiddenButtonSchema
|
||||||
|
.extend({
|
||||||
|
type: z
|
||||||
|
.enum(['momentary', 'toggle'])
|
||||||
|
.default(menuConfigDefault.buttons.microphone.type),
|
||||||
|
})
|
||||||
|
.default(menuConfigDefault.buttons.microphone),
|
||||||
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
||||||
|
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
|
||||||
|
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
||||||
})
|
})
|
||||||
.default(menuConfigDefault.buttons),
|
.default(menuConfigDefault.buttons),
|
||||||
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
|
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
|
||||||
@@ -1091,9 +1140,11 @@ const viewerConfigDefault = {
|
|||||||
auto_unmute: 'never' as const,
|
auto_unmute: 'never' as const,
|
||||||
lazy_load: true,
|
lazy_load: true,
|
||||||
draggable: true,
|
draggable: true,
|
||||||
|
zoomable: true,
|
||||||
transition_effect: 'slide' as const,
|
transition_effect: 'slide' as const,
|
||||||
snapshot_click_plays_clip: true,
|
snapshot_click_plays_clip: true,
|
||||||
controls: {
|
controls: {
|
||||||
|
builtin: true,
|
||||||
next_previous: {
|
next_previous: {
|
||||||
size: 48,
|
size: 48,
|
||||||
style: 'thumbnails' as const,
|
style: 'thumbnails' as const,
|
||||||
@@ -1131,6 +1182,7 @@ const viewerConfigSchema = z
|
|||||||
.default(viewerConfigDefault.auto_unmute),
|
.default(viewerConfigDefault.auto_unmute),
|
||||||
lazy_load: z.boolean().default(viewerConfigDefault.lazy_load),
|
lazy_load: z.boolean().default(viewerConfigDefault.lazy_load),
|
||||||
draggable: z.boolean().default(viewerConfigDefault.draggable),
|
draggable: z.boolean().default(viewerConfigDefault.draggable),
|
||||||
|
zoomable: z.boolean().default(viewerConfigDefault.zoomable),
|
||||||
transition_effect: transitionEffectConfigSchema.default(
|
transition_effect: transitionEffectConfigSchema.default(
|
||||||
viewerConfigDefault.transition_effect,
|
viewerConfigDefault.transition_effect,
|
||||||
),
|
),
|
||||||
@@ -1139,6 +1191,7 @@ const viewerConfigSchema = z
|
|||||||
.default(viewerConfigDefault.snapshot_click_plays_clip),
|
.default(viewerConfigDefault.snapshot_click_plays_clip),
|
||||||
controls: z
|
controls: z
|
||||||
.object({
|
.object({
|
||||||
|
builtin: z.boolean().default(viewerConfigDefault.controls.builtin),
|
||||||
next_previous: viewerNextPreviousControlConfigSchema.default(
|
next_previous: viewerNextPreviousControlConfigSchema.default(
|
||||||
viewerConfigDefault.controls.next_previous,
|
viewerConfigDefault.controls.next_previous,
|
||||||
),
|
),
|
||||||
@@ -1294,6 +1347,19 @@ const liveOverridesSchema = z
|
|||||||
.optional();
|
.optional();
|
||||||
export type LiveOverrides = z.infer<typeof liveOverridesSchema>;
|
export type LiveOverrides = z.infer<typeof liveOverridesSchema>;
|
||||||
|
|
||||||
|
const automationActionSchema = actionSchema.array().optional();
|
||||||
|
export type AutomationActions = z.infer<typeof automationActionSchema>;
|
||||||
|
|
||||||
|
const automationSchema = z.object({
|
||||||
|
conditions: frigateCardConditionSchema,
|
||||||
|
actions: automationActionSchema,
|
||||||
|
actions_not: automationActionSchema,
|
||||||
|
});
|
||||||
|
export type Automation = z.infer<typeof automationSchema>;
|
||||||
|
|
||||||
|
export const automationsSchema = automationSchema.array().optional();
|
||||||
|
export type Automations = z.infer<typeof automationsSchema>;
|
||||||
|
|
||||||
const performanceConfigDefault = {
|
const performanceConfigDefault = {
|
||||||
profile: 'high' as const,
|
profile: 'high' as const,
|
||||||
features: {
|
features: {
|
||||||
@@ -1370,6 +1436,7 @@ export const frigateCardConfigSchema = z.object({
|
|||||||
timeline: timelineConfigSchema,
|
timeline: timelineConfigSchema,
|
||||||
performance: performanceConfigSchema,
|
performance: performanceConfigSchema,
|
||||||
debug: debugConfigSchema,
|
debug: debugConfigSchema,
|
||||||
|
automations: automationsSchema,
|
||||||
|
|
||||||
// Configuration overrides.
|
// Configuration overrides.
|
||||||
overrides: overridesSchema,
|
overrides: overridesSchema,
|
||||||
@@ -1416,9 +1483,17 @@ export interface ExtendedHomeAssistant extends HomeAssistant {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MediaLoadedCapabilities {
|
||||||
|
supports2WayAudio?: boolean;
|
||||||
|
supportsPause?: boolean;
|
||||||
|
hasAudio?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MediaLoadedInfo {
|
export interface MediaLoadedInfo {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
player?: FrigateCardMediaPlayer;
|
||||||
|
capabilities?: MediaLoadedCapabilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MESSAGE_TYPE_PRIORITIES = {
|
export const MESSAGE_TYPE_PRIORITIES = {
|
||||||
@@ -1455,6 +1530,9 @@ export interface FrigateCardMediaPlayer {
|
|||||||
unmute(): Promise<void>;
|
unmute(): Promise<void>;
|
||||||
isMuted(): boolean;
|
isMuted(): boolean;
|
||||||
seek(seconds: number): Promise<void>;
|
seek(seconds: number): Promise<void>;
|
||||||
|
// If no value for controls if specified, the player should use the default.
|
||||||
|
setControls(controls?: boolean): Promise<void>;
|
||||||
|
isPaused(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardHelpers {
|
export interface CardHelpers {
|
||||||
|
|||||||
+29
-43
@@ -6,7 +6,6 @@ import {
|
|||||||
} from 'custom-card-helpers';
|
} from 'custom-card-helpers';
|
||||||
import {
|
import {
|
||||||
Actions,
|
Actions,
|
||||||
ActionsConfig,
|
|
||||||
ActionType,
|
ActionType,
|
||||||
FrigateCardAction,
|
FrigateCardAction,
|
||||||
FrigateCardCustomAction,
|
FrigateCardCustomAction,
|
||||||
@@ -52,7 +51,7 @@ export function createFrigateCardCustomAction(
|
|||||||
action: 'fire-dom-event',
|
action: 'fire-dom-event',
|
||||||
frigate_card_action: action,
|
frigate_card_action: action,
|
||||||
camera: args.camera as string,
|
camera: args.camera as string,
|
||||||
...(args.cardID && { card_id: args.cardID})
|
...(args.cardID && { card_id: args.cardID }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action === 'media_player') {
|
if (action === 'media_player') {
|
||||||
@@ -64,13 +63,13 @@ export function createFrigateCardCustomAction(
|
|||||||
frigate_card_action: action,
|
frigate_card_action: action,
|
||||||
media_player: args.media_player,
|
media_player: args.media_player,
|
||||||
media_player_action: args.media_player_action,
|
media_player_action: args.media_player_action,
|
||||||
...(args.cardID && { card_id: args.cardID})
|
...(args.cardID && { card_id: args.cardID }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
action: 'fire-dom-event',
|
action: 'fire-dom-event',
|
||||||
frigate_card_action: action,
|
frigate_card_action: action,
|
||||||
...(args?.cardID && { card_id: args.cardID})
|
...(args?.cardID && { card_id: args.cardID }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,29 +106,6 @@ export function getActionConfigGivenAction(
|
|||||||
* that handles the custom action events the card supports.
|
* that handles the custom action events the card supports.
|
||||||
* @param node The node that fired the event.
|
* @param node The node that fired the event.
|
||||||
* @param hass The Home Assistant object.
|
* @param hass The Home Assistant object.
|
||||||
* @param config The multi-action configuration.
|
|
||||||
* @param action The action string (e.g. 'hold')
|
|
||||||
* @returns Whether or not an action was executed.
|
|
||||||
*/
|
|
||||||
export const frigateCardHandleAction = (
|
|
||||||
node: HTMLElement,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config: ActionsConfig,
|
|
||||||
action: string,
|
|
||||||
): boolean => {
|
|
||||||
return frigateCardHandleActionConfig(
|
|
||||||
node,
|
|
||||||
hass,
|
|
||||||
config,
|
|
||||||
action,
|
|
||||||
getActionConfigGivenAction(action, config),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an ActionConfig or array of ActionConfigs.
|
|
||||||
* @param node The node that fired the event.
|
|
||||||
* @param hass The Home Assistant object.
|
|
||||||
* @param actionConfig A single action config, array of action configs or
|
* @param actionConfig A single action config, array of action configs or
|
||||||
* undefined for the default action config for 'tap'.
|
* undefined for the default action config for 'tap'.
|
||||||
* @param action The action string (e.g. 'hold')
|
* @param action The action string (e.g. 'hold')
|
||||||
@@ -143,36 +119,46 @@ export const frigateCardHandleActionConfig = (
|
|||||||
entity?: string;
|
entity?: string;
|
||||||
},
|
},
|
||||||
action: string,
|
action: string,
|
||||||
actionConfig: ActionType | ActionType[] | undefined,
|
actionConfig?: ActionType | ActionType[],
|
||||||
): boolean => {
|
): boolean => {
|
||||||
// Only allow a tap action to use a default non-config (the more-info config).
|
// Only allow a tap action to use a default non-config (the more-info config).
|
||||||
if (actionConfig || action == 'tap') {
|
if (actionConfig || action == 'tap') {
|
||||||
// ActionConfig vs ActionType:
|
frigateCardHandleAction(node, hass, config, actionConfig);
|
||||||
// There is a slight typing (but not functional) difference between
|
|
||||||
// ActionType in this card and ActionConfig in `custom-card-helpers`. See
|
|
||||||
// `ExtendedConfirmationRestrictionConfig` in `types.ts` for the source and
|
|
||||||
// reason behind this difference.
|
|
||||||
if (Array.isArray(actionConfig)) {
|
|
||||||
actionConfig.forEach((action) =>
|
|
||||||
handleActionConfig(node, hass, config, action as ActionConfig | undefined),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
handleActionConfig(node, hass, config, actionConfig as ActionConfig | undefined);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const frigateCardHandleAction = (
|
||||||
|
node: HTMLElement,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config: {
|
||||||
|
camera_image?: string;
|
||||||
|
entity?: string;
|
||||||
|
},
|
||||||
|
actionConfig: ActionType | ActionType[] | undefined,
|
||||||
|
): void => {
|
||||||
|
// ActionConfig vs ActionType:
|
||||||
|
// * There is a slight typing (but not functional) difference between
|
||||||
|
// ActionType in this card and ActionConfig in `custom-card-helpers`. See
|
||||||
|
// `ExtendedConfirmationRestrictionConfig` in `types.ts` for the source and
|
||||||
|
// reason behind this difference.
|
||||||
|
if (Array.isArray(actionConfig)) {
|
||||||
|
actionConfig.forEach((action) =>
|
||||||
|
handleActionConfig(node, hass, config, action as ActionConfig | undefined),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
handleActionConfig(node, hass, config, actionConfig as ActionConfig | undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if an action config has a real action. A modified version of
|
* Determine if an action config has a real action. A modified version of
|
||||||
* custom-card-helpers hasAction to also work with arrays of action configs.
|
* custom-card-helpers hasAction to also work with arrays of action configs.
|
||||||
* @param config The action config in question.
|
* @param config The action config in question.
|
||||||
* @returns `true` if there's a real action defined, `false` otherwise.
|
* @returns `true` if there's a real action defined, `false` otherwise.
|
||||||
*/
|
*/
|
||||||
export const frigateCardHasAction = (
|
export const frigateCardHasAction = (config?: ActionType | ActionType[]): boolean => {
|
||||||
config?: ActionType | ActionType[] | undefined,
|
|
||||||
): boolean => {
|
|
||||||
// See note above on 'ActionConfig vs ActionType' for why this cast is
|
// See note above on 'ActionConfig vs ActionType' for why this cast is
|
||||||
// necessary and harmless.
|
// necessary and harmless.
|
||||||
if (Array.isArray(config)) {
|
if (Array.isArray(config)) {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export interface AudioProperties {
|
||||||
|
mozHasAudio?: boolean;
|
||||||
|
audioTracks?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// There is currently no consistent cross-browser modern way to determine if a
|
||||||
|
// viden has audio tracks. The below will work in ~24% of browsers, but notably
|
||||||
|
// not in Chrome. There used to be a usable `webkitAudioDecodedByteCount`
|
||||||
|
// property, but this now seems to be consistently 0 in Chrome. This generously
|
||||||
|
// defaults to assuming there is audio when we cannot rule it out.
|
||||||
|
export const mayHaveAudio = (video: HTMLVideoElement & AudioProperties): boolean => {
|
||||||
|
if (video.mozHasAudio !== undefined) {
|
||||||
|
return video.mozHasAudio;
|
||||||
|
}
|
||||||
|
if (video.audioTracks !== undefined) {
|
||||||
|
return Boolean(video.audioTracks?.length);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
+14
-7
@@ -28,12 +28,21 @@ export function getCameraID(
|
|||||||
* Get all cameras that depend on a given camera.
|
* Get all cameras that depend on a given camera.
|
||||||
* @param cameraManager The camera manager.
|
* @param cameraManager The camera manager.
|
||||||
* @param cameraID ID of the target camera.
|
* @param cameraID ID of the target camera.
|
||||||
* @returns A set of dependent cameraIDs or null.
|
* @returns A set of dependent cameraIDs or null (since JS sets guarantee order,
|
||||||
|
* the first item in the set is guaranteed to be the cameraID itself).
|
||||||
*/
|
*/
|
||||||
export const getAllDependentCameras = (
|
export function getAllDependentCameras(
|
||||||
|
cameraManager: CameraManager,
|
||||||
|
cameraID: string,
|
||||||
|
): Set<string>;
|
||||||
|
export function getAllDependentCameras(
|
||||||
cameraManager?: CameraManager,
|
cameraManager?: CameraManager,
|
||||||
cameraID?: string,
|
cameraID?: string,
|
||||||
): Set<string> | null => {
|
): Set<string> | null;
|
||||||
|
export function getAllDependentCameras(
|
||||||
|
cameraManager?: CameraManager,
|
||||||
|
cameraID?: string,
|
||||||
|
): Set<string> | null {
|
||||||
if (!cameraManager || !cameraID) {
|
if (!cameraManager || !cameraID) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -45,9 +54,7 @@ export const getAllDependentCameras = (
|
|||||||
if (cameraConfig) {
|
if (cameraConfig) {
|
||||||
cameraIDs.add(cameraID);
|
cameraIDs.add(cameraID);
|
||||||
const dependentCameras: Set<string> = new Set();
|
const dependentCameras: Set<string> = new Set();
|
||||||
(cameraConfig.dependencies.cameras || []).forEach((item) =>
|
cameraConfig.dependencies.cameras.forEach((item) => dependentCameras.add(item));
|
||||||
dependentCameras.add(item),
|
|
||||||
);
|
|
||||||
if (cameraConfig.dependencies.all_cameras) {
|
if (cameraConfig.dependencies.all_cameras) {
|
||||||
cameras.forEach((_, key) => dependentCameras.add(key));
|
cameras.forEach((_, key) => dependentCameras.add(key));
|
||||||
}
|
}
|
||||||
@@ -62,4 +69,4 @@ export const getAllDependentCameras = (
|
|||||||
getDependentCameras(cameraID);
|
getDependentCameras(cameraID);
|
||||||
}
|
}
|
||||||
return cameraIDs;
|
return cameraIDs;
|
||||||
};
|
}
|
||||||
|
|||||||
+30
-2
@@ -1,4 +1,8 @@
|
|||||||
import { MediaLoadedInfo } from '../types.js';
|
import {
|
||||||
|
FrigateCardMediaPlayer,
|
||||||
|
MediaLoadedCapabilities,
|
||||||
|
MediaLoadedInfo,
|
||||||
|
} from '../types.js';
|
||||||
import { dispatchFrigateCardEvent } from './basic.js';
|
import { dispatchFrigateCardEvent } from './basic.js';
|
||||||
|
|
||||||
const MEDIA_INFO_HEIGHT_CUTOFF = 50;
|
const MEDIA_INFO_HEIGHT_CUTOFF = 50;
|
||||||
@@ -11,6 +15,10 @@ const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF;
|
|||||||
*/
|
*/
|
||||||
export function createMediaLoadedInfo(
|
export function createMediaLoadedInfo(
|
||||||
source: Event | HTMLElement,
|
source: Event | HTMLElement,
|
||||||
|
options?: {
|
||||||
|
player?: FrigateCardMediaPlayer;
|
||||||
|
capabilities?: MediaLoadedCapabilities;
|
||||||
|
},
|
||||||
): MediaLoadedInfo | null {
|
): MediaLoadedInfo | null {
|
||||||
let target: HTMLElement | EventTarget;
|
let target: HTMLElement | EventTarget;
|
||||||
if (source instanceof Event) {
|
if (source instanceof Event) {
|
||||||
@@ -23,16 +31,20 @@ export function createMediaLoadedInfo(
|
|||||||
return {
|
return {
|
||||||
width: (target as HTMLImageElement).naturalWidth,
|
width: (target as HTMLImageElement).naturalWidth,
|
||||||
height: (target as HTMLImageElement).naturalHeight,
|
height: (target as HTMLImageElement).naturalHeight,
|
||||||
|
...options,
|
||||||
};
|
};
|
||||||
} else if (target instanceof HTMLVideoElement) {
|
} else if (target instanceof HTMLVideoElement) {
|
||||||
return {
|
return {
|
||||||
width: (target as HTMLVideoElement).videoWidth,
|
width: (target as HTMLVideoElement).videoWidth,
|
||||||
height: (target as HTMLVideoElement).videoHeight,
|
height: (target as HTMLVideoElement).videoHeight,
|
||||||
|
...options,
|
||||||
};
|
};
|
||||||
} else if (target instanceof HTMLCanvasElement) {
|
} else if (target instanceof HTMLCanvasElement) {
|
||||||
return {
|
return {
|
||||||
width: (target as HTMLCanvasElement).width,
|
width: (target as HTMLCanvasElement).width,
|
||||||
height: (target as HTMLCanvasElement).height,
|
height: (target as HTMLCanvasElement).height,
|
||||||
|
player: options?.player,
|
||||||
|
...options,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -46,13 +58,29 @@ export function createMediaLoadedInfo(
|
|||||||
export function dispatchMediaLoadedEvent(
|
export function dispatchMediaLoadedEvent(
|
||||||
target: HTMLElement,
|
target: HTMLElement,
|
||||||
source: Event | HTMLElement,
|
source: Event | HTMLElement,
|
||||||
|
options?: {
|
||||||
|
player?: FrigateCardMediaPlayer;
|
||||||
|
capabilities?: MediaLoadedCapabilities;
|
||||||
|
},
|
||||||
): void {
|
): void {
|
||||||
const mediaLoadedInfo = createMediaLoadedInfo(source);
|
const mediaLoadedInfo = createMediaLoadedInfo(source, options);
|
||||||
if (mediaLoadedInfo) {
|
if (mediaLoadedInfo) {
|
||||||
dispatchExistingMediaLoadedInfoAsEvent(target, mediaLoadedInfo);
|
dispatchExistingMediaLoadedInfoAsEvent(target, mediaLoadedInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function dispatchMediaVolumeChangeEvent(target: HTMLElement): void {
|
||||||
|
dispatchFrigateCardEvent(target, 'media:volumechange');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchMediaPlayEvent(target: HTMLElement): void {
|
||||||
|
dispatchFrigateCardEvent(target, 'media:play');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchMediaPauseEvent(target: HTMLElement): void {
|
||||||
|
dispatchFrigateCardEvent(target, 'media:pause');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch a pre-existing MediaLoadedInfo object as an event.
|
* Dispatch a pre-existing MediaLoadedInfo object as an event.
|
||||||
* @param element The element to send the event.
|
* @param element The element to send the event.
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { errorToConsole } from "./basic";
|
||||||
|
|
||||||
|
export class MicrophoneController {
|
||||||
|
protected _stream?: MediaStream | null;
|
||||||
|
protected _timerID: number | null = null;
|
||||||
|
|
||||||
|
// We keep mute state separate from the stream state so that mute/unmute can
|
||||||
|
// be expressed before the stream is created -- and when it's create it will
|
||||||
|
// have the right mute status.
|
||||||
|
protected _mute = true;
|
||||||
|
|
||||||
|
protected _disconnectSeconds: number;
|
||||||
|
|
||||||
|
constructor(disconnectSeconds?: number) {
|
||||||
|
this._disconnectSeconds = disconnectSeconds ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async connect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this._stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: true,
|
||||||
|
video: false,
|
||||||
|
});
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errorToConsole(e as Error);
|
||||||
|
this._stream = null;
|
||||||
|
}
|
||||||
|
this._setMute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disconnect(): Promise<void> {
|
||||||
|
this._stream?.getTracks().forEach((track) => track.stop());
|
||||||
|
this._stream = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStream(): MediaStream | undefined {
|
||||||
|
return this._stream ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _setMute(): void {
|
||||||
|
this._stream?.getTracks().forEach((track) => {
|
||||||
|
track.enabled = !this._mute;
|
||||||
|
});
|
||||||
|
this._startTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
public mute(): void {
|
||||||
|
this._mute = true;
|
||||||
|
this._setMute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public unmute(): void {
|
||||||
|
this._mute = false;
|
||||||
|
this._setMute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public isConnected(): boolean {
|
||||||
|
return !!this._stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isForbidden(): boolean {
|
||||||
|
return this._stream === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isMuted(): boolean {
|
||||||
|
// For safety, this function always returns the stream mute status directly
|
||||||
|
// (rather the internal state).
|
||||||
|
return !this._stream || this._stream.getTracks().every((track) => !track.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _clearTimer(): void {
|
||||||
|
if (this._timerID) {
|
||||||
|
window.clearTimeout(this._timerID);
|
||||||
|
this._timerID = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _startTimer(): void {
|
||||||
|
if (this._disconnectSeconds) {
|
||||||
|
this._clearTimer();
|
||||||
|
this._timerID = window.setTimeout(() => {
|
||||||
|
this._clearTimer();
|
||||||
|
this.disconnect();
|
||||||
|
}, this._disconnectSeconds * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { CameraManager } from '../camera-manager/manager';
|
||||||
|
import { View } from '../view/view';
|
||||||
|
import { getAllDependentCameras } from './camera';
|
||||||
|
|
||||||
|
export const createViewWithSelectedSubstream = (
|
||||||
|
view: View,
|
||||||
|
substreamID: string,
|
||||||
|
): View | null => {
|
||||||
|
const overrides: Map<string, string> = view.context?.live?.overrides ?? new Map();
|
||||||
|
overrides.set(view.camera, substreamID);
|
||||||
|
return view.clone().mergeInContext({
|
||||||
|
live: { overrides: overrides },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createViewWithoutSubstream = (view: View): View => {
|
||||||
|
const newView = view.clone();
|
||||||
|
const overrides: Map<string, string> | undefined = newView.context?.live?.overrides;
|
||||||
|
if (overrides && overrides.has(view.camera)) {
|
||||||
|
newView.context?.live?.overrides?.delete(view.camera);
|
||||||
|
}
|
||||||
|
return newView;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasSubstream = (view: View): boolean => {
|
||||||
|
const override = view?.context?.live?.overrides?.get(view.camera);
|
||||||
|
return !!override && override !== view.camera;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createViewWithNextStream = (
|
||||||
|
cameraManager: CameraManager,
|
||||||
|
view: View,
|
||||||
|
): View => {
|
||||||
|
const dependencies = [...getAllDependentCameras(cameraManager, view.camera)];
|
||||||
|
if (dependencies.length <= 1) {
|
||||||
|
return view.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
const newView = view.clone();
|
||||||
|
const overrides: Map<string, string> = newView.context?.live?.overrides ?? new Map();
|
||||||
|
const currentOverride = overrides.get(newView.camera) ?? newView.camera;
|
||||||
|
const currentIndex = dependencies.indexOf(currentOverride);
|
||||||
|
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
|
||||||
|
overrides.set(view.camera, dependencies[newIndex]);
|
||||||
|
newView.mergeInContext({ live: { overrides: overrides } });
|
||||||
|
|
||||||
|
return newView;
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { PanzoomObject, PanzoomEventDetail } from '@dermotduffy/panzoom';
|
||||||
|
import Panzoom from '@dermotduffy/panzoom';
|
||||||
|
import round from 'lodash-es/round';
|
||||||
|
import { dispatchFrigateCardEvent, isHoverableDevice } from '../basic';
|
||||||
|
|
||||||
|
export class Zoom {
|
||||||
|
constructor(element: HTMLElement) {
|
||||||
|
this._element = element;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _element: HTMLElement;
|
||||||
|
protected _panzoom?: PanzoomObject;
|
||||||
|
protected _zoomed = false;
|
||||||
|
|
||||||
|
protected _events = isHoverableDevice()
|
||||||
|
? {
|
||||||
|
down: ['pointerdown'],
|
||||||
|
move: ['pointermove'],
|
||||||
|
up: ['pointerup', 'pointerleave', 'pointercancel'],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
down: ['touchstart'],
|
||||||
|
move: ['touchmove'],
|
||||||
|
up: ['touchend', 'touchcancel'],
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _downHandler = (ev: Event) => {
|
||||||
|
if (this._shouldZoomOrPan(ev)) {
|
||||||
|
this._panzoom?.handleDown(ev as PointerEvent);
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
// If we do not prevent default here, the media carousels scroll.
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _moveHandler = (ev: Event) => {
|
||||||
|
if (this._shouldZoomOrPan(ev)) {
|
||||||
|
this._panzoom?.handleMove(ev as PointerEvent);
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _upHandler = (ev: Event) => {
|
||||||
|
if (this._shouldZoomOrPan(ev)) {
|
||||||
|
this._panzoom?.handleUp(ev as PointerEvent);
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _wheelHandler = (ev: Event) => {
|
||||||
|
if (ev instanceof WheelEvent && this._shouldZoomOrPan(ev)) {
|
||||||
|
this._panzoom?.zoomWithWheel(ev);
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _isScaleNormal(scale?: number): boolean {
|
||||||
|
// Floating point arithmetic warning: comparing floating point numbers,
|
||||||
|
// round them first.
|
||||||
|
return scale !== undefined && round(scale, 4) <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _shouldZoomOrPan(ev: Event): boolean {
|
||||||
|
return (
|
||||||
|
!this._isScaleNormal(this._panzoom?.getScale()) ||
|
||||||
|
(ev instanceof TouchEvent && ev.touches.length > 1) ||
|
||||||
|
(ev instanceof WheelEvent && ev.ctrlKey)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public activate(): void {
|
||||||
|
this._panzoom = Panzoom(this._element, {
|
||||||
|
contain: 'outside',
|
||||||
|
maxScale: 10,
|
||||||
|
minScale: 1,
|
||||||
|
noBind: true,
|
||||||
|
// Do not force the cursor style (by default it will always show the
|
||||||
|
// 'move' type cursor whether or not it is zoomed in).
|
||||||
|
cursor: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerListeners = (
|
||||||
|
events: string[],
|
||||||
|
func: (ev: Event) => void,
|
||||||
|
options?: AddEventListenerOptions,
|
||||||
|
) => {
|
||||||
|
events.forEach((eventName) => {
|
||||||
|
this._element.addEventListener(eventName, func, options);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
registerListeners(this._events['down'], this._downHandler, { capture: true });
|
||||||
|
registerListeners(this._events['move'], this._moveHandler, { capture: true });
|
||||||
|
registerListeners(this._events['up'], this._upHandler, { capture: true });
|
||||||
|
registerListeners(['wheel'], this._wheelHandler);
|
||||||
|
|
||||||
|
this._element.addEventListener('panzoomzoom', (ev: Event) => {
|
||||||
|
// Take care here to only dispatch the zoomed/unzoomed events when the
|
||||||
|
// absolute state changes (rather than on every single zoom adjustment).
|
||||||
|
if (this._isScaleNormal((<CustomEvent<PanzoomEventDetail>>ev).detail.scale)) {
|
||||||
|
if (this._zoomed) {
|
||||||
|
dispatchFrigateCardEvent(this._element, 'zoom:unzoomed');
|
||||||
|
}
|
||||||
|
this._zoomed = false;
|
||||||
|
} else {
|
||||||
|
if (!this._zoomed) {
|
||||||
|
dispatchFrigateCardEvent(this._element, 'zoom:zoomed');
|
||||||
|
}
|
||||||
|
this._zoomed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public deactivate(): void {
|
||||||
|
const unregisterListener = (events: string[], func: (ev: Event) => void) => {
|
||||||
|
events.forEach((eventName) => {
|
||||||
|
this._element.removeEventListener(eventName, func);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
unregisterListener(this._events['down'], this._downHandler);
|
||||||
|
unregisterListener(this._events['move'], this._moveHandler);
|
||||||
|
unregisterListener(this._events['up'], this._upHandler);
|
||||||
|
unregisterListener(['wheel'], this._wheelHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,11 @@ export class View {
|
|||||||
// changes camera to the *current* camera (via the menu) it will cause a
|
// changes camera to the *current* camera (via the menu) it will cause a
|
||||||
// new view to issue without a query and just the 'media' view, which
|
// new view to issue without a query and just the 'media' view, which
|
||||||
// means the viewer cannot know what kind of media to fetch.
|
// means the viewer cannot know what kind of media to fetch.
|
||||||
|
//
|
||||||
|
// * Case #3: Staying within the live view in order to preserve substreams
|
||||||
|
// turned on. See:
|
||||||
|
// https://github.com/dermotduffy/frigate-hass-card/issues/1122
|
||||||
|
//
|
||||||
|
|
||||||
let currentQueriesView: ClipsOrSnapshots | 'recordings' | null = null;
|
let currentQueriesView: ClipsOrSnapshots | 'recordings' | null = null;
|
||||||
if (MediaQueriesClassifier.areEventQueries(curr.query)) {
|
if (MediaQueriesClassifier.areEventQueries(curr.query)) {
|
||||||
@@ -114,6 +119,17 @@ export class View {
|
|||||||
: 'recording';
|
: 'recording';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
curr.is('live') &&
|
||||||
|
next.is('live') &&
|
||||||
|
curr.context?.live?.overrides &&
|
||||||
|
!next.context?.live?.overrides
|
||||||
|
) {
|
||||||
|
const nextLiveContext = next.context?.live ?? {};
|
||||||
|
nextLiveContext.overrides = curr.context.live.overrides;
|
||||||
|
next.mergeInContext({ live: nextLiveContext });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import { AutomationsController, AutomationsControllerError } from '../src/automations';
|
||||||
|
import { ConditionController } from '../src/conditions';
|
||||||
|
import { automationsSchema, FrigateCardError } from '../src/types';
|
||||||
|
import { frigateCardHandleAction } from '../src/utils/action.js';
|
||||||
|
import { createHASS } from './test-utils';
|
||||||
|
|
||||||
|
vi.mock('../src/utils/action.js');
|
||||||
|
|
||||||
|
describe('AutomationsController', () => {
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
action: 'custom:frigate-card-action',
|
||||||
|
frigate_card_action: 'clips',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const conditions = { fullscreen: true };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing without automations', () => {
|
||||||
|
const automationController = new AutomationsController(undefined);
|
||||||
|
automationController.execute(
|
||||||
|
mock<HTMLElement>(),
|
||||||
|
createHASS(),
|
||||||
|
new ConditionController(),
|
||||||
|
);
|
||||||
|
expect(frigateCardHandleAction).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should execute actions', () => {
|
||||||
|
const automations = automationsSchema.parse([
|
||||||
|
{
|
||||||
|
conditions: conditions,
|
||||||
|
actions: actions,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const automationController = new AutomationsController(automations);
|
||||||
|
const conditionController = new ConditionController();
|
||||||
|
const element = mock<HTMLElement>();
|
||||||
|
const hass = createHASS();
|
||||||
|
|
||||||
|
automationController.execute(element, hass, conditionController);
|
||||||
|
expect(frigateCardHandleAction).not.toBeCalled();
|
||||||
|
|
||||||
|
conditionController.setState({ fullscreen: true });
|
||||||
|
automationController.execute(element, hass, conditionController);
|
||||||
|
expect(frigateCardHandleAction).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
// Automation will not re-fire when condition continues to evaluate the
|
||||||
|
// same.
|
||||||
|
automationController.execute(element, hass, conditionController);
|
||||||
|
expect(frigateCardHandleAction).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
conditionController.setState({ fullscreen: false });
|
||||||
|
automationController.execute(element, hass, conditionController);
|
||||||
|
expect(frigateCardHandleAction).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
conditionController.setState({ fullscreen: true });
|
||||||
|
automationController.execute(element, hass, conditionController);
|
||||||
|
expect(frigateCardHandleAction).toBeCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should execute actions_not', () => {
|
||||||
|
const automations = automationsSchema.parse([
|
||||||
|
{
|
||||||
|
conditions: conditions,
|
||||||
|
actions_not: actions,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const automationController = new AutomationsController(automations);
|
||||||
|
automationController.execute(
|
||||||
|
mock<HTMLElement>(),
|
||||||
|
createHASS(),
|
||||||
|
new ConditionController(),
|
||||||
|
);
|
||||||
|
expect(frigateCardHandleAction).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent automation loops', () => {
|
||||||
|
const automations = automationsSchema.parse([
|
||||||
|
{
|
||||||
|
conditions: { fullscreen: true },
|
||||||
|
actions: actions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
conditions: { fullscreen: false },
|
||||||
|
actions: actions,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const automationController = new AutomationsController(automations);
|
||||||
|
const conditionController = new ConditionController();
|
||||||
|
const element = mock<HTMLElement>();
|
||||||
|
const hass = createHASS();
|
||||||
|
|
||||||
|
// Create a setup where one automation action causes another...
|
||||||
|
let fullscreen = true;
|
||||||
|
vi.mocked(frigateCardHandleAction).mockImplementation(() => {
|
||||||
|
fullscreen = !fullscreen;
|
||||||
|
conditionController.setState({ fullscreen: fullscreen });
|
||||||
|
automationController.execute(element, hass, conditionController);
|
||||||
|
});
|
||||||
|
|
||||||
|
conditionController.setState({ fullscreen: fullscreen });
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
automationController.execute(element, hass, conditionController),
|
||||||
|
).toThrowError(/Too many nested automation calls/);
|
||||||
|
expect(frigateCardHandleAction).toBeCalledTimes(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to construct error', () => {
|
||||||
|
const error = new AutomationsControllerError('message');
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
expect(error instanceof FrigateCardError).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { ReactiveControllerHost } from 'lit';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import { CachedValueController } from '../src/cached-value-controller';
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('CachedValueController', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should construct', () => {
|
||||||
|
const host = mock<ReactiveControllerHost>();
|
||||||
|
const callback = vi.fn();
|
||||||
|
const controller = new CachedValueController(host, 10, callback);
|
||||||
|
|
||||||
|
expect(controller).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove host', () => {
|
||||||
|
const host = mock<ReactiveControllerHost>();
|
||||||
|
const callback = vi.fn();
|
||||||
|
const controller = new CachedValueController(host, 10, callback);
|
||||||
|
|
||||||
|
controller.removeController();
|
||||||
|
expect(host.removeController).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have timer', () => {
|
||||||
|
const host = mock<ReactiveControllerHost>();
|
||||||
|
const callback = vi.fn();
|
||||||
|
const startCallback = vi.fn();
|
||||||
|
const stopCallback = vi.fn();
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const controller = new CachedValueController(
|
||||||
|
host,
|
||||||
|
10,
|
||||||
|
callback,
|
||||||
|
startCallback,
|
||||||
|
stopCallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.startTimer();
|
||||||
|
expect(startCallback).toBeCalled();
|
||||||
|
|
||||||
|
callback.mockReturnValue(3);
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
expect(callback).toBeCalled();
|
||||||
|
expect(host.requestUpdate).toBeCalled();
|
||||||
|
expect(controller.value).toBe(3);
|
||||||
|
|
||||||
|
callback.mockReturnValue(4);
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
expect(callback).toBeCalled();
|
||||||
|
expect(host.requestUpdate).toBeCalled();
|
||||||
|
expect(controller.value).toBe(4);
|
||||||
|
|
||||||
|
expect(controller.hasTimer()).toBeTruthy();
|
||||||
|
|
||||||
|
controller.stopTimer();
|
||||||
|
expect(stopCallback).toBeCalled();
|
||||||
|
|
||||||
|
callback.mockReset();
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
expect(callback).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear value', () => {
|
||||||
|
const host = mock<ReactiveControllerHost>();
|
||||||
|
const callback = vi.fn().mockReturnValue(42);
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const controller = new CachedValueController(host, 10, callback);
|
||||||
|
controller.startTimer();
|
||||||
|
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
expect(controller.value).equal(42);
|
||||||
|
|
||||||
|
controller.clearValue();
|
||||||
|
expect(controller.value).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should connect and disconnect host', () => {
|
||||||
|
const host = mock<ReactiveControllerHost>();
|
||||||
|
const callback = vi.fn().mockReturnValue(43);
|
||||||
|
const startCallback = vi.fn();
|
||||||
|
const stopCallback = vi.fn();
|
||||||
|
|
||||||
|
const controller = new CachedValueController(
|
||||||
|
host,
|
||||||
|
10,
|
||||||
|
callback,
|
||||||
|
startCallback,
|
||||||
|
stopCallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.hostConnected();
|
||||||
|
expect(controller.value).equal(43);
|
||||||
|
expect(startCallback).toBeCalled();
|
||||||
|
expect(host.requestUpdate).toBeCalled();
|
||||||
|
|
||||||
|
controller.hostDisconnected();
|
||||||
|
expect(controller.value).toBeUndefined();
|
||||||
|
expect(stopCallback).toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
|
||||||
|
import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/engine-frigate';
|
||||||
|
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic';
|
||||||
|
import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye';
|
||||||
|
import { Engine } from '../../src/camera-manager/types.js';
|
||||||
|
import { CardWideConfig } from '../../src/types.js';
|
||||||
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
|
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
|
||||||
import { EntityCache } from '../../src/utils/ha/entity-registry/cache';
|
import { EntityCache } from '../../src/utils/ha/entity-registry/cache';
|
||||||
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
|
|
||||||
import { CameraConfig, cameraConfigSchema, CardWideConfig } from '../../src/types.js';
|
|
||||||
import { HomeAssistant } from 'custom-card-helpers';
|
|
||||||
import { Engine } from '../../src/camera-manager/types.js';
|
|
||||||
import { Entity } from '../../src/utils/ha/entity-registry/types.js';
|
|
||||||
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
|
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
|
||||||
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic';
|
import { createCameraConfig, createHASS, createRegistryEntity } from '../test-utils';
|
||||||
import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/engine-frigate';
|
|
||||||
import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye';
|
|
||||||
|
|
||||||
vi.mock('../../src/utils/ha/entity-registry');
|
vi.mock('../../src/utils/ha/entity-registry');
|
||||||
vi.mock('../../src/utils/ha/entity-registry/cache');
|
vi.mock('../../src/utils/ha/entity-registry/cache');
|
||||||
@@ -27,28 +25,6 @@ const createFactory = (options?: {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createCameraConfig = (config: Partial<CameraConfig>): CameraConfig => {
|
|
||||||
return cameraConfigSchema.parse(config);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createHASS = (): HomeAssistant => {
|
|
||||||
return mock<HomeAssistant>();
|
|
||||||
};
|
|
||||||
|
|
||||||
const createEntity = (entity: Partial<Entity>): Entity => {
|
|
||||||
return {
|
|
||||||
...entity,
|
|
||||||
config_entry_id: entity.config_entry_id ?? null,
|
|
||||||
device_id: entity.device_id ?? null,
|
|
||||||
disabled_by: entity.disabled_by ?? null,
|
|
||||||
entity_id: entity.entity_id ?? 'entity_id',
|
|
||||||
hidden_by: entity.hidden_by ?? null,
|
|
||||||
platform: entity.platform ?? 'platform',
|
|
||||||
translation_key: entity.translation_key ?? null,
|
|
||||||
unique_id: entity.unique_id ?? 'unique_id',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
|
describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
|
||||||
it('should get frigate engine from config', async () => {
|
it('should get frigate engine from config', async () => {
|
||||||
const config = createCameraConfig({ engine: 'frigate' });
|
const config = createCameraConfig({ engine: 'frigate' });
|
||||||
@@ -62,13 +38,21 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
|
|||||||
Engine.MotionEye,
|
Engine.MotionEye,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
it('should get generic engine from config', async () => {
|
||||||
|
const config = createCameraConfig({ engine: 'generic' });
|
||||||
|
expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe(
|
||||||
|
Engine.Generic,
|
||||||
|
);
|
||||||
|
});
|
||||||
it('should get frigate engine from auto config', async () => {
|
it('should get frigate engine from auto config', async () => {
|
||||||
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
|
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
|
||||||
const entityRegistryManager = new EntityRegistryManager(new EntityCache());
|
const entityRegistryManager = new EntityRegistryManager(new EntityCache());
|
||||||
|
|
||||||
entityRegistryManager.getEntity = vi
|
entityRegistryManager.getEntity = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue(createEntity({ entity_id: 'camera.foo', platform: 'frigate' }));
|
.mockResolvedValue(
|
||||||
|
createRegistryEntity({ entity_id: 'camera.foo', platform: 'frigate' }),
|
||||||
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await createFactory({
|
await createFactory({
|
||||||
@@ -83,7 +67,7 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
|
|||||||
entityRegistryManager.getEntity = vi
|
entityRegistryManager.getEntity = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue(
|
.mockResolvedValue(
|
||||||
createEntity({ entity_id: 'camera.foo', platform: 'motioneye' }),
|
createRegistryEntity({ entity_id: 'camera.foo', platform: 'motioneye' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -98,7 +82,9 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
|
|||||||
|
|
||||||
entityRegistryManager.getEntity = vi
|
entityRegistryManager.getEntity = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue(createEntity({ entity_id: 'camera.foo', platform: 'generic' }));
|
.mockResolvedValue(
|
||||||
|
createRegistryEntity({ entity_id: 'camera.foo', platform: 'generic' }),
|
||||||
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await createFactory({
|
await createFactory({
|
||||||
@@ -120,6 +106,49 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
|
|||||||
|
|
||||||
entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error());
|
entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createFactory({
|
||||||
|
entityRegistryManager: entityRegistryManager,
|
||||||
|
}).getEngineForCamera(createHASS(), config),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
it('should treat entity not in registry but with state as generic', async () => {
|
||||||
|
const config = createCameraConfig({
|
||||||
|
engine: 'auto',
|
||||||
|
webrtc_card: { entity: 'camera.foo' },
|
||||||
|
});
|
||||||
|
const entityRegistryManager = new EntityRegistryManager(new EntityCache());
|
||||||
|
|
||||||
|
entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error());
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await createFactory({
|
||||||
|
entityRegistryManager: entityRegistryManager,
|
||||||
|
}).getEngineForCamera(
|
||||||
|
createHASS({
|
||||||
|
'camera.foo': {
|
||||||
|
entity_id: 'camera.foo',
|
||||||
|
state: 'streaming',
|
||||||
|
last_changed: 'bar',
|
||||||
|
last_updated: 'baz',
|
||||||
|
attributes: {},
|
||||||
|
context: {
|
||||||
|
id: 'context',
|
||||||
|
user_id: null,
|
||||||
|
parent_id: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
config,
|
||||||
|
),
|
||||||
|
).toBe(Engine.Generic);
|
||||||
|
});
|
||||||
|
it('should get engine from webrtc-card configuration', async () => {
|
||||||
|
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
|
||||||
|
const entityRegistryManager = new EntityRegistryManager(new EntityCache());
|
||||||
|
|
||||||
|
entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
createFactory({
|
createFactory({
|
||||||
entityRegistryManager: entityRegistryManager,
|
entityRegistryManager: entityRegistryManager,
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import add from 'date-fns/add';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import {
|
||||||
|
getEventMediaContentID,
|
||||||
|
getEventThumbnailURL,
|
||||||
|
getEventTitle,
|
||||||
|
getRecordingID,
|
||||||
|
getRecordingMediaContentID,
|
||||||
|
getRecordingTitle,
|
||||||
|
} from '../../../src/camera-manager/frigate/util';
|
||||||
|
import { CameraConfig } from '../../../src/types';
|
||||||
|
import {
|
||||||
|
createCameraConfig,
|
||||||
|
createFrigateEvent,
|
||||||
|
createFrigateRecording,
|
||||||
|
} from '../../test-utils';
|
||||||
|
|
||||||
|
describe('getEventTitle', () => {
|
||||||
|
const start = new Date('2023-05-06T10:43:00');
|
||||||
|
const end = new Date('2023-05-06T10:44:12');
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
it('should get finished event title', () => {
|
||||||
|
expect(
|
||||||
|
getEventTitle(
|
||||||
|
createFrigateEvent({
|
||||||
|
start_time: start.getTime() / 1000,
|
||||||
|
end_time: end.getTime() / 1000,
|
||||||
|
top_score: 0.841796875,
|
||||||
|
label: 'person',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('2023-05-06 10:43 [72s, Person 84%]');
|
||||||
|
});
|
||||||
|
it('should get in-progress event title', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(add(start, { seconds: 60 }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getEventTitle(
|
||||||
|
createFrigateEvent({
|
||||||
|
start_time: start.getTime() / 1000,
|
||||||
|
end_time: null,
|
||||||
|
top_score: 0.841796875,
|
||||||
|
label: 'person',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('2023-05-06 10:43 [60s, Person 84%]');
|
||||||
|
});
|
||||||
|
it('should get scoreless event title', () => {
|
||||||
|
expect(
|
||||||
|
getEventTitle(
|
||||||
|
createFrigateEvent({
|
||||||
|
start_time: start.getTime() / 1000,
|
||||||
|
end_time: end.getTime() / 1000,
|
||||||
|
top_score: null,
|
||||||
|
label: 'person',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('2023-05-06 10:43 [72s, Person]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRecordingTitle', () => {
|
||||||
|
it('should get recording title', () => {
|
||||||
|
expect(
|
||||||
|
getRecordingTitle(
|
||||||
|
'Kitchen',
|
||||||
|
createFrigateRecording({
|
||||||
|
startTime: new Date('2023-04-29T14:00:00'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('Kitchen 2023-04-29 14:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEventThumbnailURL', () => {
|
||||||
|
it('should get thumbnail URL', () => {
|
||||||
|
expect(
|
||||||
|
getEventThumbnailURL(
|
||||||
|
'clientid',
|
||||||
|
createFrigateEvent({
|
||||||
|
id: '1683396875.643998-hmzrh5',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('/api/frigate/clientid/thumbnail/1683396875.643998-hmzrh5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEventMediaContentID', () => {
|
||||||
|
it('should get event content ID', () => {
|
||||||
|
expect(
|
||||||
|
getEventMediaContentID(
|
||||||
|
'clientid',
|
||||||
|
'kitchen',
|
||||||
|
createFrigateEvent({
|
||||||
|
id: '1683396875.643998-hmzrh5',
|
||||||
|
}),
|
||||||
|
'clips',
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
'media-source://frigate/clientid/event/clips/kitchen/1683396875.643998-hmzrh5',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRecordingMediaContentID', () => {
|
||||||
|
it('should get recording content ID', () => {
|
||||||
|
expect(
|
||||||
|
getRecordingMediaContentID(
|
||||||
|
'clientid',
|
||||||
|
'kitchen',
|
||||||
|
createFrigateRecording({
|
||||||
|
startTime: new Date('2023-04-29T14:00:00'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('media-source://frigate/clientid/recordings/kitchen/2023-04-29/14');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRecordingID', () => {
|
||||||
|
it('should get recording ID', () => {
|
||||||
|
expect(
|
||||||
|
getRecordingID(
|
||||||
|
createCameraConfig({
|
||||||
|
frigate: {
|
||||||
|
client_id: 'unique_client_id',
|
||||||
|
camera_name: 'kitchen',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createFrigateRecording({
|
||||||
|
startTime: new Date('2023-04-29T14:00:00Z'),
|
||||||
|
endTime: new Date('2023-04-29T14:59:59Z'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('unique_client_id/kitchen/1682776800000/1682780399000');
|
||||||
|
});
|
||||||
|
it('should get recording ID without client_id or camera_name', () => {
|
||||||
|
// Note: This path is defended against in the code but should not happen in
|
||||||
|
// practice as this would be a malformed (not-zod-parsed) camera config.
|
||||||
|
const cameraConfig = mock<CameraConfig>();
|
||||||
|
expect(
|
||||||
|
getRecordingID(
|
||||||
|
cameraConfig,
|
||||||
|
createFrigateRecording({
|
||||||
|
startTime: new Date('2023-04-29T14:00:00Z'),
|
||||||
|
endTime: new Date('2023-04-29T14:59:59Z'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('//1682776800000/1682780399000');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
capEndDate,
|
||||||
|
convertRangeToCacheFriendlyTimes,
|
||||||
|
getCameraEntityFromConfig,
|
||||||
|
sortMedia,
|
||||||
|
} from '../../src/camera-manager/util.js';
|
||||||
|
import { ViewMedia, ViewMediaType } from '../../src/view/media.js';
|
||||||
|
import { CameraConfig, cameraConfigSchema } from '../../src/types.js';
|
||||||
|
|
||||||
|
describe('convertRangeToCacheFriendlyTimes', () => {
|
||||||
|
it('should return cache friendly within hour range', () => {
|
||||||
|
expect(
|
||||||
|
convertRangeToCacheFriendlyTimes({
|
||||||
|
start: new Date('2023-04-29T14:01:02'),
|
||||||
|
end: new Date('2023-04-29T14:11:03'),
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
start: new Date('2023-04-29T14:00:00'),
|
||||||
|
end: new Date('2023-04-29T14:59:59.999'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return cache friendly within day range', () => {
|
||||||
|
expect(
|
||||||
|
convertRangeToCacheFriendlyTimes({
|
||||||
|
start: new Date('2023-04-29T14:01:02'),
|
||||||
|
end: new Date('2023-04-29T15:11:03'),
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
start: new Date('2023-04-29T00:00:00'),
|
||||||
|
end: new Date('2023-04-29T23:59:59.999'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cap end date', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2023-04-29T14:25'));
|
||||||
|
expect(
|
||||||
|
convertRangeToCacheFriendlyTimes(
|
||||||
|
{
|
||||||
|
start: new Date('2023-04-29T14:01:02'),
|
||||||
|
end: new Date('2023-04-29T14:11:03'),
|
||||||
|
},
|
||||||
|
{ endCap: true },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
start: new Date('2023-04-29T14:00:00'),
|
||||||
|
end: new Date('2023-04-29T14:25:59.999'),
|
||||||
|
});
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('capEndDate', () => {
|
||||||
|
it('should cap end date', () => {
|
||||||
|
const fakeNow = new Date('2023-04-29T14:25');
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(fakeNow);
|
||||||
|
|
||||||
|
expect(capEndDate(new Date('2023-04-29T15:02'))).toEqual(fakeNow);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not cap end date', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2023-04-29T14:25'));
|
||||||
|
|
||||||
|
const testDate = new Date('2023-04-29T14:24');
|
||||||
|
expect(capEndDate(testDate)).toEqual(testDate);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ViewMedia itself has no native way to set startTime and ID that aren't linked
|
||||||
|
// to an engine.
|
||||||
|
class TestViewMedia extends ViewMedia {
|
||||||
|
protected _ID: string | null;
|
||||||
|
protected _startTime: Date;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
ID: string | null,
|
||||||
|
startTime: Date,
|
||||||
|
mediaType: ViewMediaType,
|
||||||
|
cameraID: string,
|
||||||
|
) {
|
||||||
|
super(mediaType, cameraID);
|
||||||
|
this._ID = ID;
|
||||||
|
this._startTime = startTime;
|
||||||
|
}
|
||||||
|
public getID(): string | null {
|
||||||
|
return this._ID;
|
||||||
|
}
|
||||||
|
public getStartTime(): Date | null {
|
||||||
|
return this._startTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sortMedia', () => {
|
||||||
|
const media_1 = new TestViewMedia(
|
||||||
|
'id-1',
|
||||||
|
new Date('2023-04-29T14:25'),
|
||||||
|
'clip',
|
||||||
|
'camera-1',
|
||||||
|
);
|
||||||
|
const media_2 = new TestViewMedia(
|
||||||
|
'id-2',
|
||||||
|
new Date('2023-04-29T14:26'),
|
||||||
|
'clip',
|
||||||
|
'camera-1',
|
||||||
|
);
|
||||||
|
const media_3_dup_id = new TestViewMedia(
|
||||||
|
'id-2',
|
||||||
|
new Date('2023-04-29T14:26'),
|
||||||
|
'clip',
|
||||||
|
'camera-1',
|
||||||
|
);
|
||||||
|
const media_4_no_id = new TestViewMedia(
|
||||||
|
null,
|
||||||
|
new Date('2023-04-29T14:27'),
|
||||||
|
'clip',
|
||||||
|
'camera-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should sort sorted media', () => {
|
||||||
|
const media = [media_1, media_2];
|
||||||
|
expect(sortMedia(media)).toEqual(media);
|
||||||
|
});
|
||||||
|
it('should sort unsorted media', () => {
|
||||||
|
expect(sortMedia([media_2, media_1])).toEqual([media_1, media_2]);
|
||||||
|
});
|
||||||
|
it('should remove duplicate id', () => {
|
||||||
|
expect(sortMedia([media_1, media_2, media_3_dup_id])).toEqual([media_1, media_2]);
|
||||||
|
});
|
||||||
|
it('should remove de-duplicate by object if no id', () => {
|
||||||
|
expect(sortMedia([media_1, media_2, media_4_no_id, media_4_no_id])).toEqual([
|
||||||
|
media_1,
|
||||||
|
media_2,
|
||||||
|
media_4_no_id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCameraEntityFromConfig', () => {
|
||||||
|
const createCameraConfig = (config: Partial<CameraConfig>): CameraConfig => {
|
||||||
|
return cameraConfigSchema.parse(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should get camera_entity', () => {
|
||||||
|
expect(getCameraEntityFromConfig(createCameraConfig({ camera_entity: 'foo' }))).toBe(
|
||||||
|
'foo',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('should get camera_entity from webrtc_card config', () => {
|
||||||
|
expect(
|
||||||
|
getCameraEntityFromConfig(createCameraConfig({ webrtc_card: { entity: 'bar' } })),
|
||||||
|
).toBe('bar');
|
||||||
|
});
|
||||||
|
it('should get no camera_entity', () => {
|
||||||
|
expect(getCameraEntityFromConfig(createCameraConfig({}))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
ConditionController,
|
||||||
|
ConditionEvaluateRequestEvent,
|
||||||
|
evaluateConditionViaEvent,
|
||||||
|
getOverriddenConfig,
|
||||||
|
getOverridesByKey,
|
||||||
|
} from '../src/conditions';
|
||||||
|
import { createCondition, createConfig, createStateEntity } from './test-utils';
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('ConditionEvaluateRequestEvent', () => {
|
||||||
|
it('should construct', () => {
|
||||||
|
const condition = createCondition({ fullscreen: true });
|
||||||
|
const event = new ConditionEvaluateRequestEvent(condition, {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(event.type).toBe('frigate-card:condition:evaluate');
|
||||||
|
expect(event.condition).toBe(condition);
|
||||||
|
expect(event.bubbles).toBeTruthy();
|
||||||
|
expect(event.composed).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluateConditionViaEvent', () => {
|
||||||
|
it('should evaluate true without condition', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
expect(evaluateConditionViaEvent(element)).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('should dispatch event with condition and evaluate true', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const condition = createCondition({ fullscreen: true });
|
||||||
|
const handler = vi.fn().mockImplementation((ev: ConditionEvaluateRequestEvent) => {
|
||||||
|
expect(ev.condition).toBe(condition);
|
||||||
|
ev.evaluation = true;
|
||||||
|
});
|
||||||
|
element.addEventListener('frigate-card:condition:evaluate', handler);
|
||||||
|
|
||||||
|
expect(evaluateConditionViaEvent(element, condition)).toBeTruthy();
|
||||||
|
expect(handler).toBeCalled();
|
||||||
|
});
|
||||||
|
it('should dispatch event with condition and evaluate false', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const condition = createCondition({ fullscreen: true });
|
||||||
|
const handler = vi.fn().mockImplementation((ev: ConditionEvaluateRequestEvent) => {
|
||||||
|
expect(ev.condition).toBe(condition);
|
||||||
|
ev.evaluation = false;
|
||||||
|
});
|
||||||
|
element.addEventListener('frigate-card:condition:evaluate', handler);
|
||||||
|
|
||||||
|
expect(evaluateConditionViaEvent(element, condition)).toBeFalsy();
|
||||||
|
expect(handler).toBeCalled();
|
||||||
|
});
|
||||||
|
it('should dispatch event evaluate false if no evaluation', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const condition = createCondition({ fullscreen: true });
|
||||||
|
const handler = vi.fn();
|
||||||
|
element.addEventListener('frigate-card:condition:evaluate', handler);
|
||||||
|
|
||||||
|
expect(evaluateConditionViaEvent(element, condition)).toBeFalsy();
|
||||||
|
expect(handler).toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOverriddenConfig', () => {
|
||||||
|
const config = {
|
||||||
|
menu: {
|
||||||
|
style: 'none',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const overrides = [
|
||||||
|
{
|
||||||
|
overrides: {
|
||||||
|
menu: {
|
||||||
|
style: 'above',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {
|
||||||
|
fullscreen: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('should not override config', () => {
|
||||||
|
const controller = new ConditionController();
|
||||||
|
expect(getOverriddenConfig(controller, config, overrides)).toBe(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should override config', () => {
|
||||||
|
const controller = new ConditionController();
|
||||||
|
controller.setState({ fullscreen: true });
|
||||||
|
|
||||||
|
expect(getOverriddenConfig(controller, config, overrides)).toEqual({
|
||||||
|
menu: {
|
||||||
|
style: 'above',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOverridesByKey', () => {
|
||||||
|
const condition = {
|
||||||
|
fullscreen: true,
|
||||||
|
};
|
||||||
|
const override = {
|
||||||
|
menu: {
|
||||||
|
style: 'above',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const overrides = [
|
||||||
|
{
|
||||||
|
overrides: override,
|
||||||
|
conditions: condition,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('should get overrides', () => {
|
||||||
|
expect(getOverridesByKey('menu', overrides)).toEqual([
|
||||||
|
{ conditions: condition, overrides: { style: 'above' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get no overrides', () => {
|
||||||
|
expect(getOverridesByKey('live', overrides)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get no overrides when undefined', () => {
|
||||||
|
expect(getOverridesByKey('live')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ConditionController', () => {
|
||||||
|
const config = {
|
||||||
|
type: 'custom:frigate-card',
|
||||||
|
cameras: [],
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: 'custom:frigate-card-conditional',
|
||||||
|
conditions: {
|
||||||
|
fullscreen: true,
|
||||||
|
},
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: 'custom:nested-unknown-object',
|
||||||
|
unknown_key: {
|
||||||
|
type: 'custom:frigate-card-conditional',
|
||||||
|
conditions: {
|
||||||
|
media_query: 'media query goes here',
|
||||||
|
},
|
||||||
|
elements: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
overrides: {
|
||||||
|
menu: {
|
||||||
|
style: 'overlay',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {
|
||||||
|
fullscreen: true,
|
||||||
|
state: [
|
||||||
|
{
|
||||||
|
entity: 'binary_sensor.foo',
|
||||||
|
state: 'on',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add listener', () => {
|
||||||
|
const controller = new ConditionController();
|
||||||
|
const handler = vi.fn();
|
||||||
|
controller.addStateListener(handler);
|
||||||
|
controller.setState({ fullscreen: true });
|
||||||
|
expect(handler).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove listener', () => {
|
||||||
|
const controller = new ConditionController();
|
||||||
|
const handler = vi.fn();
|
||||||
|
controller.addStateListener(handler);
|
||||||
|
controller.removeStateListener(handler);
|
||||||
|
controller.setState({ fullscreen: true });
|
||||||
|
expect(handler).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get wrapper', () => {
|
||||||
|
const controller = new ConditionController();
|
||||||
|
const wrapper_1 = controller.getEpoch();
|
||||||
|
expect(wrapper_1).toEqual({ controller: controller });
|
||||||
|
|
||||||
|
controller.setState({ fullscreen: true });
|
||||||
|
|
||||||
|
const wrapper_2 = controller.getEpoch();
|
||||||
|
expect(wrapper_2).toEqual({ controller: controller });
|
||||||
|
|
||||||
|
// Since the state was set the wrappers should be different.
|
||||||
|
expect(wrapper_1).not.toBe(wrapper_2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not return hasHAStateConditions without HA state conditions', () => {
|
||||||
|
const controller = new ConditionController();
|
||||||
|
expect(controller.hasHAStateConditions).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return hasHAStateConditions with HA state conditions', () => {
|
||||||
|
vi.spyOn(window, 'matchMedia').mockReturnValueOnce({
|
||||||
|
matches: false,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
} as unknown as MediaQueryList);
|
||||||
|
const controller = new ConditionController(createConfig(config));
|
||||||
|
expect(controller.hasHAStateConditions).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate conditions with a view', () => {
|
||||||
|
const controller = new ConditionController();
|
||||||
|
const condition = { view: ['foo'] };
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
controller.setState({ view: 'foo' });
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate conditions with fullscreen', () => {
|
||||||
|
const controller = new ConditionController();
|
||||||
|
const condition = { fullscreen: true };
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
controller.setState({ fullscreen: true });
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeTruthy();
|
||||||
|
controller.setState({ fullscreen: false });
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate conditions with expand', () => {
|
||||||
|
const controller = new ConditionController();
|
||||||
|
const condition = { expand: true };
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
controller.setState({ expand: true });
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeTruthy();
|
||||||
|
controller.setState({ expand: false });
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate conditions with camera', () => {
|
||||||
|
const controller = new ConditionController();
|
||||||
|
const condition = { camera: ['bar'] };
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
controller.setState({ camera: 'bar' });
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeTruthy();
|
||||||
|
controller.setState({ camera: 'will-not-match' });
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate conditions with ha state positive check', () => {
|
||||||
|
const controller = new ConditionController();
|
||||||
|
const condition = {
|
||||||
|
state: [
|
||||||
|
{
|
||||||
|
entity: 'binary_sensor.foo',
|
||||||
|
state: 'on',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
controller.setState({ state: { 'binary_sensor.foo': createStateEntity() } });
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeTruthy();
|
||||||
|
controller.setState({
|
||||||
|
state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) },
|
||||||
|
});
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate conditions with ha state negative check', () => {
|
||||||
|
const controller = new ConditionController();
|
||||||
|
const condition = {
|
||||||
|
state: [
|
||||||
|
{
|
||||||
|
entity: 'binary_sensor.foo',
|
||||||
|
state_not: 'on',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
controller.setState({ state: { 'binary_sensor.foo': createStateEntity() } });
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
controller.setState({
|
||||||
|
state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) },
|
||||||
|
});
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate conditions with media_loaded', () => {
|
||||||
|
const controller = new ConditionController();
|
||||||
|
const condition = { media_loaded: true };
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
controller.setState({ media_loaded: true });
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeTruthy();
|
||||||
|
controller.setState({ media_loaded: false });
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate conditions with media query', () => {
|
||||||
|
vi.spyOn(window, 'matchMedia')
|
||||||
|
.mockReturnValueOnce(<MediaQueryList>{ matches: true })
|
||||||
|
.mockReturnValueOnce(<MediaQueryList>{ matches: false });
|
||||||
|
|
||||||
|
const controller = new ConditionController();
|
||||||
|
const condition = { media_query: 'whatever' };
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeTruthy();
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger on changes to media query conditions', () => {
|
||||||
|
const addEventListener = vi.fn();
|
||||||
|
const removeEventListener = vi.fn();
|
||||||
|
vi.spyOn(window, 'matchMedia').mockReturnValueOnce({
|
||||||
|
matches: true,
|
||||||
|
addEventListener: addEventListener,
|
||||||
|
removeEventListener: removeEventListener,
|
||||||
|
} as unknown as MediaQueryList);
|
||||||
|
|
||||||
|
const controller = new ConditionController(createConfig(config));
|
||||||
|
expect(addEventListener).toHaveBeenCalledWith('change', expect.anything());
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
controller.addStateListener(callback);
|
||||||
|
|
||||||
|
// Call the media query callback and use it to pretend a match happened. The
|
||||||
|
// callback is the 0th mock innvocation and the 1st argument.
|
||||||
|
addEventListener.mock.calls[0][1]();
|
||||||
|
|
||||||
|
// This should result in a callback to our state listener.
|
||||||
|
expect(callback).toBeCalled();
|
||||||
|
|
||||||
|
// Destroy the controller, which should remove the media query listener.
|
||||||
|
controller.destroy();
|
||||||
|
expect(removeEventListener).toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import { FrigateEvent, FrigateRecording } from '../src/camera-manager/frigate/types';
|
||||||
|
import {
|
||||||
|
CameraConfig,
|
||||||
|
FrigateCardCondition,
|
||||||
|
FrigateCardConfig,
|
||||||
|
cameraConfigSchema,
|
||||||
|
frigateCardConditionSchema,
|
||||||
|
frigateCardConfigSchema,
|
||||||
|
} from '../src/types';
|
||||||
|
import { Entity } from '../src/utils/ha/entity-registry/types';
|
||||||
|
|
||||||
|
export const createCameraConfig = (config: unknown): CameraConfig => {
|
||||||
|
return cameraConfigSchema.parse(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createCondition = (
|
||||||
|
condition?: Partial<FrigateCardCondition>,
|
||||||
|
): FrigateCardCondition => {
|
||||||
|
return frigateCardConditionSchema.parse(condition ?? {});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createConfig = (config?: Partial<FrigateCardConfig>): FrigateCardConfig => {
|
||||||
|
return frigateCardConfigSchema.parse(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createHASS = (states?: HassEntities): HomeAssistant => {
|
||||||
|
const hass = mock<HomeAssistant>();
|
||||||
|
if (states) {
|
||||||
|
hass.states = states;
|
||||||
|
}
|
||||||
|
return hass;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRegistryEntity = (entity?: Partial<Entity>): Entity => {
|
||||||
|
return {
|
||||||
|
config_entry_id: entity?.config_entry_id ?? null,
|
||||||
|
device_id: entity?.device_id ?? null,
|
||||||
|
disabled_by: entity?.disabled_by ?? null,
|
||||||
|
entity_id: entity?.entity_id ?? 'entity_id',
|
||||||
|
hidden_by: entity?.hidden_by ?? null,
|
||||||
|
platform: entity?.platform ?? 'platform',
|
||||||
|
translation_key: entity?.translation_key ?? null,
|
||||||
|
unique_id: entity?.unique_id ?? 'unique_id',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createStateEntity = (entity?: Partial<HassEntity>): HassEntity => {
|
||||||
|
return {
|
||||||
|
entity_id: entity?.entity_id ?? 'entity_id',
|
||||||
|
state: entity?.state ?? 'on',
|
||||||
|
last_changed: entity?.last_changed ?? 'never',
|
||||||
|
last_updated: entity?.last_updated ?? 'never',
|
||||||
|
attributes: entity?.attributes ?? {},
|
||||||
|
context: entity?.context ?? {
|
||||||
|
id: 'id',
|
||||||
|
parent_id: 'parent_id',
|
||||||
|
user_id: 'user_id',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createFrigateEvent = (event?: Partial<FrigateEvent>) => {
|
||||||
|
return {
|
||||||
|
camera: 'camera',
|
||||||
|
end_time: 1683397124,
|
||||||
|
false_positive: false,
|
||||||
|
has_clip: true,
|
||||||
|
has_snapshot: true,
|
||||||
|
id: '1683396875.643998-hmzrh5',
|
||||||
|
label: 'person',
|
||||||
|
sub_label: null,
|
||||||
|
start_time: 1683395000,
|
||||||
|
top_score: 0.841796875,
|
||||||
|
zones: [],
|
||||||
|
retain_indefinitely: false,
|
||||||
|
...event,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createFrigateRecording = (recording?: Partial<FrigateRecording>) => {
|
||||||
|
return {
|
||||||
|
cameraID: 'cameraID',
|
||||||
|
startTime: new Date('2023-04-29T14:00:00'),
|
||||||
|
endTime: new Date('2023-04-29T14:59:59'),
|
||||||
|
events: 42,
|
||||||
|
...recording,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { handleActionConfig, hasAction } from 'custom-card-helpers';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import { actionSchema } from '../../src/types';
|
||||||
|
import {
|
||||||
|
convertActionToFrigateCardCustomAction,
|
||||||
|
createFrigateCardCustomAction,
|
||||||
|
frigateCardHandleActionConfig,
|
||||||
|
frigateCardHasAction,
|
||||||
|
getActionConfigGivenAction,
|
||||||
|
stopEventFromActivatingCardWideActions,
|
||||||
|
} from '../../src/utils/action';
|
||||||
|
import { createHASS } from '../test-utils';
|
||||||
|
|
||||||
|
vi.mock('custom-card-helpers');
|
||||||
|
|
||||||
|
describe('convertActionToFrigateCardCustomAction', () => {
|
||||||
|
it('should skip null action', () => {
|
||||||
|
expect(convertActionToFrigateCardCustomAction(null)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse valid', () => {
|
||||||
|
expect(
|
||||||
|
convertActionToFrigateCardCustomAction({
|
||||||
|
action: 'custom:frigate-card-action',
|
||||||
|
frigate_card_action: 'download',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
frigate_card_action: 'download',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not parse invalid', () => {
|
||||||
|
expect(convertActionToFrigateCardCustomAction('this is garbage')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createFrigateCardCustomAction', () => {
|
||||||
|
it('should create camera_select', () => {
|
||||||
|
expect(
|
||||||
|
createFrigateCardCustomAction('camera_select', {
|
||||||
|
camera: 'camera',
|
||||||
|
cardID: 'card_id',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
camera: 'camera',
|
||||||
|
frigate_card_action: 'camera_select',
|
||||||
|
card_id: 'card_id',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not create camera_select without camera', () => {
|
||||||
|
expect(createFrigateCardCustomAction('camera_select')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create media_player', () => {
|
||||||
|
expect(
|
||||||
|
createFrigateCardCustomAction('media_player', {
|
||||||
|
media_player: 'device',
|
||||||
|
media_player_action: 'play',
|
||||||
|
cardID: 'card_id',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
frigate_card_action: 'media_player',
|
||||||
|
media_player: 'device',
|
||||||
|
media_player_action: 'play',
|
||||||
|
card_id: 'card_id',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not create media_player without player or action', () => {
|
||||||
|
expect(
|
||||||
|
createFrigateCardCustomAction('media_player', {
|
||||||
|
media_player_action: 'play',
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createFrigateCardCustomAction('media_player', {
|
||||||
|
media_player: 'device',
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create general action', () => {
|
||||||
|
expect(
|
||||||
|
createFrigateCardCustomAction('clips', {
|
||||||
|
cardID: 'card_id',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
frigate_card_action: 'clips',
|
||||||
|
card_id: 'card_id',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getActionConfigGivenAction', () => {
|
||||||
|
const action = actionSchema.parse({
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
frigate_card_action: 'clips',
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not handle undefined arguments', () => {
|
||||||
|
expect(getActionConfigGivenAction()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not handle unknown interactions', () => {
|
||||||
|
expect(
|
||||||
|
getActionConfigGivenAction('triple_poke', { triple_poke_action: action }),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle tap actions', () => {
|
||||||
|
expect(getActionConfigGivenAction('tap', { tap_action: action })).toBe(action);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle hold actions', () => {
|
||||||
|
expect(getActionConfigGivenAction('hold', { hold_action: action })).toBe(action);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle double_tap actions', () => {
|
||||||
|
expect(getActionConfigGivenAction('double_tap', { double_tap_action: action })).toBe(
|
||||||
|
action,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle end_tap actions', () => {
|
||||||
|
expect(getActionConfigGivenAction('end_tap', { end_tap_action: action })).toBe(
|
||||||
|
action,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle start_tap actions', () => {
|
||||||
|
expect(getActionConfigGivenAction('start_tap', { start_tap_action: action })).toBe(
|
||||||
|
action,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('frigateCardHandleActionConfig', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const action = actionSchema.parse({
|
||||||
|
action: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not handle missing arguments', () => {
|
||||||
|
expect(
|
||||||
|
frigateCardHandleActionConfig(element, createHASS(), {}, 'triple_poke'),
|
||||||
|
).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle simple case', () => {
|
||||||
|
frigateCardHandleActionConfig(element, createHASS(), {}, 'tap', action);
|
||||||
|
expect(handleActionConfig).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle array case', () => {
|
||||||
|
frigateCardHandleActionConfig(element, createHASS(), {}, 'tap', [
|
||||||
|
action,
|
||||||
|
action,
|
||||||
|
action,
|
||||||
|
]);
|
||||||
|
expect(handleActionConfig).toBeCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('frigateCardHasAction', () => {
|
||||||
|
const action = actionSchema.parse({
|
||||||
|
action: 'toggle',
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-array case', () => {
|
||||||
|
expect(frigateCardHasAction(action)).toBeFalsy();
|
||||||
|
expect(hasAction).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('should handle array case', () => {
|
||||||
|
expect(frigateCardHasAction([action, action, action])).toBeFalsy();
|
||||||
|
expect(hasAction).toBeCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('stopEventFromActivatingCardWideActions', () => {
|
||||||
|
it('should stop event from propogating', () => {
|
||||||
|
const event = mock<Event>();
|
||||||
|
stopEventFromActivatingCardWideActions(event);
|
||||||
|
expect(event.stopPropagation).toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { AudioProperties, mayHaveAudio } from '../../src/utils/audio';
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('mayHaveAudio', () => {
|
||||||
|
it('should detect audio when mozHasAudio true', () => {
|
||||||
|
const element: HTMLVideoElement & AudioProperties = document.createElement('video');
|
||||||
|
element.mozHasAudio = true;
|
||||||
|
expect(mayHaveAudio(element)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not detect audio when mozHasAudio undefined', () => {
|
||||||
|
const element: HTMLVideoElement & AudioProperties = document.createElement('video');
|
||||||
|
element.mozHasAudio = undefined;
|
||||||
|
expect(mayHaveAudio(element)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect audio when audioTracks has length', () => {
|
||||||
|
// Workaround: "Cannot set property audioTracks of #<HTMLMediaElement> which has only a getter"
|
||||||
|
const element = {} as HTMLVideoElement & AudioProperties;
|
||||||
|
element.audioTracks = [1, 2, 3];
|
||||||
|
expect(mayHaveAudio(element)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not detect audio when audioTracks has no length', () => {
|
||||||
|
// Workaround: "Cannot set property audioTracks of #<HTMLMediaElement> which has only a getter"
|
||||||
|
const element = {} as HTMLVideoElement & AudioProperties;
|
||||||
|
element.audioTracks = [];
|
||||||
|
expect(mayHaveAudio(element)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect audio when no evidence to the contrary', () => {
|
||||||
|
const element = {} as HTMLVideoElement & AudioProperties;
|
||||||
|
expect(mayHaveAudio(element)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, afterAll, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, afterAll } from 'vitest';
|
||||||
import { FrigateCardError } from '../../src/types';
|
import { FrigateCardError } from '../../src/types';
|
||||||
import {
|
import {
|
||||||
allPromises,
|
allPromises,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
|
||||||
|
import { CameraManager } from '../../src/camera-manager/manager.js';
|
||||||
|
import { CameraManagerStore } from '../../src/camera-manager/store.js';
|
||||||
|
import { CameraConfigs } from '../../src/camera-manager/types.js';
|
||||||
|
import { getAllDependentCameras, getCameraID } from '../../src/utils/camera.js';
|
||||||
|
import { createCameraConfig } from '../test-utils.js';
|
||||||
|
|
||||||
|
vi.mock('../../src/camera-manager/manager.js');
|
||||||
|
|
||||||
|
describe('getCameraID', () => {
|
||||||
|
it('should get camera id with id', () => {
|
||||||
|
const config = createCameraConfig({ id: 'foo' });
|
||||||
|
expect(getCameraID(config)).toBe('foo');
|
||||||
|
});
|
||||||
|
it('should get camera id with camera_entity', () => {
|
||||||
|
const config = createCameraConfig({ camera_entity: 'foo' });
|
||||||
|
expect(getCameraID(config)).toBe('foo');
|
||||||
|
});
|
||||||
|
it('should get camera id with webrtc entity', () => {
|
||||||
|
const config = createCameraConfig({ webrtc_card: { entity: 'foo' } });
|
||||||
|
expect(getCameraID(config)).toBe('foo');
|
||||||
|
});
|
||||||
|
it('should get camera id with frigate camera_name', () => {
|
||||||
|
const config = createCameraConfig({
|
||||||
|
frigate: { client_id: 'bar', camera_name: 'foo' },
|
||||||
|
});
|
||||||
|
expect(getCameraID(config)).toBe('foo');
|
||||||
|
});
|
||||||
|
it('should get blank id without anything', () => {
|
||||||
|
const config = createCameraConfig({});
|
||||||
|
expect(getCameraID(config)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllDependentCameras', () => {
|
||||||
|
it('should return null without cameraManager', () => {
|
||||||
|
expect(getAllDependentCameras()).toBeNull();
|
||||||
|
});
|
||||||
|
it('should return null without cameraID', () => {
|
||||||
|
expect(getAllDependentCameras(mock<CameraManager>())).toBeNull();
|
||||||
|
});
|
||||||
|
it('should return dependent cameras', () => {
|
||||||
|
const cameraConfigs: CameraConfigs = new Map([
|
||||||
|
[
|
||||||
|
'one',
|
||||||
|
createCameraConfig({
|
||||||
|
dependencies: {
|
||||||
|
cameras: ['two', 'three'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
['two', createCameraConfig({})],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cameraManager = new CameraManager(mock<CameraManagerEngineFactory>(), {});
|
||||||
|
const store = mock<CameraManagerStore>();
|
||||||
|
vi.mocked(cameraManager.getStore).mockReturnValue(store);
|
||||||
|
store.getCameras.mockReturnValue(cameraConfigs);
|
||||||
|
|
||||||
|
expect(getAllDependentCameras(cameraManager, 'one')).toEqual(
|
||||||
|
new Set(['one', 'two']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('should return all cameras', () => {
|
||||||
|
const cameraConfigs: CameraConfigs = new Map([
|
||||||
|
[
|
||||||
|
'one',
|
||||||
|
createCameraConfig({
|
||||||
|
dependencies: {
|
||||||
|
all_cameras: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
['two', createCameraConfig({})],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cameraManager = new CameraManager(mock<CameraManagerEngineFactory>(), {});
|
||||||
|
const store = mock<CameraManagerStore>();
|
||||||
|
vi.mocked(cameraManager.getStore).mockReturnValue(store);
|
||||||
|
store.getCameras.mockReturnValue(cameraConfigs);
|
||||||
|
|
||||||
|
expect(getAllDependentCameras(cameraManager, 'one')).toEqual(
|
||||||
|
new Set(['one', 'two']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { log } from '../../src/utils/debug.js';
|
||||||
|
|
||||||
|
describe('log', () => {
|
||||||
|
const spy = vi.spyOn(global.console, 'debug').mockReturnValue(undefined);
|
||||||
|
afterAll(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
it('should do nothing without debug logging set', () => {
|
||||||
|
log({}, 'foo');
|
||||||
|
expect(spy).not.toBeCalled();
|
||||||
|
});
|
||||||
|
it('should log debug when appropriately configured', () => {
|
||||||
|
log({ debug: { logging: true } }, 'foo');
|
||||||
|
expect(spy).toBeCalledWith('foo');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
|
||||||
|
import { MicrophoneController } from '../../src/utils/microphone';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
const navigatorMock = {
|
||||||
|
mediaDevices: {
|
||||||
|
getUserMedia: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('MicrophoneController', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('navigator', navigatorMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.unstubAllGlobals;
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMockStream = (mute?: boolean): MediaStream => {
|
||||||
|
const stream = mock<MediaStream>();
|
||||||
|
const track = mock<MediaStreamTrack>();
|
||||||
|
track.enabled = !mute;
|
||||||
|
stream.getTracks.mockImplementation(() => [track]);
|
||||||
|
return stream;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should be muted on creation', () => {
|
||||||
|
const controller = new MicrophoneController();
|
||||||
|
expect(controller).toBeTruthy();
|
||||||
|
expect(controller.isMuted()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be undefined without creation', () => {
|
||||||
|
const controller = new MicrophoneController();
|
||||||
|
expect(controller.getStream()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should connect', async () => {
|
||||||
|
const controller = new MicrophoneController();
|
||||||
|
const stream = createMockStream();
|
||||||
|
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(stream);
|
||||||
|
await controller.connect();
|
||||||
|
expect(controller.isConnected()).toBeTruthy();
|
||||||
|
expect(controller.getStream()).toBe(stream);
|
||||||
|
expect(controller.isMuted()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be forbidden when permission denied', async () => {
|
||||||
|
// Don't actually log messages to the console during the test.
|
||||||
|
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||||
|
const controller = new MicrophoneController();
|
||||||
|
navigatorMock.mediaDevices.getUserMedia.mockRejectedValue(new Error());
|
||||||
|
await controller.connect();
|
||||||
|
expect(controller.isConnected()).toBeFalsy();
|
||||||
|
expect(controller.isForbidden()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mute', async () => {
|
||||||
|
const controller = new MicrophoneController();
|
||||||
|
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream());
|
||||||
|
await controller.connect();
|
||||||
|
controller.mute();
|
||||||
|
expect(controller.isMuted()).toBeTruthy();
|
||||||
|
|
||||||
|
controller.unmute();
|
||||||
|
expect(controller.isMuted()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be unmuted on creation if unmute called first', async () => {
|
||||||
|
const controller = new MicrophoneController();
|
||||||
|
controller.unmute();
|
||||||
|
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream());
|
||||||
|
await controller.connect();
|
||||||
|
expect(controller.isMuted()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disconnect', async () => {
|
||||||
|
const controller = new MicrophoneController();
|
||||||
|
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream());
|
||||||
|
await controller.connect();
|
||||||
|
expect(controller.isConnected()).toBeTruthy();
|
||||||
|
|
||||||
|
await controller.disconnect();
|
||||||
|
expect(controller.isConnected()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should automatically disconnect', async () => {
|
||||||
|
const seconds = 10;
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const controller = new MicrophoneController(seconds);
|
||||||
|
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream());
|
||||||
|
|
||||||
|
await controller.connect();
|
||||||
|
expect(controller.isConnected()).toBeTruthy();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(seconds * 1000);
|
||||||
|
|
||||||
|
expect(controller.isConnected()).toBeFalsy();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import { CameraManager } from '../../src/camera-manager/manager';
|
||||||
|
import { getAllDependentCameras } from '../../src/utils/camera';
|
||||||
|
import {
|
||||||
|
createViewWithNextStream,
|
||||||
|
createViewWithSelectedSubstream,
|
||||||
|
createViewWithoutSubstream,
|
||||||
|
hasSubstream,
|
||||||
|
} from '../../src/utils/substream';
|
||||||
|
import { View } from '../../src/view/view';
|
||||||
|
|
||||||
|
vi.mock('../../src/utils/camera');
|
||||||
|
|
||||||
|
describe('createViewWithSelectedSubstream', () => {
|
||||||
|
it('should create view with selected substream', () => {
|
||||||
|
const view = new View({ view: 'live', camera: 'camera' });
|
||||||
|
const newView = createViewWithSelectedSubstream(view, 'substream');
|
||||||
|
expect(newView?.context?.live?.overrides).toEqual(
|
||||||
|
new Map([['camera', 'substream']]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create view with selected substream with existing overrides', () => {
|
||||||
|
const view = new View({
|
||||||
|
view: 'live',
|
||||||
|
camera: 'camera',
|
||||||
|
context: {
|
||||||
|
live: {
|
||||||
|
overrides: new Map([['camera', 'camera']]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const newView = createViewWithSelectedSubstream(view, 'substream');
|
||||||
|
expect(newView?.context?.live?.overrides).toEqual(
|
||||||
|
new Map([['camera', 'substream']]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createViewWithoutSubstream', () => {
|
||||||
|
it('should create view without substream', () => {
|
||||||
|
const view = new View({
|
||||||
|
view: 'live',
|
||||||
|
camera: 'camera',
|
||||||
|
context: {
|
||||||
|
live: {
|
||||||
|
overrides: new Map([['camera', 'camera']]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const newView = createViewWithoutSubstream(view);
|
||||||
|
expect(newView?.context?.live?.overrides).toEqual(new Map());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasSubstream', () => {
|
||||||
|
it('should detect substream', () => {
|
||||||
|
const view = new View({
|
||||||
|
view: 'live',
|
||||||
|
camera: 'camera',
|
||||||
|
context: {
|
||||||
|
live: {
|
||||||
|
overrides: new Map([['camera', 'camera2']]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(hasSubstream(view)).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('should not detect substream when absent', () => {
|
||||||
|
const view = new View({
|
||||||
|
view: 'live',
|
||||||
|
camera: 'camera',
|
||||||
|
});
|
||||||
|
expect(hasSubstream(view)).toBeFalsy();
|
||||||
|
});
|
||||||
|
it('should not detect substream when main stream', () => {
|
||||||
|
const view = new View({
|
||||||
|
view: 'live',
|
||||||
|
camera: 'camera',
|
||||||
|
context: {
|
||||||
|
live: {
|
||||||
|
overrides: new Map([['camera', 'camera']]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(hasSubstream(view)).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createViewWithNextStream', () => {
|
||||||
|
it('should create new equal view with no dependencies', () => {
|
||||||
|
const view = new View({
|
||||||
|
view: 'live',
|
||||||
|
camera: 'camera',
|
||||||
|
});
|
||||||
|
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera']));
|
||||||
|
const cameraManager = mock<CameraManager>();
|
||||||
|
const newView = createViewWithNextStream(cameraManager, view);
|
||||||
|
expect(newView.camera).toBe(view.camera);
|
||||||
|
expect(newView.view).toBe(view.view);
|
||||||
|
expect(newView.context).toEqual(view.context);
|
||||||
|
});
|
||||||
|
it('should create new view with next stream', () => {
|
||||||
|
const view = new View({
|
||||||
|
view: 'live',
|
||||||
|
camera: 'camera',
|
||||||
|
});
|
||||||
|
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
||||||
|
const cameraManager = mock<CameraManager>();
|
||||||
|
const newView = createViewWithNextStream(cameraManager, view);
|
||||||
|
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']]));
|
||||||
|
});
|
||||||
|
it('should create new view with next stream that cycles back', () => {
|
||||||
|
const view = new View({
|
||||||
|
view: 'live',
|
||||||
|
camera: 'camera',
|
||||||
|
context: {
|
||||||
|
live: {
|
||||||
|
overrides: new Map([['camera', 'camera2']]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
||||||
|
const cameraManager = mock<CameraManager>();
|
||||||
|
const newView = createViewWithNextStream(cameraManager, view);
|
||||||
|
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
|
||||||
|
});
|
||||||
|
it('should create new view with first stream with invalid substream', () => {
|
||||||
|
const view = new View({
|
||||||
|
view: 'live',
|
||||||
|
camera: 'camera',
|
||||||
|
context: {
|
||||||
|
live: {
|
||||||
|
overrides: new Map([['camera', 'camera-that-does-not-exist']]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
||||||
|
const cameraManager = mock<CameraManager>();
|
||||||
|
const newView = createViewWithNextStream(cameraManager, view);
|
||||||
|
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
|
import { Zoom } from '../../src/utils/zoom/zoom';
|
||||||
|
import { PanzoomObject, PanzoomEventDetail } from '@dermotduffy/panzoom';
|
||||||
|
import Panzoom from '@dermotduffy/panzoom';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
vi.mock('@dermotduffy/panzoom');
|
||||||
|
|
||||||
|
// https://github.com/jsdom/jsdom/issues/2527
|
||||||
|
(window as any).PointerEvent = MouseEvent;
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('Zoom', () => {
|
||||||
|
const mediaMediSpy = vi.spyOn(window, 'matchMedia');
|
||||||
|
|
||||||
|
const createMockPanZoom = (): PanzoomObject => {
|
||||||
|
const panzoom = mock<PanzoomObject>();
|
||||||
|
panzoom.getScale.mockReturnValue(1.0);
|
||||||
|
return panzoom;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAndRegisterZoom = (element: HTMLElement): Zoom => {
|
||||||
|
const zoom = new Zoom(element);
|
||||||
|
zoom.activate();
|
||||||
|
return zoom;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTouch = (target: HTMLElement): Touch => {
|
||||||
|
return {
|
||||||
|
clientX: 0,
|
||||||
|
clientY: 0,
|
||||||
|
force: 0,
|
||||||
|
identifier: 0,
|
||||||
|
pageX: 0,
|
||||||
|
pageY: 0,
|
||||||
|
radiusX: 0,
|
||||||
|
radiusY: 0,
|
||||||
|
rotationAngle: 0,
|
||||||
|
screenX: 0,
|
||||||
|
screenY: 0,
|
||||||
|
target: target,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mediaMediSpy.mockReturnValue(<MediaQueryList>{ matches: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be creatable', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const zoom = new Zoom(element);
|
||||||
|
expect(zoom).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respond with pointer', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
createAndRegisterZoom(element);
|
||||||
|
|
||||||
|
// Won't zoom without control key.
|
||||||
|
const ev_1 = new WheelEvent('wheel', { bubbles: false, deltaY: -120 });
|
||||||
|
element.dispatchEvent(ev_1);
|
||||||
|
expect(panzoom.zoomWithWheel).not.toBeCalled();
|
||||||
|
|
||||||
|
const ev_2 = new WheelEvent('wheel', {
|
||||||
|
bubbles: false,
|
||||||
|
deltaY: -120,
|
||||||
|
ctrlKey: true,
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_2);
|
||||||
|
expect(panzoom.zoomWithWheel).toBeCalledWith(ev_2);
|
||||||
|
|
||||||
|
panzoom.getScale = vi.fn().mockReturnValue(1.2);
|
||||||
|
|
||||||
|
const ev_3 = new PointerEvent('pointerdown');
|
||||||
|
element.dispatchEvent(ev_3);
|
||||||
|
expect(panzoom.handleDown).toBeCalledWith(ev_3);
|
||||||
|
|
||||||
|
const ev_4 = new PointerEvent('pointermove');
|
||||||
|
element.dispatchEvent(ev_4);
|
||||||
|
expect(panzoom.handleMove).toBeCalledWith(ev_4);
|
||||||
|
|
||||||
|
const ev_5 = new PointerEvent('pointerup');
|
||||||
|
element.dispatchEvent(ev_5);
|
||||||
|
expect(panzoom.handleUp).toBeCalledWith(ev_5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respond with touch', () => {
|
||||||
|
mediaMediSpy.mockReturnValue(<MediaQueryList>{ matches: false });
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
createAndRegisterZoom(element);
|
||||||
|
|
||||||
|
const ev_1 = new TouchEvent('touchstart', {
|
||||||
|
bubbles: false,
|
||||||
|
touches: [createTouch(element), createTouch(element)],
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_1);
|
||||||
|
expect(panzoom.handleDown).toBeCalledWith(ev_1);
|
||||||
|
|
||||||
|
panzoom.getScale = vi.fn().mockReturnValue(1.2);
|
||||||
|
|
||||||
|
const ev_3 = new TouchEvent('touchstart');
|
||||||
|
element.dispatchEvent(ev_3);
|
||||||
|
expect(panzoom.handleDown).toBeCalledWith(ev_3);
|
||||||
|
|
||||||
|
const ev_4 = new TouchEvent('touchmove');
|
||||||
|
element.dispatchEvent(ev_4);
|
||||||
|
expect(panzoom.handleMove).toBeCalledWith(ev_4);
|
||||||
|
|
||||||
|
const ev_5 = new TouchEvent('touchend');
|
||||||
|
element.dispatchEvent(ev_5);
|
||||||
|
expect(panzoom.handleUp).toBeCalledWith(ev_5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deactivate should remove event handlers', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
createAndRegisterZoom(element).deactivate();
|
||||||
|
|
||||||
|
const ev_1 = new WheelEvent('wheel', {
|
||||||
|
bubbles: false,
|
||||||
|
deltaY: -120,
|
||||||
|
ctrlKey: true,
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_1);
|
||||||
|
expect(panzoom.zoomWithWheel).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fire frigate cards on zoom/unzoom', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
|
||||||
|
const zoomedFunc = vi.fn();
|
||||||
|
const unzoomedFunc = vi.fn();
|
||||||
|
|
||||||
|
element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc);
|
||||||
|
element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc);
|
||||||
|
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
createAndRegisterZoom(element);
|
||||||
|
|
||||||
|
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
||||||
|
detail: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1.2,
|
||||||
|
isSVG: false,
|
||||||
|
originalEvent: new PointerEvent('pointermove'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_1);
|
||||||
|
expect(zoomedFunc).toBeCalled();
|
||||||
|
expect(unzoomedFunc).not.toBeCalled();
|
||||||
|
|
||||||
|
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
||||||
|
detail: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
isSVG: false,
|
||||||
|
originalEvent: new PointerEvent('pointermove'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_2);
|
||||||
|
expect(unzoomedFunc).toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -367,6 +367,60 @@ describe('View.adoptFromViewIfAppropriate', () => {
|
|||||||
View.adoptFromViewIfAppropriate(next, current);
|
View.adoptFromViewIfAppropriate(next, current);
|
||||||
expect(next.view).toBe('clip');
|
expect(next.view).toBe('clip');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should adopt live context overrides for substreams', () => {
|
||||||
|
const current = createView({
|
||||||
|
view: 'live',
|
||||||
|
context: {
|
||||||
|
live: {
|
||||||
|
overrides: new Map([['camera', 'camera2']]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const next = createView({
|
||||||
|
view: 'live',
|
||||||
|
});
|
||||||
|
View.adoptFromViewIfAppropriate(next, current);
|
||||||
|
expect(next.context?.live).toEqual(current.context?.live);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not adopt live context overrides if there are new overrides', () => {
|
||||||
|
const current = createView({
|
||||||
|
view: 'live',
|
||||||
|
context: {
|
||||||
|
live: {
|
||||||
|
overrides: new Map([['camera', 'camera2']]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const next = createView({
|
||||||
|
view: 'live',
|
||||||
|
context: {
|
||||||
|
live: {
|
||||||
|
overrides: new Map([['camera', 'camera3']]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
View.adoptFromViewIfAppropriate(next, current);
|
||||||
|
expect(next.context?.live?.overrides).toEqual(new Map([['camera', 'camera3']]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should adopt live context overrides even if there is new context', () => {
|
||||||
|
const current = createView({
|
||||||
|
view: 'live',
|
||||||
|
context: {
|
||||||
|
live: {
|
||||||
|
overrides: new Map([['camera', 'camera2']]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const next = createView({
|
||||||
|
view: 'live',
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
View.adoptFromViewIfAppropriate(next, current);
|
||||||
|
expect(next.context?.live).toEqual(current.context?.live);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|||||||
@@ -375,6 +375,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@dermotduffy/panzoom@npm:^4.5.1":
|
||||||
|
version: 4.5.1
|
||||||
|
resolution: "@dermotduffy/panzoom@npm:4.5.1"
|
||||||
|
checksum: 4c826f910425e50d9155005947e14bf344ec6b6a68783feb5a62f6bd2d7261cc541ceb18462552be271539a284a24b423dca27201caf45e153002fc5bf1c188e
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@duetds/date-picker@npm:^1.4.0":
|
"@duetds/date-picker@npm:^1.4.0":
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
resolution: "@duetds/date-picker@npm:1.4.0"
|
resolution: "@duetds/date-picker@npm:1.4.0"
|
||||||
@@ -3032,6 +3039,7 @@ __metadata:
|
|||||||
"@babel/plugin-proposal-class-properties": ^7.18.6
|
"@babel/plugin-proposal-class-properties": ^7.18.6
|
||||||
"@babel/plugin-proposal-decorators": ^7.19.0
|
"@babel/plugin-proposal-decorators": ^7.19.0
|
||||||
"@cycjimmy/jsmpeg-player": ^6.0.4
|
"@cycjimmy/jsmpeg-player": ^6.0.4
|
||||||
|
"@dermotduffy/panzoom": ^4.5.1
|
||||||
"@egjs/hammerjs": ^2.0.17
|
"@egjs/hammerjs": ^2.0.17
|
||||||
"@graphiteds/core": ^1.9.6
|
"@graphiteds/core": ^1.9.6
|
||||||
"@lit-labs/scoped-registry-mixin": ^1.0.1
|
"@lit-labs/scoped-registry-mixin": ^1.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user