Merge branch 'dermotduffy:main' into main

This commit is contained in:
Niklas Buchfink
2023-05-23 19:34:48 +02:00
committed by GitHub
75 changed files with 5267 additions and 1355 deletions
+10
View File
@@ -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.
-->
**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):**
```
+1
View File
@@ -5,3 +5,4 @@ updates:
directory: /
schedule:
interval: daily
target-branch: "dev"
+3
View File
@@ -64,3 +64,6 @@
- name: wontfix
description: This will not be worked on
color: ffffff
- name: votes needed
description: Vote for this feature to encourage prioritization!
color: e2ed82
+2 -2
View File
@@ -5,7 +5,7 @@ on:
push:
branches:
- main
- release-*
- dev
pull_request:
schedule:
- cron: "17 6 * * *"
@@ -20,7 +20,7 @@ jobs:
uses: actions/checkout@v3
- name: Setup Node and Yarn
uses: volta-cli/action@v3
uses: volta-cli/action@v4
- name: Install dependencies
run: yarn install --immutable
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v3
- name: Setup Node and Yarn
uses: volta-cli/action@v3
uses: volta-cli/action@v4
- name: Install dependencies
run: yarn install --immutable
+252 -6
View File
@@ -70,16 +70,17 @@ lovelace:
### 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.
* Add the location as a Lovelace resource via the UI, or via [YAML configuration](https://www.home-assistant.io/lovelace/dashboards/#resources)) such as:
* 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.
* 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
lovelace:
mode: yaml
resources:
- url: /local/frigate-hass-card.js
- url: /local/frigate-card/frigate-hass-card.js
type: module
```
@@ -317,7 +318,7 @@ cameras:
#### 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
@@ -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`.|
| `buttons` | | :white_check_mark: | Whether to show or hide built-in buttons. See below. |
<a name="menu-buttons"></a>
#### Menu Options: Buttons
All configuration is under:
@@ -432,6 +435,7 @@ menu:
| `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. |
| `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
@@ -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_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. |
| `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`. |
| `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.|
| `controls` | | :white_check_mark: | Configuration for the `live` view controls. See 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
@@ -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` . |
| `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
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.|
| `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. |
| `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. |
| `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. |
| `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.|
#### 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
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. |
| `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. |
| `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.|
**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). |
| `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>
### 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
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>
## 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). |
| `service` | | An optional Home Assistant service to call when the `data_` parameters are used. |
<a name="frigate-card-actions"></a>
### Special Actions
#### `custom:frigate-card-action`
@@ -1180,7 +1291,7 @@ Parameters for the `custom:frigate-card-ptz` element:
| Parameter | Description |
| - | - |
| `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>
@@ -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. |
|`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. |
|`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>
@@ -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">
### 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
### Illustrative Expanded Configuration Reference
@@ -1744,6 +1870,22 @@ menu:
enabled: false
alignment: matching
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
```
</details>
@@ -1765,8 +1907,10 @@ live:
lazy_load: true
lazy_unload: never
draggable: true
zoomable: true
transition_effect: slide
controls:
builtin: true
next_previous:
style: chevrons
size: 48
@@ -1793,6 +1937,9 @@ live:
position:
x: 50
y: 50
microphone:
always_connected: false
disconnect_seconds: 60
actions:
entity: light.office_main_lights
tap_action:
@@ -1824,9 +1971,11 @@ media_viewer:
auto_unmute: never
lazy_load: true
draggable: true
zoomable: true
snapshot_click_plays_clip: true
transition_effect: slide
controls:
builtin: true
next_previous:
size: 48
style: thumbnails
@@ -1911,6 +2060,7 @@ Reference: [Image Options](#image-options).
image:
mode: url
refresh_seconds: 0
zoomable: true
layout:
fit: contain
position:
@@ -2570,6 +2720,24 @@ performance:
```
</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>
<summary>Expand: Other options</summary>
@@ -3460,6 +3628,32 @@ overrides:
```
</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
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>
### 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>
## Card Refreshes
@@ -3652,6 +3871,9 @@ To send an action to a named Frigate card on the dashboard:
| `live` | :white_check_mark: | |
| `media_player`| :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/frigate-hass-card/issues) if you need this. |
| `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: | |
| `recordings` | :white_check_mark: | |
| `snapshot` | :white_check_mark: | |
@@ -3718,6 +3940,24 @@ live:
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
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`
### 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
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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frigate-hass-card",
"version": "5.0.0",
"version": "5.1.1",
"description": "Frigate Lovelace Card for Home Assistant",
"keywords": [
"frigate",
@@ -16,6 +16,7 @@
"license": "MIT",
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.0.4",
"@dermotduffy/panzoom": "^4.5.1",
"@egjs/hammerjs": "^2.0.17",
"@graphiteds/core": "^1.9.6",
"@lit-labs/scoped-registry-mixin": "^1.0.1",
+2 -7
View File
@@ -78,14 +78,10 @@ class ActionHandler extends HTMLElement implements ActionHandler {
});
const start = (): void => {
const options = element.actionHandlerOptions;
if (options?.hasHold) {
this.held = false;
this.timer = window.setTimeout(() => {
this.held = true;
}, this.holdTime);
}
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
// touch devices, in order to ensure that embedded cards (e.g. WebRTC)
// can use stock click handlers. The exception is for hold events.
!(options?.hasHold && this.held)
!this.held
) {
return;
}
if (options?.hasHold) {
clearTimeout(this.timer);
this.timer = undefined;
}
fireEvent(element, 'action', { action: 'end_tap' });
+50
View File
@@ -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;
}
}
+17 -1
View File
@@ -5,11 +5,21 @@ export class CachedValueController<T> implements ReactiveController {
protected _host: ReactiveControllerHost;
protected _timerSeconds: number;
protected _callback: () => T;
protected _timerStartCallback?: () => void;
protected _timerStopCallback?: () => void;
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._callback = callback;
this._timerStartCallback = timerStartCallback;
this._timerStopCallback = timerStopCallback;
(this._host = host).addController(this);
}
@@ -48,6 +58,7 @@ export class CachedValueController<T> implements ReactiveController {
public stopTimer(): void {
if (this._timerID !== undefined) {
window.clearInterval(this._timerID);
this._timerStopCallback?.();
}
this._timerID = undefined;
}
@@ -59,6 +70,7 @@ export class CachedValueController<T> implements ReactiveController {
this.stopTimer();
if (this._timerSeconds > 0) {
this._timerStartCallback?.();
this._timerID = window.setInterval(() => {
this.updateValue();
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.
*/
+10 -3
View File
@@ -10,6 +10,7 @@ import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cach
import { CameraManagerEngine } from './engine';
import { CameraInitializationError } from './error';
import { Engine } from './types';
import { getCameraEntityFromConfig } from './util';
export class CameraManagerEngineFactory {
protected _entityRegistryManager: EntityRegistryManager;
@@ -63,16 +64,22 @@ export class CameraManagerEngineFactory {
engine = Engine.Frigate;
} else if (cameraConfig.engine === 'motioneye') {
engine = Engine.MotionEye;
} else if (cameraConfig.engine === 'generic') {
engine = Engine.Generic;
} else if (cameraConfig.engine === 'auto') {
const cameraEntity = cameraConfig.camera_entity;
const cameraEntity = getCameraEntityFromConfig(cameraConfig);
if (cameraEntity) {
let entity: Entity | null;
try {
entity = await this._entityRegistryManager.getEntity(hass, cameraEntity);
} catch (e) {
// Throw a slightly friendlier exception (as a typo in the entity is
// likely to be a common failure mode).
// If the camera is not in the registry, but is in the HA states it is
// 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(
localize('error.no_camera_entity'),
cameraConfig,
+5 -5
View File
@@ -81,6 +81,7 @@ import uniq from 'lodash-es/uniq';
import format from 'date-fns/format';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import frigateLogo from './assets/frigate-logo-dark.svg';
import { getCameraEntityFromConfig } from '../util';
const EVENT_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;
let entity: Entity | null = null;
const cameraEntity = getCameraEntityFromConfig(cameraConfig);
// Entity information is required if the Frigate camera name is missing, or
// if the entity requires automatic resolution of motion/occupancy sensors.
if (cameraConfig.camera_entity && (!hasCameraName || hasAutoTriggers)) {
if (cameraEntity && (!hasCameraName || hasAutoTriggers)) {
try {
entity = await entityRegistryManager.getEntity(hass, cameraConfig.camera_entity);
entity = await entityRegistryManager.getEntity(hass, cameraEntity);
} catch (e) {
throw new CameraInitializationError(
localize('error.no_camera_entity'),
@@ -989,9 +991,7 @@ export class FrigateCameraManagerEngine
const countSegments = () =>
sum(
cameraIDs.map(
(cameraID) => this._recordingSegmentsCache.getSize(cameraID) ?? 0,
),
cameraIDs.map((cameraID) => this._recordingSegmentsCache.getSize(cameraID) ?? 0),
);
const segmentsStart = countSegments();
+1 -1
View File
@@ -21,7 +21,7 @@ const eventSchema = z.object({
label: z.string(),
sub_label: z.string().nullable(),
start_time: z.number(),
top_score: z.number(),
top_score: z.number().nullable(),
zones: z.string().array(),
retain_indefinitely: z.boolean().optional(),
});
+4 -4
View File
@@ -14,11 +14,11 @@ export const getEventTitle = (event: FrigateEvent): string => {
? event.end_time - event.start_time
: Date.now() / 1000 - event.start_time,
);
const score = event.top_score !== null ? ` ${Math.round(event.top_score * 100)}%` : '';
return `${formatDateAndTime(
utcToZonedTime(event.start_time * 1000, localTimezone),
)} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round(
event.top_score * 100,
)}%]`;
)} [${durationSeconds}s, ${prettifyTitle(event.label)}${score}]`;
};
export const getRecordingTitle = (
@@ -93,5 +93,5 @@ export const getRecordingID = (
// can be dedup'd from this id.
return `${cameraConfig.frigate?.client_id ?? ''}/${
cameraConfig.frigate.camera_name ?? ''
}/${recording.startTime.getTime()}/${recording.endTime.getTime()}}`;
}/${recording.startTime.getTime()}/${recording.endTime.getTime()}`;
};
+5
View File
@@ -7,6 +7,7 @@ import { DateRange } from './range';
import orderBy from 'lodash-es/orderBy';
import uniqBy from 'lodash-es/uniqBy';
import { ViewMedia } from '../view/media';
import { CameraConfig } from '../types';
export const convertRangeToCacheFriendlyTimes = (
range: DateRange,
@@ -53,3 +54,7 @@ export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => {
'asc',
);
};
export const getCameraEntityFromConfig = (cameraConfig: CameraConfig): string | null => {
return cameraConfig.camera_entity ?? cameraConfig.webrtc_card?.entity ?? null;
};
-230
View File
@@ -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);
}
});
}
}
+318 -106
View File
@@ -21,23 +21,23 @@ import 'web-dialog';
import { z } from 'zod';
import pkg from '../package.json';
import { actionHandler } from './action-handler-directive.js';
import { AutomationsController } from './automations';
import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js';
import { CameraManager } from './camera-manager/manager.js';
import {
CardConditionManager,
ConditionState,
conditionStateRequestHandler,
getOverriddenConfig,
} from './card-condition.js';
import './components/elements.js';
import { FrigateCardElements } from './components/elements.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 { renderMessage, renderProgressIndicator } from './components/message.js';
import './components/thumbnail-carousel.js';
import './components/views.js';
import { FrigateCardViews } from './components/views.js';
import {
ConditionController,
ConditionEvaluateRequestEvent,
getOverriddenConfig,
} from './conditions.js';
import { isConfigUpgradeable } from './config-mgmt.js';
import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, REPO_URL } from './const.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 {
Actions,
ActionType,
ActionsConfig,
CameraConfig,
CardWideConfig,
ExtendedHomeAssistant,
FRIGATE_CARD_VIEW_DEFAULT,
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
FrigateCardConfig,
frigateCardConfigSchema,
FrigateCardCustomAction,
FrigateCardError,
FrigateCardView,
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
FRIGATE_CARD_VIEW_DEFAULT,
MediaLoadedInfo,
MenuButton,
Message,
@@ -65,7 +65,7 @@ import {
import {
convertActionToFrigateCardCustomAction,
createFrigateCardCustomAction,
frigateCardHandleAction,
frigateCardHandleActionConfig,
frigateCardHasAction,
getActionConfigGivenAction,
} from './utils/action.js';
@@ -90,7 +90,14 @@ import { ResolvedMediaCache } from './utils/ha/resolved-media.js';
import { supportsFeature } from './utils/ha/update.js';
import { FrigateCardInitializer } from './utils/initializer.js';
import { isValidMediaLoadedInfo } from './utils/media-info.js';
import { MicrophoneController } from './utils/microphone';
import { getActionsFromQueryString } from './utils/querystring.js';
import {
createViewWithNextStream,
createViewWithoutSubstream,
createViewWithSelectedSubstream,
hasSubstream,
} from './utils/substream';
import { View } from './view/view.js';
/** A note on media callbacks:
@@ -112,11 +119,11 @@ import { View } from './view/view.js';
/** A note on action/menu/ll-custom events:
*
* The card supports actions being configured in a number of places (e.g. tap on an
* element, double_tap on a menu item, hold on the live view). These actions are
* handled frigateCardHandleAction(). For Frigate-card specific actions,
* frigateCardHandleAction() call will result in an ll-custom DOM event being
* fired, which needs to be caught at the card level to handle.
* The card supports actions being configured in a number of places (e.g. tap on
* an element, double_tap on a menu item, hold on the live view). These actions
* are handled by frigateCardHandleActionConfig(). For Frigate-card specific
* actions, the frigateCardHandleActionConfig() call will result in an ll-custom
* DOM event being fired, which needs to be caught at the card level to handle.
*/
/* eslint no-console: 0 */
@@ -145,6 +152,7 @@ enum InitializationAspect {
SIDE_LOAD_ELEMENTS = 'side-load-elements',
MEDIA_PLAYERS = 'media-players',
CAMERAS = 'cameras',
MICROPHONE = 'microphone',
}
/**
@@ -179,7 +187,9 @@ class FrigateCard extends LitElement {
@state()
protected _expand?: boolean = false;
protected _conditionState?: ConditionState;
protected _microphoneController?: MicrophoneController;
protected _conditionController?: ConditionController;
protected _automationsController?: AutomationsController;
protected _refMenu: Ref<FrigateCardMenu> = 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
// per second for performance reasons.
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 _untriggerTimerID: number | null = null;
protected _conditionManager: CardConditionManager | null = null;
protected _mediaPlayers?: string[];
protected _initializer = new FrigateCardInitializer();
@@ -249,9 +259,8 @@ class FrigateCard extends LitElement {
}
}
if (this._conditionManager?.hasHAStateConditions) {
// HA entity state is part of the condition state.
this._generateConditionState();
if (this._conditionController?.hasHAStateConditions) {
this._conditionController.setState({ state: this._hass.states });
}
// Dark mode may depend on HASS.
@@ -290,36 +299,30 @@ class FrigateCard extends LitElement {
} as unknown as FrigateCardConfig;
}
/**
* Generate the state used to evaluate conditions.
*/
protected _generateConditionState(): void {
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).
protected _requestUpdateForComponentsThatUseConditions(): void {
// 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
// card re-render (esp. when using card-mod).
// https://github.com/dermotduffy/frigate-hass-card/issues/678
if (this._refViews.value) {
this._refViews.value.conditionState = this._conditionState;
this._refViews.value.conditionControllerEpoch =
this._conditionController?.getEpoch();
}
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(
this._conditionController,
this._config,
this._config.overrides,
this._conditionState,
) as FrigateCardConfig;
// 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.
* @returns A StyleInfo.
*/
protected _getEmphasizedStyle(): StyleInfo {
protected _getEmphasizedStyle(critical?: boolean): StyleInfo {
if (critical) {
return {
animation: 'pulse 3s infinite',
color: 'var(--error-color, white)',
};
}
return {
color: 'var(--primary-color, white)',
};
@@ -475,12 +484,9 @@ class FrigateCard extends LitElement {
title: localize('config.menu.buttons.substreams'),
...this._getConfig().menu.buttons.substreams,
type: 'custom:frigate-card-menu-icon',
tap_action: createFrigateCardCustomAction('live_substream_select', {
camera:
override === undefined || override === dependencies[0]
? dependencies[1]
: dependencies[0],
}) as FrigateCardCustomAction,
tap_action: createFrigateCardCustomAction(
hasSubstream(this._view) ? 'live_substream_off' : 'live_substream_on',
) as FrigateCardCustomAction,
});
} else if (dependencies.length > 2) {
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()) {
buttons.push({
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) => ({
style: this._getStyleFromActions(button),
...button,
@@ -827,18 +895,52 @@ class FrigateCard extends LitElement {
this._view = undefined;
this._message = null;
this._conditionManager?.destroy();
this._conditionManager = new CardConditionManager(
config,
this._generateConditionState.bind(this),
);
this._setupConditionController();
this._generateConditionState();
this._automationsController = new AutomationsController(this._config.automations);
this._setLightOrDarkMode();
this._setPropertiesForMinMaxHeight();
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.
* @returns A FrigateCardConfig.
@@ -860,7 +962,10 @@ class FrigateCard extends LitElement {
View.adoptFromViewIfAppropriate(view, this._view);
this._view = view;
this._generateConditionState();
this._conditionController?.setState({
view: this._view.view,
camera: this._view.camera,
});
};
if (args?.resetMessage ?? true) {
@@ -944,11 +1049,30 @@ class FrigateCard extends LitElement {
setPerformanceCSSStyles(this, this._cardWideConfig?.performance);
}
this._initializeBackground();
if (changedProps.has('_view')) {
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 {
@@ -1124,6 +1248,10 @@ class FrigateCard extends LitElement {
}
}
protected async _initializeMicrophone(): Promise<void> {
await this._microphoneController?.connect();
}
protected async _initializeMediaPlayers(hass: HomeAssistant): Promise<void> {
const isValidMediaPlayer = (entityID: string): boolean => {
if (entityID.startsWith('media_player.')) {
@@ -1219,19 +1347,33 @@ class FrigateCard extends LitElement {
protected _initializeBackground(): void {
const hass = this._hass;
const config = this._getConfig();
const needMediaPlayers = config.menu.buttons.media_player.enabled;
if (!hass || !config || !needMediaPlayers) {
if (!hass || !config) {
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;
}
this._initializer
.initializeMultipleIfNecessary({
...(config.menu.buttons.media_player.enabled && {
[InitializationAspect.MEDIA_PLAYERS]: async () =>
await this._initializeMediaPlayers(hass),
}),
...(config.live.microphone.always_connected && {
[InitializationAspect.MICROPHONE]: async () =>
await this._initializeMicrophone(),
}),
})
.then((initialized) => {
if (initialized) {
@@ -1389,15 +1531,21 @@ class FrigateCard extends LitElement {
}
}
protected _cardActionEventHandler(ev: CustomEvent<ActionType>): void {
protected _cardActionEventHandler(ev: Event): void {
// The event may not actually be a CustomEvent object, but may still have a
// detail field (see:
// 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 {
if (!this._view) {
if (!this._view || !this._cameraManager) {
return;
}
@@ -1444,7 +1592,7 @@ class FrigateCard extends LitElement {
this._setExpand(!this._expand);
break;
case 'fullscreen':
this._toggleFullscreen();
screenfull.toggle(this);
break;
case 'menu_toggle':
// This is a rare code path: this would only be used if someone has a
@@ -1469,16 +1617,24 @@ class FrigateCard extends LitElement {
});
}
break;
case 'live_substream_select':
const overrides: Map<string, string> =
this._view.context?.live?.overrides ?? new Map();
overrides.set(this._view.camera, frigateCardAction.camera);
this._changeView({
view: this._view.clone().mergeInContext({
live: { overrides: overrides },
}),
});
case 'live_substream_select': {
const view = createViewWithSelectedSubstream(
this._view,
frigateCardAction.camera,
);
view && this._changeView({ view: view });
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':
this._mediaPlayerAction(
frigateCardAction.media_player,
@@ -1488,6 +1644,43 @@ class FrigateCard extends LitElement {
case 'diagnostics':
this._diagnostics();
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:
console.warn(`Frigate card received unknown card action: ${action}`);
}
@@ -1592,23 +1785,26 @@ class FrigateCard extends LitElement {
* Handle an action called on an element.
* @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 node: HTMLElement | null = ev.currentTarget as HTMLElement | null;
const actionConfig = getActionConfigGivenAction(interaction, config);
if (
this._hass &&
config &&
node &&
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
// have default tap/click actions).
getActionConfigGivenAction(interaction, config)
actionConfig
) {
frigateCardHandleAction(
frigateCardHandleActionConfig(
node,
this._hass as HomeAssistant,
this._hass,
config,
ev.detail.action,
actionConfig,
);
}
@@ -1764,8 +1960,10 @@ class FrigateCard extends LitElement {
this._setPropertiesForExpandedMode();
// An update may be required to draw elements.
this._generateConditionState();
this._conditionController?.setState({
media_loaded: !!this._currentMediaLoadedInfo,
});
this.requestUpdate();
}
@@ -1796,18 +1994,7 @@ class FrigateCard extends LitElement {
*/
protected _mediaUnloadedHandler(): void {
this._currentMediaLoadedInfo = null;
this._generateConditionState();
}
/**
* 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();
this._conditionController?.setState({ media_loaded: false });
}
/**
@@ -1816,9 +2003,10 @@ class FrigateCard extends LitElement {
connectedCallback(): void {
super.connectedCallback();
if (screenfull.isEnabled) {
screenfull.on('change', this._fullscreenHandler.bind(this));
screenfull.on('change', this._boundFullscreenHandler);
}
this.addEventListener('mousemove', this._boundMouseHandler);
this.addEventListener('ll-custom', this._boundCardActionEventHandler);
this._panel = isCardInPanel(this);
}
@@ -1830,9 +2018,10 @@ class FrigateCard extends LitElement {
this._mediaUnloadedHandler();
if (screenfull.isEnabled) {
screenfull.off('change', this._fullscreenHandler.bind(this));
screenfull.off('change', this._boundFullscreenHandler);
}
this.removeEventListener('mousemove', this._boundMouseHandler);
this.removeEventListener('ll-custom', this._boundCardActionEventHandler);
super.disconnectedCallback();
}
@@ -1919,22 +2108,36 @@ class FrigateCard extends LitElement {
return { ...this._getConfig().view.actions, ...specificActions };
}
protected _isInFullscreen(): boolean {
return screenfull.isEnabled && screenfull.isFullscreen;
}
protected _setExpand(expand: boolean): void {
if (screenfull.isEnabled && screenfull.isFullscreen) {
if (expand && this._isInFullscreen()) {
// Fullscreen and expanded mode are mutually exclusive.
screenfull.exit();
}
this._expand = expand;
this._generateConditionState();
this._conditionController?.setState({
expand: this._expand,
});
}
protected _toggleFullscreen(): void {
if (screenfull.isEnabled) {
// Fullscreen and expanded mode are mutually exclusive.
protected _fullscreenHandler(): void {
if (this._isInFullscreen()) {
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 {
@@ -1995,12 +2198,20 @@ class FrigateCard extends LitElement {
class="${classMap(cardClasses)}"
style="${styleMap(cardStyle)}"
@action=${(ev: CustomEvent) => this._actionHandler(ev, actions)}
@ll-custom=${this._cardActionEventHandler.bind(this)}
@frigate-card:message=${this._messageHandler.bind(this)}
@frigate-card:view:change=${this._changeViewHandler.bind(this)}
@frigate-card:view:change-context=${this._addViewContextHandler.bind(this)}
@frigate-card:media:loaded=${this._mediaLoadedHandler.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()}
>
${renderMenuAbove ? this._renderMenu() : ''}
@@ -2018,8 +2229,9 @@ class FrigateCard extends LitElement {
.resolvedMediaCache=${this._resolvedMediaCache}
.config=${this._getConfig()}
.nonOverriddenConfig=${this._config}
.conditionState=${this._conditionState}
.conditionControllerEpoch=${this._conditionController?.getEpoch()}
.hide=${!!this._message}
.microphoneStream=${this._microphoneController?.getStream()}
></frigate-card-views>`}
${
// Keep message rendering to last to show messages that may have been
@@ -2035,15 +2247,15 @@ class FrigateCard extends LitElement {
${ref(this._refElements)}
.hass=${this._hass}
.elements=${this._getConfig().elements}
.conditionState=${this._conditionState}
.conditionControllerEpoch=${this._conditionController?.getEpoch()}
@frigate-card:menu-add=${(e) => {
this._addDynamicMenuButton(e.detail);
}}
@frigate-card:menu-remove=${(e) => {
this._removeDynamicMenuButton(e.detail);
}}
@frigate-card:condition-state-request=${(ev) => {
conditionStateRequestHandler(ev, this._conditionState);
@frigate-card:condition:evaluate=${(ev: ConditionEvaluateRequestEvent) => {
ev.evaluation = this._conditionController?.evaluateCondition(ev.condition);
}}
>
</frigate-card-elements>`
+10 -21
View File
@@ -8,7 +8,6 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { ConditionState, fetchStateAndEvaluateCondition } from '../card-condition.js';
import { localize } from '../localize/localize.js';
import elementsStyle from '../scss/elements.scss';
import ptzStyle from '../scss/elements-ptz.scss';
@@ -34,6 +33,7 @@ import {
getActionConfigGivenAction,
} from '../utils/action.js';
import { classMap } from 'lit/directives/class-map.js';
import { ConditionControllerEpoch, evaluateConditionViaEvent } from '../conditions.js';
/* A note on picture element rendering:
*
@@ -81,11 +81,11 @@ export class FrigateCardElementsCore extends LitElement {
public elements: PictureElements;
/**
* Need to ensure card re-renders when conditionState changes, hence having it
* as a property even though it is not currently directly used by this class.
* Need to ensure card re-renders when conditions change, hence having it as a
* property even though it is not currently directly used by this class.
*/
@property({ attribute: false })
public conditionState?: ConditionState;
public conditionControllerEpoch?: ConditionControllerEpoch;
protected _root: HuiConditionalElement | null = null;
@@ -167,7 +167,7 @@ export class FrigateCardElements extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false })
public conditionState?: ConditionState;
public conditionControllerEpoch?: ConditionControllerEpoch;
@property({ attribute: false })
public elements: PictureElements;
@@ -210,9 +210,6 @@ export class FrigateCardElements extends LitElement {
path[0].addEventListener('frigate-card:menu-remove', this._boundMenuRemoveHandler);
}
/**
* Connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
@@ -221,30 +218,20 @@ export class FrigateCardElements extends LitElement {
this.addEventListener('frigate-card:menu-add', this._menuAddHandler);
}
/**
* Disconnected callback.
*/
disconnectedCallback(): void {
this.removeEventListener('frigate-card:menu-add', this._menuAddHandler);
super.disconnectedCallback();
}
/**
* Render the template.
* @returns A rendered template.
*/
protected render(): TemplateResult {
return html` <frigate-card-elements-core
return html`<frigate-card-elements-core
.hass=${this.hass}
.conditionState=${this.conditionState}
.conditionControllerEpoch=${this.conditionControllerEpoch}
.elements=${this.elements}
>
</frigate-card-elements-core>`;
}
/**
* Get styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(elementsStyle);
}
@@ -259,6 +246,8 @@ export class FrigateCardElements extends LitElement {
export class FrigateCardElementsConditional extends LitElement {
protected _config?: FrigateConditional;
// 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
// elements), the hass object is used as the (only) trigger for condition
@@ -298,7 +287,7 @@ export class FrigateCardElementsConditional extends LitElement {
* Render the card.
*/
protected render(): TemplateResult | void {
if (fetchStateAndEvaluateCondition(this, this._config.conditions)) {
if (evaluateConditionViaEvent(this, this._config.conditions)) {
return html` <frigate-card-elements-core
.hass=${this.hass}
.elements=${this._config.elements}
+67 -9
View File
@@ -6,32 +6,39 @@ import {
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
unsafeCSS
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { live } from 'lit/directives/live.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import isEqual from 'lodash-es/isEqual';
import { CachedValueController } from '../cached-value-controller.js';
import defaultImage from '../images/frigate-bird-in-sky.jpg';
import { localize } from '../localize/localize.js';
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 { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
import {
createMediaLoadedInfo,
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent
} from '../utils/media-info.js';
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
import { View } from '../view/view.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 .
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
@customElement('frigate-card-image')
export class FrigateCardImage extends LitElement {
export class FrigateCardImage extends LitElement implements FrigateCardMediaPlayer {
@property({ attribute: false })
public hass?: HomeAssistant;
@@ -54,6 +61,40 @@ export class FrigateCardImage extends LitElement {
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.
* @returns The entity or undefined if no camera entity is available.
@@ -108,9 +149,15 @@ export class FrigateCardImage extends LitElement {
this,
this.imageConfig.refresh_seconds,
this._getImageSource.bind(this),
() => dispatchMediaPlayEvent(this),
() => dispatchMediaPauseEvent(this),
);
}
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
@@ -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 {
const src = this._cachedValueController?.value;
// Note the use of live() below to ensure the update will restore the image
// src if it's been changed via _forceSafeImage().
return src
? html` <img
? this._useZoomIfRequired(html` <img
${ref(this._refImage)}
src=${live(src)}
@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
// media info changes.
if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) {
@@ -263,7 +321,7 @@ export class FrigateCardImage extends LitElement {
});
}
}}
/>`
/>`)
: html``;
}
+15
View File
@@ -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)
@@ -12,11 +12,25 @@ export class VideoRTC extends HTMLElement {
video: HTMLVideoElement;
ws: WebSocket | null;
wsURL: string;
pc: RTCPeerConnection;
pc: RTCPeerConnection | null;
connectTS: number;
mseCodecs: string;
src: string | URL;
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();
}
+695
View File
@@ -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);
}
}
+46 -38
View File
@@ -7,23 +7,21 @@ import {
unsafeCSS,
} from 'lit';
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 {
CameraConfig,
ExtendedHomeAssistant,
FrigateCardMediaPlayer,
MicrophoneConfig,
} 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 '../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
// 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).
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')
export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPlayer {
// Not an reactive property to avoid resetting the video.
@@ -69,7 +41,16 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
@property({ attribute: false })
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> {
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 {
this._player = undefined;
}
@@ -135,9 +126,12 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
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.visibilityCheck = false;
this._player.controls = this.controls;
if (this.cameraConfig?.go2rtc?.modes && this.cameraConfig.go2rtc.modes.length) {
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')) {
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 {
+12 -1
View File
@@ -17,6 +17,9 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@property({ attribute: true, type: Boolean })
public controls = true;
protected _playerRef: Ref<Element & FrigateCardMediaPlayer> = createRef();
public async play(): Promise<void> {
@@ -43,6 +46,14 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
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 {
if (!this.hass) {
return;
@@ -57,7 +68,7 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
${ref(this._playerRef)}
.hass=${this.hass}
.stateObj=${stateObj}
.controls=${true}
.controls=${this.controls}
.muted=${true}
>
</frigate-card-ha-camera-stream>`;
+24 -15
View File
@@ -1,10 +1,11 @@
import { HomeAssistant } from 'custom-card-helpers';
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 { CameraConfig, FrigateCardMediaPlayer } from '../../types.js';
import { getStateObjOrDispatchError } from './live.js';
import '../image.js';
import { getStateObjOrDispatchError } from './live.js';
@customElement('frigate-card-live-image')
export class FrigateCardLiveImage extends LitElement implements FrigateCardMediaPlayer {
@@ -14,32 +15,38 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@state()
protected _playing = true;
protected _refImage: Ref<Element & FrigateCardMediaPlayer> = createRef();
public async play(): Promise<void> {
this._playing = true;
await this._refImage.value?.play();
}
public async pause(): Promise<void> {
this._playing = false;
await this._refImage.value?.pause();
}
public async mute(): Promise<void> {
// Not implemented.
await this._refImage.value?.mute();
}
public async unmute(): Promise<void> {
// Not implemented.
await this._refImage.value?.unmute();
}
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> {
// Not implemented.
public async seek(seconds: number): Promise<void> {
await this._refImage.value?.seek(seconds);
}
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 {
@@ -50,12 +57,14 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
getStateObjOrDispatchError(this, this.hass, this.cameraConfig);
return html` <frigate-card-image
${ref(this._refImage)}
.imageConfig=${{
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,
// 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}
.cameraConfig=${this.cameraConfig}
+32 -6
View File
@@ -2,6 +2,7 @@ import JSMpeg from '@cycjimmy/jsmpeg-player';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { CameraEndpoints } from '../../camera-manager/types.js';
import { renderProgressIndicator } from '../../components/message.js';
import { localize } from '../../localize/localize.js';
import liveJSMPEGStyle from '../../scss/live-jsmpeg.scss';
@@ -11,10 +12,13 @@ import {
ExtendedHomeAssistant,
FrigateCardMediaPlayer,
} 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 {
dispatchMediaLoadedEvent,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
} from '../../utils/media-info.js';
import { dispatchErrorMessageEvent } from '../message.js';
// Number of seconds a signed URL is valid for.
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.
}
// 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.
* @param url The URL for the player to connect to.
* @returns A JSMPEG player.
*/
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;
const player = new JSMpeg.VideoElement(
this,
@@ -106,13 +119,26 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
// ignore any subsequent calls.
if (!videoDecoded && this._jsmpegCanvasElement) {
videoDecoded = true;
dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement);
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;
}
this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(address);
await this._createJSMPEGPlayer(address);
this._refreshPlayerTimerID = window.setTimeout(() => {
this.requestUpdate();
}, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
+35 -8
View File
@@ -10,7 +10,12 @@ import {
FrigateCardError,
FrigateCardMediaPlayer,
} 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 { renderTask } from '../../utils/task.js';
import {
@@ -18,6 +23,7 @@ import {
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
} from '../../utils/media.js';
import { CameraEndpoints } from '../../camera-manager/types.js';
import { mayHaveAudio } from '../../utils/audio.js';
// Create a wrapper for AlexxIT's WebRTC card
// - https://github.com/AlexxIT/WebRTC
@@ -35,6 +41,9 @@ export class FrigateCardLiveWebRTCCard
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: true, type: Boolean })
public controls = false;
protected hass?: HomeAssistant;
// 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 {
super.connectedCallback();
@@ -171,15 +191,22 @@ export class FrigateCardLiveWebRTCCard
this.updateComplete.then(() => {
const video = this._getPlayer();
if (video) {
const onloadeddata = video.onloadeddata;
video.onloadeddata = (e) => {
if (onloadeddata) {
onloadeddata.call(video, e);
}
video.onloadeddata = () => {
if (this.controls) {
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
View File
@@ -1,5 +1,7 @@
import { HomeAssistant } from 'custom-card-helpers';
import { EmblaOptionsType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import { HassEntity } from 'home-assistant-js-websocket';
import {
CSSResultGroup,
html,
@@ -9,14 +11,17 @@ import {
unsafeCSS,
} from 'lit';
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 { 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 liveStyle from '../../scss/live.scss';
import liveCarouselStyle from '../../scss/live-carousel.scss';
import liveProviderStyle from '../../scss/live-provider.scss';
import liveStyle from '../../scss/live.scss';
import {
CameraConfig,
CardWideConfig,
@@ -36,26 +41,21 @@ import {
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaUnloadedEvent,
} 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 { AutoMediaPlugin } from './../embla-plugins/automedia.js';
import { Lazyload } from './../embla-plugins/lazyload.js';
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
import {
FrigateCardMediaCarousel,
wrapMediaLoadedEventForCarousel,
wrapMediaUnloadedEventForCarousel,
} from '../media-carousel.js';
import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../message.js';
import '../next-prev-control.js';
import '../title-control.js';
import '../surround.js';
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
import { classMap } from 'lit/directives/class-map.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.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';
import '../title-control.js';
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
import { Lazyload } from './../embla-plugins/lazyload.js';
interface LiveViewContext {
// A cameraID override (used for dependencies/substreams to force a different
@@ -117,7 +117,7 @@ export const getStateObjOrDispatchError = (
@customElement('frigate-card-live')
export class FrigateCardLive extends LitElement {
@property({ attribute: false })
public conditionState?: ConditionState;
public conditionControllerEpoch?: ConditionControllerEpoch;
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@@ -137,6 +137,9 @@ export class FrigateCardLive extends LitElement {
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public microphoneStream?: MediaStream;
// Whether or not the live view is currently in the background (i.e. preloaded
// but not visible)
@state()
@@ -246,10 +249,11 @@ export class FrigateCardLive extends LitElement {
.view=${this.view}
.liveConfig=${this.liveConfig}
.inBackground=${this._inBackground}
.conditionState=${this.conditionState}
.conditionControllerEpoch=${this.conditionControllerEpoch}
.liveOverrides=${this.liveOverrides}
.cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager}
.microphoneStream=${this.microphoneStream}
@frigate-card:message=${(ev: CustomEvent<Message>) => {
this._renderKey++;
this._messageReceivedPostRender = true;
@@ -306,7 +310,7 @@ export class FrigateCardLiveCarousel extends LitElement {
public inBackground?: boolean;
@property({ attribute: false })
public conditionState?: ConditionState;
public conditionControllerEpoch?: ConditionControllerEpoch;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@@ -314,6 +318,9 @@ export class FrigateCardLiveCarousel extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public microphoneStream?: MediaStream;
// Index between camera name and slide number.
protected _cameraToSlide: Record<string, number> = {};
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
@@ -517,21 +524,23 @@ export class FrigateCardLiveCarousel extends LitElement {
cameraConfig: CameraConfig,
slideIndex: number,
): TemplateResult | void {
if (!this.liveConfig || !this.hass || !this.cameraManager) {
if (
!this.liveConfig ||
!this.hass ||
!this.cameraManager ||
!this.conditionControllerEpoch
) {
return;
}
// The conditionState object contains the currently live camera, which (in
// the carousel for example) is not necessarily the live camera this
// <frigate-card-live-provider> is rendering right now.
const conditionState = {
...this.conditionState,
camera: cameraID,
};
// The condition controller object contains the currently live camera, which
// (in the carousel for example) is not necessarily the live camera *this*
// <frigate-card-live-provider> is rendering right now, so we provide a
// stateOverride to evaluate the condition in that context.
const config = getOverriddenConfig(
this.conditionControllerEpoch.controller,
this.liveConfig,
this.liveOverrides,
conditionState,
{ camera: cameraID },
) as LiveConfig;
const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID);
@@ -540,6 +549,9 @@ export class FrigateCardLiveCarousel extends LitElement {
<div class="embla__slide">
<frigate-card-live-provider
?disabled=${this.liveConfig.lazy_load}
.microphoneStream=${this.view?.camera === cameraID
? this.microphoneStream
: undefined}
.cameraConfig=${cameraConfig}
.cameraEndpoints=${guard(
[this.cameraManager, cameraID],
@@ -594,12 +606,6 @@ export class FrigateCardLiveCarousel extends LitElement {
return;
}
const config = getOverriddenConfig(
this.liveConfig,
this.liveOverrides,
this.conditionState,
) as LiveConfig;
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
const overrideCameraID = (cameraID: string): string => {
@@ -642,7 +648,7 @@ export class FrigateCardLiveCarousel extends LitElement {
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
: ''}"
.logo="${cameraMetadataCurrent?.engineLogo}"
.titlePopupConfig=${config.controls.title}
.titlePopupConfig=${this.liveConfig.controls.title}
.selected=${this._getSelectedCameraIndex()}
transitionEffect=${this._getTransitionEffect()}
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
@@ -655,7 +661,7 @@ export class FrigateCardLiveCarousel extends LitElement {
slot="previous"
.hass=${this.hass}
.direction=${'previous'}
.controlConfig=${config.controls.next_previous}
.controlConfig=${this.liveConfig.controls.next_previous}
.label=${cameraMetadataPrevious?.title ?? ''}
.icon=${cameraMetadataPrevious?.icon}
?disabled=${prevID === null}
@@ -670,7 +676,7 @@ export class FrigateCardLiveCarousel extends LitElement {
slot="next"
.hass=${this.hass}
.direction=${'next'}
.controlConfig=${config.controls.next_previous}
.controlConfig=${this.liveConfig.controls.next_previous}
.label=${cameraMetadataNext?.title ?? ''}
.icon=${cameraMetadataNext?.icon}
?disabled=${nextID === null}
@@ -721,6 +727,9 @@ export class FrigateCardLiveProvider
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public microphoneStream?: MediaStream;
@state()
protected _isVideoMediaLoaded = false;
@@ -772,6 +781,16 @@ export class FrigateCardLiveProvider
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.
* @returns A live provider (that is not 'auto').
@@ -839,6 +858,9 @@ export class FrigateCardLiveProvider
if (this.liveConfig?.show_image_during_load) {
this._importPromises.push(import('./live-image.js'));
}
if (this.liveConfig?.zoomable) {
this._importPromises.push(import('./../zoomer.js'));
}
}
if (changedProps.has('cameraConfig')) {
const provider = this._getResolvedProvider();
@@ -865,6 +887,17 @@ export class FrigateCardLiveProvider
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.
* @returns A rendered template.
@@ -885,9 +918,9 @@ export class FrigateCardLiveProvider
hidden: showImageDuringLoading,
};
return html`
return this._useZoomIfRequired(html`
${showImageDuringLoading || provider === 'image'
? html`<frigate-card-live-image
? html` <frigate-card-live-image
${ref(this._refProvider)}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
@@ -910,6 +943,7 @@ export class FrigateCardLiveProvider
class=${classMap(providerClasses)}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
?controls=${this.liveConfig.controls.builtin}
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
>
</frigate-card-live-ha>`
@@ -920,6 +954,9 @@ export class FrigateCardLiveProvider
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
.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-live-webrtc-card>`
@@ -931,6 +968,7 @@ export class FrigateCardLiveProvider
.cameraConfig=${this.cameraConfig}
.cameraEndpoints=${this.cameraEndpoints}
.cardWideConfig=${this.cardWideConfig}
?controls=${this.liveConfig.controls.builtin}
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
>
</frigate-card-live-webrtc-card>`
@@ -946,12 +984,9 @@ export class FrigateCardLiveProvider
>
</frigate-card-live-jsmpeg>`
: html``}
`;
`);
}
/**
* Get styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(liveProviderStyle);
}
+79 -29
View File
@@ -9,8 +9,12 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.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 { localize } from '../localize/localize.js';
import '../patches/ha-hls-player';
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
import viewerProviderStyle from '../scss/viewer-provider.scss';
import viewerStyle from '../scss/viewer.scss';
@@ -24,39 +28,41 @@ import {
ViewerConfig,
} from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { mayHaveAudio } from '../utils/audio.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 { 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 { 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 { Lazyload } from './embla-plugins/lazyload.js';
import {
FrigateCardMediaCarousel,
wrapMediaLoadedEventForCarousel,
} from './media-carousel.js';
import type { CarouselSelect } from './carousel.js';
import './next-prev-control.js';
import './title-control.js';
import '../patches/ha-hls-player';
import './surround.js';
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.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';
import './title-control.js';
export interface MediaViewerViewContext {
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.
*/
@@ -674,6 +697,21 @@ export class FrigateCardViewerProvider
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 {
@@ -693,7 +731,8 @@ export class FrigateCardViewerProvider
});
}
return ViewMediaClassifier.isVideo(this.media)
return this._useZoomIfRequired(html`
${ViewMediaClassifier.isVideo(this.media)
? this.media.getVideoContentType() === VideoContentType.HLS
? html`<frigate-card-ha-hls-player
${ref(this._refFrigateCardMediaPlayer)}
@@ -706,6 +745,7 @@ export class FrigateCardViewerProvider
title="${this.media.getTitle() ?? ''}"
url=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
.hass=${this.hass}
?controls=${this.viewerConfig.controls.builtin}
>
</frigate-card-ha-hls-player>`
: html`
@@ -714,11 +754,11 @@ export class FrigateCardViewerProvider
aria-label="${this.media.getTitle() ?? ''}"
title="${this.media.getTitle() ?? ''}"
muted
controls
playsinline
?autoplay=${false}
?controls=${this.viewerConfig.controls.builtin}
@loadedmetadata=${(ev: Event) => {
if (ev.target) {
if (ev.target && !!this.viewerConfig?.controls.builtin) {
hideMediaControlsTemporarily(
ev.target as HTMLVideoElement,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
@@ -726,8 +766,17 @@ export class FrigateCardViewerProvider
}
}}
@loadeddata=${(ev: Event) => {
dispatchMediaLoadedEvent(this, ev);
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) ?? ''}
@@ -744,10 +793,11 @@ export class FrigateCardViewerProvider
this._dispatchRelatedClipView();
}
}}
@load=${(e: Event) => {
dispatchMediaLoadedEvent(this, e);
@load=${(ev: Event) => {
dispatchMediaLoadedEvent(this, ev, { player: this });
}}
/>`;
/>`}
`);
}
static get styles(): CSSResultGroup {
+10 -8
View File
@@ -4,12 +4,12 @@ import {
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
import { ConditionState, getOverridesByKey } from '../card-condition';
import { ConditionControllerEpoch, getOverridesByKey } from '../conditions';
import viewsStyle from '../scss/views.scss';
import { CardWideConfig, ExtendedHomeAssistant, FrigateCardConfig } from '../types.js';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
@@ -40,14 +40,14 @@ export class FrigateCardViews extends LitElement {
public resolvedMediaCache?: ResolvedMediaCache;
@property({ attribute: false })
public conditionState?: ConditionState;
@property({ attribute: false })
public cameras?: ConditionState;
public conditionControllerEpoch?: ConditionControllerEpoch;
@property({ attribute: false })
public hide?: boolean;
@property({ attribute: false })
public microphoneStream?: MediaStream;
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('view') || changedProps.has('config')) {
if (this.view?.is('live') || this._shouldLivePreload()) {
@@ -150,6 +150,7 @@ export class FrigateCardViews extends LitElement {
.view=${this.view}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
.supportZoom=${true}
>
</frigate-card-image>`
: ``}
@@ -199,10 +200,11 @@ export class FrigateCardViews extends LitElement {
.hass=${this.hass}
.view=${this.view}
.liveConfig=${this.nonOverriddenConfig.live}
.conditionState=${this.conditionState}
.liveOverrides=${getOverridesByKey(this.config.overrides, 'live')}
.conditionControllerEpoch=${this.conditionControllerEpoch}
.liveOverrides=${getOverridesByKey('live', this.config.overrides)}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.microphoneStream=${this.microphoneStream}
class="${classMap(liveClasses)}"
>
</frigate-card-live>
+64
View File
@@ -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;
}
}
+251
View File
@@ -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);
}
});
}
}
+10
View File
@@ -104,6 +104,8 @@ export const CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP =
`${CONF_MEDIA_VIEWER}.snapshot_click_plays_clip` as const;
export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT =
`${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 =
`${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const;
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;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS =
`${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 =
`${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_MUTE = `${CONF_LIVE}.auto_mute` 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 =
`${CONF_LIVE}.controls.next_previous.style` as const;
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_SHOW_IMAGE_DURING_LOAD =
`${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;
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_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` 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;
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
+86 -22
View File
@@ -2,6 +2,11 @@ import { fireEvent, HomeAssistant, LovelaceCardEditor } from 'custom-card-helper
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators.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 {
copyConfig,
deleteConfigValue,
@@ -12,6 +17,7 @@ import {
upgradeConfig,
} from './config-mgmt.js';
import {
CONF_CAMERAS,
CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS,
CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS,
@@ -39,19 +45,22 @@ import {
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
CONF_CAMERAS,
CONF_DIMENSIONS_ASPECT_RATIO_MODE,
CONF_DIMENSIONS_ASPECT_RATIO,
CONF_DIMENSIONS_ASPECT_RATIO_MODE,
CONF_DIMENSIONS_MAX_HEIGHT,
CONF_DIMENSIONS_MIN_HEIGHT,
CONF_IMAGE_LAYOUT_FIT,
CONF_IMAGE_LAYOUT_POSITION_X,
CONF_IMAGE_LAYOUT_POSITION_Y,
CONF_IMAGE_MODE,
CONF_IMAGE_REFRESH_SECONDS,
CONF_IMAGE_URL,
CONF_IMAGE_ZOOMABLE,
CONF_LIVE_AUTO_MUTE,
CONF_LIVE_AUTO_PAUSE,
CONF_LIVE_AUTO_PLAY,
CONF_LIVE_AUTO_UNMUTE,
CONF_LIVE_CONTROLS_BUILTIN,
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA,
@@ -65,6 +74,7 @@ import {
CONF_LIVE_CONTROLS_TIMELINE_MEDIA,
CONF_LIVE_CONTROLS_TIMELINE_MODE,
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
CONF_LIVE_CONTROLS_TIMELINE_STYLE,
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
CONF_LIVE_CONTROLS_TITLE_MODE,
@@ -74,9 +84,12 @@ import {
CONF_LIVE_LAYOUT_POSITION_Y,
CONF_LIVE_LAZY_LOAD,
CONF_LIVE_LAZY_UNLOAD,
CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS,
CONF_LIVE_PRELOAD,
CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
CONF_LIVE_TRANSITION_EFFECT,
CONF_LIVE_ZOOMABLE,
CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE,
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS,
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
@@ -87,6 +100,7 @@ import {
CONF_MEDIA_VIEWER_AUTO_PAUSE,
CONF_MEDIA_VIEWER_AUTO_PLAY,
CONF_MEDIA_VIEWER_AUTO_UNMUTE,
CONF_MEDIA_VIEWER_CONTROLS_BUILTIN,
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
@@ -99,6 +113,7 @@ import {
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
@@ -109,6 +124,7 @@ import {
CONF_MEDIA_VIEWER_LAZY_LOAD,
CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP,
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
CONF_MEDIA_VIEWER_ZOOMABLE,
CONF_MENU_ALIGNMENT,
CONF_MENU_BUTTON_SIZE,
CONF_MENU_BUTTONS,
@@ -128,27 +144,24 @@ import {
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
CONF_TIMELINE_MEDIA,
CONF_TIMELINE_SHOW_RECORDINGS,
CONF_TIMELINE_STYLE,
CONF_TIMELINE_WINDOW_SECONDS,
CONF_VIEW_CAMERA_SELECT,
CONF_VIEW_DARK_MODE,
CONF_VIEW_DEFAULT,
CONF_VIEW_SCAN,
CONF_VIEW_SCAN_ENABLED,
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS,
CONF_VIEW_SCAN_UNTRIGGER_RESET,
CONF_VIEW_SCAN_UNTRIGGER_SECONDS,
CONF_VIEW_SCAN,
CONF_VIEW_TIMEOUT_SECONDS,
CONF_VIEW_UPDATE_CYCLE_CAMERA,
CONF_VIEW_UPDATE_FORCE,
CONF_VIEW_UPDATE_SECONDS,
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';
import { localize } from './localize/localize.js';
import { setLowPerformanceProfile } from './performance.js';
import frigate_card_editor_style from './scss/editor.scss';
import {
BUTTON_SIZE_MIN,
@@ -162,29 +175,23 @@ import {
} from './types.js';
import { arrayMove, prettifyTitle } from './utils/basic.js';
import { getCameraID } from './utils/camera.js';
import { FRIGATE_ICON_SVG_PATH } from './camera-manager/frigate/icon.js';
import {
getEntitiesFromHASS,
getEntityTitle,
sideLoadHomeAssistantElements,
} 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_CAMERAS = 'cameras';
const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies';
const MENU_CAMERAS_ENGINE = 'cameras.engine';
const MENU_CAMERAS_FRIGATE = 'cameras.frigate';
const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc';
const MENU_CAMERAS_IMAGE = 'cameras.image';
const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider';
const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye';
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
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_LIVE_CONTROLS = 'live.controls';
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_TITLE = 'live.controls.title';
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_THUMBNAILS = 'media_gallery.controls.thumbnails';
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_THUMBNAILS = 'media_viewer.controls.thumbnails';
const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline';
const MENU_MEDIA_VIEWER_CONTROLS_TITLE = 'media_viewer.controls.title';
const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout';
const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails';
const MENU_OPTIONS = 'options';
const MENU_PERFORMANCE_FEATURES = 'performance.features';
const MENU_PERFORMANCE_STYLE = 'performance.style';
const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails';
const MENU_VIEW_SCAN = 'scan';
interface EditorOptionsSet {
@@ -531,6 +539,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ 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 {
// 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
@@ -836,7 +850,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
* @param button The name of the button.
* @returns A rendered template.
*/
protected _renderMenuButton(button: string): TemplateResult {
protected _renderMenuButton(
button: string,
additionalOptions?: TemplateResult,
): TemplateResult {
const menuButtonAlignments: EditorSelectOption[] = [
{ value: '', label: '' },
{ 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`, {
label: localize('config.menu.buttons.icon'),
})}
${additionalOptions}
</div>`
: ''}
</div>
@@ -994,10 +1012,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
configPathShowRecordings: string,
defaultShowRecordings: boolean,
): TemplateResult {
return html`
${this._renderOptionSelector(configPathStyle, this._timelineStyleTypes, {
return html` ${this._renderOptionSelector(
configPathStyle,
this._timelineStyleTypes,
{
label: localize(`config.common.${CONF_TIMELINE_STYLE}`),
})}
},
)}
${this._renderNumberInput(configPathWindowSeconds, {
label: localize(`config.common.${CONF_TIMELINE_WINDOW_SECONDS}`),
})}
@@ -1755,6 +1776,16 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderMenuButton('expand') /* */}
${this._renderMenuButton('timeline')}
${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>
`
: ''}
@@ -1764,6 +1795,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
<div class="values">
${this._renderSwitch(CONF_LIVE_PRELOAD, this._defaults.live.preload)}
${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._renderOptionSelector(
CONF_LIVE_LAZY_UNLOAD,
@@ -1799,6 +1831,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
'config.live.controls.editor_label',
{ name: 'mdi:gamepad' },
html`
${this._renderSwitch(
CONF_LIVE_CONTROLS_BUILTIN,
this._defaults.live.controls.builtin,
{
label: localize('config.common.controls.builtin'),
},
)}
${this._renderNextPreviousControls(
MENU_LIVE_CONTROLS_NEXT_PREVIOUS,
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_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>
`
: ''}
@@ -1888,6 +1940,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_MEDIA_VIEWER_DRAGGABLE,
this._defaults.media_viewer.draggable,
)}
${this._renderSwitch(
CONF_MEDIA_VIEWER_ZOOMABLE,
this._defaults.media_viewer.zoomable,
)}
${this._renderSwitch(
CONF_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',
{ name: 'mdi:gamepad' },
html`
${this._renderSwitch(
CONF_MEDIA_VIEWER_CONTROLS_BUILTIN,
this._defaults.media_viewer.controls.builtin,
{
label: localize('config.common.controls.builtin'),
},
)}
${this._renderNextPreviousControls(
MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS,
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._renderStringInput(CONF_IMAGE_URL)}
${this._renderNumberInput(CONF_IMAGE_REFRESH_SECONDS)}
${this._renderSwitch(CONF_IMAGE_ZOOMABLE, this._defaults.image.zoomable)}
${this._renderMediaLayout(
MENU_IMAGE_LAYOUT,
'config.image.layout',
-9
View File
@@ -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)
-597
View File
@@ -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);
}
}
+23 -4
View File
@@ -84,6 +84,7 @@
},
"common": {
"controls": {
"builtin": "Built-in video controls",
"filter": {
"editor_label": "Media Filter",
"mode": "Filter mode",
@@ -204,7 +205,8 @@
"url": "Arbitrary image specified by URL"
},
"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": {
"auto_mute": "Automatically mute live cameras",
@@ -218,9 +220,16 @@
"layout": "Live Layout",
"lazy_load": "Live cameras are lazily loaded",
"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",
"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": {
"auto_mute": "Automatically mute media",
@@ -238,7 +247,8 @@
"transition_effects": {
"none": "No transition",
"slide": "Slide transition"
}
},
"zoomable": "Media Viewer can be zoomed/panned"
},
"menu": {
"alignment": "Menu alignment",
@@ -267,11 +277,19 @@
"image": "Image",
"live": "Live",
"media_player": "Send to media player",
"microphone": "Microphone",
"mute": "Mute / Unmute",
"play": "Play / Pause",
"priority": "Priority",
"recordings": "Recordings",
"snapshots": "Snapshots",
"substreams": "Substream(s)",
"timeline": "Timeline"
"timeline": "Timeline",
"type": "Button type",
"types": {
"momentary": "Momentary",
"toggle": "Toggle"
}
},
"position": "Menu position",
"positions": {
@@ -417,6 +435,7 @@
"no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera",
"reconnecting": "Reconnecting",
"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",
"unknown": "Unknown error",
"upgrade_available": "An automated card configuration upgrade is available, please visit the visual card editor",
+22 -4
View File
@@ -84,6 +84,7 @@
},
"common": {
"controls": {
"builtin": "",
"filter": {
"editor_label": "Filtro multimediale",
"mode": "Modalità filtro",
@@ -204,7 +205,8 @@
"url": "Immagine arbitraria specificata dall'URL"
},
"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": {
"auto_mute": "Muta automaticamente le telecamere in diretta",
@@ -218,9 +220,16 @@
"layout": "Disposizione dal vivo",
"lazy_load": "Le telecamere dal vivo sono pigramente cariche",
"lazy_unload": "Le telecamere dal vivo sono pigramente non caricate",
"microphone": {
"always_connected": "",
"disconnect_seconds": "",
"editor_label": "",
"enabled": ""
},
"preload": "Precarica Live View in background",
"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": {
"auto_mute": "Muta automaticamente i media",
@@ -237,7 +246,8 @@
"transition_effects": {
"none": "Nessuna transizione",
"slide": "Transizione diapositiva"
}
},
"zoomable": ""
},
"menu": {
"alignment": "Allineamento dei menu",
@@ -266,10 +276,17 @@
"image": "Immagine",
"live": "Abitare",
"media_player": "Invia a Media Player",
"mute": "",
"play": "",
"priority": "Priorità",
"snapshots": "Istantanee",
"substreams": "Flusso/i secondario/i",
"timeline": "Timeline"
"timeline": "Timeline",
"type": "",
"types": {
"momentary": "",
"toggle": ""
}
},
"position": "Posizione del menu",
"positions": {
@@ -411,6 +428,7 @@
"reconnecting": "Riconnessione",
"timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline",
"troubleshooting": "Controllare la risoluzione dei problemi",
"too_many_automations": "",
"unknown": "Errore sconosciuto",
"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",
+22 -4
View File
@@ -84,6 +84,7 @@
},
"common": {
"controls": {
"builtin": "",
"filter": {
"editor_label": "Filtro de Mídia",
"mode": "Modo do filtro",
@@ -204,7 +205,8 @@
"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"
"url": "Imagem arbitrária especificada por URL",
"zoomable": ""
},
"live": {
"auto_mute": "Silenciar câmeras ao vivo automaticamente",
@@ -218,9 +220,16 @@
"layout": "Layout dinâmico",
"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": "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": {
"auto_mute": "Silenciar mídia automaticamente",
@@ -238,7 +247,8 @@
"transition_effects": {
"none": "Sem transição",
"slide": "Transição de slides"
}
},
"zoomable": ""
},
"menu": {
"alignment": "Alinhamento do menu",
@@ -267,11 +277,18 @@
"image": "Imagem",
"live": "Ao vivo",
"media_player": "Enviar para o reprodutor de mídia",
"mute": "",
"play": "",
"priority": "Prioridade",
"recordings": "Gravações",
"snapshots": "Instantâneos",
"substreams": "Substream(s)",
"timeline": "Linha do tempo"
"timeline": "Linha do tempo",
"type": "",
"types": {
"momentary": "",
"toggle": ""
}
},
"position": "Posição do menu",
"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",
"reconnecting": "Reconectando",
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
"too_many_automations": "",
"troubleshooting": "Verifique a solução de problemas",
"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",
+488
View File
@@ -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"
}
}
+2
View File
@@ -56,6 +56,8 @@ export const loadLanguages = async (hass: HomeAssistant): Promise<void> => {
const lang = getLanguage(hass);
if (lang === 'it') {
languages[lang] = await import('./languages/it.json');
} else if (lang === 'pt') {
languages[lang] = await import('./languages/pt-PT.json');
} else if (lang === 'pt_BR') {
languages[lang] = await import('./languages/pt-BR.json');
}
+11 -1
View File
@@ -71,6 +71,16 @@ customElements.whenDefined('ha-camera-stream').then(() => {
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.
* @returns A rendered template.
@@ -84,7 +94,7 @@ customElements.whenDefined('ha-camera-stream').then(() => {
return html`
<img
@load=${(ev: Event) => {
dispatchMediaLoadedEvent(this, ev);
dispatchMediaLoadedEvent(this, ev, { player: this });
}}
.src=${typeof this._connected == 'undefined' || this._connected
? computeMJPEGStreamUrl(this.stateObj)
+39 -9
View File
@@ -9,17 +9,23 @@
// 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 { query } from 'lit/decorators/query.js';
import { dispatchErrorMessageEvent } from '../components/message.js';
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
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 { 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(() => {
@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:
// - 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}
?controls=${this.controls}
@loadedmetadata=${() => {
hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
if (this.controls) {
hideMediaControlsTemporarily(
this._video,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
);
}
}}
@loadeddata=${(e) => {
dispatchMediaLoadedEvent(this, e);
@loadeddata=${(ev) => {
dispatchMediaLoadedEvent(this, ev, {
player: this,
capabilities: {
supportsPause: true,
hasAudio: mayHaveAudio(this._video),
},
});
}}
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
@play=${() => dispatchMediaPlayEvent(this)}
@pause=${() => dispatchMediaPauseEvent(this)}
></video>
`;
}
+35 -5
View File
@@ -15,10 +15,16 @@ import { query } from 'lit/decorators/query.js';
import { dispatchErrorMessageEvent } from '../components/message.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
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 {
hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
} from '../utils/media.js';
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:
// - 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}
?controls=${this.controls}
@loadedmetadata=${() => {
hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
if (this.controls) {
hideMediaControlsTemporarily(
this._video,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
);
}
}}
@loadeddata=${(e) => {
dispatchMediaLoadedEvent(this, e);
@loadeddata=${(ev) => {
dispatchMediaLoadedEvent(this, ev, {
player: this,
capabilities: {
supportsPause: true,
hasAudio: mayHaveAudio(this._video),
},
});
}}
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
@play=${() => dispatchMediaPlayEvent(this)}
@pause=${() => dispatchMediaPauseEvent(this)}
></video>
`;
}
+1 -1
View File
@@ -19,7 +19,7 @@ ha-icon-button.button {
opacity: 1;
}
50% {
opacity: 0;
opacity: 0.6;
}
100% {
opacity: 1;
+6
View File
@@ -1,3 +1,9 @@
:host {
display: block;
height: 100%;
width: 100;
}
.hidden {
display: none;
}
+80 -2
View File
@@ -219,11 +219,19 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
'image',
'live',
'menu_toggle',
'mute',
'live_substream_on',
'live_substream_off',
'microphone_mute',
'microphone_unmute',
'play',
'pause',
'recording',
'recordings',
'snapshot',
'snapshots',
'timeline',
'unmute',
] as const;
const FRIGATE_CARD_ACTIONS = [
...FRIGATE_CARD_GENERAL_ACTIONS,
@@ -261,7 +269,7 @@ export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSche
// Cannot use discriminatedUnion since frigateCardCustomActionSchema uses a
// transform on the discriminated union key.
const actionSchema = z.union([
export const actionSchema = z.union([
toggleActionSchema,
callServiceActionSchema,
navigateActionSchema,
@@ -409,6 +417,22 @@ const imageBaseConfigSchema = z.object({
* 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({
modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(),
stream: z.string().optional(),
@@ -616,7 +640,7 @@ export type MenuSubmenuSelect = z.infer<typeof menuSubmenuSelectSchema>;
export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu | MenuSubmenuSelect;
const frigateCardConditionSchema = z.object({
export const frigateCardConditionSchema = z.object({
view: z.string().array().optional(),
fullscreen: z.boolean().optional(),
expand: z.boolean().optional(),
@@ -765,12 +789,14 @@ const viewConfigSchema = z
const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const;
const imageConfigDefault = {
mode: 'url' as const,
zoomable: true,
...imageBaseConfigDefault,
};
const imageConfigSchema = imageBaseConfigSchema
.extend({
mode: z.enum(IMAGE_MODES).default(imageConfigDefault.mode),
layout: mediaLayoutConfigSchema.optional(),
zoomable: z.boolean().default(imageConfigDefault.zoomable),
})
.merge(actionsSchema)
.default(imageConfigDefault);
@@ -918,9 +944,11 @@ const liveConfigDefault = {
lazy_load: true,
lazy_unload: 'never' as const,
draggable: true,
zoomable: true,
transition_effect: 'slide' as const,
show_image_during_load: true,
controls: {
builtin: true,
next_previous: {
size: 48,
style: 'chevrons' as const,
@@ -932,6 +960,9 @@ const liveConfigDefault = {
duration_seconds: 2,
},
},
microphone: {
...microphoneConfigDefault,
},
};
const livethumbnailsControlSchema = thumbnailsControlSchema.extend({
@@ -944,6 +975,7 @@ const liveOverridableConfigSchema = z
.object({
controls: z
.object({
builtin: z.boolean().default(liveConfigDefault.controls.builtin),
next_previous: nextPreviousControlConfigSchema
.extend({
// Live cannot show thumbnails, remove that option.
@@ -975,6 +1007,8 @@ const liveOverridableConfigSchema = z
.boolean()
.default(liveConfigDefault.show_image_during_load),
layout: mediaLayoutConfigSchema.optional(),
microphone: microphoneConfigSchema.default(liveConfigDefault.microphone),
zoomable: z.boolean().default(liveConfigDefault.zoomable),
})
.merge(actionsSchema);
@@ -1037,6 +1071,12 @@ const menuConfigDefault = {
fullscreen: visibleButtonDefault,
expand: hiddenButtonDefault,
media_player: visibleButtonDefault,
microphone: {
...hiddenButtonDefault,
type: 'momentary' as const,
},
mute: hiddenButtonDefault,
play: hiddenButtonDefault,
recordings: hiddenButtonDefault,
},
button_size: 40,
@@ -1073,7 +1113,16 @@ const menuConfigSchema = z
media_player: visibleButtonSchema.default(
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),
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
})
.default(menuConfigDefault.buttons),
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
@@ -1091,9 +1140,11 @@ const viewerConfigDefault = {
auto_unmute: 'never' as const,
lazy_load: true,
draggable: true,
zoomable: true,
transition_effect: 'slide' as const,
snapshot_click_plays_clip: true,
controls: {
builtin: true,
next_previous: {
size: 48,
style: 'thumbnails' as const,
@@ -1131,6 +1182,7 @@ const viewerConfigSchema = z
.default(viewerConfigDefault.auto_unmute),
lazy_load: z.boolean().default(viewerConfigDefault.lazy_load),
draggable: z.boolean().default(viewerConfigDefault.draggable),
zoomable: z.boolean().default(viewerConfigDefault.zoomable),
transition_effect: transitionEffectConfigSchema.default(
viewerConfigDefault.transition_effect,
),
@@ -1139,6 +1191,7 @@ const viewerConfigSchema = z
.default(viewerConfigDefault.snapshot_click_plays_clip),
controls: z
.object({
builtin: z.boolean().default(viewerConfigDefault.controls.builtin),
next_previous: viewerNextPreviousControlConfigSchema.default(
viewerConfigDefault.controls.next_previous,
),
@@ -1294,6 +1347,19 @@ const liveOverridesSchema = z
.optional();
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 = {
profile: 'high' as const,
features: {
@@ -1370,6 +1436,7 @@ export const frigateCardConfigSchema = z.object({
timeline: timelineConfigSchema,
performance: performanceConfigSchema,
debug: debugConfigSchema,
automations: automationsSchema,
// Configuration overrides.
overrides: overridesSchema,
@@ -1416,9 +1483,17 @@ export interface ExtendedHomeAssistant extends HomeAssistant {
};
}
export interface MediaLoadedCapabilities {
supports2WayAudio?: boolean;
supportsPause?: boolean;
hasAudio?: boolean;
}
export interface MediaLoadedInfo {
width: number;
height: number;
player?: FrigateCardMediaPlayer;
capabilities?: MediaLoadedCapabilities;
}
export const MESSAGE_TYPE_PRIORITIES = {
@@ -1455,6 +1530,9 @@ export interface FrigateCardMediaPlayer {
unmute(): Promise<void>;
isMuted(): boolean;
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 {
+21 -35
View File
@@ -6,7 +6,6 @@ import {
} from 'custom-card-helpers';
import {
Actions,
ActionsConfig,
ActionType,
FrigateCardAction,
FrigateCardCustomAction,
@@ -52,7 +51,7 @@ export function createFrigateCardCustomAction(
action: 'fire-dom-event',
frigate_card_action: action,
camera: args.camera as string,
...(args.cardID && { card_id: args.cardID})
...(args.cardID && { card_id: args.cardID }),
};
}
if (action === 'media_player') {
@@ -64,13 +63,13 @@ export function createFrigateCardCustomAction(
frigate_card_action: action,
media_player: args.media_player,
media_player_action: args.media_player_action,
...(args.cardID && { card_id: args.cardID})
...(args.cardID && { card_id: args.cardID }),
};
}
return {
action: 'fire-dom-event',
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.
* @param node The node that fired the event.
* @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
* undefined for the default action config for 'tap'.
* @param action The action string (e.g. 'hold')
@@ -143,12 +119,27 @@ export const frigateCardHandleActionConfig = (
entity?: string;
},
action: string,
actionConfig: ActionType | ActionType[] | undefined,
actionConfig?: ActionType | ActionType[],
): boolean => {
// Only allow a tap action to use a default non-config (the more-info config).
if (actionConfig || action == 'tap') {
frigateCardHandleAction(node, hass, config, actionConfig);
return true;
}
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
// * 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.
@@ -159,9 +150,6 @@ export const frigateCardHandleActionConfig = (
} else {
handleActionConfig(node, hass, config, actionConfig as ActionConfig | undefined);
}
return true;
}
return false;
};
/**
@@ -170,9 +158,7 @@ export const frigateCardHandleActionConfig = (
* @param config The action config in question.
* @returns `true` if there's a real action defined, `false` otherwise.
*/
export const frigateCardHasAction = (
config?: ActionType | ActionType[] | undefined,
): boolean => {
export const frigateCardHasAction = (config?: ActionType | ActionType[]): boolean => {
// See note above on 'ActionConfig vs ActionType' for why this cast is
// necessary and harmless.
if (Array.isArray(config)) {
+19
View File
@@ -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
View File
@@ -28,12 +28,21 @@ export function getCameraID(
* Get all cameras that depend on a given camera.
* @param cameraManager The camera manager.
* @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,
cameraID?: string,
): Set<string> | null => {
): Set<string> | null;
export function getAllDependentCameras(
cameraManager?: CameraManager,
cameraID?: string,
): Set<string> | null {
if (!cameraManager || !cameraID) {
return null;
}
@@ -45,9 +54,7 @@ export const getAllDependentCameras = (
if (cameraConfig) {
cameraIDs.add(cameraID);
const dependentCameras: Set<string> = new Set();
(cameraConfig.dependencies.cameras || []).forEach((item) =>
dependentCameras.add(item),
);
cameraConfig.dependencies.cameras.forEach((item) => dependentCameras.add(item));
if (cameraConfig.dependencies.all_cameras) {
cameras.forEach((_, key) => dependentCameras.add(key));
}
@@ -62,4 +69,4 @@ export const getAllDependentCameras = (
getDependentCameras(cameraID);
}
return cameraIDs;
};
}
+30 -2
View File
@@ -1,4 +1,8 @@
import { MediaLoadedInfo } from '../types.js';
import {
FrigateCardMediaPlayer,
MediaLoadedCapabilities,
MediaLoadedInfo,
} from '../types.js';
import { dispatchFrigateCardEvent } from './basic.js';
const MEDIA_INFO_HEIGHT_CUTOFF = 50;
@@ -11,6 +15,10 @@ const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF;
*/
export function createMediaLoadedInfo(
source: Event | HTMLElement,
options?: {
player?: FrigateCardMediaPlayer;
capabilities?: MediaLoadedCapabilities;
},
): MediaLoadedInfo | null {
let target: HTMLElement | EventTarget;
if (source instanceof Event) {
@@ -23,16 +31,20 @@ export function createMediaLoadedInfo(
return {
width: (target as HTMLImageElement).naturalWidth,
height: (target as HTMLImageElement).naturalHeight,
...options,
};
} else if (target instanceof HTMLVideoElement) {
return {
width: (target as HTMLVideoElement).videoWidth,
height: (target as HTMLVideoElement).videoHeight,
...options,
};
} else if (target instanceof HTMLCanvasElement) {
return {
width: (target as HTMLCanvasElement).width,
height: (target as HTMLCanvasElement).height,
player: options?.player,
...options,
};
}
return null;
@@ -46,13 +58,29 @@ export function createMediaLoadedInfo(
export function dispatchMediaLoadedEvent(
target: HTMLElement,
source: Event | HTMLElement,
options?: {
player?: FrigateCardMediaPlayer;
capabilities?: MediaLoadedCapabilities;
},
): void {
const mediaLoadedInfo = createMediaLoadedInfo(source);
const mediaLoadedInfo = createMediaLoadedInfo(source, options);
if (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.
* @param element The element to send the event.
+87
View File
@@ -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);
}
}
}
+48
View File
@@ -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;
};
+127
View File
@@ -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);
}
}
+16
View File
@@ -79,6 +79,11 @@ export class View {
// 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
// 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;
if (MediaQueriesClassifier.areEventQueries(curr.query)) {
@@ -114,6 +119,17 @@ export class View {
: '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 });
}
}
/**
+123
View File
@@ -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();
});
});
+109
View File
@@ -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();
});
});
+63 -34
View File
@@ -1,16 +1,14 @@
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 { 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 { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic';
import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/engine-frigate';
import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye';
import { createCameraConfig, createHASS, createRegistryEntity } from '../test-utils';
vi.mock('../../src/utils/ha/entity-registry');
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()', () => {
it('should get frigate engine from config', async () => {
const config = createCameraConfig({ engine: 'frigate' });
@@ -62,13 +38,21 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
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 () => {
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
const entityRegistryManager = new EntityRegistryManager(new EntityCache());
entityRegistryManager.getEntity = vi
.fn()
.mockResolvedValue(createEntity({ entity_id: 'camera.foo', platform: 'frigate' }));
.mockResolvedValue(
createRegistryEntity({ entity_id: 'camera.foo', platform: 'frigate' }),
);
expect(
await createFactory({
@@ -83,7 +67,7 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
entityRegistryManager.getEntity = vi
.fn()
.mockResolvedValue(
createEntity({ entity_id: 'camera.foo', platform: 'motioneye' }),
createRegistryEntity({ entity_id: 'camera.foo', platform: 'motioneye' }),
);
expect(
@@ -98,7 +82,9 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
entityRegistryManager.getEntity = vi
.fn()
.mockResolvedValue(createEntity({ entity_id: 'camera.foo', platform: 'generic' }));
.mockResolvedValue(
createRegistryEntity({ entity_id: 'camera.foo', platform: 'generic' }),
);
expect(
await createFactory({
@@ -120,6 +106,49 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
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(
createFactory({
entityRegistryManager: entityRegistryManager,
+154
View File
@@ -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');
});
});
+164
View File
@@ -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();
});
});
+350
View File
@@ -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();
});
});
+91
View File
@@ -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,
};
};
+202
View File
@@ -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();
});
});
+36
View File
@@ -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 -1
View File
@@ -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 {
allPromises,
+88
View File
@@ -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']),
);
});
});
+17
View File
@@ -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');
});
});
+105
View File
@@ -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();
});
});
+144
View File
@@ -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']]));
});
});
+183
View File
@@ -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();
});
});
+54
View File
@@ -367,6 +367,60 @@ describe('View.adoptFromViewIfAppropriate', () => {
View.adoptFromViewIfAppropriate(next, current);
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
+8
View File
@@ -375,6 +375,13 @@ __metadata:
languageName: node
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":
version: 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-decorators": ^7.19.0
"@cycjimmy/jsmpeg-player": ^6.0.4
"@dermotduffy/panzoom": ^4.5.1
"@egjs/hammerjs": ^2.0.17
"@graphiteds/core": ^1.9.6
"@lit-labs/scoped-registry-mixin": ^1.0.1