Initial version of status bar.

This commit is contained in:
Dermot Duffy
2024-08-16 20:08:21 -07:00
parent db9303c60d
commit e503f0e429
77 changed files with 3072 additions and 1061 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ Only the `cameras` option is required, all other parameters are optional.
| [`cameras_global`](cameras/README.md) | Global defaults that apply to all cameras from the `cameras` section. |
| `card_id` | An optional ID to uniquely identify this card. For use when actions are being sent to card(s) via [URL actions](../usage/url-actions.md). Must exclusively consist of these characters: `[a-zA-Z0-9_]`. |
| [`dimensions`](dimensions.md) | Configures the overall card dimensions. |
| [`elements`](elements.md) | Add custom elements to the card. |
| [`elements`](elements/README.md) | Add custom elements to the card. |
| [`image`](image.md) | Configures the `image` view. |
| [`live`](live.md) | Configures the `live` view. |
| [`media_gallery`](media-gallery.md) | Configures the media gallery. |
+2 -1
View File
@@ -5,7 +5,7 @@
- [`cameras`](cameras/README.md)
- [`conditions`](conditions.md)
- [`dimensions`](dimensions.md)
- [`elements`](elements.md)
- [`elements`](elements/README.md)
- [`image`](image.md)
- [`live`](live.md)
- [`media_gallery`](media-gallery.md)
@@ -14,6 +14,7 @@
- [`overrides`](overrides.md)
- [`performance`](performance.md)
- [`profiles`](profiles.md)
- [`status_bar`](status-bar.md)
- [`timeline`](timeline.md)
- [`view`](view.md)
- [Examples](../examples.md)
+1 -1
View File
@@ -7,7 +7,7 @@ multiple other blocks.
Actions are pre-configured activities that can be triggered in response to a
variety of circumstances (e.g. tapping on a menu icon, double tapping on an
[element](../elements.md) or holding the mouse/tap down on a particular
[element](../elements/README.md) or holding the mouse/tap down on a particular
[view](../view.md?id=supported-views)).
### Differences in actions between Frigate Card and Home Assistant
+3 -1
View File
@@ -7,7 +7,7 @@
- [`cameras`](../cameras/README.md)
- [`conditions`](../conditions.md)
- [`dimensions`](../dimensions.md)
- [`elements`](../elements.md)
- [`elements`](../elements/README.md)
- [`image`](../image.md)
- [`live`](../live.md)
- [`media_gallery`](../media-gallery.md)
@@ -16,8 +16,10 @@
- [`overrides`](../overrides.md)
- [`performance`](../performance.md)
- [`profiles`](../profiles.md)
- [`status_bar`](../status-bar.md)
- [`timeline`](../timeline.md)
- [`view`](../view.md)
- [Examples](../../examples.md)
- [Screenshots](../../screenshots.md)
- [Troubleshooting](../../troubleshooting.md)
- [Usage](../../usage/README.md)
+78 -3
View File
@@ -450,15 +450,25 @@ action: custom:frigate-card-action
frigate_card_action: snapshot
```
## `snapshots`
## `status_bar`
Change to the `snapshots` view.
Add or remove items from the status bar.
```yaml
action: custom:frigate-card-action
frigate_card_action: snapshots
frigate_card_action: status_bar
# [...]
```
| Parameter | Default | Description |
| ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status_bar_action` | | If `add` adds `items` to the status bar, if `remove` removes items and if `reset` resets the status bar entirely (removes all dynamically added items). |
| `items` | | The items to `add` or `remove`. See below. |
### `items`
The items parameter is a list of items to `add` or `remove`. See [`custom:frigate-card-status-bar-icon`](../../elements/custom/README.md?id=status-bar-icon), [`custom:frigate-card-status-bar-image`](../../elements/custom/README.md?id=status-bar-image), [`custom:frigate-card-status-bar-string`](../../elements/custom/README.md?id=status-bar-string) for the allowable items and their parameters. See the [fully expanded reference](./README.md?fully-expanded-reference) below for a complete example.
## `timeline`
Change to the `timeline` view.
@@ -732,4 +742,69 @@ elements:
tap_action:
action: custom:frigate-card-action
frigate_card_action: unmute
- type: custom:frigate-card-menu-icon
icon: mdi:alpha-h-circle-outline
title: Add status bar contents
tap_action:
- action: custom:frigate-card-action
frigate_card_action: status_bar
status_bar_action: add
items:
- type: custom:frigate-card-status-bar-string
enabled: true
exclusive: false
expand: false
string: 'Intruder alert!'
priority: 50
sufficient: false
- type: custom:frigate-card-status-bar-icon
enabled: true
exclusive: false
expand: false
icon: 'mdi:cow'
priority: 50
sufficient: false
- type: custom:frigate-card-status-bar-image
enabled: true
exclusive: false
expand: false
image: https://my.site.com/status.png
priority: 50
sufficient: false
- type: custom:frigate-card-menu-icon
icon: mdi:alpha-i-circle-outline
title: Remove status bar contents
tap_action:
- action: custom:frigate-card-action
frigate_card_action: status_bar
status_bar_action: remove
items:
- type: custom:frigate-card-status-bar-string
enabled: true
exclusive: false
expand: false
string: 'Intruder alert!'
priority: 50
sufficient: false
- type: custom:frigate-card-status-bar-icon
enabled: true
exclusive: false
expand: false
icon: 'mdi:cow'
priority: 50
sufficient: false
- type: custom:frigate-card-status-bar-image
enabled: true
exclusive: false
expand: false
image: https://my.site.com/status.png
priority: 50
sufficient: false
- type: custom:frigate-card-menu-icon
icon: mdi:alpha-i-circle-outline
title: Reset status bar contents
tap_action:
- action: custom:frigate-card-action
frigate_card_action: status_bar
status_bar_action: reset
```
@@ -7,7 +7,7 @@
- [`cameras`](../../cameras/README.md)
- [`conditions`](../../conditions.md)
- [`dimensions`](../../dimensions.md)
- [`elements`](../../elements.md)
- [`elements`](../../elements/README.md)
- [`image`](../../image.md)
- [`live`](../../live.md)
- [`media_gallery`](../../media-gallery.md)
@@ -16,8 +16,10 @@
- [`overrides`](../../overrides.md)
- [`performance`](../../performance.md)
- [`profiles`](../../profiles.md)
- [`status_bar`](../../status-bar.md)
- [`timeline`](../../timeline.md)
- [`view`](../../view.md)
- [Examples](../../../examples.md)
- [Screenshots](../../../screenshots.md)
- [Troubleshooting](../../../troubleshooting.md)
- [Usage](../../../usage/README.md)
+3 -1
View File
@@ -7,7 +7,7 @@
- [`cameras`](../../cameras/README.md)
- [`conditions`](../../conditions.md)
- [`dimensions`](../../dimensions.md)
- [`elements`](../../elements.md)
- [`elements`](../../elements/README.md)
- [`image`](../../image.md)
- [`live`](../../live.md)
- [`media_gallery`](../../media-gallery.md)
@@ -16,8 +16,10 @@
- [`overrides`](../../overrides.md)
- [`performance`](../../performance.md)
- [`profiles`](../../profiles.md)
- [`status_bar`](../../status-bar.md)
- [`timeline`](../../timeline.md)
- [`view`](../../view.md)
- [Examples](../../../examples.md)
- [Screenshots](../../../screenshots.md)
- [Troubleshooting](../../../troubleshooting.md)
- [Usage](../../../usage/README.md)
+3 -1
View File
@@ -7,7 +7,7 @@
- [`engine`](engine.md)
- [`conditions`](../conditions.md)
- [`dimensions`](../dimensions.md)
- [`elements`](../elements.md)
- [`elements`](../elements/README.md)
- [`image`](../image.md)
- [`live`](../live.md)
- [`media_gallery`](../media-gallery.md)
@@ -16,8 +16,10 @@
- [`overrides`](../overrides.md)
- [`performance`](../performance.md)
- [`profiles`](../profiles.md)
- [`status_bar`](../status-bar.md)
- [`timeline`](../timeline.md)
- [`view`](../view.md)
- [Examples](../../examples.md)
- [Screenshots](../../screenshots.md)
- [Troubleshooting](../../troubleshooting.md)
- [Usage](../../usage/README.md)
-356
View File
@@ -1,356 +0,0 @@
# `elements`
This card supports the [Picture Elements configuration
syntax](https://www.home-assistant.io/lovelace/picture-elements/) to seamlessly
allow the user to add custom elements to the card.
```yaml
elements:
- [element_1]
- [element_2]
```
?> The Frigate Card allows either a single [action](actions/README.md) (as in stock Home
Assistant) or list of [actions](actions/README.md) to be defined for each class of user interaction
(e.g. `tap`, `double_tap`, `hold`, etc). See [an example of multiple actions](../examples.md?id=multiple-actions).
## `conditional`
This element will let you show its sub-elements based on entity states. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#conditional-element).
```yaml
elements:
- type: conditional
# [...]
```
## `custom`
Custom elements provided by a card. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#custom-elements).
```yaml
elements:
- type: custom
# [...]
```
## `custom:frigate-card-menu-icon`
Add an arbitrary icon to the Frigate Card menu. Configuration is ~identical to that of the [Picture Elements Icon](https://www.home-assistant.io/lovelace/picture-elements/#icon-element) except with a type name of `custom:frigate-card-menu-icon`.
```yaml
elements:
- type: custom:frigate-card-menu-icon
# [...]
```
## `custom:frigate-card-menu-state-icon`
Add a state icon to the Frigate Card menu that represents the state of a Home Assistant entity. Configuration is ~identical to that of the [Picture Elements State Icon](https://www.home-assistant.io/lovelace/picture-elements/#state-icon) except with a type name of `custom:frigate-card-menu-state-icon`.
```yaml
elements:
- type: custom:frigate-card-menu-icon
# [...]
```
## `custom:frigate-card-menu-submenu`
Add a configurable submenu dropdown.
```yaml
elements:
- type: custom:frigate-card-menu-submenu
# [...]
```
Parameters for this element are identical to the parameters of the [stock Home Assistant Icon Element](https://www.home-assistant.io/lovelace/picture-elements/#icon-element) with the exception of these parameters which differ:
| Parameter | Description |
| --------- | ------------------------------------------- |
| `type` | Must be `custom:frigate-card-menu-submenu`. |
| `items` | A list of menu items, as described below. |
### Submenu items
| Parameter | Default | Description |
| ------------------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------- |
| `enabled` | `true` | Whether or not to show this item as enabled / selectable. |
| `entity` | | An optional Home Assistant entity from which title, icon and style can be automatically computed. |
| `icon` | | An optional item icon to display, e.g. `mdi:car` |
| `selected` | `false` | Whether or not to show this item as selected. |
| `state_color` | `true` | Whether or not the title and icon should be stylized based on state. |
| `style` | | Position and style the element using CSS. |
| `tap_action`, `double_tap_action`, `hold_action`, `start_tap`, `end_tap` | | The [actions](actions/README.md) to take when this item is interacted with. |
| `title` | | An optional title to display. |
## `custom:frigate-card-menu-submenu-select`
Add a submenu based on a `select` or `input_select`. This element allows you to convert a [Home Assistant Select Entity](https://www.home-assistant.io/integrations/select/) or [Home Assistant Input Select Entity](https://www.home-assistant.io/integrations/input_select/) (an entity either starting with `select` or `input_select`) into an overridable submenu. This _could_ be done by hand using a regular submenu (above) -- this element is a convenience.
```yaml
elements:
- type: custom:frigate-card-menu-submenu-select
# [...]
```
Parameters for the `custom:frigate-card-menu-submenu-select` element are identical to the parameters of the [stock Home Assistant State Icon Element](https://www.home-assistant.io/dashboards/picture-elements/#state-icon) with the exception of these parameters which differ:
| Parameter | Description |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | Must be `custom:frigate-card-menu-submenu-select`. |
| `options` | An optional dictionary of overrides keyed by the option name that the given select entity supports. These options can be used to set or override submenu item parameters on a per-option basis. The format is as described in [Submenu Items](elements.md?id=submenu-items) above. |
See the `select` [submenu example](../examples.md?id=select-submenu).
## `custom:frigate-card-conditional`
Restrict a set of elements to only render when the card is matches a set of [conditions](conditions.md). This is analogous to [`conditional`](elements.md?id=conditional) element above, except supporting a rich set of Frigate Card [conditions](conditions.md).
```yaml
elements:
- type: custom:frigate-card-conditional
# [...]
```
Parameters for the `custom:frigate-card-conditional` element:
| Parameter | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------- |
| `type` | Must be `custom:frigate-card-conditional`. |
| `conditions` | A list of [conditions](conditions.md) that must evaluate to true in order for the elements to be rendered. |
| `elements` | The elements to render. Can be any supported element. |
See the [conditional elements example](../examples.md?id=conditional-elements).
## `icon`
This element creates a static icon that is not linked to the state of an entity. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#icon-element).
```yaml
elements:
- type: icon
# [...]
```
## `image`
This creates an image element that overlays the background image. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#image-element).
```yaml
elements:
- type: image
# [...]
```
## `service-button`
This entity creates a button (with arbitrary text) that can be used to call a service. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#service-call-button).
```yaml
elements:
- type: service-button
# [...]
```
## `state-badge`
This element creates a badge representing the state of an entity. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#state-badge).
```yaml
elements:
- type: state-badge
# [...]
```
## `state-icon`
This element represents an entity state using an icon. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#state-icon).
```yaml
elements:
- type: state-icon
# [...]
```
## `state-label`
This element represents an entity’s state via text. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#state-label).
```yaml
elements:
- type: state-label
# [...]
```
## Fully expanded reference
> [Actions](actions/README.md) are omitted for simplicity.
[](common/expanded-warning.md ':include')
### Stock Home Assistant elements
Reference: [Home Assistant Picture Elements](https://www.home-assistant.io/dashboards/picture-elements/)
```yaml
elements:
- type: state-badge
entity: sensor.kitchen_dining_multisensor_air_temperature
style:
left: 100px
top: 50px
title: 'Temperature'
- type: state-icon
entity: light.office_main_lights
icon: mdi:lamp
state_color: true
style:
left: 100px
top: 100px
- type: state-label
entity: sensor.kitchen_motion_sensor_battery
attribute: battery_voltage
prefix: Volts
title: Battery Voltage
style:
left: 100px
top: 150px
- type: state-label
entity: sensor.kitchen_motion_sensor_battery
attribute: battery_voltage
prefix: 'Volts: '
title: Battery Voltage
style:
background-color: black
left: 100px
top: 200px
- type: service-button
title: Light on
service: homeassistant.turn_on
service_data:
entity: light.office_main_lights
style:
left: 100px
top: 250px
- type: icon
icon: mdi:cow
title: Moo
style:
left: 100px
top: 300px
- type: image
entity: light.office_main_lights
title: Image
state_image:
on: 'https://picsum.photos/id/1003/1181/1772'
off: 'https://picsum.photos/id/102/4320/3240'
state_filter:
'on': brightness(110%) saturate(1.2)
'off': brightness(50%) hue-rotate(45deg)
style:
left: 100px
top: 350px
height: 50px
width: 100px
- type: conditional
conditions:
- condition: state
entity: light.office_main_lights
state: on
state_not: off
- condition: numeric_state
entity: sensor.light_level
above: 20
below: 100
- condition: user
users:
- 581fca7fdc014b8b894519cc531f9a04
elements:
- type: icon
icon: mdi:dog
title: Woof
style:
left: 100px
top: 400px
```
### Frigate Card elements
```yaml
elements:
- type: custom:frigate-card-menu-icon
icon: mdi:car
title: Vroom
- type: custom:frigate-card-menu-state-icon
entity: light.office_main_lights
title: Office lights
icon: mdi:chair-rolling
state_color: true
- type: custom:frigate-card-menu-submenu
icon: mdi:menu
items:
- title: Lights
icon: mdi:lightbulb
entity: light.office_main_lights
tap_action:
action: toggle
- title: Google
icon: mdi:google
enabled: false
tap_action:
action: url
url_path: https://www.google.com
- type: custom:frigate-card-menu-submenu-select
icon: mdi:lamps
entity: input_select.kitchen_scene
options:
scene.kitchen_cooking_scene:
icon: mdi:chef-hat
title: Cooking time!
scene.kitchen_tv_scene:
icon: mdi:television
title: TV!
# Show a pig icon if a variety of conditions are met.
- type: custom:frigate-card-conditional
elements:
- type: icon
icon: mdi:pig
title: Oink
style:
left: 300px
top: 100px
conditions:
- condition: view
views:
- live
- condition: fullscreen
fullscreen: true
- condition: expand
expand: true
- condition: camera
cameras: camera.front_door
- condition: media_loaded
media_loaded: true
- condition: display_mode
display_mode: single
- condition: triggered
triggered:
- camera.front_door
- condition: interaction
interaction: true
- condition: microphone
muted: true
connected: true
- condition: state
entity: light.office_main_lights
state: on
state_not: off
- condition: numeric_state
entity: sensor.light_level
above: 20
below: 100
- condition: user
users:
- 581fca7fdc014b8b894519cc531f9a04
```
+24
View File
@@ -0,0 +1,24 @@
# `elements`
## Introduction to elements <!-- {docsify-ignore} -->
This card supports the [Picture Elements configuration
syntax](https://www.home-assistant.io/lovelace/picture-elements/) to seamlessly
allow the user to add custom elements to the card.
```yaml
elements:
- [element_1]
- [element_2]
```
?> The Frigate Card allows either a single [action](../actions/README.md) (as in stock Home
Assistant) or list of [actions](../actions/README.md) to be defined for each class of user interaction
(e.g. `tap`, `double_tap`, `hold`, etc). See [an example of multiple actions](../../examples.md?id=multiple-actions).
## Elements <!-- {docsify-ignore} -->
| Option | Description |
| ------------------------------------- | --------------------------------- |
| [Custom Elements](./custom/README.md) | Custom elements. |
| [Stock Elements](./stock/README.md) | Standard Home Assistant elements. |
+29
View File
@@ -0,0 +1,29 @@
- [Getting Started](../../README.md)
- [Configuration](../README.md)
- [`actions`](../actions/README.md)
- [`automations`](../automations.md)
- [`cameras`](../cameras/README.md)
- [`conditions`](../conditions.md)
- [`dimensions`](../dimensions.md)
- [`elements`](README.md)
- [Custom Elements](./custom/README.md)
- [Stock Elements](./stock/README.md)
- [`image`](../image.md)
- [`live`](../live.md)
- [`media_gallery`](../media-gallery.md)
- [`media_viewer`](../media-viewer.md)
- [`menu`](../menu.md)
- [`overrides`](../overrides.md)
- [`performance`](../performance.md)
- [`profiles`](../profiles.md)
- [`status_bar`](../status-bar.md)
- [`timeline`](../timeline.md)
- [`view`](../view.md)
- [Examples](../../examples.md)
- [Screenshots](../../screenshots.md)
- [Troubleshooting](../../troubleshooting.md)
- [Usage](../../usage/README.md)
---
- [Developing](../../developing.md)
@@ -0,0 +1,258 @@
# Custom Elements
## `conditional`
Restrict a set of elements to only render when the card is matches a set of [conditions](../../conditions.md). This is analogous to the stock [`conditional`](../stock/README.md?id=conditional) element except supporting a rich set of Frigate Card [conditions](../../conditions.md).
```yaml
elements:
- type: custom:frigate-card-conditional
# [...]
```
Parameters for the `custom:frigate-card-conditional` element:
| Parameter | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------------- |
| `type` | Must be `custom:frigate-card-conditional`. |
| `conditions` | A list of [conditions](../../conditions.md) that must evaluate to true in order for the elements to be rendered. |
| `elements` | The elements to render. Can be any supported element. |
See the [conditional elements example](../../../examples.md?id=conditional-elements).
## `menu-icon`
Add an arbitrary icon to the Frigate Card menu. Configuration is ~identical to that of the [Picture Elements Icon](https://www.home-assistant.io/lovelace/picture-elements/#icon-element) except with a type name of `custom:frigate-card-menu-icon`.
```yaml
elements:
- type: custom:frigate-card-menu-icon
# [...]
```
## `menu-submenu`
Add a configurable submenu dropdown.
```yaml
elements:
- type: custom:frigate-card-menu-submenu
# [...]
```
Parameters for this element are identical to the parameters of the [stock Home Assistant Icon Element](https://www.home-assistant.io/lovelace/picture-elements/#icon-element) with the exception of these parameters which differ:
| Parameter | Description |
| --------- | ------------------------------------------- |
| `type` | Must be `custom:frigate-card-menu-submenu`. |
| `items` | A list of menu items, as described below. |
### Submenu items
| Parameter | Default | Description |
| ------------------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------- |
| `enabled` | `true` | Whether or not to show this item as enabled / selectable. |
| `entity` | | An optional Home Assistant entity from which title, icon and style can be automatically computed. |
| `icon` | | An optional item icon to display, e.g. `mdi:car` |
| `selected` | `false` | Whether or not to show this item as selected. |
| `state_color` | `true` | Whether or not the title and icon should be stylized based on state. |
| `style` | | Position and style the element using CSS. |
| `tap_action`, `double_tap_action`, `hold_action`, `start_tap`, `end_tap` | | The [actions](../../actions/README.md) to take when this item is interacted with. |
| `title` | | An optional title to display. |
## `menu-submenu-select`
Add a submenu based on a `select` or `input_select`. This element allows you to convert a [Home Assistant Select Entity](https://www.home-assistant.io/integrations/select/) or [Home Assistant Input Select Entity](https://www.home-assistant.io/integrations/input_select/) (an entity either starting with `select` or `input_select`) into an overridable submenu. This _could_ be done by hand using a regular submenu (above) -- this element is a convenience.
```yaml
elements:
- type: custom:frigate-card-menu-submenu-select
# [...]
```
Parameters for the `custom:frigate-card-menu-submenu-select` element are identical to the parameters of the [stock Home Assistant State Icon Element](https://www.home-assistant.io/dashboards/picture-elements/#state-icon) with the exception of these parameters which differ:
| Parameter | Description |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | Must be `custom:frigate-card-menu-submenu-select`. |
| `options` | An optional dictionary of overrides keyed by the option name that the given select entity supports. These options can be used to set or override submenu item parameters on a per-option basis. The format is as described in [Submenu Items](./README.md?id=submenu-items) above. |
See the `select` [submenu example](../../../examples.md?id=select-submenu).
## `state-icon`
Add a state icon to the Frigate Card menu that represents the state of a Home Assistant entity. Configuration is ~identical to that of the [Picture Elements State Icon](https://www.home-assistant.io/lovelace/picture-elements/#state-icon) except with a type name of `custom:frigate-card-menu-state-icon`.
```yaml
elements:
- type: custom:frigate-card-menu-icon
# [...]
```
## `status-bar-icon`
Add an arbitrary icon to the status bar.
```yaml
elements:
- type: custom:frigate-card-status-bar-icon
# [...]
```
| Parameter | Default | Description |
| ------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `type` | | Must be `custom:frigate-card-status-bar-icon`. |
| `actions` | | Actions to performs when the status bar item is interacted with. See [actions](../../actions/README.md). |
| `enabled` | `true` | `true` to enable this status bar item, `false` to disable. |
| `exclusive` | `false` | Whether or not this item should evict non-exclusive items from the status bar. |
| `expand` | `false` | If `false` this status bar item will consume the minimum possible space, if `true` will expand to the available space. |
| `icon` | | The icon to show in the status bar, e.g. `mdi:camera-front`. |
| `priority` | `50` | The item priority. Higher priority items are ordered closer to the start of the status bar (i.e. an item with priority `70` will order further to the left than an item with priority `60`). Minimum `0`, maximum `100`. |
| `sufficient` | `false` | Whether or not this item is sufficient to display the status bar if it's otherwise hidden (e.g. with the `popup` [status bar style](../../status-bar.md)). |
## `status-bar-image`
Add an arbitrary image to the status bar.
```yaml
elements:
- type: custom:frigate-card-status-bar-image
# [...]
```
| Parameter | Default | Description |
| ------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `type` | | Must be `custom:frigate-card-status-bar-image`. |
| `actions` | | Actions to performs when the status bar item is interacted with. See [actions](../../actions/README.md). |
| `enabled` | `true` | `true` to enable this status bar item, `false` to disable. |
| `exclusive` | `false` | Whether or not this item should evict non-exclusive items from the status bar. |
| `expand` | `false` | If `false` this status bar item will consume the minimum possible space, if `true` will expand to the available space. |
| `image` | | The image to show in the status bar, e.g. `https://my.site.com/status.png`. |
| `priority` | `50` | The item priority. Higher priority items are ordered closer to the start of the status bar (i.e. an item with priority `70` will order further to the left than an item with priority `60`). Minimum `0`, maximum `100`. |
| `sufficient` | `false` | Whether or not this item is sufficient to display the status bar if it's otherwise hidden (e.g. with the `popup` [status bar style](../../status-bar.md)). |
## `status-bar-string`
Add an arbitrary string to the status bar.
```yaml
elements:
- type: custom:frigate-card-status-bar-string
# [...]
```
| Parameter | Default | Description |
| ------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `type` | | Must be `custom:frigate-card-status-bar-string`. |
| `actions` | | Actions to performs when the status bar item is interacted with. See [actions](../../actions/README.md). |
| `enabled` | `true` | `true` to enable this status bar item, `false` to disable. |
| `exclusive` | `false` | Whether or not this item should evict non-exclusive items from the status bar. |
| `expand` | `false` | If `false` this status bar item will consume the minimum possible space, if `true` will expand to the available space. |
| `string` | | The string to show in the status bar, e.g. `Intruder detected!` |
| `priority` | `50` | The item priority. Higher priority items are ordered closer to the start of the status bar (i.e. an item with priority `70` will order further to the left than an item with priority `60`). Minimum `0`, maximum `100`. |
| `sufficient` | `false` | Whether or not this item is sufficient to display the status bar if it's otherwise hidden (e.g. with the `popup` [status bar style](../../status-bar.md)). |
## Fully expanded reference
> [Actions](../../actions/README.md) are omitted for simplicity.
[](../../common/expanded-warning.md ':include')
```yaml
elements:
- type: custom:frigate-card-menu-icon
icon: mdi:car
title: Vroom
- type: custom:frigate-card-menu-state-icon
entity: light.office_main_lights
title: Office lights
icon: mdi:chair-rolling
state_color: true
- type: custom:frigate-card-menu-submenu
icon: mdi:menu
items:
- title: Lights
icon: mdi:lightbulb
entity: light.office_main_lights
tap_action:
action: toggle
- title: Google
icon: mdi:google
enabled: false
tap_action:
action: url
url_path: https://www.google.com
- type: custom:frigate-card-menu-submenu-select
icon: mdi:lamps
entity: input_select.kitchen_scene
options:
scene.kitchen_cooking_scene:
icon: mdi:chef-hat
title: Cooking time!
scene.kitchen_tv_scene:
icon: mdi:television
title: TV!
# Show a pig icon if a variety of conditions are met.
- type: custom:frigate-card-conditional
elements:
- type: icon
icon: mdi:pig
title: Oink
style:
left: 300px
top: 100px
conditions:
- condition: view
views:
- live
- condition: fullscreen
fullscreen: true
- condition: expand
expand: true
- condition: camera
cameras: camera.front_door
- condition: media_loaded
media_loaded: true
- condition: display_mode
display_mode: single
- condition: triggered
triggered:
- camera.front_door
- condition: interaction
interaction: true
- condition: microphone
muted: true
connected: true
- condition: state
entity: light.office_main_lights
state: on
state_not: off
- condition: numeric_state
entity: sensor.light_level
above: 20
below: 100
- condition: user
users:
- 581fca7fdc014b8b894519cc531f9a04
- type: custom:frigate-card-status-bar-string
enabled: true
exclusive: false
expand: false
string: 'Intruder alert!'
priority: 50
sufficient: false
- type: custom:frigate-card-status-bar-icon
enabled: true
exclusive: false
expand: false
icon: 'mdi:cow'
priority: 50
sufficient: false
- type: custom:frigate-card-status-bar-image
enabled: true
exclusive: false
expand: false
image: https://my.site.com/status.png
priority: 50
sufficient: false
```
@@ -0,0 +1,29 @@
- [Getting Started](../../../README.md)
- [Configuration](../../README.md)
- [`actions`](../README.md)
- [`automations`](../../automations.md)
- [`cameras`](../../cameras/README.md)
- [`conditions`](../../conditions.md)
- [`dimensions`](../../dimensions.md)
- [`elements`](../../elements/README.md)
- [Custom Actions](README.md)
- [Stock Actions](../stock/README.md)
- [`image`](../../image.md)
- [`live`](../../live.md)
- [`media_gallery`](../../media-gallery.md)
- [`media_viewer`](../../media-viewer.md)
- [`menu`](../../menu.md)
- [`overrides`](../../overrides.md)
- [`performance`](../../performance.md)
- [`profiles`](../../profiles.md)
- [`status_bar`](../../status-bar.md)
- [`timeline`](../../timeline.md)
- [`view`](../../view.md)
- [Examples](../../../examples.md)
- [Screenshots](../../../screenshots.md)
- [Troubleshooting](../../../troubleshooting.md)
- [Usage](../../../usage/README.md)
---
- [Developing](../../../developing.md)
+173
View File
@@ -0,0 +1,173 @@
# Stock Elements
## `conditional`
This element will let you show its sub-elements based on entity states. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#conditional-element).
```yaml
elements:
- type: conditional
# [...]
```
## `custom`
Custom elements provided by a card. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#custom-elements).
```yaml
elements:
- type: custom
# [...]
```
?> See [Frigate Card custom elements](../custom//README.md) for the custom elements offered by _this_ card.
## `icon`
This element creates a static icon that is not linked to the state of an entity. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#icon-element).
```yaml
elements:
- type: icon
# [...]
```
## `image`
This creates an image element that overlays the background image. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#image-element).
```yaml
elements:
- type: image
# [...]
```
## `service-button`
This entity creates a button (with arbitrary text) that can be used to call a service. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#service-call-button).
```yaml
elements:
- type: service-button
# [...]
```
## `state-badge`
This element creates a badge representing the state of an entity. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#state-badge).
```yaml
elements:
- type: state-badge
# [...]
```
## `state-icon`
This element represents an entity state using an icon. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#state-icon).
```yaml
elements:
- type: state-icon
# [...]
```
## `state-label`
This element represents an entity’s state via text. See [Home Assistant elements documentation](https://www.home-assistant.io/dashboards/picture-elements/#state-label).
```yaml
elements:
- type: state-label
# [...]
```
## Fully expanded reference
> [Actions](../../actions/README.md) are omitted for simplicity.
[](../../common/expanded-warning.md ':include')
Reference: [Home Assistant Picture Elements](https://www.home-assistant.io/dashboards/picture-elements/)
```yaml
elements:
- type: state-badge
entity: sensor.kitchen_dining_multisensor_air_temperature
style:
left: 100px
top: 50px
title: 'Temperature'
- type: state-icon
entity: light.office_main_lights
icon: mdi:lamp
state_color: true
style:
left: 100px
top: 100px
- type: state-label
entity: sensor.kitchen_motion_sensor_battery
attribute: battery_voltage
prefix: Volts
title: Battery Voltage
style:
left: 100px
top: 150px
- type: state-label
entity: sensor.kitchen_motion_sensor_battery
attribute: battery_voltage
prefix: 'Volts: '
title: Battery Voltage
style:
background-color: black
left: 100px
top: 200px
- type: service-button
title: Light on
service: homeassistant.turn_on
service_data:
entity: light.office_main_lights
style:
left: 100px
top: 250px
- type: icon
icon: mdi:cow
title: Moo
style:
left: 100px
top: 300px
- type: image
entity: light.office_main_lights
title: Image
state_image:
on: 'https://picsum.photos/id/1003/1181/1772'
off: 'https://picsum.photos/id/102/4320/3240'
state_filter:
'on': brightness(110%) saturate(1.2)
'off': brightness(50%) hue-rotate(45deg)
style:
left: 100px
top: 350px
height: 50px
width: 100px
- type: conditional
conditions:
- condition: state
entity: light.office_main_lights
state: on
state_not: off
- condition: numeric_state
entity: sensor.light_level
above: 20
below: 100
- condition: user
users:
- 581fca7fdc014b8b894519cc531f9a04
elements:
- type: icon
icon: mdi:dog
title: Woof
style:
left: 100px
top: 400px
```
@@ -0,0 +1,29 @@
- [Getting Started](../../../README.md)
- [Configuration](../../README.md)
- [`actions`](../README.md)
- [`automations`](../../automations.md)
- [`cameras`](../../cameras/README.md)
- [`conditions`](../../conditions.md)
- [`dimensions`](../../dimensions.md)
- [`elements`](../../elements/README.md)
- [Custom Actions](../custom/README.md)
- [Stock Actions](README.md)
- [`image`](../../image.md)
- [`live`](../../live.md)
- [`media_gallery`](../../media-gallery.md)
- [`media_viewer`](../../media-viewer.md)
- [`menu`](../../menu.md)
- [`overrides`](../../overrides.md)
- [`performance`](../../performance.md)
- [`profiles`](../../profiles.md)
- [`status_bar`](../../status-bar.md)
- [`timeline`](../../timeline.md)
- [`view`](../../view.md)
- [Examples](../../../examples.md)
- [Screenshots](../../../screenshots.md)
- [Troubleshooting](../../../troubleshooting.md)
- [Usage](../../../usage/README.md)
---
- [Developing](../../../developing.md)
-20
View File
@@ -41,7 +41,6 @@ live:
| `next_previous` | | Configures how the "Next & Previous" controls are shown on the `live` view. See below. |
| `thumbnails` | | Configures how thumbnails are shown on the `live` view. See below. |
| `timeline` | | Configures how the mini-timeline is shown on the `live` view. See below. |
| `title` | | Configures how the camera title is shown on the `live` view. See below. |
### `next_previous`
@@ -127,22 +126,6 @@ live:
[](common/timeline-seek-info.md ':include')
### `title`
Configures how the camera title is shown on the live view.
```yaml
live:
controls:
title:
# [...]
```
| Option | Default | Description |
| ------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `duration_seconds` | `2` | The number of seconds to display the title popup. `0` implies forever. |
| `mode` | `popup-bottom-right` | How to display the live camera title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . |
## `display`
Controls whether to show a single or grid `live` view.
@@ -230,9 +213,6 @@ live:
events_media_type: all
show_recordings: true
window_seconds: 3600
title:
mode: popup-bottom-right
duration_seconds: 2
microphone:
always_connected: false
disconnect_seconds: 90
-20
View File
@@ -37,7 +37,6 @@ media_viewer:
| `next_previous` | | Configures how the "Next & Previous" controls are shown on the media viewer. See below. |
| `thumbnails` | | Configures how thumbnails are shown on the media viewer. See below. |
| `timeline` | | Configures how the mini-timeline is shown on the media viewer. See below. |
| `title` | | Configures how the media title is shown on the media viewer. See below. |
### `next_previous`
@@ -121,22 +120,6 @@ media_viewer:
[](common/timeline-seek-info.md ':include')
### `title`
Configures how the media title is shown on the media viewer.
```yaml
media_viewer:
controls:
title:
# [...]
```
| Option | Default | Description |
| ------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `duration_seconds` | `2` | The number of seconds to display the title popup. `0` implies forever. |
| `mode` | `popup-bottom-right` | How to display the Media viewer media title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . |
## `display`
Controls whether to show a single media item or grid in the media viewer.
@@ -205,9 +188,6 @@ media_viewer:
events_media_type: all
show_recordings: true
window_seconds: 3600
title:
mode: popup-bottom-right
duration_seconds: 2
display:
mode: single
grid_selected_width_factor: 2
+82
View File
@@ -0,0 +1,82 @@
# `status_bar`
Configures the card status bar.
```yaml
status_bar:
# [...]
```
| Option | Default | Description |
| --------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- |
| `position` | `bottom` | Whether to place the status bar at the `top` or `bottom` of the card. |
| `popup_seconds` | `3` | The number of seconds to display the status bar when using the `popup` style. |
| `height` | `46` | The height of the status bar in pixels. |
| `items` | | Whether to show or hide built-in status bar items. See below. |
| `style` | `popup` | The status bar style to show by default, one of `none`, `hover`, `hover-card`, `overlay`, `outside` or `popup`. See below. |
## `items`
All configuration is under:
```yaml
status_bar:
items:
[item]:
# [...]
```
### Available Items
| Button name | Description |
| ------------ | ------------------------------------------------------ |
| `engine` | The icon of the camera engine for the relevant camera. |
| `resolution` | The detected media resolution (if any). |
| `technology` | The detected media technology (if any). |
| `title` | The media title. |
### Options for each item
| Option | Default | Description |
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true` | Whether or not to show the item. |
| `priority` | `50` | The item priority. Higher priority items are ordered closer to the start of the status bar (i.e. an item with priority `70` will order further to the left than an item with priority `60`). Minimum `0`, maximum `100`. |
## `style`
This card supports several menu styles.
| Key | Description |
| ------------ | --------------------------------------------------------------------------------------------------------------------- |
| `hover-card` | Overlay the status bar over the card contents when the mouse is over the **card**, otherwise it is not shown. |
| `hover` | Overlay the status bar over the card contents when the mouse is over the **status bar**, otherwise it is not shown. |
| `none` | No status bar is shown. |
| `outside` | Render the status bar outside the card (i.e. above it if `position` is `top`, or below it if `position` is `bottom`). |
| `overlay` | Overlay the status bar over the card contents. |
## Fully expanded reference
?> To add custom status bar contents, see [status bar custom elements](elements/custom/README.md?id=status-bar-icon).
[](common/expanded-warning.md ':include')
```yaml
status_bar:
position: bottom
popup_seconds: 3
height: 46
style: popup
items:
engine:
enabled: true
priority: 50
resolution:
enabled: true
priority: 50
technology:
enabled: true
priority: 50
title:
enabled: true
priority: 50
```
+46
View File
@@ -695,6 +695,52 @@ image:
refresh_seconds: 10
```
## Status bar
### Disable status bar
This example disables the status bar.
```yaml
type: custom:frigate-card
cameras:
- camera_entity: camera.office
status_bar:
style: none
```
### Dynamic status bar contents
This example shows an icon and a message on the status bar when a camera is triggered, replacing the existing contents of the status bar through the use of `exclusive`.
```yaml
type: custom:frigate-card
cameras:
- camera_entity: camera.office
automations:
- conditions:
- condition: triggered
triggered:
- camera.office
actions:
- action: custom:frigate-card-action
frigate_card_action: status_bar
status_bar_action: add
items:
- type: custom:frigate-card-status-bar-icon
icon: mdi:alarm-light
exclusive: true
- type: custom:frigate-card-status-bar-string
string: Intruder detected!
expand: true
exclusive: true
sufficient: true
actions_not:
- action: custom:frigate-card-action
frigate_card_action: status_bar
status_bar_action: reset
```
## Submenus
You can add submenus to the menu -- buttons that when pressed reveal a dropdown submenu of configurable options.
+6 -13
View File
@@ -78,8 +78,8 @@ The Android video player swallows `double_tap` interactions in order to
rewind or fast-forward. Workarounds:
- Use `hold` instead of `double_tap` for your card-wide action.
- Use a [Frigate Card Element](configuration/elements.md) or menu icon to trigger
the action instead.
- Use a [Frigate Card Element](configuration/elements/README.md) or menu icon to
trigger the action instead.
### Dragging in carousels broken in Firefox
@@ -180,20 +180,13 @@ 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.
### Title "Popups" continually popping up
### Status "popup" continually popping up
Title popups can be disabled for live or media viewer views with this
configuration:
Status popup can be disabled with this configuration:
```yaml
live:
controls:
title:
mode: none
media_viewer:
controls:
title:
mode: none
status_bar:
style: none
```
### Watermark shown on livestream
@@ -0,0 +1,24 @@
import { StatusBarActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class StatusBarAction extends FrigateCardAction<StatusBarActionConfig> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async execute(api: CardActionsAPI): Promise<void> {
switch (this._action.status_bar_action) {
case 'reset':
api.getStatusBarItemManager().removeAllDynamicStatusBarItems();
break;
case 'add':
this._action.items?.forEach((item) =>
api.getStatusBarItemManager().addDynamicStatusBarItem(item),
);
break;
case 'remove':
this._action.items?.forEach((item) =>
api.getStatusBarItemManager().removeDynamicStatusBarItem(item),
);
break;
}
}
}
+4 -1
View File
@@ -19,11 +19,12 @@ import { MuteAction } from './actions/mute';
import { PauseAction } from './actions/pause';
import { PlayAction } from './actions/play';
import { PTZAction } from './actions/ptz';
import { PTZControlsAction } from './actions/ptz-controls';
import { PTZDigitalAction } from './actions/ptz-digital';
import { PTZMultiAction } from './actions/ptz-multi';
import { ScreenshotAction } from './actions/screenshot';
import { PTZControlsAction } from './actions/ptz-controls';
import { SleepAction } from './actions/sleep';
import { StatusBarAction } from './actions/status-bar';
import { SubstreamOffAction } from './actions/substream-off';
import { SubstreamOnAction } from './actions/substream-on';
import { SubstreamSelectAction } from './actions/substream-select';
@@ -121,6 +122,8 @@ export class ActionFactory {
return new PTZControlsAction(context, frigateCardAction, options?.config);
case 'log':
return new LogAction(context, frigateCardAction, options?.config);
case 'status_bar':
return new StatusBarAction(context, frigateCardAction, options?.config);
}
/* istanbul ignore next: this path cannot be reached -- @preserve */
@@ -96,6 +96,8 @@ export class ConfigManager {
this._api.getMessageManager().reset();
this._api.getStyleManager().setPerformance();
this._api.getCardElementManager().update();
this._api.getStatusBarItemManager().removeAllDynamicStatusBarItems();
setKeyboardShortcutsFromConfig(this._api, this);
setAutomationsFromConfig(this._api);
+6
View File
@@ -28,6 +28,7 @@ import { MediaPlayerManager } from './media-player-manager';
import { MessageManager } from './message-manager';
import { MicrophoneManager } from './microphone-manager';
import { QueryStringManager } from './query-string-manager';
import { StatusBarItemManager } from './status-bar-item-manager';
import { StyleManager } from './style-manager';
import { TriggersManager } from './triggers-manager';
import {
@@ -110,6 +111,7 @@ export class CardController
protected _messageManager = new MessageManager(this);
protected _microphoneManager = new MicrophoneManager(this);
protected _queryStringManager = new QueryStringManager(this);
protected _statusBarItemManager = new StatusBarItemManager(this);
protected _styleManager = new StyleManager(this);
protected _triggersManager = new TriggersManager(this);
protected _viewManager = new ViewManager(this);
@@ -228,6 +230,10 @@ export class CardController
return this._resolvedMediaCache;
}
public getStatusBarItemManager(): StatusBarItemManager {
return this._statusBarItemManager;
}
public static getStubConfig(entities: string[]): FrigateCardConfig {
const cameraEntity = entities.find((element) => element.startsWith('camera.'));
return {
@@ -0,0 +1,160 @@
import { isEqual } from 'lodash-es';
import { CameraManager } from '../camera-manager/manager';
import { StatusBarConfig, StatusBarItem } from '../config/types';
import { MediaLoadedInfo } from '../types';
import { View } from '../view/view';
import { CardStatusBarAPI } from './types';
const RESOLUTION_TOLERANCE_PCT = 0.01;
export class StatusBarItemManager {
protected _api: CardStatusBarAPI;
constructor(api: CardStatusBarAPI) {
this._api = api;
}
protected _items: StatusBarItem[] = [];
protected _dynamicItems: StatusBarItem[] = [];
public addDynamicStatusBarItem(item: StatusBarItem): void {
if (!this._dynamicItems.includes(item)) {
this._dynamicItems.push(item);
}
this._api.getCardElementManager().update();
}
public removeDynamicStatusBarItem(item: StatusBarItem): void {
this._dynamicItems = this._dynamicItems.filter(
(existingItem) => !isEqual(existingItem, item),
);
this._api.getCardElementManager().update();
}
public removeAllDynamicStatusBarItems(): void {
this._dynamicItems = [];
this._api.getCardElementManager().update();
}
public calculateItems(options?: {
statusConfig?: StatusBarConfig | null;
cameraManager?: CameraManager | null;
view?: View | null;
mediaLoadedInfo?: MediaLoadedInfo | null;
}): StatusBarItem[] {
const cameraMetadata = options?.view
? options?.cameraManager?.getCameraMetadata(options?.view?.camera)
: null;
const engineLogoIcon = cameraMetadata?.engineLogo ?? null;
const title = options?.view?.is('live')
? cameraMetadata?.title ?? null
: options?.view?.isAnyMediaView()
? options?.view.queryResults?.getSelectedResult()?.getTitle() ?? null
: null;
const resolution = options?.mediaLoadedInfo
? this._calculateResolution(options?.mediaLoadedInfo)
: null;
const technology = options?.mediaLoadedInfo?.technology?.length
? options?.mediaLoadedInfo.technology[0]
: null;
return [
...(title
? [
{
type: 'custom:frigate-card-status-bar-string' as const,
string: title,
expand: true,
sufficient: true,
...options?.statusConfig?.items.title,
},
]
: []),
...(resolution
? [
{
type: 'custom:frigate-card-status-bar-string' as const,
string: resolution,
...options?.statusConfig?.items.resolution,
},
]
: []),
...(technology && technology === 'webrtc'
? [
{
type: 'custom:frigate-card-status-bar-icon' as const,
icon: 'mdi:webrtc',
...options?.statusConfig?.items.technology,
},
]
: !!technology
? [
{
type: 'custom:frigate-card-status-bar-string' as const,
string: technology.toUpperCase(),
...options?.statusConfig?.items.technology,
},
]
: []),
...(engineLogoIcon
? [
{
type: 'custom:frigate-card-status-bar-image' as const,
image: engineLogoIcon,
...options?.statusConfig?.items.engine,
},
]
: []),
...this._dynamicItems,
];
}
protected _matchesWidthHeight(
mediaLoadedInfo: MediaLoadedInfo | null,
width: number,
height: number,
): boolean {
const widthMin = width * (1 - RESOLUTION_TOLERANCE_PCT);
const widthMax = width * (1 + RESOLUTION_TOLERANCE_PCT);
const heightMin = height * (1 - RESOLUTION_TOLERANCE_PCT);
const heightMax = height * (1 + RESOLUTION_TOLERANCE_PCT);
const matchesDimension = (val: number, min: number, max: number): boolean => {
return val >= min && val <= max;
};
// Allows matching the resolution width and height in either orientation,
// and within RESOLUTION_TOLERANCE_PCT of the resolution.
return (
!!mediaLoadedInfo &&
((matchesDimension(mediaLoadedInfo.width, widthMin, widthMax) &&
matchesDimension(mediaLoadedInfo.height, heightMin, heightMax)) ||
(matchesDimension(mediaLoadedInfo.height, widthMin, widthMax) &&
matchesDimension(mediaLoadedInfo.width, heightMin, heightMax)))
);
}
protected _calculateResolution(mediaLoadedInfo: MediaLoadedInfo): string {
// Ordered roughly by a guess at most common towards the top.
if (this._matchesWidthHeight(mediaLoadedInfo, 1920, 1080)) {
return '1080p';
} else if (this._matchesWidthHeight(mediaLoadedInfo, 1280, 720)) {
return '720p';
} else if (this._matchesWidthHeight(mediaLoadedInfo, 640, 480)) {
return 'VGA';
} else if (this._matchesWidthHeight(mediaLoadedInfo, 3840, 2160)) {
return '4K';
} else if (this._matchesWidthHeight(mediaLoadedInfo, 720, 480)) {
return '480p';
} else if (this._matchesWidthHeight(mediaLoadedInfo, 720, 576)) {
return '576p';
} else if (this._matchesWidthHeight(mediaLoadedInfo, 7680, 4320)) {
return '8K';
} else {
return `${mediaLoadedInfo.width}x${mediaLoadedInfo.height}`;
}
}
}
+12 -5
View File
@@ -1,29 +1,30 @@
import type { CameraManager } from '../camera-manager/manager';
import type { ConditionsManager } from './conditions-manager';
import type { Automation } from '../config/types';
import type { EntityRegistryManager } from '../utils/ha/entity-registry';
import type { ResolvedMediaCache } from '../utils/ha/resolved-media';
import type { ActionsManager } from './actions/actions-manager';
import type { DefaultManager } from './default-manager';
import type { AutomationsManager } from './automations-manager';
import type { CameraURLManager } from './camera-url-manager';
import type { CardElementManager } from './card-element-manager';
import type { ConditionsManager } from './conditions-manager';
import type { ConfigManager } from './config/config-manager';
import type { DefaultManager } from './default-manager';
import type { DownloadManager } from './download-manager';
import type { ExpandManager } from './expand-manager';
import type { FullscreenManager } from './fullscreen-manager';
import type { HASSManager } from './hass-manager';
import type { InitializationManager } from './initialization-manager';
import type { InteractionManager } from './interaction-manager';
import type { KeyboardStateManager } from './keyboard-state-manager';
import type { MediaLoadedInfoManager } from './media-info-manager';
import type { MediaPlayerManager } from './media-player-manager';
import type { MessageManager } from './message-manager';
import type { MicrophoneManager } from './microphone-manager';
import type { QueryStringManager } from './query-string-manager';
import type { StatusBarItemManager } from './status-bar-item-manager';
import type { StyleManager } from './style-manager';
import type { TriggersManager } from './triggers-manager';
import type { ViewManager } from './view/view-manager';
import type { QueryStringManager } from './query-string-manager';
import { KeyboardStateManager } from './keyboard-state-manager';
import { Automation } from '../config/types';
// *************************************************************************
// Manager APIs
@@ -47,6 +48,7 @@ export interface CardActionsAPI {
getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager;
getMicrophoneManager(): MicrophoneManager;
getStatusBarItemManager(): StatusBarItemManager;
getTriggersManager(): TriggersManager;
getViewManager(): ViewManager;
}
@@ -89,6 +91,7 @@ export interface CardConfigAPI {
getInitializationManager(): InitializationManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMessageManager(): MessageManager;
getStatusBarItemManager(): StatusBarItemManager;
getStyleManager(): StyleManager;
getViewManager(): ViewManager;
}
@@ -218,6 +221,10 @@ export interface CardQueryStringAPI {
getViewManager(): ViewManager;
}
export interface CardStatusBarAPI {
getCardElementManager(): CardElementManager;
}
export interface CardStyleAPI {
getCardElementManager(): CardElementManager;
getConfigManager(): ConfigManager;
+104 -8
View File
@@ -2,6 +2,7 @@ import { HomeAssistant, LovelaceCardEditor } from '@dermotduffy/custom-card-help
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import 'web-dialog';
@@ -16,10 +17,18 @@ import './components/menu.js';
import { FrigateCardMenu } from './components/menu.js';
import './components/message.js';
import { renderMessage, renderProgressIndicator } from './components/message.js';
import './components/overlay.js';
import { FrigateCardOverlay } from './components/overlay.js';
import './components/status-bar';
import './components/thumbnail-carousel.js';
import './components/views.js';
import { FrigateCardViews } from './components/views.js';
import { FrigateCardConfig, MenuItem, RawFrigateCardConfig } from './config/types';
import {
FrigateCardConfig,
MenuItem,
RawFrigateCardConfig,
StatusBarItem,
} from './config/types';
import { REPO_URL } from './const.js';
import { localize } from './localize/localize.js';
import cardStyle from './scss/card.scss';
@@ -103,6 +112,7 @@ class FrigateCard extends LitElement {
protected _menuButtonController = new MenuButtonController();
protected _refMenu: Ref<FrigateCardMenu> = createRef();
protected _refOverlay: Ref<FrigateCardOverlay> = createRef();
protected _refMain: Ref<HTMLElement> = createRef();
protected _refElements: Ref<FrigateCardElements> = createRef();
protected _refViews: Ref<FrigateCardViews> = createRef();
@@ -185,7 +195,64 @@ class FrigateCard extends LitElement {
this._controller.getInitializationManager().initializeBackgroundIfNecessary();
}
protected _renderMenu(): TemplateResult | void {
protected _renderMenuStatusContainer(
position: 'top' | 'bottom' | 'overlay',
): TemplateResult | void {
if (!this._config) {
return;
}
const menuStyle = this._config.menu.style;
const menuPosition = this._config.menu.position;
const statusBarStyle = this._config.status_bar.style;
const statusBarPosition = this._config.status_bar.position;
if (
// If there's nothing to render...
(menuStyle === 'none' && statusBarStyle === 'none') ||
// ... or the position I'm rendering does not contain the menu/status bar
(position !== 'overlay' &&
menuPosition !== position &&
statusBarPosition !== position)
) {
// ... then there's nothing to do.
return;
}
const getContents = (kind: 'overlay' | 'outerlay'): TemplateResult => {
const shouldRenderMenu =
menuStyle !== 'none' &&
((menuStyle === 'outside' && kind === 'outerlay') ||
(menuStyle !== 'outside' && kind === 'overlay'));
const shouldRenderStatusBar =
statusBarStyle !== 'none' &&
((statusBarStyle === 'outside' && kind === 'outerlay') ||
(statusBarStyle !== 'outside' && kind === 'overlay'));
// As an exception, to improve the visual flow, if the menu is being
// rendered on the bottom, render the status bar first.
return html`
${shouldRenderMenu && menuPosition !== 'bottom'
? this._renderMenu(menuPosition)
: ''}
${shouldRenderStatusBar ? this._renderStatusBar(statusBarPosition) : ''}
${shouldRenderMenu && menuPosition === 'bottom'
? this._renderMenu(menuPosition)
: ''}
`;
};
return html`
${position === 'overlay'
? html`<frigate-card-overlay>${getContents('overlay')}</frigate-card-overlay>`
: html`<div class="outerlay" data-position="${position}">
${getContents('outerlay')}
</div>`}
`;
}
protected _renderMenu(slot?: string): TemplateResult | void {
const view = this._controller.getViewManager().getView();
if (!this._hass || !this._config || !view) {
return;
@@ -193,6 +260,7 @@ class FrigateCard extends LitElement {
return html`
<frigate-card-menu
${ref(this._refMenu)}
slot=${ifDefined(slot)}
.hass=${this._hass}
.menuConfig=${this._config.menu}
.buttons=${this._menuButtonController.calculateButtons(
@@ -215,6 +283,25 @@ class FrigateCard extends LitElement {
`;
}
protected _renderStatusBar(slot?: string): TemplateResult | void {
if (!this._config) {
return;
}
return html`
<frigate-card-status-bar
slot=${ifDefined(slot)}
.items=${this._controller.getStatusBarItemManager().calculateItems({
statusConfig: this._config.status_bar,
cameraManager: this._controller.getCameraManager(),
view: this._controller.getViewManager().getView(),
mediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(),
})}
.config=${this._config.status_bar}
></frigate-card-status-bar>
`;
}
protected firstUpdated(): void {
// Execute query string actions after first render is complete.
this._controller.getQueryStringManager().executeNonViewRelated();
@@ -250,8 +337,6 @@ class FrigateCard extends LitElement {
};
const actions = this._controller.getActionsManager().getMergedActions();
const renderMenuAbove =
this._config?.menu.style === 'outside' && this._config?.menu.position === 'top';
const cameraManager = this._controller.getCameraManager();
// Caution: Keep the main div and the menu next to one another in order to
@@ -281,8 +366,9 @@ class FrigateCard extends LitElement {
}
@frigate-card:focus=${() => this.focus()}
>
${renderMenuAbove ? this._renderMenu() : ''}
${this._renderMenuStatusContainer('top')}
<div ${ref(this._refMain)} class="${classMap(mainClasses)}">
${this._renderMenuStatusContainer('overlay')}
${!cameraManager.isInitialized() &&
!this._controller.getMessageManager().hasMessage()
? renderProgressIndicator({
@@ -320,7 +406,7 @@ class FrigateCard extends LitElement {
renderMessage(this._controller.getMessageManager().getMessage())
}
</div>
${!renderMenuAbove ? this._renderMenu() : ''}
${this._renderMenuStatusContainer('bottom')}
${this._config?.elements
? // Elements need to render after the main views so it can render 'on
// top'.
@@ -331,14 +417,24 @@ class FrigateCard extends LitElement {
.conditionsManagerEpoch=${this._controller
.getConditionsManager()
?.getEpoch()}
@frigate-card:menu-add=${(ev: CustomEvent<MenuItem>) => {
@frigate-card:menu:add=${(ev: CustomEvent<MenuItem>) => {
this._menuButtonController.addDynamicMenuButton(ev.detail);
this.requestUpdate();
}}
@frigate-card:menu-remove=${(ev: CustomEvent<MenuItem>) => {
@frigate-card:menu:remove=${(ev: CustomEvent<MenuItem>) => {
this._menuButtonController.removeDynamicMenuButton(ev.detail);
this.requestUpdate();
}}
@frigate-card:status-bar:add=${(ev: CustomEvent<StatusBarItem>) => {
this._controller
.getStatusBarItemManager()
.addDynamicStatusBarItem(ev.detail);
}}
@frigate-card:status-bar:remove=${(ev: CustomEvent<StatusBarItem>) => {
this._controller
.getStatusBarItemManager()
.removeDynamicStatusBarItem(ev.detail);
}}
@frigate-card:conditions:evaluate=${(
ev: ConditionsEvaluateRequestEvent,
) => {
@@ -0,0 +1,12 @@
import { MediaTechnology } from '../../../types';
import { VideoRTC } from '../../../components/live/go2rtc/video-rtc';
export const getTechnologyForVideoRTC = (
element: VideoRTC,
): MediaTechnology[] | undefined => {
const tech = [
...(!!element.pc ? ['webrtc'] : []),
...(!element.pc && element.mseCodecs ? ['mse', 'hls'] : []),
];
return tech.length ? tech : undefined;
};
+1 -1
View File
@@ -165,7 +165,7 @@ export class MenuController {
}
public getFreshButtonState(hass: HomeAssistant, button: MenuItem): StateParameters {
const stateParameters = { ...button };
const stateParameters = { ...button } as StateParameters;
return hass && button.type === 'custom:frigate-card-menu-state-icon'
? refreshDynamicStateParameters(hass, stateParameters)
: stateParameters;
+119
View File
@@ -0,0 +1,119 @@
import { HASSDomEvent } from '@dermotduffy/custom-card-helpers';
import { LitElement } from 'lit';
import { isEqual, orderBy } from 'lodash-es';
import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request';
import {
ActionsConfig,
FRIGATE_STATUS_BAR_PRIORITY_DEFAULT,
StatusBarConfig,
StatusBarItem,
} from '../config/types';
import { getActionConfigGivenAction } from '../utils/action';
import { arrayify, setOrRemoveAttribute } from '../utils/basic';
import { Timer } from '../utils/timer';
export class StatusBarController {
protected _host: LitElement;
protected _config: StatusBarConfig | null = null;
protected _popupTimer = new Timer();
protected _items: StatusBarItem[] = [];
constructor(host: LitElement) {
this._host = host;
}
public getRenderItems(): StatusBarItem[] {
return this._items;
}
public setItems(items: StatusBarItem[]): void {
const exclusiveItems = items.filter((item) => !!item.exclusive);
const newItems = orderBy(
exclusiveItems.length ? exclusiveItems : items,
(item) => item.priority ?? FRIGATE_STATUS_BAR_PRIORITY_DEFAULT,
'desc',
);
const sufficientBefore = this._getSufficientValues(this._items);
const sufficientAfter = this._getSufficientValues(newItems);
this._items = newItems;
if (this._config?.style === 'popup' && !isEqual(sufficientBefore, sufficientAfter)) {
this._show();
this._popupTimer.start(this._config.popup_seconds, () => this._hide());
}
this._host.requestUpdate();
}
public setConfig(config: StatusBarConfig): void {
this._config = config;
this._host.style.setProperty(
'--frigate-card-status-bar-height',
`${config.height}px`,
);
this._host.setAttribute('data-style', config.style);
this._host.setAttribute('data-position', config.position);
if (this._config?.style !== 'popup') {
this._show();
}
this._host.requestUpdate();
}
public shouldRender(): boolean {
return this._items.some((item) => item.enabled !== false && item.sufficient);
}
public actionHandler(
ev: HASSDomEvent<{ action: string; config?: ActionsConfig }>,
config?: ActionsConfig,
): void {
// These interactions should only be handled by the status bar, as nothing
// upstream has the user-provided configuration.
ev.stopPropagation();
const interaction: string = ev.detail.action;
const action = getActionConfigGivenAction(interaction, config);
if (!action) {
return;
}
dispatchActionExecutionRequest(this._host, {
action: arrayify(action),
config: config,
});
}
protected _getSufficientValue(item: StatusBarItem): string | null {
/* istanbul ignore else: cannot happen -- @preserve */
if (item.type === 'custom:frigate-card-status-bar-icon') {
return item.icon;
} else if (item.type === 'custom:frigate-card-status-bar-string') {
return item.string;
} else if (item.type === 'custom:frigate-card-status-bar-image') {
return item.image;
} else {
return null;
}
}
protected _getSufficientValues(items: StatusBarItem[]): (string | null)[] {
return items
.filter((item) => item.enabled !== false && item.sufficient)
.map((item) => this._getSufficientValue(item));
}
protected _show(): void {
setOrRemoveAttribute(this._host, false, 'hide');
}
protected _hide(): void {
setOrRemoveAttribute(this._host, true, 'hide');
}
}
+107 -52
View File
@@ -20,6 +20,10 @@ import {
MenuSubmenu,
MenuSubmenuSelect,
PictureElements,
StatusBarIcon,
StatusBarImage,
StatusBarItem,
StatusBarString,
} from '../config/types.js';
import { localize } from '../localize/localize.js';
import elementsStyle from '../scss/elements.scss';
@@ -164,50 +168,66 @@ export class FrigateCardElements extends LitElement {
@property({ attribute: false })
public elements: PictureElements;
protected _boundMenuRemoveHandler = this._menuRemoveHandler.bind(this);
/**
* Handle a picture element to be removed from the menu.
* @param ev The event.
*/
protected _menuRemoveHandler(ev: Event): void {
// Re-dispatch event from this element (instead of the disconnected one, as
// there is no parent of the disconnected element).
dispatchFrigateCardEvent<MenuItem>(this, 'menu-remove', (ev as CustomEvent).detail);
protected _addHandler(
target: EventTarget,
eventName: string,
handler: (ev: Event) => void,
) {
// Ensure listener is only attached 1 time by removing it first.
target.removeEventListener(eventName, handler);
target.addEventListener(eventName, handler);
}
/**
* Handle a picture element to be added to the menu.
* @param ev The event.
*/
protected _menuAddHandler(ev: Event): void {
protected _menuRemoveHandler = (ev: Event): void => {
// Re-dispatch event from this element (instead of the disconnected one, as
// there is no parent of the disconnected element).
dispatchFrigateCardEvent<MenuItem>(this, 'menu:remove', (ev as CustomEvent).detail);
};
protected _statusBarRemoveHandler = (ev: Event): void => {
// Re-dispatch event from this element (instead of the disconnected one, as
// there is no parent of the disconnected element).
dispatchFrigateCardEvent<StatusBarItem>(
this,
'status-bar:remove',
(ev as CustomEvent).detail,
);
};
protected _menuAddHandler = (ev: Event): void => {
ev = ev as CustomEvent<MenuItem>;
const path = ev.composedPath();
if (!path.length) {
return;
}
this._addHandler(path[0], 'frigate-card:menu:remove', this._menuRemoveHandler);
};
// See 'A note on custom elements' above to explain what's going on here.
// Ensure listener is only attached 1 time by removing it first.
path[0].removeEventListener(
'frigate-card:menu-remove',
this._boundMenuRemoveHandler,
protected _statusBarAddHandler = (ev: Event): void => {
ev = ev as CustomEvent<MenuItem>;
const path = ev.composedPath();
if (!path.length) {
return;
}
this._addHandler(
path[0],
'frigate-card:status-bar:add',
this._statusBarRemoveHandler,
);
path[0].addEventListener('frigate-card:menu-remove', this._boundMenuRemoveHandler);
}
};
connectedCallback(): void {
super.connectedCallback();
// Catch icons being added to the menu (so their removal can be subsequently
// handled).
this.addEventListener('frigate-card:menu-add', this._menuAddHandler);
// Catch icons being added to the menu or status-bar (so their removal can
// be subsequently handled).
this.addEventListener('frigate-card:menu:add', this._menuAddHandler);
this.addEventListener('frigate-card:status-bar:add', this._statusBarAddHandler);
}
disconnectedCallback(): void {
this.removeEventListener('frigate-card:menu-add', this._menuAddHandler);
this.removeEventListener('frigate-card:menu:add', this._menuAddHandler);
this.addEventListener('frigate-card:status-bar:add', this._statusBarAddHandler);
super.disconnectedCallback();
}
@@ -286,59 +306,94 @@ export class FrigateCardElementsConditional extends LitElement {
}
// A base class for rendering menu icons / menu state icons.
export class FrigateCardElementsBaseMenuIcon<T> extends LitElement {
@state()
protected _config: T | null = null;
export class FrigateCardElementsBaseItem<ConfigType> extends LitElement {
protected _eventCategory: string;
/**
* Set the card config.
* @param config The configuration.
*/
public setConfig(config: T): void {
constructor(eventCategory: string) {
super();
this._eventCategory = eventCategory;
}
@state()
protected _config: ConfigType | null = null;
public setConfig(config: ConfigType): void {
this._config = config;
}
/**
* Connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
if (this._config) {
dispatchFrigateCardEvent<T>(this, 'menu-add', this._config);
dispatchFrigateCardEvent<ConfigType>(
this,
`${this._eventCategory}:add`,
this._config,
);
}
}
/**
* Disconnected callback.
*/
disconnectedCallback(): void {
if (this._config) {
dispatchFrigateCardEvent<T>(this, 'menu-remove', this._config);
dispatchFrigateCardEvent<ConfigType>(
this,
`${this._eventCategory}:remove`,
this._config,
);
}
super.disconnectedCallback();
}
}
export class FrigateCardElementsBaseMenuItem<
ConfigType,
> extends FrigateCardElementsBaseItem<ConfigType> {
constructor() {
super('menu');
}
}
@customElement('frigate-card-menu-icon')
export class FrigateCardElementsMenuIcon extends FrigateCardElementsBaseMenuIcon<MenuIcon> {}
export class FrigateCardElementsMenuIcon extends FrigateCardElementsBaseMenuItem<MenuIcon> {}
@customElement('frigate-card-menu-state-icon')
export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMenuIcon<MenuStateIcon> {}
export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMenuItem<MenuStateIcon> {}
@customElement('frigate-card-menu-submenu')
export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuIcon<MenuSubmenu> {}
export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuItem<MenuSubmenu> {}
@customElement('frigate-card-menu-submenu-select')
export class FrigateCardElementsMenuSubmenuSelect extends FrigateCardElementsBaseMenuIcon<MenuSubmenuSelect> {}
export class FrigateCardElementsMenuSubmenuSelect extends FrigateCardElementsBaseMenuItem<MenuSubmenuSelect> {}
export class FrigateCardElementsBaseStatusBarItem<
ConfigType,
> extends FrigateCardElementsBaseItem<ConfigType> {
constructor() {
super('status-bar');
}
}
@customElement('frigate-card-status-bar-icon')
export class FrigateCardElementsStatusBarIcon extends FrigateCardElementsBaseStatusBarItem<StatusBarIcon> {}
@customElement('frigate-card-status-bar-image')
export class FrigateCardElementsStatusBarImage extends FrigateCardElementsBaseStatusBarItem<StatusBarImage> {}
@customElement('frigate-card-status-bar-string')
export class FrigateCardElementsStatusBarString extends FrigateCardElementsBaseStatusBarItem<StatusBarString> {}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-conditional': FrigateCardElementsConditional;
'frigate-card-elements': FrigateCardElements;
'frigate-card-menu-submenu-select': FrigateCardElementsMenuSubmenuSelect;
'frigate-card-menu-submenu': FrigateCardElementsMenuSubmenu;
'frigate-card-menu-state-icon': FrigateCardElementsMenuStateIcon;
'frigate-card-menu-icon': FrigateCardElementsMenuIcon;
'frigate-card-elements-core': FrigateCardElementsCore;
'frigate-card-menu-icon': FrigateCardElementsMenuIcon;
'frigate-card-menu-state-icon': FrigateCardElementsMenuStateIcon;
'frigate-card-menu-submenu': FrigateCardElementsMenuSubmenu;
'frigate-card-menu-submenu-select': FrigateCardElementsMenuSubmenuSelect;
'frigate-card-status-bar-icon': FrigateCardElementsStatusBarIcon;
'frigate-card-status-bar-image': FrigateCardElementsStatusBarImage;
'frigate-card-status-bar-string': FrigateCardElementsStatusBarString;
}
}
+4
View File
@@ -10,6 +10,7 @@ import {
dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent,
} from '../../../utils/media-info';
import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get-technology-for-video-rtc.js';
/**
* Video player for go2rtc streaming application.
@@ -325,6 +326,7 @@ export class VideoRTC extends HTMLElement {
supportsPause: true,
hasAudio: mayHaveAudio(this.video),
},
technology: getTechnologyForVideoRTC(this),
});
};
this.video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this);
@@ -641,6 +643,7 @@ export class VideoRTC extends HTMLElement {
receivedFirstFrame = true;
dispatchMediaLoadedEvent(this, this.video, {
player: this.containingPlayer,
technology: ['mjpeg'],
});
}
};
@@ -668,6 +671,7 @@ export class VideoRTC extends HTMLElement {
dispatchMediaLoadedEvent(this, video2, {
player: this.containingPlayer,
technology: ['mp4'],
});
}
+1
View File
@@ -141,6 +141,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
capabilities: {
supportsPause: true,
},
technology: ['jsmpeg'],
});
}
}
+10 -5
View File
@@ -1,8 +1,9 @@
import { Task } from '@lit-labs/task';
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { Task } from '@lit-labs/task';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { CameraEndpoints } from '../../camera-manager/types.js';
import { getTechnologyForVideoRTC } from '../../components-lib/live/utils/get-technology-for-video-rtc.js';
import { CameraConfig, CardWideConfig } from '../../config/types.js';
import { localize } from '../../localize/localize.js';
import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss';
@@ -22,6 +23,7 @@ import {
import { screenshotMedia } from '../../utils/screenshot.js';
import { renderTask } from '../../utils/task.js';
import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js';
import { VideoRTC } from './go2rtc/video-rtc.js';
// Create a wrapper for AlexxIT's WebRTC card
// - https://github.com/AlexxIT/WebRTC
@@ -104,15 +106,16 @@ export class FrigateCardLiveWebRTCCard
this.requestUpdate();
}
protected _getVideoRTC(): VideoRTC | null {
return (this.renderRoot?.querySelector('#webrtc') ?? null) as VideoRTC | null;
}
/**
* Get the underlying video player.
* @returns The player or `null` if not found.
*/
protected _getPlayer(): HTMLVideoElement | null {
const root = this.renderRoot?.querySelector('#webrtc') as
| (HTMLElement & { video?: HTMLVideoElement })
| null;
return root?.video ?? null;
return this._getVideoRTC()?.video ?? null;
}
protected async _getWebRTCCardElement(): Promise<
@@ -192,6 +195,7 @@ export class FrigateCardLiveWebRTCCard
// Extract the video component after it has been rendered and generate the
// media load event.
this.updateComplete.then(() => {
const videoRTC = this._getVideoRTC();
const video = this._getPlayer();
if (video) {
setControlsOnVideo(video, this.controls);
@@ -205,6 +209,7 @@ export class FrigateCardLiveWebRTCCard
supportsPause: true,
hasAudio: mayHaveAudio(video),
},
...(videoRTC && { technology: getTechnologyForVideoRTC(videoRTC) }),
});
};
video.onplay = () => dispatchMediaPlayEvent(this);
-29
View File
@@ -62,11 +62,6 @@ import '../next-prev-control.js';
import '../ptz.js';
import { FrigateCardPTZ } from '../ptz.js';
import '../surround.js';
import '../title-control.js';
import {
FrigateCardTitleControl,
getDefaultTitleConfigForView,
} from '../title-control.js';
const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider';
@@ -294,7 +289,6 @@ export class FrigateCardLiveCarousel extends LitElement {
// Index between camera name and slide number.
protected _cameraToSlide: Record<string, number> = {};
protected _refTitleControl: Ref<FrigateCardTitleControl> = createRef();
protected _refPTZControl: Ref<FrigateCardPTZ> = createRef();
@state()
@@ -540,18 +534,10 @@ export class FrigateCardLiveCarousel extends LitElement {
? this.cameraManager.getCameraMetadata(this._getSubstreamCameraID(prevID, view))
: null;
const cameraID = this.viewFilterCameraID ?? view.camera;
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
this._getSubstreamCameraID(cameraID, view),
);
const cameraMetadataNext = nextID
? this.cameraManager.getCameraMetadata(this._getSubstreamCameraID(nextID, view))
: null;
const titleConfig = getDefaultTitleConfigForView(
view,
this.overriddenLiveConfig?.controls.title,
);
// Notes on the below:
// - guard() is used to avoid reseting the carousel unless the
// options/plugins actually change.
@@ -574,9 +560,6 @@ export class FrigateCardLiveCarousel extends LitElement {
transitionEffect=${this._getTransitionEffect()}
@frigate-card:carousel:select=${this._setViewHandler.bind(this)}
@frigate-card:media:loaded=${() => {
if (this._refTitleControl.value) {
this._refTitleControl.value.show();
}
this._mediaHasLoaded = true;
}}
@frigate-card:media:unloaded=${() => {
@@ -620,18 +603,6 @@ export class FrigateCardLiveCarousel extends LitElement {
.forceVisibility=${this._mediaHasLoaded && view.context?.ptzControls?.enabled}
>
</frigate-card-ptz>
${cameraMetadataCurrent && titleConfig
? html`<frigate-card-title-control
${ref(this._refTitleControl)}
.config=${titleConfig}
.text="${cameraMetadataCurrent
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
: ''}"
.logo="${cameraMetadataCurrent.engineLogo}"
.fitInto=${this as HTMLElement}
>
</frigate-card-title-control> `
: ``}
`;
}
+25
View File
@@ -0,0 +1,25 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import overlayStyle from '../scss/overlay.scss';
@customElement('frigate-card-overlay')
export class FrigateCardOverlay extends LitElement {
protected render(): TemplateResult | void {
return html`
<slot name="top"></slot>
<slot name="left"></slot>
<slot name="right"></slot>
<slot name="bottom"></slot>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(overlayStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-overlay': FrigateCardOverlay;
}
}
+91
View File
@@ -0,0 +1,91 @@
import { CSSResultGroup, LitElement, PropertyValues, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { actionHandler } from '../action-handler-directive.js';
import { StatusBarController } from '../components-lib/status-bar-controller';
import { StatusBarConfig, StatusBarItem } from '../config/types';
import statusStyle from '../scss/status.scss';
import { frigateCardHasAction } from '../utils/action';
@customElement('frigate-card-status-bar')
export class FrigateCardStatusBar extends LitElement {
protected _controller = new StatusBarController(this);
@property({ attribute: false })
public items?: StatusBarItem[];
@property({ attribute: false })
public config?: StatusBarConfig;
protected willUpdate(changedProperties: PropertyValues): void {
// Always set config before items.
if (changedProperties.has('config') && this.config) {
this._controller.setConfig(this.config);
}
if (changedProperties.has('items')) {
this._controller.setItems(this.items ?? []);
}
}
protected render(): TemplateResult | void {
if (!this._controller.shouldRender()) {
return;
}
return html`
<div class="status">
${this._controller.getRenderItems().map((item): TemplateResult | void => {
if (item.enabled === false) {
return;
}
const classes = classMap({
item: true,
expand: !!item.expand,
action: !!Object.keys(item.actions ?? {}).length,
});
const handler = actionHandler({
hasHold: frigateCardHasAction(item.actions?.hold_action),
hasDoubleClick: frigateCardHasAction(item.actions?.double_tap_action),
});
if (item.type === 'custom:frigate-card-status-bar-string') {
return html`<div
.actionHandler=${handler}
class="${classes}"
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
>
${item.string}
</div>`;
} else if (item.type === 'custom:frigate-card-status-bar-icon') {
return html`<ha-icon
.actionHandler=${handler}
class="${classes}"
icon="${item.icon}"
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
></ha-icon>`;
} else if (item.type === 'custom:frigate-card-status-bar-image') {
return html`<img
.actionHandler=${handler}
class="${classes}"
src="${item.image}"
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
/>`;
}
})}
</div>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(statusStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-status-bar': FrigateCardStatusBar;
}
}
+5 -2
View File
@@ -81,6 +81,7 @@ export class FrigateCardSubmenu extends LitElement {
if (!this.submenu) {
return html``;
}
const items = this.submenu.items as MenuSubmenuItem[];
return html`
<ha-button-menu
corner=${'BOTTOM_LEFT'}
@@ -109,7 +110,7 @@ export class FrigateCardSubmenu extends LitElement {
>
<ha-icon icon="${this.submenu.icon}"></ha-icon>
</ha-icon-button>
${this.submenu.items.map(this._renderItem.bind(this))}
${items.map(this._renderItem.bind(this))}
</ha-button-menu>
`;
}
@@ -217,9 +218,11 @@ export class FrigateCardSubmenuSelect extends LitElement {
// the items correctly (below).
delete submenu['options'];
const items = submenu.items as MenuSubmenuItem[];
for (const option of options) {
const title = this._optionTitles?.[option] ?? option;
submenu.items.push({
items.push({
state_color: true,
selected: stateObj.state === option,
enabled: true,
-4
View File
@@ -51,10 +51,6 @@ export class FrigateCardSurroundBasic extends LitElement {
}
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
return html` <slot name="above"></slot>
<slot></slot>
-98
View File
@@ -1,98 +0,0 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { TitleControlConfig } from '../config/types';
import titleStyle from '../scss/title-control.scss';
import { View } from '../view/view.js';
type PaperToast = HTMLElement & {
opened: boolean;
};
export const getDefaultTitleConfigForView = (
view?: Readonly<View> | null,
baseConfig?: TitleControlConfig,
): TitleControlConfig | null => {
if (!baseConfig && view?.isGrid()) {
return { mode: 'none', duration_seconds: 2 };
}
return {
mode: 'popup-bottom-right',
duration_seconds: 2,
...baseConfig,
};
};
@customElement('frigate-card-title-control')
export class FrigateCardTitleControl extends LitElement {
@property({ attribute: false })
public config?: TitleControlConfig;
@property({ attribute: false })
public text?: string;
@property({ attribute: false })
public fitInto?: HTMLElement;
@property({ attribute: false })
public logo?: string;
protected _toastRef: Ref<PaperToast> = createRef();
protected render(): TemplateResult {
if (
!this.text ||
!this.config ||
!this.config.mode ||
this.config.duration_seconds === undefined ||
this.config.mode === 'none' ||
!this.fitInto
) {
return html``;
}
const verticalAlign = this.config.mode.match(/-top-/) ? 'top' : 'bottom';
const horizontalAlign = this.config.mode.match(/-left$/) ? 'left' : 'right';
return html` <paper-toast
${ref(this._toastRef)}
class="capsule"
.duration=${this.config.duration_seconds * 1000}
.verticalAlign=${verticalAlign}
.horizontalAlign=${horizontalAlign}
.text="${this.text}"
.fitInto=${this.fitInto}
>
${this.logo ? html`<img src=${this.logo} />` : ''}
</paper-toast>`;
}
public isVisible(): boolean {
return this._toastRef.value?.opened ?? false;
}
public hide(): void {
if (this._toastRef.value) {
// Set it to false first, to ensure the timer resets.
this._toastRef.value.opened = false;
}
}
public show(): void {
if (this._toastRef.value) {
// Set it to false first, to ensure the timer resets.
this._toastRef.value.opened = false;
this._toastRef.value.opened = true;
}
}
static get styles(): CSSResultGroup {
return unsafeCSS(titleStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-title-control': FrigateCardTitleControl;
}
}
+2 -28
View File
@@ -73,11 +73,6 @@ import type { EmblaCarouselPlugins } from './carousel.js';
import './next-prev-control.js';
import './ptz';
import './surround.js';
import './title-control.js';
import {
FrigateCardTitleControl,
getDefaultTitleConfigForView,
} from './title-control.js';
export interface MediaViewerViewContext {
seek?: Date;
@@ -194,7 +189,6 @@ export class FrigateCardViewerCarousel extends LitElement {
protected _selected = 0;
protected _media: ViewMedia[] | null = null;
protected _refTitleControl: Ref<FrigateCardTitleControl> = createRef();
protected _player: FrigateCardMediaPlayer | null = null;
/**
@@ -407,15 +401,7 @@ export class FrigateCardViewerCarousel extends LitElement {
}
};
const cameraMetadata = this.cameraManager.getCameraMetadata(
selectedMedia.getCameraID(),
);
const view = this.viewManagerEpoch?.manager.getView();
const titleConfig = getDefaultTitleConfigForView(
view,
this.viewerConfig?.controls.title,
);
return html`
<frigate-card-carousel
@@ -427,9 +413,6 @@ export class FrigateCardViewerCarousel extends LitElement {
this._setViewSelectedIndex(ev.detail.index);
}}
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
if (this._refTitleControl.value) {
this._refTitleControl.value.show();
}
this._player = ev.detail.player ?? null;
this._seekHandler();
}}
@@ -480,16 +463,6 @@ export class FrigateCardViewerCarousel extends LitElement {
<ha-icon title="${localize('media_viewer.unseekable')}" icon="mdi:clock-remove">
</ha-icon>
</div>
${cameraMetadata && titleConfig
? html`<frigate-card-title-control
${ref(this._refTitleControl)}
.config=${titleConfig}
.text="${selectedMedia.getTitle() ?? undefined}"
.logo="${cameraMetadata?.engineLogo}"
.fitInto=${this as HTMLElement}
>
</frigate-card-title-control> `
: ``}
`;
}
@@ -919,6 +892,7 @@ export class FrigateCardViewerProvider
supportsPause: true,
hasAudio: mayHaveAudio(ev.target as HTMLVideoElement),
},
technology: ['hls'],
});
}}
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
@@ -942,7 +916,7 @@ export class FrigateCardViewerProvider
}
}}
@load=${(ev: Event) => {
dispatchMediaLoadedEvent(this, ev, { player: this });
dispatchMediaLoadedEvent(this, ev, { player: this, technology: ['jpg'] });
}}
/>`}
`);
+34 -7
View File
@@ -3,11 +3,6 @@ import get from 'lodash-es/get';
import isEqual from 'lodash-es/isEqual';
import set from 'lodash-es/set';
import unset from 'lodash-es/unset';
import {
FrigateCardCondition,
RawFrigateCardConfig,
RawFrigateCardConfigArray,
} from './types';
import {
CONF_AUTOMATIONS,
CONF_CAMERAS,
@@ -24,18 +19,24 @@ import {
CONF_MENU_BUTTONS_CAMERA_UI,
CONF_OVERRIDES,
CONF_PROFILES,
CONF_STATUS_BAR,
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
CONF_VIEW_INTERACTION_SECONDS,
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
CONF_VIEW_DEFAULT_RESET_ENTITIES,
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE,
CONF_VIEW_INTERACTION_SECONDS,
CONF_VIEW_TRIGGERS,
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
} from '../const';
import { arrayify } from '../utils/basic';
import {
FrigateCardCondition,
RawFrigateCardConfig,
RawFrigateCardConfigArray,
} from './types';
// *************************************************************************
// General Config Management Functions
@@ -660,6 +661,27 @@ const ptzControlSettingsTransform = (data: unknown): unknown => {
}, {});
};
const titleControlTransform = (data: unknown): unknown => {
if (typeof data !== 'object' || !data || typeof data['mode'] !== 'string') {
return null;
}
if (data['mode'] === 'none') {
return {
style: 'none',
};
}
if (data['mode'].includes('bottom')) {
return {
position: 'bottom',
};
} else if (data['mode'].includes('top')) {
return {
position: 'top',
};
}
return null;
};
const UPGRADES = [
// v4.0.0 -> v4.1.0
upgradeArrayOfObjects(
@@ -822,4 +844,9 @@ const UPGRADES = [
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
),
upgradeMoveToWithOverrides('view.update_entities', CONF_VIEW_DEFAULT_RESET_ENTITIES),
upgradeMoveTo('live.controls.title', CONF_STATUS_BAR, {
transform: titleControlTransform,
}),
deleteWithOverrides('live.controls.title'),
deleteWithOverrides('media_viewer.controls.title'),
];
+3 -4
View File
@@ -9,7 +9,6 @@ import {
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
CONF_LIVE_CONTROLS_TITLE_MODE,
CONF_LIVE_DRAGGABLE,
CONF_LIVE_LAZY_UNLOAD,
CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
@@ -28,7 +27,6 @@ import {
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
CONF_MEDIA_VIEWER_DRAGGABLE,
CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP,
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
@@ -40,6 +38,7 @@ import {
CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE,
CONF_PERFORMANCE_STYLE_BORDER_RADIUS,
CONF_PERFORMANCE_STYLE_BOX_SHADOW,
CONF_STATUS_BAR_STYLE,
CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
@@ -83,8 +82,8 @@ export const LOW_PERFORMANCE_PROFILE = {
// Media player next/previous are chevrons.
[CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE]: 'chevrons' as const,
[CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE]: 'none' as const,
[CONF_LIVE_CONTROLS_TITLE_MODE]: 'none' as const,
// Disable the status bar.
[CONF_STATUS_BAR_STYLE]: 'none' as const,
// Move the menu to outside to remove the need to interact with it with open.
[CONF_MENU_STYLE]: 'outside',
+138 -26
View File
@@ -21,12 +21,15 @@ import { PTZ_ACTIONS } from './ptz';
// Common Configuration Constants
// *************************************************************************
// The min allowed size of buttons.
export const BUTTON_SIZE_MIN = 20;
export const STATUS_BAR_HEIGHT_MIN = BUTTON_SIZE_MIN;
const FRIGATE_MENU_PRIORITY_DEFAULT = 50;
export const FRIGATE_MENU_PRIORITY_MAX = 100;
export const FRIGATE_STATUS_BAR_PRIORITY_DEFAULT = FRIGATE_MENU_PRIORITY_DEFAULT;
export const FRIGATE_STATUS_BAR_PRIORITY_MAX = FRIGATE_MENU_PRIORITY_MAX;
export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
'diagnostics',
'live',
@@ -343,6 +346,19 @@ const sleepActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({
});
export type SleepActionConfig = z.infer<typeof sleepActionConfigSchema>;
const statusBarActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({
frigate_card_action: z.literal('status_bar'),
status_bar_action: z.enum(['add', 'remove', 'reset']),
// This needs to be lazily evaluated since statusBarItemSchema may itself
// contain actions.
items: z
.lazy(() => statusBarItemSchema)
.array()
.optional(),
});
export type StatusBarActionConfig = z.infer<typeof statusBarActionConfigSchema>;
const LOG_ACTIONS_LEVELS = ['debug', 'info', 'warn', 'error'] as const;
export type LogActionLevel = (typeof LOG_ACTIONS_LEVELS)[number];
@@ -366,6 +382,7 @@ export const frigateCardCustomActionSchema = z.union([
viewActionConfigSchema,
viewDisplayModeActionConfigSchema,
sleepActionConfigSchema,
statusBarActionConfigSchema,
]);
export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>;
@@ -581,6 +598,52 @@ const menuSubmenuSelectSchema = menuBaseSchema.merge(stateIconSchema).extend({
export type MenuSubmenuSelect = z.infer<typeof menuSubmenuSelectSchema>;
export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu | MenuSubmenuSelect;
// *************************************************************************
// Custom Element Configuration: Status bar
// *************************************************************************
const statusBarItemBaseSchema = z.object({
enabled: z.boolean().default(true).optional(),
priority: z
.number()
.min(0)
.max(FRIGATE_STATUS_BAR_PRIORITY_MAX)
.default(FRIGATE_STATUS_BAR_PRIORITY_DEFAULT)
.optional(),
});
const statusBarItemElementsBaseSchema = statusBarItemBaseSchema.extend({
sufficient: z.boolean().default(false).optional(),
exclusive: z.boolean().default(false).optional(),
expand: z.boolean().default(false).optional(),
actions: actionsBaseSchema.optional(),
});
const statusBarIconItemSchema = statusBarItemElementsBaseSchema.extend({
type: z.literal('custom:frigate-card-status-bar-icon'),
icon: z.string(),
});
export type StatusBarIcon = z.infer<typeof statusBarIconItemSchema>;
const statusBarImageItemSchema = statusBarItemElementsBaseSchema.extend({
type: z.literal('custom:frigate-card-status-bar-image'),
image: z.string(),
});
export type StatusBarImage = z.infer<typeof statusBarImageItemSchema>;
const statusBarStringItemSchema = statusBarItemElementsBaseSchema.extend({
type: z.literal('custom:frigate-card-status-bar-string'),
string: z.string(),
});
export type StatusBarString = z.infer<typeof statusBarStringItemSchema>;
const statusBarItemSchema = z.union([
statusBarIconItemSchema,
statusBarImageItemSchema,
statusBarStringItemSchema,
]);
export type StatusBarItem = z.infer<typeof statusBarItemSchema>;
// *************************************************************************
// Custom Element Configuration: Conditions
// *************************************************************************
@@ -680,6 +743,9 @@ const pictureElementSchema = z.union([
stateBadgeIconSchema,
stateIconSchema,
stateLabelSchema,
statusBarIconItemSchema,
statusBarImageItemSchema,
statusBarStringItemSchema,
]);
const pictureElementsSchema = pictureElementSchema.array().optional();
export type PictureElements = z.infer<typeof pictureElementsSchema>;
@@ -1009,24 +1075,6 @@ const nextPreviousControlConfigSchema = z.object({
});
export type NextPreviousControlConfig = z.infer<typeof nextPreviousControlConfigSchema>;
// *************************************************************************
// Title Control Configuration
// *************************************************************************
const titleControlConfigSchema = z.object({
mode: z
.enum([
'none',
'popup-top-right',
'popup-top-left',
'popup-bottom-right',
'popup-bottom-left',
])
.optional(),
duration_seconds: z.number().min(0).max(60).optional(),
});
export type TitleControlConfig = z.infer<typeof titleControlConfigSchema>;
// *************************************************************************
// Carousel Transition Configuration
// *************************************************************************
@@ -1188,7 +1236,6 @@ const liveConfigSchema = z
liveConfigDefault.controls.thumbnails,
),
timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline),
title: titleControlConfigSchema.optional(),
})
.default(liveConfigDefault.controls),
display: viewDisplaySchema,
@@ -1584,6 +1631,65 @@ export const menuConfigSchema = z
.default(menuConfigDefault);
export type MenuConfig = z.infer<typeof menuConfigSchema>;
// *************************************************************************
// Status Bar Configuration
// *************************************************************************
const STATUS_BAR_STYLES = [
'none',
'overlay',
'hover',
'hover-card',
'outside',
'popup',
] as const;
const STATUS_BAR_POSITIONS = ['top', 'bottom'] as const;
const statusBarItemDefault = {
priority: FRIGATE_STATUS_BAR_PRIORITY_DEFAULT,
enabled: true,
};
const statusBarConfigDefault = {
height: 46,
items: {
engine: statusBarItemDefault,
resolution: statusBarItemDefault,
technology: statusBarItemDefault,
title: statusBarItemDefault,
},
position: 'bottom' as const,
style: 'popup' as const,
popup_seconds: 3,
};
export const statusBarConfigSchema = z
.object({
position: z.enum(STATUS_BAR_POSITIONS).default(statusBarConfigDefault.position),
style: z.enum(STATUS_BAR_STYLES).default(statusBarConfigDefault.style),
popup_seconds: z
.number()
.min(0)
.max(60)
.default(statusBarConfigDefault.popup_seconds),
height: z.number().min(STATUS_BAR_HEIGHT_MIN).default(statusBarConfigDefault.height),
items: z
.object({
engine: statusBarItemBaseSchema.default(statusBarConfigDefault.items.engine),
technology: statusBarItemBaseSchema.default(
statusBarConfigDefault.items.technology,
),
resolution: statusBarItemBaseSchema.default(
statusBarConfigDefault.items.resolution,
),
title: statusBarItemBaseSchema.default(statusBarConfigDefault.items.title),
})
.default(statusBarConfigDefault.items),
})
.default(statusBarConfigDefault);
export type StatusBarConfig = z.infer<typeof statusBarConfigSchema>;
// *************************************************************************
// Event Viewer Configuration
// *************************************************************************
@@ -1672,7 +1778,6 @@ const viewerConfigSchema = z
timeline: miniTimelineConfigSchema.default(
viewerConfigDefault.controls.timeline,
),
title: titleControlConfigSchema.optional(),
})
.default(viewerConfigDefault.controls),
})
@@ -1767,11 +1872,16 @@ export type Overrides = z.infer<typeof overridesSchema>;
const automationActionSchema = actionSchema.array();
export type AutomationActions = z.infer<typeof automationActionSchema>;
const automationSchema = z.object({
conditions: frigateCardConditionSchema.array(),
actions: automationActionSchema.optional(),
actions_not: automationActionSchema.optional(),
});
const automationSchema = z
.object({
conditions: frigateCardConditionSchema.array(),
actions: automationActionSchema.optional(),
actions_not: automationActionSchema.optional(),
})
.refine(
(data) => data.actions?.length || data.actions_not?.length,
'Automations must include at least one action',
);
export type Automation = z.infer<typeof automationSchema>;
const automationsSchema = automationSchema.array();
@@ -1857,6 +1967,7 @@ export const frigateCardConfigSchema = z.object({
view: viewConfigSchema,
menu: menuConfigSchema,
status_bar: statusBarConfigSchema,
live: liveConfigSchema,
media_gallery: galleryConfigSchema,
media_viewer: viewerConfigSchema,
@@ -1892,6 +2003,7 @@ export const frigateCardConfigDefaults = {
cameras: cameraConfigDefault,
view: viewConfigDefault,
menu: menuConfigDefault,
status_bar: statusBarConfigDefault,
live: liveConfigDefault,
media_gallery: galleryConfigDefault,
media_viewer: viewerConfigDefault,
+8 -8
View File
@@ -216,11 +216,6 @@ 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;
export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS =
`${CONF_MEDIA_VIEWER}.controls.title.duration_seconds` as const;
const CONF_LIVE = 'live' as const;
export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const;
export const CONF_LIVE_AUTO_PAUSE = `${CONF_LIVE}.auto_pause` as const;
@@ -272,9 +267,6 @@ export const CONF_LIVE_CONTROLS_TIMELINE_STYLE =
`${CONF_LIVE}.controls.timeline.style` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS =
`${CONF_LIVE}.controls.timeline.window_seconds` as const;
export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const;
export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS =
`${CONF_LIVE}.controls.title.duration_seconds` as const;
export const CONF_LIVE_DISPLAY_MODE = `${CONF_LIVE}.display.mode` as const;
export const CONF_LIVE_DISPLAY_GRID_COLUMNS =
`${CONF_LIVE}.display.grid_columns` as const;
@@ -336,6 +328,14 @@ export const CONF_MENU_BUTTONS_MEDIA_PLAYER =
`${CONF_MENU}.buttons.media_player` as const;
export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU}.buttons.timeline` as const;
export const CONF_STATUS_BAR = 'status_bar' as const;
export const CONF_STATUS_BAR_POSITION = `${CONF_STATUS_BAR}.position` as const;
export const CONF_STATUS_BAR_STYLE = `${CONF_STATUS_BAR}.style` as const;
export const CONF_STATUS_BAR_POPUP_SECONDS = `${CONF_STATUS_BAR}.popup_seconds` as const;
export const CONF_STATUS_BAR_HEIGHT = `${CONF_STATUS_BAR}.height` as const;
export const CONF_STATUS_BAR_ITEMS = `${CONF_STATUS_BAR}.items` as const;
const CONF_DIMENSIONS = 'dimensions' as const;
export const CONF_DIMENSIONS_ASPECT_RATIO = `${CONF_DIMENSIONS}.aspect_ratio` as const;
export const CONF_DIMENSIONS_ASPECT_RATIO_MODE =
+124 -131
View File
@@ -11,6 +11,8 @@ import {
MOTIONEYE_ICON_SVG_PATH,
MOTIONEYE_ICON_SVG_VIEWBOX,
} from './camera-manager/motioneye/icon.js';
import './components/key-assigner.js';
import { KeyboardShortcut } from './config/keyboard-shortcuts.js';
import {
copyConfig,
deleteConfigValue,
@@ -23,12 +25,14 @@ import {
import { setProfiles } from './config/profiles/index.js';
import {
BUTTON_SIZE_MIN,
FRIGATE_MENU_PRIORITY_MAX,
FRIGATE_STATUS_BAR_PRIORITY_MAX,
FrigateCardConfig,
frigateCardConfigDefaults,
FRIGATE_MENU_PRIORITY_MAX,
profilesSchema,
RawFrigateCardConfig,
RawFrigateCardConfigArray,
STATUS_BAR_HEIGHT_MIN,
THUMBNAIL_WIDTH_MAX,
THUMBNAIL_WIDTH_MIN,
} from './config/types.js';
@@ -112,8 +116,6 @@ import {
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,
CONF_LIVE_DISPLAY_GRID_COLUMNS,
CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS,
CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR,
@@ -154,8 +156,6 @@ import {
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,
CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS,
CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS,
CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR,
@@ -166,8 +166,8 @@ import {
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
CONF_MEDIA_VIEWER_ZOOMABLE,
CONF_MENU_ALIGNMENT,
CONF_MENU_BUTTONS,
CONF_MENU_BUTTON_SIZE,
CONF_MENU_BUTTONS,
CONF_MENU_POSITION,
CONF_MENU_STYLE,
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
@@ -176,6 +176,11 @@ import {
CONF_PERFORMANCE_STYLE_BORDER_RADIUS,
CONF_PERFORMANCE_STYLE_BOX_SHADOW,
CONF_PROFILES,
CONF_STATUS_BAR_HEIGHT,
CONF_STATUS_BAR_ITEMS,
CONF_STATUS_BAR_POPUP_SECONDS,
CONF_STATUS_BAR_POSITION,
CONF_STATUS_BAR_STYLE,
CONF_TIMELINE_CLUSTERING_THRESHOLD,
CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
@@ -190,6 +195,12 @@ import {
CONF_VIEW_CAMERA_SELECT,
CONF_VIEW_DARK_MODE,
CONF_VIEW_DEFAULT,
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
CONF_VIEW_DEFAULT_RESET,
CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION,
CONF_VIEW_DEFAULT_RESET_ENTITIES,
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE,
CONF_VIEW_INTERACTION_SECONDS,
CONF_VIEW_KEYBOARD_SHORTCUTS,
CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED,
@@ -200,7 +211,6 @@ import {
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP,
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN,
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT,
CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION,
CONF_VIEW_TRIGGERS,
CONF_VIEW_TRIGGERS_ACTIONS,
CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
@@ -209,12 +219,7 @@ import {
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS,
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
MEDIA_CHUNK_SIZE_MAX,
CONF_VIEW_DEFAULT_RESET,
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE,
CONF_VIEW_DEFAULT_RESET_ENTITIES,
} from './const.js';
import { localize } from './localize/localize.js';
import frigate_card_editor_style from './scss/editor.scss';
@@ -225,10 +230,7 @@ import {
getEntityTitle,
sideLoadHomeAssistantElements,
} from './utils/ha';
import './components/key-assigner.js';
import { KeyboardShortcut } from './config/keyboard-shortcuts.js';
const MENU_BUTTONS = 'buttons';
const MENU_CAMERAS = 'cameras';
const MENU_CAMERAS_CAPABILITIES = 'cameras.capabilities';
const MENU_CAMERAS_CAST = 'cameras.cast';
@@ -248,7 +250,6 @@ const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous';
const MENU_LIVE_CONTROLS_PTZ = 'live.controls.ptz';
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_DISPLAY = 'live.display';
const MENU_LIVE_MICROPHONE = 'live.microphone';
const MENU_MEDIA_GALLERY_CONTROLS_FILTER = 'media_gallery.controls.filter';
@@ -257,14 +258,15 @@ 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_DISPLAY = 'media_viewer.display';
const MENU_MENU_BUTTONS = 'menu.buttons';
const MENU_OPTIONS = 'options';
const MENU_PERFORMANCE_FEATURES = 'performance.features';
const MENU_PERFORMANCE_STYLE = 'performance.style';
const MENU_STATUS_BAR_ITEMS = 'status_bar.items';
const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails';
const MENU_VIEW_KEYBOARD_SHORTCUTS = 'view.keyboard_shortcuts';
const MENU_VIEW_DEFAULT_RESET = 'view.default_reset';
const MENU_VIEW_KEYBOARD_SHORTCUTS = 'view.keyboard_shortcuts';
const MENU_VIEW_TRIGGERS = 'view.triggers';
const MENU_VIEW_TRIGGERS_ACTIONS = 'view.triggers.actions';
@@ -303,6 +305,11 @@ const options: EditorOptions = {
name: localize('editor.menu'),
secondary: localize('editor.menu_secondary'),
},
status_bar: {
icon: 'sign-text',
name: localize('editor.status_bar'),
secondary: localize('editor.status_bar_secondary'),
},
live: {
icon: 'cctv',
name: localize('editor.live'),
@@ -503,27 +510,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
},
];
protected _titleModes: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'none', label: localize('config.common.controls.title.modes.none') },
{
value: 'popup-top-left',
label: localize('config.common.controls.title.modes.popup-top-left'),
},
{
value: 'popup-top-right',
label: localize('config.common.controls.title.modes.popup-top-right'),
},
{
value: 'popup-bottom-left',
label: localize('config.common.controls.title.modes.popup-bottom-left'),
},
{
value: 'popup-bottom-right',
label: localize('config.common.controls.title.modes.popup-bottom-right'),
},
];
protected _transitionEffects: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'none', label: localize('config.media_viewer.transition_effects.none') },
@@ -831,6 +817,22 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
},
];
protected _statusBarStyles: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'hover', label: localize('config.status_bar.styles.hover') },
{ value: 'hover-card', label: localize('config.status_bar.styles.hover-card') },
{ value: 'none', label: localize('config.status_bar.styles.none') },
{ value: 'outside', label: localize('config.status_bar.styles.outside') },
{ value: 'overlay', label: localize('config.status_bar.styles.overlay') },
{ value: 'popup', label: localize('config.status_bar.styles.popup') },
];
protected _statusBarPositions: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'top', label: localize('config.status_bar.positions.top') },
{ value: 'bottom', label: localize('config.status_bar.positions.bottom') },
];
public setConfig(config: RawFrigateCardConfig): void {
// Note: This does not use Zod to parse the full configuration, so it may be
// partially or completely invalid. It's more useful to have a partially
@@ -1230,11 +1232,29 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
);
}
/**
* Render an editor menu for the card menu buttons.
* @param button The name of the button.
* @returns A rendered template.
*/
protected _renderStatusBarItem(item: string): TemplateResult {
return html` ${this._putInSubmenu(
MENU_STATUS_BAR_ITEMS,
item,
`config.status_bar.items.${item}`,
{ name: 'mdi:feature-search' },
html`
${this._renderSwitch(
`${CONF_STATUS_BAR_ITEMS}.${item}.enabled`,
this._defaults.status_bar.items[item]?.enabled ?? true,
{
label: localize('config.status_bar.items.enabled'),
},
)}
${this._renderNumberInput(`${CONF_STATUS_BAR_ITEMS}.${item}.priority`, {
max: FRIGATE_STATUS_BAR_PRIORITY_MAX,
default: this._defaults.status_bar.items[item]?.priority,
label: localize('config.status_bar.items.priority'),
})}
`,
)}`;
}
protected _renderMenuButton(
button: string,
additionalOptions?: TemplateResult,
@@ -1244,56 +1264,38 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: 'matching', label: localize('config.menu.buttons.alignments.matching') },
{ value: 'opposing', label: localize('config.menu.buttons.alignments.opposing') },
];
const submenuClasses = {
submenu: true,
selected: this._expandedMenus[MENU_BUTTONS] === button,
};
return html`
<div class="${classMap(submenuClasses)}">
<div
class="submenu-header"
@click=${this._toggleMenu}
.domain=${MENU_BUTTONS}
.key=${button}
>
<ha-icon .icon=${'mdi:gesture-tap-button'}></ha-icon>
<span
>${localize('editor.button') +
': ' +
localize(`config.${CONF_MENU_BUTTONS}.${button}`)}</span
>
</div>
${this._expandedMenus[MENU_BUTTONS] === button
? html` <div class="values">
${this._renderSwitch(
`${CONF_MENU_BUTTONS}.${button}.enabled`,
this._defaults.menu.buttons[button]?.enabled ?? true,
{
label: localize('config.menu.buttons.enabled'),
},
)}
${this._renderOptionSelector(
`${CONF_MENU_BUTTONS}.${button}.alignment`,
menuButtonAlignments,
{
label: localize('config.menu.buttons.alignment'),
},
)}
${this._renderNumberInput(`${CONF_MENU_BUTTONS}.${button}.priority`, {
max: FRIGATE_MENU_PRIORITY_MAX,
default: this._defaults.menu.buttons[button]?.priority,
label: localize('config.menu.buttons.priority'),
})}
${this._renderIconSelector(`${CONF_MENU_BUTTONS}.${button}.icon`, {
label: localize('config.menu.buttons.icon'),
})}
${additionalOptions}
</div>`
: ''}
</div>
`;
return html` ${this._putInSubmenu(
MENU_MENU_BUTTONS,
button,
`config.menu.buttons.${button}`,
{ name: 'mdi:gesture-tap-button' },
html`
${this._renderSwitch(
`${CONF_MENU_BUTTONS}.${button}.enabled`,
this._defaults.menu.buttons[button]?.enabled ?? true,
{
label: localize('config.menu.buttons.enabled'),
},
)}
${this._renderOptionSelector(
`${CONF_MENU_BUTTONS}.${button}.alignment`,
menuButtonAlignments,
{
label: localize('config.menu.buttons.alignment'),
},
)}
${this._renderNumberInput(`${CONF_MENU_BUTTONS}.${button}.priority`, {
max: FRIGATE_MENU_PRIORITY_MAX,
default: this._defaults.menu.buttons[button]?.priority,
label: localize('config.menu.buttons.priority'),
})}
${this._renderIconSelector(`${CONF_MENU_BUTTONS}.${button}.icon`, {
label: localize('config.menu.buttons.icon'),
})}
${additionalOptions}
`,
)}`;
}
/**
@@ -1730,34 +1732,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
);
}
/**
* Render the titles controls.
* @param domain The submenu domain.
* @param configPathMode Title mode config path.
* @param configPathDurationSeconds Title duration seconds config path.
* @returns A rendered template.
*/
protected _renderTitleControls(
menuDomain: string,
configPathMode: string,
configPathDurationSeconds: string,
): TemplateResult | void {
return this._putInSubmenu(
menuDomain,
true,
'config.common.controls.title.editor_label',
{ name: 'mdi:subtitles' },
html` ${this._renderOptionSelector(configPathMode, this._titleModes, {
label: localize('config.common.controls.title.mode'),
})}
${this._renderNumberInput(configPathDurationSeconds, {
min: 0,
max: 60,
label: localize('config.common.controls.title.duration_seconds'),
})}`,
);
}
/**
* Render a camera section.
* @param cameras The full array of cameras.
@@ -2427,6 +2401,35 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
</div>
`
: ''}
${this._renderOptionSetHeader('status_bar')}
${this._expandedMenus[MENU_OPTIONS] === 'status_bar'
? html`
<div class="values">
${this._renderOptionSelector(
CONF_STATUS_BAR_STYLE,
this._statusBarStyles,
)}
${this._renderOptionSelector(
CONF_STATUS_BAR_POSITION,
this._statusBarPositions,
)}
${this._renderNumberInput(CONF_STATUS_BAR_HEIGHT, {
min: STATUS_BAR_HEIGHT_MIN,
label: localize('config.status_bar.height'),
})}
${this._renderNumberInput(CONF_STATUS_BAR_POPUP_SECONDS, {
min: 0,
max: 60,
default: this._defaults.status_bar.popup_seconds,
label: localize('config.status_bar.popup_seconds'),
})}
${this._renderStatusBarItem('title') /* */}
${this._renderStatusBarItem('resolution') /* */}
${this._renderStatusBarItem('technology') /* */}
${this._renderStatusBarItem('engine') /* */}
</div>
`
: ''}
${this._renderOptionSetHeader('live')}
${this._expandedMenus[MENU_OPTIONS] === 'live'
? html`
@@ -2521,11 +2524,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
configPathMode: CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
},
)}
${this._renderTitleControls(
MENU_LIVE_CONTROLS_TITLE,
CONF_LIVE_CONTROLS_TITLE_MODE,
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
)}
${this._renderMiniTimeline(
MENU_LIVE_CONTROLS_TIMELINE,
CONF_LIVE_CONTROLS_TIMELINE_MODE,
@@ -2708,11 +2706,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
configPathMode: CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
},
)}
${this._renderTitleControls(
MENU_MEDIA_VIEWER_CONTROLS_TITLE,
CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
)}
${this._renderMiniTimeline(
MENU_MEDIA_VIEWER_CONTROLS_TIMELINE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
+29 -12
View File
@@ -210,18 +210,6 @@
"seek-in-camera": "Cerca d'escombratge només a la càmera seleccionada",
"seek-in-media": "Cerca d'escombratge només dins de l'element multimèdia seleccionat"
}
},
"title": {
"duration_seconds": "Segons per mostrar el títol emergent (0=per sempre)",
"editor_label": "Controls del títol emergent",
"mode": "Mode de visualització del títol emergent",
"modes": {
"none": "No es mostra el títol",
"popup-bottom-left": "Popup a la part inferior esquerra",
"popup-bottom-right": "Popup a la part inferior dreta",
"popup-top-left": "Popup a la part superior esquerra",
"popup-top-right": "Popup a la part superior dreta"
}
}
},
"display": {
@@ -427,6 +415,33 @@
"low-performance": "",
"scrubbing": ""
},
"status_bar": {
"height": "",
"items": {
"enabled": "",
"engine": "",
"live_provider": "",
"priority": "",
"resolution": "",
"technology": "",
"title": ""
},
"popup_seconds": "",
"position": "",
"positions": {
"bottom": "",
"top": ""
},
"style": "",
"styles": {
"hover": "",
"hover-card": "",
"none": "",
"outside": "",
"overlay": "",
"popup": ""
}
},
"view": {
"camera_select": "Visualitza les càmeres seleccionades recentment",
"dark_mode": "Mode fosc",
@@ -533,6 +548,8 @@
"performance_secondary": "Opcions de rendiment de la targeta",
"profiles": "",
"profiles_secondary": "",
"status_bar": "",
"status_bar_secondary": "",
"timeline": "Cronologia",
"timeline_secondary": "Opcions de la cronologia d'esdeveniments",
"upgrade": "Upgrade",
+29 -12
View File
@@ -210,18 +210,6 @@
"seek-in-camera": "Pan seeks within selected camera only",
"seek-in-media": "Pan seeks within selected media item only"
}
},
"title": {
"duration_seconds": "Seconds to display popup title (0=forever)",
"editor_label": "Popup Title Controls",
"mode": "Popup title display mode",
"modes": {
"none": "No title display",
"popup-bottom-left": "Popup on the bottom left",
"popup-bottom-right": "Popup on the bottom right",
"popup-top-left": "Popup on the top left",
"popup-top-right": "Popup on the top right"
}
}
},
"display": {
@@ -427,6 +415,33 @@
"low-performance": "Low performance",
"scrubbing": "Video scrubbing"
},
"status_bar": {
"height": "Status bar height in pixels",
"items": {
"enabled": "Item enabled",
"engine": "Camera engine",
"live_provider": "Live provider",
"priority": "Item priority",
"resolution": "Resolution",
"technology": "Technology",
"title": "Title"
},
"popup_seconds": "Status bar popup seconds",
"position": "Status bar position",
"positions": {
"bottom": "Positioned on the bottom",
"top": "Positioned on the top"
},
"style": "Status bar style",
"styles": {
"hover": "Hover status bar",
"hover-card": "Hover status bar (card-wide)",
"none": "No status bar",
"outside": "Outside status bar",
"overlay": "Overlay status bar",
"popup": "Popup status bar"
}
},
"view": {
"camera_select": "View for newly selected cameras",
"dark_mode": "Dark mode",
@@ -533,6 +548,8 @@
"performance_secondary": "Card performance options",
"profiles": "Configuration profiles",
"profiles_secondary": "Choose pre-configured sets of defaults",
"status_bar": "Status bar",
"status_bar_secondary": "Status bar look & feel options",
"timeline": "Timeline",
"timeline_secondary": "Event timeline options",
"upgrade": "Upgrade",
+29 -12
View File
@@ -210,18 +210,6 @@
"seek-in-camera": "",
"seek-in-media": ""
}
},
"title": {
"duration_seconds": "Secondes pour afficher le titre de la fenêtre contextuelle (0 = toujours)",
"editor_label": "Contrôles de titre contextuel",
"mode": "Mode d'affichage du titre contextuel",
"modes": {
"none": "Pas d'affichage du titre",
"popup-bottom-left": "Popup en bas à gauche",
"popup-bottom-right": "Popup en bas à droite",
"popup-top-left": "Popup en haut à gauche",
"popup-top-right": "Popup en haut à droite"
}
}
},
"display": {
@@ -427,6 +415,33 @@
"low-performance": "",
"scrubbing": ""
},
"status_bar": {
"height": "",
"items": {
"enabled": "",
"engine": "",
"live_provider": "",
"priority": "",
"resolution": "",
"technology": "",
"title": ""
},
"popup_seconds": "",
"position": "",
"positions": {
"bottom": "",
"top": ""
},
"style": "",
"styles": {
"hover": "",
"hover-card": "",
"none": "",
"outside": "",
"overlay": "",
"popup": ""
}
},
"view": {
"camera_select": "Afficher les caméras nouvellement sélectionnées",
"dark_mode": "Mode sombre",
@@ -533,6 +548,8 @@
"performance_secondary": "Options de performances de la carte",
"profiles": "",
"profiles_secondary": "",
"status_bar": "",
"status_bar_secondary": "",
"timeline": "Chronologie",
"timeline_secondary": "Options de chronologie des événements",
"upgrade": "Mise à niveau",
+29 -12
View File
@@ -210,18 +210,6 @@
"seek-in-camera": "",
"seek-in-media": ""
}
},
"title": {
"duration_seconds": "Secondi per visualizzare il titolo popup (0 = per sempre)",
"editor_label": "Controlli titolo popup",
"mode": "Modalità di visualizzazione del titolo",
"modes": {
"none": "Nessuna visualizzazione del titolo",
"popup-bottom-left": "Popup in basso a sinistra",
"popup-bottom-right": "Popup in basso a destra",
"popup-top-left": "Popup in alto a sinistra",
"popup-top-right": "Popup in alto a destra"
}
}
},
"display": {
@@ -427,6 +415,33 @@
"low-performance": "",
"scrubbing": ""
},
"status_bar": {
"height": "",
"items": {
"enabled": "",
"engine": "",
"live_provider": "",
"priority": "",
"resolution": "",
"technology": "",
"title": ""
},
"popup_seconds": "",
"position": "",
"positions": {
"bottom": "",
"top": ""
},
"style": "",
"styles": {
"hover": "",
"hover-card": "",
"none": "",
"outside": "",
"overlay": "",
"popup": ""
}
},
"view": {
"camera_select": "Visualizza per le telecamere appena selezionate",
"dark_mode": "Tema scuro",
@@ -533,6 +548,8 @@
"performance_secondary": "",
"profiles": "",
"profiles_secondary": "",
"status_bar": "",
"status_bar_secondary": "",
"timeline": "Timeline",
"timeline_secondary": "Opzioni della timeline degli eventi",
"upgrade": "Aggiornamento",
+29 -12
View File
@@ -210,18 +210,6 @@
"seek-in-camera": "",
"seek-in-media": ""
}
},
"title": {
"duration_seconds": "Segundos para exibir o pop-up (0 = para sempre)",
"editor_label": "Controles do pop-up de título",
"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"
}
}
},
"display": {
@@ -427,6 +415,33 @@
"low-performance": "",
"scrubbing": ""
},
"status_bar": {
"height": "",
"items": {
"enabled": "",
"engine": "",
"live_provider": "",
"priority": "",
"resolution": "",
"technology": "",
"title": ""
},
"popup_seconds": "",
"position": "",
"positions": {
"bottom": "",
"top": ""
},
"style": "",
"styles": {
"hover": "",
"hover-card": "",
"none": "",
"outside": "",
"overlay": "",
"popup": ""
}
},
"view": {
"camera_select": "Visualização de câmeras recém-selecionadas",
"dark_mode": "Modo escuro",
@@ -533,6 +548,8 @@
"performance_secondary": "Opções de desempenho do cartão",
"profiles": "",
"profiles_secondary": "",
"status_bar": "",
"status_bar_secondary": "",
"timeline": "Linha do tempo",
"timeline_secondary": "Opções do evento da linha do tempo",
"upgrade": "Upgrade",
+29 -12
View File
@@ -210,18 +210,6 @@
"seek-in-camera": "",
"seek-in-media": ""
}
},
"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"
}
}
},
"display": {
@@ -427,6 +415,33 @@
"low-performance": "",
"scrubbing": ""
},
"status_bar": {
"height": "",
"items": {
"enabled": "",
"engine": "",
"live_provider": "",
"priority": "",
"resolution": "",
"technology": "",
"title": ""
},
"popup_seconds": "",
"position": "",
"positions": {
"bottom": "",
"top": ""
},
"style": "",
"styles": {
"hover": "",
"hover-card": "",
"none": "",
"outside": "",
"overlay": "",
"popup": ""
}
},
"view": {
"camera_select": "Visualização de câmeras recém-selecionadas",
"dark_mode": "Modo escuro",
@@ -533,6 +548,8 @@
"performance_secondary": "",
"profiles": "",
"profiles_secondary": "",
"status_bar": "",
"status_bar_secondary": "",
"timeline": "Linha do tempo",
"timeline_secondary": "Opções do evento da linha do tempo",
"upgrade": "Actualização",
+4 -1
View File
@@ -98,7 +98,10 @@ customElements.whenDefined('ha-camera-stream').then(() => {
return html`
<img
@load=${(ev: Event) => {
dispatchMediaLoadedEvent(this, ev, { player: this });
dispatchMediaLoadedEvent(this, ev, {
player: this,
technology: ['mjpeg'],
});
}}
.src=${typeof this._connected == 'undefined' || this._connected
? computeMJPEGStreamUrl(this.stateObj)
+1
View File
@@ -125,6 +125,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
supportsPause: true,
hasAudio: mayHaveAudio(this._video),
},
technology: ['hls'],
});
}}
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
+1
View File
@@ -120,6 +120,7 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
supportsPause: true,
hasAudio: mayHaveAudio(this._video),
},
technology: ['webrtc'],
});
}}
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
+49 -17
View File
@@ -80,23 +80,6 @@ div.main.curve-bottom {
border-bottom-right-radius: var(--ha-card-border-radius, 4px);
}
/* The 'hover' menu mode is styled applied outside of the menu itself */
frigate-card-menu[data-style*='hover'] {
z-index: 1;
transition: opacity 0.5s ease;
}
.main + frigate-card-menu[data-style*='hover'] {
opacity: 0;
}
frigate-card-menu[data-style='hover']:hover {
opacity: 1;
}
.main:hover + frigate-card-menu[data-style='hover-card'],
frigate-card-menu[data-style='hover-card']:hover {
opacity: 1;
}
ha-card {
display: flex;
flex-direction: column;
@@ -181,3 +164,52 @@ web-dialog::part(dialog) {
border-radius: 0px;
background: transparent;
}
/*************************************
* "Outside" style for menu/status bar
*************************************/
.outerlay[data-position='top'] {
border-top-left-radius: var(--ha-card-border-radius, 4px);
border-top-right-radius: var(--ha-card-border-radius, 4px);
}
.outerlay[data-position='bottom'] {
border-bottom-left-radius: var(--ha-card-border-radius, 4px);
border-bottom-right-radius: var(--ha-card-border-radius, 4px);
}
/****************
* Overlay styles
****************/
frigate-card-overlay {
z-index: 1;
}
/*******************
* Menu hover styles
*******************/
frigate-card-menu[data-style*='hover'] {
z-index: 4;
transition: opacity 0.5s ease;
opacity: 0;
}
frigate-card-menu[data-style*='hover']:hover,
.main:hover > frigate-card-overlay > frigate-card-menu[data-style='hover-card'] {
opacity: 1;
}
/*************************
* Status bar hover styles
*************************/
frigate-card-status-bar[data-style*='hover'] {
z-index: 3;
transition: opacity 0.5s ease;
opacity: 0;
}
frigate-card-status-bar[data-style*='hover']:hover,
.main:hover > frigate-card-overlay > frigate-card-status-bar[data-style='hover-card'] {
opacity: 1;
}
+9 -55
View File
@@ -5,9 +5,7 @@
--mdc-icon-button-size: var(--frigate-card-menu-button-size);
--mdc-icon-size: calc(var(--mdc-icon-button-size) / 2);
// Menu itself does not handle click events. Without this, in overlay mode the
// menu component prevents clicking on gallery items 'behind' the overlay.
pointer-events: none;
pointer-events: auto;
display: flex;
flex-direction: row;
@@ -45,52 +43,6 @@ div.opposing {
background: var(--secondary-background-color);
}
/************************************
* Match menu rounded corners to card
************************************/
:host([data-position='top']),
:host([data-position='left']) {
border-top-left-radius: var(--ha-card-border-radius, 4px);
}
:host([data-position='top']),
:host([data-position='right']) {
border-top-right-radius: var(--ha-card-border-radius, 4px);
}
:host([data-position='bottom']),
:host([data-position='left']) {
border-bottom-left-radius: var(--ha-card-border-radius, 4px);
}
:host([data-position='bottom']),
:host([data-position='right']) {
border-bottom-right-radius: var(--ha-card-border-radius, 4px);
}
/**************************************
* Positioning for absolute menu styles
**************************************/
:host(:not([data-style='outside'])[data-position='top']),
:host(:not([data-style='outside'])[data-position='left'][data-alignment='top']),
:host(:not([data-style='outside'])[data-position='right'][data-alignment='top']) {
top: 0px;
}
:host(:not([data-style='outside'])[data-position='bottom']),
:host(:not([data-style='outside'])[data-position='left'][data-alignment='bottom']),
:host(:not([data-style='outside'])[data-position='right'][data-alignment='bottom']) {
bottom: 0px;
}
:host(:not([data-style='outside'])[data-position='left']),
:host(:not([data-style='outside'])[data-position='top'][data-alignment='left']),
:host(:not([data-style='outside'])[data-position='bottom'][data-alignment='left']) {
left: 0px;
}
:host(:not([data-style='outside'])[data-position='right']),
:host(:not([data-style='outside'])[data-position='top'][data-alignment='right']),
:host(:not([data-style='outside'])[data-position='bottom'][data-alignment='right']) {
right: 0px;
}
/********************************************************
* Hack: Ensure host & div expand for column flex layouts
********************************************************/
@@ -156,12 +108,18 @@ div.opposing {
:host([data-style='overlay']),
:host([data-style*='hover']),
:host([data-style='hidden']) {
position: absolute;
overflow: hidden;
width: calc(var(--frigate-card-menu-button-size) + 6px);
height: calc(var(--frigate-card-menu-button-size) + 6px);
}
:host([data-style='overlay']),
:host([data-style*='hover']),
:host([data-style='hidden'][expanded]) {
overflow: visible;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0));
}
:host([data-style='overlay'][data-position='top']),
:host([data-style='overlay'][data-position='bottom']),
:host([data-style*='hover'][data-position='top']),
@@ -170,8 +128,6 @@ div.opposing {
:host([data-style='hidden'][data-position='bottom'][expanded]) {
width: 100%;
height: auto;
overflow: visible;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0));
}
:host([data-style='overlay'][data-position='left']),
@@ -180,8 +136,6 @@ div.opposing {
:host([data-style*='hover'][data-position='right']),
:host([data-style='hidden'][data-position='left'][expanded]),
:host([data-style='hidden'][data-position='right'][expanded]) {
height: 100%;
width: auto;
overflow: visible;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0));
height: 100%;
}
+66
View File
@@ -0,0 +1,66 @@
@use './button.scss';
:host {
position: absolute;
width: 100%;
height: 100%;
// Overlay itself does not handle click events.
pointer-events: none;
}
slot {
position: absolute;
display: block;
}
/***************
* Slot position
***************/
slot[name='top'] {
top: 0px;
}
slot[name='bottom'] {
bottom: 0px;
}
slot[name='left'] {
left: 0px;
}
slot[name='right'] {
right: 0px;
}
/***********
* Slot size
***********/
slot[name='top'],
slot[name='bottom'] {
width: 100%;
}
slot[name='left'],
slot[name='right'] {
height: 100%;
}
/*******************************
* Match rounded corners to card
*******************************/
slot[name='top'],
slot[name='left'] {
border-top-left-radius: var(--ha-card-border-radius, 4px);
}
slot[name='top'],
slot[name='right'] {
border-top-right-radius: var(--ha-card-border-radius, 4px);
}
slot[name='bottom'],
slot[name='left'] {
border-bottom-left-radius: var(--ha-card-border-radius, 4px);
}
slot[name='bottom'],
slot[name='right'] {
border-bottom-right-radius: var(--ha-card-border-radius, 4px);
}
+82
View File
@@ -0,0 +1,82 @@
@use './button.scss';
:host {
--mdc-icon-button-size: calc(var(--frigate-card-status-bar-height) - 6px);
--mdc-icon-size: calc(var(--mdc-icon-button-size) / 2);
display: block;
width: 100%;
pointer-events: auto;
opacity: 1;
transition: opacity 1s;
}
/***************
* Outside style
***************/
:host([data-style='outside']) {
color: var(--primary-text-color);
background: var(--secondary-background-color);
}
/*******************
* Non-outside style
*******************/
:host(:not([data-style='outside'])) {
color: white;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0));
}
:host([data-style='popup'][hide]) {
opacity: 0;
pointer-events: none;
}
/*********************
* Status bar contents
*********************/
.status {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
overflow: hidden;
width: 100%;
height: var(--frigate-card-status-bar-height);
}
.item {
display: inline-block;
margin: 3px;
padding: 3px;
align-content: center;
opacity: 0.7;
}
.item:first-child {
margin-left: 10px;
}
.item:last-child {
margin-right: 10px;
}
.item.expand {
flex: 1;
}
.item.action {
cursor: pointer;
}
img.item {
width: var(--mdc-icon-size, 24px);
height: var(--mdc-icon-size, 24px);
}
-29
View File
@@ -1,29 +0,0 @@
:host {
--paper-toast-background-color: rgba(0, 0, 0, 0.6);
--paper-toast-color: white;
pointer-events: none;
position: relative;
}
paper-toast {
max-width: unset;
min-width: unset;
display: flex;
align-items: center;
// Without this the paper-toast will consume vertical space before being
// opened, which causes the card to render blank space needlessly. It also
// won't work with 'display: none', it appears to need something with
// width/height properties even before being opened.
position: absolute;
}
paper-toast.paper-toast-open {
position: relative;
}
paper-toast img {
max-height: 24px;
padding-left: 10px;
}
+4
View File
@@ -31,9 +31,13 @@ export interface MediaLoadedCapabilities {
hasAudio?: boolean;
}
const MEDIA_TECHNOLOGY = ['hls', 'jpg', 'jsmpeg', 'mjpeg', 'mp4', 'mse', 'webrtc'];
export type MediaTechnology = (typeof MEDIA_TECHNOLOGY)[number];
export interface MediaLoadedInfo {
width: number;
height: number;
technology?: MediaTechnology[];
player?: FrigateCardMediaPlayer;
capabilities?: MediaLoadedCapabilities;
}
+3
View File
@@ -2,6 +2,7 @@ import {
FrigateCardMediaPlayer,
MediaLoadedCapabilities,
MediaLoadedInfo,
MediaTechnology,
} from '../types.js';
import { dispatchFrigateCardEvent } from './basic.js';
@@ -18,6 +19,7 @@ export function createMediaLoadedInfo(
options?: {
player?: FrigateCardMediaPlayer;
capabilities?: MediaLoadedCapabilities;
technology?: MediaTechnology[];
},
): MediaLoadedInfo | null {
let target: HTMLElement | EventTarget;
@@ -61,6 +63,7 @@ export function dispatchMediaLoadedEvent(
options?: {
player?: FrigateCardMediaPlayer;
capabilities?: MediaLoadedCapabilities;
technology?: MediaTechnology[];
},
): void {
const mediaLoadedInfo = createMediaLoadedInfo(source, options);
-1
View File
@@ -11,7 +11,6 @@ import {
QueryClassifier,
QueryResultClassifier,
} from '../../src/camera-manager/manager';
import { CameraManagerStore } from '../../src/camera-manager/store';
import {
CameraEndpoint,
CameraEndpoints,
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { StatusBarAction } from '../../../../src/card-controller/actions/actions/status-bar';
import { createCardAPI } from '../../../test-utils';
describe('should handle status bar action', () => {
it('reset', async () => {
const api = createCardAPI();
const action = new StatusBarAction(
{},
{
action: 'fire-dom-event',
frigate_card_action: 'status_bar',
status_bar_action: 'reset',
},
);
await action.execute(api);
expect(api.getStatusBarItemManager().removeAllDynamicStatusBarItems).toBeCalled();
});
it('add', async () => {
const api = createCardAPI();
const item = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item',
};
const action = new StatusBarAction(
{},
{
action: 'fire-dom-event',
frigate_card_action: 'status_bar',
status_bar_action: 'add',
items: [item],
},
);
await action.execute(api);
expect(api.getStatusBarItemManager().addDynamicStatusBarItem).toBeCalledWith(item);
});
it('remove', async () => {
const api = createCardAPI();
const item = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item',
};
const action = new StatusBarAction(
{},
{
action: 'fire-dom-event',
frigate_card_action: 'status_bar',
status_bar_action: 'remove',
items: [item],
},
);
await action.execute(api);
expect(api.getStatusBarItemManager().removeDynamicStatusBarItem).toBeCalledWith(
item,
);
});
});
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { CameraSelectAction } from '../../../src/card-controller/actions/actions/camera-select';
import { CameraUIAction } from '../../../src/card-controller/actions/actions/camera-ui';
import { DefaultAction } from '../../../src/card-controller/actions/actions/default';
@@ -21,6 +21,7 @@ import { PTZDigitalAction } from '../../../src/card-controller/actions/actions/p
import { PTZMultiAction } from '../../../src/card-controller/actions/actions/ptz-multi';
import { ScreenshotAction } from '../../../src/card-controller/actions/actions/screenshot';
import { SleepAction } from '../../../src/card-controller/actions/actions/sleep';
import { StatusBarAction } from '../../../src/card-controller/actions/actions/status-bar';
import { SubstreamOffAction } from '../../../src/card-controller/actions/actions/substream-off';
import { SubstreamOnAction } from '../../../src/card-controller/actions/actions/substream-on';
import { SubstreamSelectAction } from '../../../src/card-controller/actions/actions/substream-select';
@@ -125,6 +126,10 @@ describe('ActionFactory', () => {
[{ frigate_card_action: 'snapshots' as const }, ViewAction],
[{ frigate_card_action: 'timeline' as const }, ViewAction],
[{ frigate_card_action: 'unmute' as const }, UnmuteAction],
[
{ frigate_card_action: 'status_bar' as const, status_bar_action: 'reset' },
StatusBarAction,
],
])(
'frigate_card_action: $frigate_card_action',
(action: Partial<FrigateCardCustomAction>, classObject: object) => {
@@ -306,28 +306,6 @@ describe('getOverriddenConfig', () => {
}),
).toEqual({});
});
// it('with empty value and object', () => {
// const manager = new ConditionsManager(createCardAPI());
// manager.setState({ fullscreen: true });
// expect(
// getOverriddenConfig(manager, config, {
// configOverrides: [
// {
// delete: [''],
// conditions: [
// {
// condition: 'fullscreen' as const,
// fullscreen: true,
// },
// ],
// },
// ],
// emptyKeyReplaces: true,
// }),
// ).toEqual({});
// });
});
describe('should validate schema', () => {
@@ -538,6 +516,11 @@ describe('ConditionsManager', () => {
state: 'on',
},
],
actions: [
{
action: 'fire-dom-event',
},
],
},
],
});
+8
View File
@@ -29,6 +29,7 @@ import { ViewManager } from '../../src/card-controller/view/view-manager';
import { FrigateCardEditor } from '../../src/editor';
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager';
vi.mock('../../src/camera-manager/manager');
vi.mock('../../src/card-controller/actions/actions-manager');
@@ -50,6 +51,7 @@ vi.mock('../../src/card-controller/media-player-manager');
vi.mock('../../src/card-controller/message-manager');
vi.mock('../../src/card-controller/microphone-manager');
vi.mock('../../src/card-controller/query-string-manager');
vi.mock('../../src/card-controller/status-bar-item-manager');
vi.mock('../../src/card-controller/style-manager');
vi.mock('../../src/card-controller/triggers-manager');
vi.mock('../../src/card-controller/view/view-manager');
@@ -247,6 +249,12 @@ describe('CardController', () => {
);
});
it('getStatusBarItemManager', () => {
expect(createController().getStatusBarItemManager()).toBe(
vi.mocked(StatusBarItemManager).mock.instances[0],
);
});
it('getStyleManager', () => {
expect(createController().getStyleManager()).toBe(
vi.mocked(StyleManager).mock.instances[0],
@@ -0,0 +1,243 @@
import { describe, expect, it, vi } from 'vitest';
import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager';
import { StatusBarString } from '../../src/config/types';
import { MediaQueriesResults } from '../../src/view/media-queries-results';
import {
TestViewMedia,
createCameraManager,
createCardAPI,
createStore,
createView,
} from '../test-utils';
describe('StatusBarItemManager', () => {
const testItem: StatusBarString = {
type: 'custom:frigate-card-status-bar-string' as const,
string: 'test',
};
it('should add', () => {
const manager = new StatusBarItemManager(createCardAPI());
manager.addDynamicStatusBarItem(testItem);
manager.addDynamicStatusBarItem(testItem);
expect(manager.calculateItems()).toContain(testItem);
expect(manager.calculateItems().find((item) => item === testItem).length === 1);
});
it('should remove', () => {
const manager = new StatusBarItemManager(createCardAPI());
manager.addDynamicStatusBarItem(testItem);
manager.removeDynamicStatusBarItem({ ...testItem });
expect(manager.calculateItems()).not.toContain(testItem);
manager.removeDynamicStatusBarItem({ ...testItem, string: 'not-present' });
expect(manager.calculateItems()).not.toContain({
...testItem,
string: 'not-present',
});
});
it('should remove all', () => {
const manager = new StatusBarItemManager(createCardAPI());
manager.addDynamicStatusBarItem(testItem);
manager.removeAllDynamicStatusBarItems();
expect(manager.calculateItems()).not.toContain(testItem);
});
describe('should have standard status bar items', () => {
describe('should have title', () => {
describe('live', () => {
it('with metadata', () => {
const manager = new StatusBarItemManager(createCardAPI());
const store = createStore([
{
cameraID: 'camera-1',
},
]);
const cameraManager = createCameraManager(store);
vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({
title: 'Camera Title',
icon: 'mdi:camera',
});
expect(
manager.calculateItems({
cameraManager: cameraManager,
view: createView({ view: 'live', camera: 'camera-1' }),
}),
).toContainEqual({
type: 'custom:frigate-card-status-bar-string' as const,
string: 'Camera Title',
expand: true,
sufficient: true,
});
});
it('without metadata', () => {
const manager = new StatusBarItemManager(createCardAPI());
const cameraManager = createCameraManager();
expect(
manager.calculateItems({
cameraManager: cameraManager,
view: createView({ view: 'live', camera: 'MISSING-CAMERA' }),
}),
).not.toContainEqual(expect.objectContaining({ sufficient: true }));
});
});
describe('media', () => {
it('with a title', () => {
const manager = new StatusBarItemManager(createCardAPI());
const cameraManager = createCameraManager();
const media = [new TestViewMedia({ title: 'Media Title' })];
const queryResults = new MediaQueriesResults({
results: media,
});
const view = createView({
view: 'media',
queryResults: queryResults,
});
expect(
manager.calculateItems({
cameraManager: cameraManager,
view: view,
}),
).toContainEqual({
type: 'custom:frigate-card-status-bar-string' as const,
string: 'Media Title',
expand: true,
sufficient: true,
});
});
it('without a title', () => {
const manager = new StatusBarItemManager(createCardAPI());
const cameraManager = createCameraManager();
const media = [new TestViewMedia()];
const queryResults = new MediaQueriesResults({
results: media,
});
const view = createView({
view: 'media',
queryResults: queryResults,
});
expect(
manager.calculateItems({
cameraManager: cameraManager,
view: view,
}),
).not.toContainEqual(expect.objectContaining({ sufficient: true }));
});
});
});
describe('should have resolution', () => {
it.each([
['1080p landscape', '1080p', 1920, 1080],
['1080p portrait', '1080p', 1080, 1920],
['1080p approximate', '1080p', 1922, 1082],
['720p landscape', '720p', 1280, 720],
['720p portrait', '720p', 720, 1280],
['720p approximate', '720p', 1282, 722],
['VGA landscape', 'VGA', 640, 480],
['VGA portrait', 'VGA', 480, 640],
['VGA approximate', 'VGA', 642, 482],
['4K landscape', '4K', 3840, 2160],
['4K portrait', '4K', 2160, 3840],
['4K approximate', '4K', 3842, 2162],
['480p landscape', '480p', 720, 480],
['480p portrait', '480p', 480, 720],
['480p approximate', '480p', 722, 482],
['576p landscape', '576p', 720, 576],
['576p portrait', '576p', 576, 720],
['576p approximate', '576p', 722, 578],
['8K landscape', '8K', 7680, 4320],
['8K portrait', '8K', 4320, 7680],
['8K approximate', '8K', 7682, 4322],
['random', '123x456', 123, 456],
])(
'%s',
(_testName: string, expectedName: string, width: number, height: number) => {
const manager = new StatusBarItemManager(createCardAPI());
expect(
manager.calculateItems({
mediaLoadedInfo: { width, height },
}),
).toContainEqual({
type: 'custom:frigate-card-status-bar-string' as const,
string: expectedName,
});
},
);
});
describe('should have technology', () => {
it('webrtc', () => {
const manager = new StatusBarItemManager(createCardAPI());
expect(
manager.calculateItems({
mediaLoadedInfo: { width: 640, height: 480, technology: ['webrtc'] },
}),
).toContainEqual({
type: 'custom:frigate-card-status-bar-icon' as const,
icon: 'mdi:webrtc',
});
});
it('non-webrtc', () => {
const manager = new StatusBarItemManager(createCardAPI());
expect(
manager.calculateItems({
mediaLoadedInfo: { width: 640, height: 480, technology: ['hls'] },
}),
).toContainEqual({
type: 'custom:frigate-card-status-bar-string' as const,
string: 'HLS',
});
});
});
it('should have engine', () => {
const manager = new StatusBarItemManager(createCardAPI());
const store = createStore([
{
cameraID: 'camera-1',
},
]);
const cameraManager = createCameraManager(store);
vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({
title: 'Camera Title',
icon: 'mdi:camera',
engineLogo: 'IMAGE_LOGO',
});
expect(
manager.calculateItems({
cameraManager: cameraManager,
view: createView({ view: 'live', camera: 'camera-1' }),
}),
).toContainEqual({
type: 'custom:frigate-card-status-bar-image' as const,
image: 'IMAGE_LOGO',
});
});
});
});
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { getTechnologyForVideoRTC } from '../../../../src/components-lib/live/utils/get-technology-for-video-rtc';
import { VideoRTC } from '../../../../src/components/live/go2rtc/video-rtc';
import { createLitElement } from '../../../test-utils';
// @vitest-environment jsdom
describe('getTechnologyForVideoRTC', () => {
it('webrtc', () => {
const element = createLitElement() as unknown as VideoRTC;
element.pc = {} as unknown as RTCPeerConnection;
expect(getTechnologyForVideoRTC(element)).toEqual(['webrtc']);
});
it('mse', () => {
const element = createLitElement() as unknown as VideoRTC;
element.mseCodecs = 'mp4a';
expect(getTechnologyForVideoRTC(element)).toEqual(['mse', 'hls']);
});
it('other', () => {
const element = createLitElement() as unknown as VideoRTC;
expect(getTechnologyForVideoRTC(element)).toBeUndefined();
});
});
+12 -25
View File
@@ -1,11 +1,11 @@
import { HASSDomEvent, handleActionConfig } from '@dermotduffy/custom-card-helpers';
import { handleActionConfig } from '@dermotduffy/custom-card-helpers';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FRIGATE_ICON_SVG_PATH } from '../../src/camera-manager/frigate/icon';
import { MenuController } from '../../src/components-lib/menu-controller';
import { ActionsConfig, MenuConfig, menuConfigSchema } from '../../src/config/types';
import { MenuConfig, menuConfigSchema } from '../../src/config/types';
import { StateParameters } from '../../src/types';
import { refreshDynamicStateParameters } from '../../src/utils/ha';
import { createHASS, createLitElement } from '../test-utils';
import { createInteractionEvent, createHASS, createLitElement } from '../test-utils';
vi.mock('@dermotduffy/custom-card-helpers');
vi.mock('../../src/utils/ha');
@@ -14,18 +14,6 @@ const createMenuConfig = (config: unknown): MenuConfig => {
return menuConfigSchema.parse(config);
};
const createEvent = (
action: string,
config?: ActionsConfig,
): HASSDomEvent<{ action: string; config?: ActionsConfig }> => {
return new CustomEvent<{ action: string; config?: ActionsConfig }>('@action', {
detail: {
action: action,
config: config,
},
});
};
// @vitest-environment jsdom
describe('MenuController', () => {
const action = {
@@ -362,7 +350,7 @@ describe('MenuController', () => {
describe('should handle actions', () => {
it('should bail without config', () => {
const controller = new MenuController(createLitElement());
controller.actionHandler(createEvent('tap'));
controller.actionHandler(createInteractionEvent('tap'));
expect(vi.mocked(handleActionConfig)).not.toBeCalled();
});
@@ -373,7 +361,7 @@ describe('MenuController', () => {
const controller = new MenuController(host);
controller.actionHandler(createEvent('tap'), tapActionConfig);
controller.actionHandler(createInteractionEvent('tap'), tapActionConfig);
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: { action: [action], config: tapActionConfig },
@@ -389,7 +377,7 @@ describe('MenuController', () => {
const controller = new MenuController(host);
controller.actionHandler(createEvent('tap', tapActionConfig));
controller.actionHandler(createInteractionEvent('tap', tapActionConfig));
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: { action: [action], config: tapActionConfig },
@@ -404,7 +392,7 @@ describe('MenuController', () => {
const controller = new MenuController(host);
controller.actionHandler(createEvent('tap'), tapActionConfigMulti);
controller.actionHandler(createInteractionEvent('tap'), tapActionConfigMulti);
expect(handler).toBeCalledWith(
expect.objectContaining({
@@ -426,7 +414,7 @@ describe('MenuController', () => {
controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(createEvent('tap'), tapActionConfig);
controller.actionHandler(createInteractionEvent('tap'), tapActionConfig);
expect(controller.isExpanded()).toBeFalsy();
});
@@ -442,7 +430,7 @@ describe('MenuController', () => {
controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(createEvent('end_tap'), {
controller.actionHandler(createInteractionEvent('end_tap'), {
end_tap_action: action,
});
expect(controller.isExpanded()).toBeFalsy();
@@ -462,7 +450,7 @@ describe('MenuController', () => {
controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(createEvent('start_tap'), {
controller.actionHandler(createInteractionEvent('start_tap'), {
start_tap_action: action,
end_tap_action: action,
});
@@ -481,7 +469,7 @@ describe('MenuController', () => {
controller.setExpanded(false);
expect(controller.isExpanded()).toBeFalsy();
controller.actionHandler(createEvent('tap'), {
controller.actionHandler(createInteractionEvent('tap'), {
camera_entity: 'foo',
tap_action: menuToggleAction,
});
@@ -490,7 +478,6 @@ describe('MenuController', () => {
it('when no action is actually taken', () => {
const host = createLitElement();
const hass = createHASS();
const controller = new MenuController(host);
controller.setMenuConfig(
createMenuConfig({
@@ -501,7 +488,7 @@ describe('MenuController', () => {
controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(createEvent('end_tap'), tapActionConfig);
controller.actionHandler(createInteractionEvent('end_tap'), tapActionConfig);
expect(controller.isExpanded()).toBeTruthy();
});
});
@@ -0,0 +1,330 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { StatusBarController } from '../../src/components-lib/status-bar-controller';
import { StatusBarConfig, statusBarConfigSchema } from '../../src/config/types';
import { setOrRemoveAttribute } from '../../src/utils/basic';
import { createInteractionEvent, createLitElement } from '../test-utils';
const createConfig = (config?: unknown): StatusBarConfig => {
return statusBarConfigSchema.parse(config);
};
// @vitest-environment jsdom
describe('StatusBarController', () => {
describe('should set config', () => {
it('should set config', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
controller.setConfig(
createConfig({
position: 'top',
style: 'hover',
height: 50,
}),
);
expect(host.style.getPropertyValue('--frigate-card-status-bar-height')).toBe(
'50px',
);
expect(host.getAttribute('data-style')).toBe('hover');
expect(host.getAttribute('data-position')).toBe('top');
expect(host.requestUpdate).toHaveBeenCalled();
});
it('should not hide when not in popup style', () => {
const host = createLitElement();
setOrRemoveAttribute(host, true, 'hide');
const controller = new StatusBarController(host);
controller.setConfig(
createConfig({
position: 'top',
style: 'hover',
height: 50,
}),
);
expect(host.getAttribute('hide')).toBe(null);
});
it('should not show when in popup style', () => {
const host = createLitElement();
setOrRemoveAttribute(host, true, 'hide');
const controller = new StatusBarController(host);
controller.setConfig(
createConfig({
position: 'top',
style: 'popup',
height: 50,
}),
);
expect(host.getAttribute('hide')).not.toBe(null);
});
});
describe('should set items', () => {
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
it('set/get basic items', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
const items = [
{
type: 'custom:frigate-card-status-bar-string',
string: 'Test',
},
];
controller.setItems(items);
expect(controller.getRenderItems()).toEqual(items);
});
it('should order items', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
const item1 = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 1',
priority: 40,
};
const item2 = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 2',
priority: 10,
};
const item3 = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 3',
priority: 60,
};
const item4 = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 4',
priority: undefined,
};
controller.setItems([item1, item2, item3, item4]);
expect(controller.getRenderItems()).toEqual([item3, item4, item1, item2]);
});
it('should treat exclusive items exclusively', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
const item1 = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 1',
priority: 100,
};
const exclusiveItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 2',
priority: 1,
exclusive: true,
};
controller.setItems([item1, exclusiveItem]);
expect(controller.getRenderItems()).toEqual([exclusiveItem]);
});
describe('should recognize sufficient items', () => {
it('with sufficient item', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
const insufficientItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 1',
sufficient: false,
};
const sufficientItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 2',
sufficient: true,
};
controller.setItems([insufficientItem, sufficientItem]);
expect(controller.getRenderItems()).toEqual([insufficientItem, sufficientItem]);
expect(controller.shouldRender()).toBeTruthy();
});
it('without sufficient item', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
const insufficientItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 1',
sufficient: false,
};
const sufficientItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 2',
sufficient: false,
};
controller.setItems([insufficientItem, sufficientItem]);
expect(controller.getRenderItems()).toEqual([insufficientItem, sufficientItem]);
expect(controller.shouldRender()).toBeFalsy();
});
});
describe('should deal with popup styles correctly', () => {
it('should show from empty to sufficient', () => {
const host = createLitElement();
setOrRemoveAttribute(host, true, 'hide');
const controller = new StatusBarController(host);
controller.setConfig(
createConfig({
style: 'popup',
}),
);
const sufficientItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 1',
priority: 100,
sufficient: true,
};
controller.setItems([sufficientItem]);
expect(host.getAttribute('hide')).toBe(null);
});
it('should not show from empty to insufficient', () => {
const host = createLitElement();
setOrRemoveAttribute(host, true, 'hide');
const controller = new StatusBarController(host);
controller.setConfig(
createConfig({
style: 'popup',
}),
);
const insufficientItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 1',
priority: 100,
sufficient: false,
};
controller.setItems([insufficientItem]);
expect(host.getAttribute('hide')).not.toBeNull();
});
it('should show from sufficient to different sufficient', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
controller.setConfig(
createConfig({
style: 'popup',
}),
);
const sufficientString = {
type: 'custom:frigate-card-status-bar-string',
string: 'String',
priority: 100,
sufficient: true,
};
const sufficientIcon = {
type: 'custom:frigate-card-status-bar-icon',
icon: 'Icon',
priority: 100,
sufficient: true,
};
const sufficientImage = {
type: 'custom:frigate-card-status-bar-image',
image: 'Image',
priority: 100,
sufficient: true,
};
controller.setItems([sufficientString]);
// Emulate the popup being hidden.
setOrRemoveAttribute(host, true, 'hide');
controller.setItems([sufficientIcon]);
expect(host.getAttribute('hide')).toBe(null);
// Emulate the popup being hidden.
setOrRemoveAttribute(host, true, 'hide');
controller.setItems([sufficientImage]);
expect(host.getAttribute('hide')).toBe(null);
});
it('should hide popup after expiry', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
controller.setConfig(
createConfig({
style: 'popup',
}),
);
const sufficientItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 1',
priority: 100,
sufficient: true,
};
controller.setItems([sufficientItem]);
expect(host.getAttribute('hide')).toBe(null);
vi.advanceTimersByTime(1000);
expect(host.getAttribute('hide')).toBe(null);
vi.advanceTimersByTime(2000);
expect(host.getAttribute('hide')).not.toBe(null);
});
});
});
describe('should handle actions', () => {
it('should bail without action', () => {
const host = createLitElement();
const handler = vi.fn();
host.addEventListener('frigate-card:action:execution-request', handler);
const controller = new StatusBarController(host);
controller.actionHandler(createInteractionEvent('tap'));
expect(handler).not.toBeCalled();
});
it('should request action execution', () => {
const host = createLitElement();
const handler = vi.fn();
host.addEventListener('frigate-card:action:execution-request', handler);
const controller = new StatusBarController(host);
const action = {
action: 'fire-dom-event' as const,
};
const tapActionConfig = {
tap_action: action,
};
controller.actionHandler(createInteractionEvent('tap'), tapActionConfig);
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: { action: [action], config: tapActionConfig },
}),
);
});
});
});
+111 -1
View File
@@ -25,7 +25,6 @@ import {
frigateCardConfigSchema,
} from '../../src/config/types';
import { getParseErrorPaths } from '../../src/utils/zod';
import { update } from 'lodash-es';
describe('general functions', () => {
it('should set value', () => {
@@ -3193,5 +3192,116 @@ describe('should handle version specific upgrades', () => {
});
postUpgradeChecks(config);
});
describe('title controls to status bar', () => {
it('when mode is none', () => {
const config = {
type: 'custom:frigate-card',
cameras: [{}],
live: {
controls: {
title: {
mode: 'none',
},
},
},
media_viewer: {
controls: {
title: {
mode: 'none',
},
},
},
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:frigate-card',
cameras: [{}],
live: { controls: {} },
media_viewer: { controls: {} },
status_bar: {
style: 'none',
},
});
postUpgradeChecks(config);
});
describe('when mode is invalid type', () => {
it.each([[{ mode: { should_not_be: 'an object' } }], ['sideways']])(
'%s',
(mode: unknown) => {
const config = {
type: 'custom:frigate-card',
cameras: [{}],
live: {
controls: {
title: {
mode,
},
},
},
media_viewer: {
controls: {
title: {
mode,
},
},
},
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:frigate-card',
cameras: [{}],
live: { controls: {} },
media_viewer: { controls: {} },
});
postUpgradeChecks(config);
},
);
});
describe.each([['bottom'], ['top']])('on the %s', (position: string) => {
it.each([
[`popup-${position}-left` as const],
[`popup-${position}-right` as const],
])('%s', (mode: string) => {
const config = {
type: 'custom:frigate-card',
cameras: [{}],
live: {
controls: {
title: {
mode,
},
},
},
media_viewer: {
controls: {
title: {
mode,
},
},
},
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:frigate-card',
cameras: [{}],
live: { controls: {} },
media_viewer: { controls: {} },
status_bar: {
position,
},
});
postUpgradeChecks(config);
});
});
});
});
});
@@ -13,7 +13,6 @@ it('low performance profile', () => {
'live.controls.thumbnails.show_favorite_control': false,
'live.controls.thumbnails.show_timeline_control': false,
'live.controls.timeline.show_recordings': false,
'live.controls.title.mode': 'none',
'live.draggable': false,
'live.lazy_unload': 'all',
'live.show_image_during_load': false,
@@ -32,7 +31,6 @@ it('low performance profile', () => {
'media_viewer.controls.thumbnails.show_favorite_control': false,
'media_viewer.controls.thumbnails.show_timeline_control': false,
'media_viewer.controls.timeline.show_recordings': false,
'media_viewer.controls.title.mode': 'none',
'media_viewer.draggable': false,
'media_viewer.snapshot_click_plays_clip': false,
'media_viewer.transition_effect': 'none',
@@ -44,6 +42,7 @@ it('low performance profile', () => {
'performance.features.media_chunk_size': 10,
'performance.style.border_radius': false,
'performance.style.box_shadow': false,
'status_bar.style': 'none',
'timeline.controls.thumbnails.mode': 'none',
'timeline.controls.thumbnails.show_details': false,
'timeline.controls.thumbnails.show_download_control': false,
+79 -1
View File
@@ -5,6 +5,7 @@ import {
customSchema,
dimensionsConfigSchema,
frigateCardCustomActionsBaseSchema,
frigateCardCustomActionSchema,
} from '../../src/config/types';
import { createConfig } from '../test-utils';
@@ -273,6 +274,30 @@ describe('config defaults', () => {
box_shadow: true,
},
},
status_bar: {
height: 46,
items: {
engine: {
enabled: true,
priority: 50,
},
resolution: {
enabled: true,
priority: 50,
},
technology: {
enabled: true,
priority: 50,
},
title: {
enabled: true,
priority: 50,
},
},
popup_seconds: 3,
position: 'bottom',
style: 'popup',
},
timeline: {
clustering_threshold: 3,
controls: {
@@ -507,7 +532,7 @@ describe('should convert webrtc card PTZ to Frigate card PTZ', () => {
});
});
describe('should lazy evaluate', () => {
describe('should lazy evaluate schemas', () => {
it('conditional picture element', () => {
expect(
conditionalSchema.parse({
@@ -555,6 +580,21 @@ describe('should lazy evaluate', () => {
type: 'conditional',
});
});
it('status bar actions', () => {
const input = {
action: 'fire-dom-event',
frigate_card_action: 'status_bar',
status_bar_action: 'reset',
items: [
{
type: 'custom:frigate-card-status-bar-string',
string: 'Item',
},
],
};
expect(frigateCardCustomActionSchema.parse(input)).toEqual(input);
});
});
describe('should handle custom frigate elements', () => {
@@ -621,3 +661,41 @@ it('media viewer should not support microphone based conditions', () => {
}),
).toThrowError();
});
describe('automations should require at least one action', () => {
it('no action', () => {
expect(() =>
createConfig({
cameras: [{}],
automations: [{ conditions: [] }],
}),
).toThrowError(/Automations must include at least one action/);
});
it('empty actions', () => {
expect(() =>
createConfig({
cameras: [{}],
automations: [{ conditions: [], actions: [], actions_not: [] }],
}),
).toThrowError(/Automations must include at least one action/);
});
it('at least one action', () => {
expect(() =>
createConfig({
cameras: [{}],
automations: [
{
conditions: [],
actions: [
{
action: 'fire-dom-event',
},
],
},
],
}),
).not.toThrowError();
});
});
+22 -3
View File
@@ -1,4 +1,8 @@
import { CurrentUser, HomeAssistant } from '@dermotduffy/custom-card-helpers';
import {
CurrentUser,
HASSDomEvent,
HomeAssistant,
} from '@dermotduffy/custom-card-helpers';
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
import { LitElement } from 'lit';
import { expect, vi } from 'vitest';
@@ -15,28 +19,31 @@ import {
CameraManagerMediaCapabilities,
} from '../src/camera-manager/types';
import { ActionsManager } from '../src/card-controller/actions/actions-manager';
import { DefaultManager } from '../src/card-controller/default-manager';
import { AutomationsManager } from '../src/card-controller/automations-manager';
import { CameraURLManager } from '../src/card-controller/camera-url-manager';
import { CardElementManager } from '../src/card-controller/card-element-manager';
import { ConditionsManager } from '../src/card-controller/conditions-manager';
import { ConfigManager } from '../src/card-controller/config/config-manager';
import { CardController } from '../src/card-controller/controller';
import { DefaultManager } from '../src/card-controller/default-manager';
import { DownloadManager } from '../src/card-controller/download-manager';
import { ExpandManager } from '../src/card-controller/expand-manager';
import { FullscreenManager } from '../src/card-controller/fullscreen-manager';
import { HASSManager } from '../src/card-controller/hass-manager';
import { InitializationManager } from '../src/card-controller/initialization-manager';
import { InteractionManager } from '../src/card-controller/interaction-manager';
import { KeyboardStateManager } from '../src/card-controller/keyboard-state-manager';
import { MediaLoadedInfoManager } from '../src/card-controller/media-info-manager';
import { MediaPlayerManager } from '../src/card-controller/media-player-manager';
import { MessageManager } from '../src/card-controller/message-manager';
import { MicrophoneManager } from '../src/card-controller/microphone-manager';
import { QueryStringManager } from '../src/card-controller/query-string-manager';
import { StatusBarItemManager } from '../src/card-controller/status-bar-item-manager';
import { StyleManager } from '../src/card-controller/style-manager';
import { TriggersManager } from '../src/card-controller/triggers-manager';
import { ViewManager } from '../src/card-controller/view/view-manager';
import {
ActionsConfig,
CameraConfig,
FrigateCardCondition,
FrigateCardConfig,
@@ -55,7 +62,6 @@ import { Entity } from '../src/utils/ha/entity-registry/types';
import { ViewMedia, ViewMediaType } from '../src/view/media';
import { MediaQueriesResults } from '../src/view/media-queries-results';
import { View, ViewParameters } from '../src/view/view';
import { KeyboardStateManager } from '../src/card-controller/keyboard-state-manager';
export const createAction = (
action: Record<string, unknown>,
@@ -450,6 +456,7 @@ export const createCardAPI = (): CardController => {
api.getMessageManager.mockReturnValue(mock<MessageManager>());
api.getMicrophoneManager.mockReturnValue(mock<MicrophoneManager>());
api.getQueryStringManager.mockReturnValue(mock<QueryStringManager>());
api.getStatusBarItemManager.mockReturnValue(mock<StatusBarItemManager>());
api.getStyleManager.mockReturnValue(mock<StyleManager>());
api.getTriggersManager.mockReturnValue(mock<TriggersManager>());
api.getViewManager.mockReturnValue(mock<ViewManager>());
@@ -473,3 +480,15 @@ export const callHASubscribeMessageHandler = (
export const flushPromises = async (): Promise<void> => {
await new Promise(process.nextTick);
};
export const createInteractionEvent = (
action: string,
config?: ActionsConfig,
): HASSDomEvent<{ action: string; config?: ActionsConfig }> => {
return new CustomEvent<{ action: string; config?: ActionsConfig }>('@action', {
detail: {
action: action,
config: config,
},
});
};
+1 -2
View File
@@ -33,8 +33,7 @@
"ha-selector",
"ha-svg-icon",
"mwc-button",
"mwc-list-item",
"paper-toast"
"mwc-list-item"
],
"rules": {
"no-unknown-tag-name": "error",
+4 -1
View File
@@ -14,10 +14,11 @@ const FULL_COVERAGE_FILES_RELATIVE = [
'card-controller/**/*.ts',
'components-lib/cached-value-controller.ts',
'components-lib/key-assigner-controller.ts',
'components-lib/live/live-controller.ts',
'components-lib/live/**/*.ts',
'components-lib/media-filter-controller.ts',
'components-lib/menu-button-controller.ts',
'components-lib/menu-controller.ts',
'components-lib/status-bar-controller.ts',
'components-lib/ptz/*.ts',
'components-lib/zoom/*.ts',
'config/**/*.ts',
@@ -74,6 +75,8 @@ export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
coverage: {
exclude: ['docs/**', 'tests/**', '.eslintrc.cjs'],
// Favor istanbul for coverage over v8 due to better accuracy.
provider: 'istanbul',
thresholds: {