diff --git a/docs/configuration/README.md b/docs/configuration/README.md index b7ac589d..b19be1b1 100644 --- a/docs/configuration/README.md +++ b/docs/configuration/README.md @@ -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. | diff --git a/docs/configuration/_sidebar.md b/docs/configuration/_sidebar.md index 0bc10e8c..1e65dac7 100644 --- a/docs/configuration/_sidebar.md +++ b/docs/configuration/_sidebar.md @@ -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) diff --git a/docs/configuration/actions/README.md b/docs/configuration/actions/README.md index 0990d952..22114ff2 100644 --- a/docs/configuration/actions/README.md +++ b/docs/configuration/actions/README.md @@ -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 diff --git a/docs/configuration/actions/_sidebar.md b/docs/configuration/actions/_sidebar.md index b9258a61..0da30b6e 100644 --- a/docs/configuration/actions/_sidebar.md +++ b/docs/configuration/actions/_sidebar.md @@ -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) diff --git a/docs/configuration/actions/custom/README.md b/docs/configuration/actions/custom/README.md index 15fb42f4..4f7d1b67 100644 --- a/docs/configuration/actions/custom/README.md +++ b/docs/configuration/actions/custom/README.md @@ -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 ``` diff --git a/docs/configuration/actions/custom/_sidebar.md b/docs/configuration/actions/custom/_sidebar.md index b565bf98..2a59a918 100644 --- a/docs/configuration/actions/custom/_sidebar.md +++ b/docs/configuration/actions/custom/_sidebar.md @@ -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) diff --git a/docs/configuration/actions/stock/_sidebar.md b/docs/configuration/actions/stock/_sidebar.md index f933ffe7..302cfa94 100644 --- a/docs/configuration/actions/stock/_sidebar.md +++ b/docs/configuration/actions/stock/_sidebar.md @@ -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) diff --git a/docs/configuration/cameras/_sidebar.md b/docs/configuration/cameras/_sidebar.md index 050ab8bf..5c901437 100644 --- a/docs/configuration/cameras/_sidebar.md +++ b/docs/configuration/cameras/_sidebar.md @@ -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) diff --git a/docs/configuration/elements.md b/docs/configuration/elements.md deleted file mode 100644 index a3caf508..00000000 --- a/docs/configuration/elements.md +++ /dev/null @@ -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 -``` diff --git a/docs/configuration/elements/README.md b/docs/configuration/elements/README.md new file mode 100644 index 00000000..5591fb39 --- /dev/null +++ b/docs/configuration/elements/README.md @@ -0,0 +1,24 @@ +# `elements` + +## Introduction to 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). + +## Elements + +| Option | Description | +| ------------------------------------- | --------------------------------- | +| [Custom Elements](./custom/README.md) | Custom elements. | +| [Stock Elements](./stock/README.md) | Standard Home Assistant elements. | diff --git a/docs/configuration/elements/_sidebar.md b/docs/configuration/elements/_sidebar.md new file mode 100644 index 00000000..f570d19b --- /dev/null +++ b/docs/configuration/elements/_sidebar.md @@ -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) diff --git a/docs/configuration/elements/custom/README.md b/docs/configuration/elements/custom/README.md new file mode 100644 index 00000000..a8200888 --- /dev/null +++ b/docs/configuration/elements/custom/README.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 +``` diff --git a/docs/configuration/elements/custom/_sidebar.md b/docs/configuration/elements/custom/_sidebar.md new file mode 100644 index 00000000..0ea893ea --- /dev/null +++ b/docs/configuration/elements/custom/_sidebar.md @@ -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) diff --git a/docs/configuration/elements/stock/README.md b/docs/configuration/elements/stock/README.md new file mode 100644 index 00000000..620ce56c --- /dev/null +++ b/docs/configuration/elements/stock/README.md @@ -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 +``` diff --git a/docs/configuration/elements/stock/_sidebar.md b/docs/configuration/elements/stock/_sidebar.md new file mode 100644 index 00000000..9edbaf3a --- /dev/null +++ b/docs/configuration/elements/stock/_sidebar.md @@ -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) diff --git a/docs/configuration/live.md b/docs/configuration/live.md index b22e29c2..58d30f97 100644 --- a/docs/configuration/live.md +++ b/docs/configuration/live.md @@ -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 diff --git a/docs/configuration/media-viewer.md b/docs/configuration/media-viewer.md index 049df860..31918b89 100644 --- a/docs/configuration/media-viewer.md +++ b/docs/configuration/media-viewer.md @@ -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 diff --git a/docs/configuration/status-bar.md b/docs/configuration/status-bar.md new file mode 100644 index 00000000..6d7a0de0 --- /dev/null +++ b/docs/configuration/status-bar.md @@ -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 +``` diff --git a/docs/examples.md b/docs/examples.md index 0bb99c27..654669d3 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e76eee91..a9e8e133 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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 diff --git a/src/card-controller/actions/actions/status-bar.ts b/src/card-controller/actions/actions/status-bar.ts new file mode 100644 index 00000000..a30e5ad0 --- /dev/null +++ b/src/card-controller/actions/actions/status-bar.ts @@ -0,0 +1,24 @@ +import { StatusBarActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class StatusBarAction extends FrigateCardAction { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async execute(api: CardActionsAPI): Promise { + 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; + } + } +} diff --git a/src/card-controller/actions/factory.ts b/src/card-controller/actions/factory.ts index 0548ab0c..21d36c77 100644 --- a/src/card-controller/actions/factory.ts +++ b/src/card-controller/actions/factory.ts @@ -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 */ diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index 7271ac99..28be50a8 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -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); diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index 758f1f31..cc10b9d1 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -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 { diff --git a/src/card-controller/status-bar-item-manager.ts b/src/card-controller/status-bar-item-manager.ts new file mode 100644 index 00000000..f37bd791 --- /dev/null +++ b/src/card-controller/status-bar-item-manager.ts @@ -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}`; + } + } +} diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index cca66b7a..0806cb66 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -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; diff --git a/src/card.ts b/src/card.ts index 8f9000c5..39e1db9d 100644 --- a/src/card.ts +++ b/src/card.ts @@ -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 = createRef(); + protected _refOverlay: Ref = createRef(); protected _refMain: Ref = createRef(); protected _refElements: Ref = createRef(); protected _refViews: Ref = 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`${getContents('overlay')}` + : html`
+ ${getContents('outerlay')} +
`} + `; + } + + 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` + `; + } + 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')}
+ ${this._renderMenuStatusContainer('overlay')} ${!cameraManager.isInitialized() && !this._controller.getMessageManager().hasMessage() ? renderProgressIndicator({ @@ -320,7 +406,7 @@ class FrigateCard extends LitElement { renderMessage(this._controller.getMessageManager().getMessage()) }
- ${!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) => { + @frigate-card:menu:add=${(ev: CustomEvent) => { this._menuButtonController.addDynamicMenuButton(ev.detail); this.requestUpdate(); }} - @frigate-card:menu-remove=${(ev: CustomEvent) => { + @frigate-card:menu:remove=${(ev: CustomEvent) => { this._menuButtonController.removeDynamicMenuButton(ev.detail); this.requestUpdate(); }} + @frigate-card:status-bar:add=${(ev: CustomEvent) => { + this._controller + .getStatusBarItemManager() + .addDynamicStatusBarItem(ev.detail); + }} + @frigate-card:status-bar:remove=${(ev: CustomEvent) => { + this._controller + .getStatusBarItemManager() + .removeDynamicStatusBarItem(ev.detail); + }} @frigate-card:conditions:evaluate=${( ev: ConditionsEvaluateRequestEvent, ) => { diff --git a/src/components-lib/live/utils/get-technology-for-video-rtc.ts b/src/components-lib/live/utils/get-technology-for-video-rtc.ts new file mode 100644 index 00000000..5d2d8aa8 --- /dev/null +++ b/src/components-lib/live/utils/get-technology-for-video-rtc.ts @@ -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; +}; diff --git a/src/components-lib/menu-controller.ts b/src/components-lib/menu-controller.ts index f61e4131..967a1bcc 100644 --- a/src/components-lib/menu-controller.ts +++ b/src/components-lib/menu-controller.ts @@ -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; diff --git a/src/components-lib/status-bar-controller.ts b/src/components-lib/status-bar-controller.ts new file mode 100644 index 00000000..89e43c02 --- /dev/null +++ b/src/components-lib/status-bar-controller.ts @@ -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'); + } +} diff --git a/src/components/elements.ts b/src/components/elements.ts index 9e3adf1d..5206a1f0 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -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(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(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( + this, + 'status-bar:remove', + (ev as CustomEvent).detail, + ); + }; + + protected _menuAddHandler = (ev: Event): void => { ev = ev as CustomEvent; 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; + 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 extends LitElement { - @state() - protected _config: T | null = null; +export class FrigateCardElementsBaseItem 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(this, 'menu-add', this._config); + dispatchFrigateCardEvent( + this, + `${this._eventCategory}:add`, + this._config, + ); } } - /** - * Disconnected callback. - */ disconnectedCallback(): void { if (this._config) { - dispatchFrigateCardEvent(this, 'menu-remove', this._config); + dispatchFrigateCardEvent( + this, + `${this._eventCategory}:remove`, + this._config, + ); } super.disconnectedCallback(); } } +export class FrigateCardElementsBaseMenuItem< + ConfigType, +> extends FrigateCardElementsBaseItem { + constructor() { + super('menu'); + } +} + @customElement('frigate-card-menu-icon') -export class FrigateCardElementsMenuIcon extends FrigateCardElementsBaseMenuIcon {} +export class FrigateCardElementsMenuIcon extends FrigateCardElementsBaseMenuItem {} @customElement('frigate-card-menu-state-icon') -export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMenuIcon {} +export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMenuItem {} @customElement('frigate-card-menu-submenu') -export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuIcon {} +export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuItem {} @customElement('frigate-card-menu-submenu-select') -export class FrigateCardElementsMenuSubmenuSelect extends FrigateCardElementsBaseMenuIcon {} +export class FrigateCardElementsMenuSubmenuSelect extends FrigateCardElementsBaseMenuItem {} + +export class FrigateCardElementsBaseStatusBarItem< + ConfigType, +> extends FrigateCardElementsBaseItem { + constructor() { + super('status-bar'); + } +} + +@customElement('frigate-card-status-bar-icon') +export class FrigateCardElementsStatusBarIcon extends FrigateCardElementsBaseStatusBarItem {} + +@customElement('frigate-card-status-bar-image') +export class FrigateCardElementsStatusBarImage extends FrigateCardElementsBaseStatusBarItem {} + +@customElement('frigate-card-status-bar-string') +export class FrigateCardElementsStatusBarString extends FrigateCardElementsBaseStatusBarItem {} 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; } } diff --git a/src/components/live/go2rtc/video-rtc.js b/src/components/live/go2rtc/video-rtc.js index 434171b2..406cb3e2 100644 --- a/src/components/live/go2rtc/video-rtc.js +++ b/src/components/live/go2rtc/video-rtc.js @@ -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'], }); } diff --git a/src/components/live/live-jsmpeg.ts b/src/components/live/live-jsmpeg.ts index 8d849d2a..1ee169c1 100644 --- a/src/components/live/live-jsmpeg.ts +++ b/src/components/live/live-jsmpeg.ts @@ -141,6 +141,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi capabilities: { supportsPause: true, }, + technology: ['jsmpeg'], }); } } diff --git a/src/components/live/live-webrtc-card.ts b/src/components/live/live-webrtc-card.ts index 6a8f139d..b0d9f170 100644 --- a/src/components/live/live-webrtc-card.ts +++ b/src/components/live/live-webrtc-card.ts @@ -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); diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 2f440e43..1bc3834b 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -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 = {}; - protected _refTitleControl: Ref = createRef(); protected _refPTZControl: Ref = 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} > - ${cameraMetadataCurrent && titleConfig - ? html` - ` - : ``} `; } diff --git a/src/components/overlay.ts b/src/components/overlay.ts new file mode 100644 index 00000000..2c98d7ba --- /dev/null +++ b/src/components/overlay.ts @@ -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` + + + + + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(overlayStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-overlay': FrigateCardOverlay; + } +} diff --git a/src/components/status-bar.ts b/src/components/status-bar.ts new file mode 100644 index 00000000..a54abfe6 --- /dev/null +++ b/src/components/status-bar.ts @@ -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` +
+ ${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`
this._controller.actionHandler(ev, item.actions)} + > + ${item.string} +
`; + } else if (item.type === 'custom:frigate-card-status-bar-icon') { + return html` this._controller.actionHandler(ev, item.actions)} + >`; + } else if (item.type === 'custom:frigate-card-status-bar-image') { + return html` this._controller.actionHandler(ev, item.actions)} + />`; + } + })} +
+ `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(statusStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-status-bar': FrigateCardStatusBar; + } +} diff --git a/src/components/submenu.ts b/src/components/submenu.ts index f0e9ad8d..4d36e1e0 100644 --- a/src/components/submenu.ts +++ b/src/components/submenu.ts @@ -81,6 +81,7 @@ export class FrigateCardSubmenu extends LitElement { if (!this.submenu) { return html``; } + const items = this.submenu.items as MenuSubmenuItem[]; return html` - ${this.submenu.items.map(this._renderItem.bind(this))} + ${items.map(this._renderItem.bind(this))} `; } @@ -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, diff --git a/src/components/surround-basic.ts b/src/components/surround-basic.ts index a49c5f1d..1975b708 100644 --- a/src/components/surround-basic.ts +++ b/src/components/surround-basic.ts @@ -51,10 +51,6 @@ export class FrigateCardSurroundBasic extends LitElement { } } - /** - * Master render method. - * @returns A rendered template. - */ protected render(): TemplateResult | void { return html` diff --git a/src/components/title-control.ts b/src/components/title-control.ts deleted file mode 100644 index af198c00..00000000 --- a/src/components/title-control.ts +++ /dev/null @@ -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 | 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 = 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` - ${this.logo ? html`` : ''} - `; - } - - 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; - } -} diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 3c79774b..a18c2ead 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -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 = 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` ) => { - 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 { - ${cameraMetadata && titleConfig - ? html` - ` - : ``} `; } @@ -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'] }); }} />`} `); diff --git a/src/config/management.ts b/src/config/management.ts index 9f7d73ae..9110fa4b 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -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'), ]; diff --git a/src/config/profiles/low-performance.ts b/src/config/profiles/low-performance.ts index e80e07cc..2ee9d8cf 100644 --- a/src/config/profiles/low-performance.ts +++ b/src/config/profiles/low-performance.ts @@ -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', diff --git a/src/config/types.ts b/src/config/types.ts index 3eae4a0c..b5875991 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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; +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; + 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; @@ -581,6 +598,52 @@ const menuSubmenuSelectSchema = menuBaseSchema.merge(stateIconSchema).extend({ export type MenuSubmenuSelect = z.infer; 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; + +const statusBarImageItemSchema = statusBarItemElementsBaseSchema.extend({ + type: z.literal('custom:frigate-card-status-bar-image'), + image: z.string(), +}); +export type StatusBarImage = z.infer; + +const statusBarStringItemSchema = statusBarItemElementsBaseSchema.extend({ + type: z.literal('custom:frigate-card-status-bar-string'), + string: z.string(), +}); +export type StatusBarString = z.infer; + +const statusBarItemSchema = z.union([ + statusBarIconItemSchema, + statusBarImageItemSchema, + statusBarStringItemSchema, +]); +export type StatusBarItem = z.infer; + // ************************************************************************* // 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; @@ -1009,24 +1075,6 @@ const nextPreviousControlConfigSchema = z.object({ }); export type NextPreviousControlConfig = z.infer; -// ************************************************************************* -// 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; - // ************************************************************************* // 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; +// ************************************************************************* +// 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; + // ************************************************************************* // 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; const automationActionSchema = actionSchema.array(); export type AutomationActions = z.infer; -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; 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, diff --git a/src/const.ts b/src/const.ts index b20219a3..92d4045f 100644 --- a/src/const.ts +++ b/src/const.ts @@ -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 = diff --git a/src/editor.ts b/src/editor.ts index 4d6bd9e5..6e0e1059 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -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` -
- - - ${this._expandedMenus[MENU_BUTTONS] === 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} -
` - : ''} -
- `; + 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 ` : ''} + ${this._renderOptionSetHeader('status_bar')} + ${this._expandedMenus[MENU_OPTIONS] === 'status_bar' + ? html` +
+ ${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') /* */} +
+ ` + : ''} ${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, diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 9492db6f..cd51cdcb 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -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", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 31904859..d4f6b6bc 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -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", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index 43acdfe4..f7193c85 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -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", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 70527221..c9eae16f 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -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", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 865334e1..137fba4f 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -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", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 119c7ada..9771e9f9 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -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", diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 5381aec2..3c5f4a91 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -98,7 +98,10 @@ customElements.whenDefined('ha-camera-stream').then(() => { return html` { - dispatchMediaLoadedEvent(this, ev, { player: this }); + dispatchMediaLoadedEvent(this, ev, { + player: this, + technology: ['mjpeg'], + }); }} .src=${typeof this._connected == 'undefined' || this._connected ? computeMJPEGStreamUrl(this.stateObj) diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index 50bb7080..a7aae1db 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -125,6 +125,7 @@ customElements.whenDefined('ha-hls-player').then(() => { supportsPause: true, hasAudio: mayHaveAudio(this._video), }, + technology: ['hls'], }); }} @volumechange=${() => dispatchMediaVolumeChangeEvent(this)} diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index de30478b..5929167b 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -120,6 +120,7 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { supportsPause: true, hasAudio: mayHaveAudio(this._video), }, + technology: ['webrtc'], }); }} @volumechange=${() => dispatchMediaVolumeChangeEvent(this)} diff --git a/src/scss/card.scss b/src/scss/card.scss index 04286562..a26f203a 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -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; +} diff --git a/src/scss/menu.scss b/src/scss/menu.scss index 9f56c136..60975dd0 100644 --- a/src/scss/menu.scss +++ b/src/scss/menu.scss @@ -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%; } diff --git a/src/scss/overlay.scss b/src/scss/overlay.scss new file mode 100644 index 00000000..ba952ab1 --- /dev/null +++ b/src/scss/overlay.scss @@ -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); +} diff --git a/src/scss/status.scss b/src/scss/status.scss new file mode 100644 index 00000000..31161fcc --- /dev/null +++ b/src/scss/status.scss @@ -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); +} diff --git a/src/scss/title-control.scss b/src/scss/title-control.scss deleted file mode 100644 index cf1e798c..00000000 --- a/src/scss/title-control.scss +++ /dev/null @@ -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; -} diff --git a/src/types.ts b/src/types.ts index a512154e..fac43e60 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; } diff --git a/src/utils/media-info.ts b/src/utils/media-info.ts index bdda5e69..41af8cfa 100644 --- a/src/utils/media-info.ts +++ b/src/utils/media-info.ts @@ -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); diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index de95a742..71451bde 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -11,7 +11,6 @@ import { QueryClassifier, QueryResultClassifier, } from '../../src/camera-manager/manager'; -import { CameraManagerStore } from '../../src/camera-manager/store'; import { CameraEndpoint, CameraEndpoints, diff --git a/tests/card-controller/actions/actions/status-bar.test.ts b/tests/card-controller/actions/actions/status-bar.test.ts new file mode 100644 index 00000000..d4ec5c8c --- /dev/null +++ b/tests/card-controller/actions/actions/status-bar.test.ts @@ -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, + ); + }); +}); diff --git a/tests/card-controller/actions/factory.test.ts b/tests/card-controller/actions/factory.test.ts index 84bb954d..7352cd05 100644 --- a/tests/card-controller/actions/factory.test.ts +++ b/tests/card-controller/actions/factory.test.ts @@ -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, classObject: object) => { diff --git a/tests/card-controller/conditions-manager.test.ts b/tests/card-controller/conditions-manager.test.ts index e0327403..1a527259 100644 --- a/tests/card-controller/conditions-manager.test.ts +++ b/tests/card-controller/conditions-manager.test.ts @@ -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', + }, + ], }, ], }); diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index 6148cb7e..ff5f0018 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -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], diff --git a/tests/card-controller/status-bar-item-manager.test.ts b/tests/card-controller/status-bar-item-manager.test.ts new file mode 100644 index 00000000..3dc3538f --- /dev/null +++ b/tests/card-controller/status-bar-item-manager.test.ts @@ -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', + }); + }); + }); +}); diff --git a/tests/components-lib/live/utils/get-technology-for-video-rts.test.ts b/tests/components-lib/live/utils/get-technology-for-video-rts.test.ts new file mode 100644 index 00000000..d2219841 --- /dev/null +++ b/tests/components-lib/live/utils/get-technology-for-video-rts.test.ts @@ -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(); + }); +}); diff --git a/tests/components-lib/menu-controller.test.ts b/tests/components-lib/menu-controller.test.ts index 58d26cde..99aee2b0 100644 --- a/tests/components-lib/menu-controller.test.ts +++ b/tests/components-lib/menu-controller.test.ts @@ -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(); }); }); diff --git a/tests/components-lib/status-bar-controller.test.ts b/tests/components-lib/status-bar-controller.test.ts new file mode 100644 index 00000000..9b33f85b --- /dev/null +++ b/tests/components-lib/status-bar-controller.test.ts @@ -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 }, + }), + ); + }); + }); +}); diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index 1ba51ed9..be8da942 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -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); + }); + }); + }); }); }); diff --git a/tests/config/profiles/low-performance.test.ts b/tests/config/profiles/low-performance.test.ts index 30832789..61c7f709 100644 --- a/tests/config/profiles/low-performance.test.ts +++ b/tests/config/profiles/low-performance.test.ts @@ -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, diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 5b926078..cd313042 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -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(); + }); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index e066e719..e5d017aa 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -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, @@ -450,6 +456,7 @@ export const createCardAPI = (): CardController => { api.getMessageManager.mockReturnValue(mock()); api.getMicrophoneManager.mockReturnValue(mock()); api.getQueryStringManager.mockReturnValue(mock()); + api.getStatusBarItemManager.mockReturnValue(mock()); api.getStyleManager.mockReturnValue(mock()); api.getTriggersManager.mockReturnValue(mock()); api.getViewManager.mockReturnValue(mock()); @@ -473,3 +480,15 @@ export const callHASubscribeMessageHandler = ( export const flushPromises = async (): Promise => { 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, + }, + }); +}; diff --git a/tsconfig.json b/tsconfig.json index 9218fb69..879e97ef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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", diff --git a/vite.config.ts b/vite.config.ts index 0808f0ab..0369ec03 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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: {