Merge pull request #1448 from dermotduffy/predefined-zoom
Add support for pre-defined zoom/pan settings per camera
@@ -40,6 +40,9 @@ jobs:
|
|||||||
# build).
|
# build).
|
||||||
ignore: 'hacs'
|
ignore: 'hacs'
|
||||||
|
|
||||||
|
- name: Verify docs links
|
||||||
|
run: yarn run docs-check-links
|
||||||
|
|
||||||
- name: Upload javascript
|
- name: Upload javascript
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
!> This documentation is for the unreleased `dev` branch! See the [Github README](https://github.com/dermotduffy/frigate-hass-card?tab=readme-ov-file#frigate-lovelace-card) for documentation of the released version.
|
||||||
|
|
||||||
# Getting Started
|
# Getting Started
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||

|

|
||||||
|
|
||||||
# Frigate Card <small>6.0.0 *DEV*</small>
|
# Frigate Card <small>6.0.0 <small style="color: red">_*DEV*_</small></small>
|
||||||
|
|
||||||
> A comprehensive camera card for Home Assistant.
|
> A comprehensive camera card for Home Assistant.
|
||||||
|
|
||||||
|
|||||||
@@ -118,15 +118,28 @@ frigate_card_action: camera_ui
|
|||||||
|
|
||||||
Open the UI for the selected camera engine (e.g. the Frigate UI).
|
Open the UI for the selected camera engine (e.g. the Frigate UI).
|
||||||
|
|
||||||
### `default`
|
### `change_zoom`
|
||||||
|
|
||||||
Change to the default view.
|
Zoom in and/or pan for a given camera.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
action: custom:frigate-card-action
|
action: custom:frigate-card-action
|
||||||
frigate_card_action: default
|
frigate_card_action: change_zoom
|
||||||
|
[...]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
| - | - |
|
||||||
|
| `action` | Must be `custom:frigate-card-action`. |
|
||||||
|
| `frigate_card_action` | Must be `change_zoom`. |
|
||||||
|
| `target_id` | The [camera ID](cameras/README.md?id=cameras) or a media ID (e.g. `frigate` event ID) to change zoom/pam settings for. |
|
||||||
|
| `zoom` | Optional parameter that controls how much to zoom-in. See the [camera zoom parameter](cameras/README.md?id=layout-configuration). |
|
||||||
|
| `pan` | Optional parameter that controls how much to pan-x/y. See the [camera pan parameter](cameras/README.md?id=layout-configuration). |
|
||||||
|
|
||||||
|
?> If neither `zoom` nor `pan` are specified the camera will return to its default zoom and pan settings.
|
||||||
|
|
||||||
|
See [example of automatically zoom/panning based on state](../examples.md?id=automatically-zoom-based-on-state).
|
||||||
|
|
||||||
### `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`
|
### `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`
|
||||||
|
|
||||||
Change to the specified view.
|
Change to the specified view.
|
||||||
@@ -136,6 +149,15 @@ action: custom:frigate-card-action
|
|||||||
frigate_card_action: [view]
|
frigate_card_action: [view]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `default`
|
||||||
|
|
||||||
|
Change to the default view.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
action: custom:frigate-card-action
|
||||||
|
frigate_card_action: default
|
||||||
|
```
|
||||||
|
|
||||||
### `download`
|
### `download`
|
||||||
|
|
||||||
Download the displayed media.
|
Download the displayed media.
|
||||||
@@ -518,4 +540,14 @@ elements:
|
|||||||
frigate_card_action: ptz
|
frigate_card_action: ptz
|
||||||
ptz_action: preset
|
ptz_action: preset
|
||||||
ptz_preset: doorway
|
ptz_preset: doorway
|
||||||
|
- type: custom:frigate-card-menu-icon
|
||||||
|
icon: mdi:alpha-r-circle
|
||||||
|
title: Change Zoom
|
||||||
|
tap_action:
|
||||||
|
action: custom:frigate-card-action
|
||||||
|
frigate_card_action: change_zoom
|
||||||
|
pan:
|
||||||
|
x: 50
|
||||||
|
y: 50
|
||||||
|
zoom: 1
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -144,10 +144,12 @@ cameras:
|
|||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| - | - | - |
|
| - | - | - |
|
||||||
| `fit` | `contain` | If `contain`, the media is contained within the card and letterboxed if necessary. If `cover`, the media is expanded proportionally (i.e. maintaining the media aspect ratio) until the camera/card dimensions are fully covered. If `fill`, the media is stretched to fill the camera/card dimensions (i.e. ignoring the media aspect ratio). See [CSS object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) for technical details and a visualization. |
|
| `fit` | `contain` | If `contain`, the media is contained within the card and letterboxed if necessary. If `cover`, the media is expanded proportionally (i.e. maintaining the media aspect ratio) until the camera/card dimensions are fully covered. If `fill`, the media is stretched to fill the camera/card dimensions (i.e. ignoring the media aspect ratio). See [CSS object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) for technical details and a visualization. |
|
||||||
|
| `pan` | | A dictionary that may contain an `x` and `y` percentage (`0` - `100`) to control the position of the media when "digitally zoomed in" (see `zoom` parameter). This can be effectively used to "pan"/cut the media shown. A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See visualizations below. |
|
||||||
| `position` | | A dictionary that may contain an `x` and `y` percentage (`0` - `100`) to control the position of the media when the fit is `cover` (for other values of `fit` this option has no effect). This can be effectively used to "pan"/cut the media shown. At any given time, only one of `x` and `y` will have an effect, depending on whether media width is larger than the camera/card dimensions (in which case `x` controls the position) or the media height is larger than the camera/card dimensions (in which case `y` controls the position). A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See [CSS object-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) for technicals. See visualizations below. |
|
| `position` | | A dictionary that may contain an `x` and `y` percentage (`0` - `100`) to control the position of the media when the fit is `cover` (for other values of `fit` this option has no effect). This can be effectively used to "pan"/cut the media shown. At any given time, only one of `x` and `y` will have an effect, depending on whether media width is larger than the camera/card dimensions (in which case `x` controls the position) or the media height is larger than the camera/card dimensions (in which case `y` controls the position). A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See [CSS object-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) for technicals. See visualizations below. |
|
||||||
| `view_box` | | A dictionary that may contain a `top`, `bottom`, `left` and `right` percentage (`0` - `100`) to precisely crop what part of the media to show by specifying a % inset value from each side. Browsers apply this cropping after `position` and `fit` have been applied. See visualizations below. |
|
| `view_box` | | A dictionary that may contain a `top`, `bottom`, `left` and `right` percentage (`0` - `100`) to precisely crop what part of the media to show by specifying a % inset value from each side. Browsers apply this cropping after `position` and `fit` have been applied. Unlike `zoom`, the user cannot dynamically zoom back out -- however the builtin media controls will work as normal. See visualizations below. Limited [browser support](https://caniuse.com/mdn-css_properties_object-view-box):    |
|
||||||
|
| `zoom` | `1.0` | A value between `1.0` and `10.0` inclusive that defines how much additional "digital zoom" to apply to this camera by default. Unlike with `view_box` the user can easily "zoom back out". Often used in conjuction with `pan`. When zoomed in the [builtin browser media controls](../live.md?id=controls) will automatically be disabled (as otherwise they would be enlarged also). |
|
||||||
|
|
||||||
!> The `view_box` parameter only has effect on browsers that support [`object-view-box`](https://caniuse.com/mdn-css_properties_object-view-box). As of writing this is exclusively Chromium based browsers (e.g. Google Chrome or Microsoft Edge).
|
?> Layout operations are effectively applied in this order: `fit`, `position`, `view_box`, `zoom` then `pan`.
|
||||||
|
|
||||||
See [media layout examples](../../examples.md?id=media-layout).
|
See [media layout examples](../../examples.md?id=media-layout).
|
||||||
|
|
||||||
@@ -169,6 +171,11 @@ See [media layout examples](../../examples.md?id=media-layout).
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
#### `pan` and `zoom`: Predefined panning and zooming
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
## `triggers`
|
## `triggers`
|
||||||
|
|
||||||
The `triggers` block configures what triggers a camera. Triggering can be used
|
The `triggers` block configures what triggers a camera. Triggering can be used
|
||||||
@@ -300,6 +307,13 @@ cameras:
|
|||||||
movies:
|
movies:
|
||||||
directory_pattern: '%Y-%m-%d'
|
directory_pattern: '%Y-%m-%d'
|
||||||
file_pattern: '%H-%M-%S'
|
file_pattern: '%H-%M-%S'
|
||||||
|
- camera_entity: camera.zoomed
|
||||||
|
dimensions:
|
||||||
|
layout:
|
||||||
|
zoom: 2.0
|
||||||
|
pan:
|
||||||
|
x: 50
|
||||||
|
y: 50
|
||||||
cameras_global:
|
cameras_global:
|
||||||
live_provider: ha
|
live_provider: ha
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -164,6 +164,11 @@ conditions:
|
|||||||
| `condition` | Must be `view`. |
|
| `condition` | Must be `view`. |
|
||||||
| `views` | A list of [views](view.md?id=supported-views) in which this condition is satified (e.g. `clips`). |
|
| `views` | A list of [views](view.md?id=supported-views) in which this condition is satified (e.g. `clips`). |
|
||||||
|
|
||||||
|
?> Internally, views associated with the media viewer (e.g. `clip`, `snapshot`,
|
||||||
|
`recording`) are translated to a special view called `media` after the relevant
|
||||||
|
media is fetched. When including views as part of a [condition](conditions.md),
|
||||||
|
you may need to refer to this special `media` view.
|
||||||
|
|
||||||
# Fully expanded reference
|
# Fully expanded reference
|
||||||
|
|
||||||
[](common/expanded-warning.md ':include')
|
[](common/expanded-warning.md ':include')
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ menu:
|
|||||||
| `camera_ui` | The `camera_ui` menu button: brings the user to a context-appropriate page on the UI of their camera engine (e.g. the Frigate camera homepage). Will only appear if the camera engine supports a camera UI (e.g. if `frigate.url` option is set for `frigate` engine users).|
|
| `camera_ui` | The `camera_ui` menu button: brings the user to a context-appropriate page on the UI of their camera engine (e.g. the Frigate camera homepage). Will only appear if the camera engine supports a camera UI (e.g. if `frigate.url` option is set for `frigate` engine users).|
|
||||||
| `cameras` | The camera selection submenu. Will only appear if multiple cameras are configured. |
|
| `cameras` | The camera selection submenu. Will only appear if multiple cameras are configured. |
|
||||||
| `clips` | The `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. |
|
| `clips` | The `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. |
|
||||||
|
| `default_zoom` | The `default_zoom` button allows easily returning the camera to default pan/zoom. |
|
||||||
| `display_mode` | The `display_mode` button allows changing between single and grid views. |
|
| `display_mode` | The `display_mode` button allows changing between single and grid views. |
|
||||||
| `download` | The `download` menu button: allow direct download of the media being displayed.|
|
| `download` | The `download` menu button: allow direct download of the media being displayed.|
|
||||||
| `expand` | The `expand` menu button: expand the card into a popup/dialog. |
|
| `expand` | The `expand` menu button: expand the card into a popup/dialog. |
|
||||||
@@ -53,7 +54,7 @@ menu:
|
|||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| - | - | - |
|
| - | - | - |
|
||||||
| `alignment` | `matching` | Whether this button should have an alignment that is `matching` the menu alignment or `opposing` the menu. Can be used to create two separate groups of buttons on the menu. `priority` orders buttons within a given `alignment`. |
|
| `alignment` | `matching` | Whether this button should have an alignment that is `matching` the menu alignment or `opposing` the menu. Can be used to create two separate groups of buttons on the menu. `priority` orders buttons within a given `alignment`. |
|
||||||
| `enabled` | `true` for `frigate`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `ptz` | Whether or not to show the button. |
|
| `enabled` | `true` for `frigate`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode` and `default_zoom`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `ptz` | Whether or not to show the button. |
|
||||||
| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. |
|
| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. |
|
||||||
| `priority` | `50` | The button priority. Higher priority buttons are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`, when the menu alignment is `left`). Minimum `0`, maximum `100`.|
|
| `priority` | `50` | The button priority. Higher priority buttons are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`, when the menu alignment is `left`). Minimum `0`, maximum `100`.|
|
||||||
|
|
||||||
@@ -90,9 +91,15 @@ menu:
|
|||||||
enabled: true
|
enabled: true
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:video-switch
|
icon: mdi:video-switch
|
||||||
|
default_zoom:
|
||||||
|
priority: 50
|
||||||
|
enabled: true
|
||||||
|
alignment: matching
|
||||||
|
icon: mdi:magnify-close
|
||||||
substreams:
|
substreams:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
alignment: matching
|
||||||
icon: mdi:video-input-component
|
icon: mdi:video-input-component
|
||||||
live:
|
live:
|
||||||
priority: 50
|
priority: 50
|
||||||
|
|||||||
@@ -997,3 +997,95 @@ cameras:
|
|||||||
webrtc_card:
|
webrtc_card:
|
||||||
ui: true
|
ui: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Zoom
|
||||||
|
|
||||||
|
### Pre-defining camera zoom and pan
|
||||||
|
|
||||||
|
This example changes the default [zoom/pan settings for a camera](./configuration/cameras/README.md?id=layout-configuration) to always zoom in on a given area:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: custom:frigate-card
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.office
|
||||||
|
dimensions:
|
||||||
|
layout:
|
||||||
|
zoom: 3
|
||||||
|
pan:
|
||||||
|
x: 20
|
||||||
|
y: 80
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disable zooming in media views
|
||||||
|
|
||||||
|
This example prevents zooming on the media viewer but keeps it on in other views (e.g. `live` view):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: custom:frigate-card
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.office
|
||||||
|
dimensions:
|
||||||
|
layout:
|
||||||
|
zoom: 3
|
||||||
|
pan:
|
||||||
|
x: 20
|
||||||
|
y: 80
|
||||||
|
media_viewer:
|
||||||
|
zoomable: false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Different zoom settings in media viewer vs `live`
|
||||||
|
|
||||||
|
This example uses different settings for the media viewer and `live` view, by overriding the camera configuration:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: custom:frigate-card
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.office
|
||||||
|
dimensions:
|
||||||
|
layout:
|
||||||
|
zoom: 2
|
||||||
|
overrides:
|
||||||
|
- conditions:
|
||||||
|
- condition: view
|
||||||
|
views:
|
||||||
|
- media
|
||||||
|
set:
|
||||||
|
'cameras[0].dimensions.layout':
|
||||||
|
zoom: 3
|
||||||
|
pan:
|
||||||
|
x: 100
|
||||||
|
y: 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automatically zoom based on state
|
||||||
|
|
||||||
|
This example automatically zooms in and out based on the state of an entity:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: custom:frigate-card
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.living_room
|
||||||
|
live_provider: go2rtc
|
||||||
|
debug:
|
||||||
|
logging: true
|
||||||
|
automations:
|
||||||
|
- conditions:
|
||||||
|
- condition: state
|
||||||
|
entity: binary_sensor.door_contact
|
||||||
|
state: 'on'
|
||||||
|
actions:
|
||||||
|
- action: custom:frigate-card-action
|
||||||
|
frigate_card_action: change_zoom
|
||||||
|
target_id: camera.living_room
|
||||||
|
zoom: 4
|
||||||
|
pan:
|
||||||
|
x: 38
|
||||||
|
y: 20
|
||||||
|
actions_not:
|
||||||
|
- action: custom:frigate-card-action
|
||||||
|
frigate_card_action: change_zoom
|
||||||
|
target_id: camera.living_room
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|||||||
|
After Width: | Height: | Size: 819 B |
|
After Width: | Height: | Size: 680 B |
|
After Width: | Height: | Size: 811 B |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
@@ -1,16 +1,18 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
WIDTH=800
|
WIDTH=1200
|
||||||
|
|
||||||
URL_FIT="https://docs.google.com/drawings/d/e/2PACX-1vTq0SVS8HWs3jGC0jjNpJoYfMbZS6P27CYyPlDhSa9OhdB_3jEb0HTLLYwu8Nv3J1TdjAJppcjTiVNy/pub?w=$WIDTH"
|
URL_FIT="https://docs.google.com/drawings/d/e/2PACX-1vTq0SVS8HWs3jGC0jjNpJoYfMbZS6P27CYyPlDhSa9OhdB_3jEb0HTLLYwu8Nv3J1TdjAJppcjTiVNy/pub?w=$WIDTH"
|
||||||
URL_THINNER_THAN_WIDTH="https://docs.google.com/drawings/d/e/2PACX-1vRkMd89N0tkZt5IghPKhR6gs8zMhB-5_hx5QfP6BCxbsSIga_h44IczP06Sj_YnKkxhe0lRdeR-uh04/pub?w=$WIDTH"
|
URL_THINNER_THAN_WIDTH="https://docs.google.com/drawings/d/e/2PACX-1vRkMd89N0tkZt5IghPKhR6gs8zMhB-5_hx5QfP6BCxbsSIga_h44IczP06Sj_YnKkxhe0lRdeR-uh04/pub?w=$WIDTH"
|
||||||
URL_SHORTER_THAN_HEIGHT="https://docs.google.com/drawings/d/e/2PACX-1vTKVsXEWIbj9lYKrCeugdLKcK_rOwAZZDK8IzhPdHH4wMwV2v7kEI0nsn2Qgugb00qDVHsE7kE8CBIC/pub?w=$WIDTH"
|
URL_SHORTER_THAN_HEIGHT="https://docs.google.com/drawings/d/e/2PACX-1vTKVsXEWIbj9lYKrCeugdLKcK_rOwAZZDK8IzhPdHH4wMwV2v7kEI0nsn2Qgugb00qDVHsE7kE8CBIC/pub?w=$WIDTH"
|
||||||
URL_VIEW_BOX="https://docs.google.com/drawings/d/e/2PACX-1vRpPNsaStxW2ENmv1kfUQg41cua9XQ2sQziq2PC8LdRCtkvjHSKYH3CyPO1Pz7kOdiQ2yQKrBX88-TF/pub?w=$WIDTH"
|
URL_VIEW_BOX="https://docs.google.com/drawings/d/e/2PACX-1vRpPNsaStxW2ENmv1kfUQg41cua9XQ2sQziq2PC8LdRCtkvjHSKYH3CyPO1Pz7kOdiQ2yQKrBX88-TF/pub?w=$WIDTH"
|
||||||
|
URL_PAN_ZOOM="https://docs.google.com/drawings/d/e/2PACX-1vTcbuMiqKo7-w0my3jaht1xdEFUhLSur1nSxhkxbuX0eagwuisaNCBfKvDjFY4hzNVGRZoBy7YehaMn/pub?w=$WIDTH"
|
||||||
|
|
||||||
DIR_MEDIA_LAYOUT_IMAGES="./docs/images/media_layout"
|
DIR_MEDIA_LAYOUT_IMAGES="./docs/images/media_layout"
|
||||||
|
|
||||||
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/fit.png" "$URL_FIT" && \
|
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/fit.png" "$URL_FIT" && \
|
||||||
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/position-thinner-than-width.png" "$URL_THINNER_THAN_WIDTH" && \
|
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/position-thinner-than-width.png" "$URL_THINNER_THAN_WIDTH" && \
|
||||||
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/position-shorter-than-height.png" "$URL_SHORTER_THAN_HEIGHT" && \
|
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/position-shorter-than-height.png" "$URL_SHORTER_THAN_HEIGHT" && \
|
||||||
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/view-box.png" "$URL_VIEW_BOX"
|
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/view-box.png" "$URL_VIEW_BOX" && \
|
||||||
|
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/pan-zoom.png" "$URL_PAN_ZOOM"
|
||||||
|
|
||||||
|
|||||||
@@ -128,10 +128,6 @@ export class CameraManager {
|
|||||||
this._store = options?.store ?? new CameraManagerStore();
|
this._store = options?.store ?? new CameraManagerStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async reset(): Promise<void> {
|
|
||||||
await this._store.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async initializeCamerasFromConfig(): Promise<boolean> {
|
public async initializeCamerasFromConfig(): Promise<boolean> {
|
||||||
const config = this._api.getConfigManager().getConfig();
|
const config = this._api.getConfigManager().getConfig();
|
||||||
const hass = this._api.getHASSManager().getHASS();
|
const hass = this._api.getHASSManager().getHASS();
|
||||||
@@ -149,7 +145,7 @@ export class CameraManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const resetAndInitialize = async () => {
|
const resetAndInitialize = async () => {
|
||||||
await this.reset();
|
await this._reset();
|
||||||
await this._initializeCameras(cameras);
|
await this._initializeCameras(cameras);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,6 +161,10 @@ export class CameraManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async _reset(): Promise<void> {
|
||||||
|
await this._store.reset();
|
||||||
|
}
|
||||||
|
|
||||||
protected async _getEnginesForCameras(
|
protected async _getEnginesForCameras(
|
||||||
camerasConfig: CamerasConfig,
|
camerasConfig: CamerasConfig,
|
||||||
): Promise<Map<CameraConfig, CameraManagerEngine>> {
|
): Promise<Map<CameraConfig, CameraManagerEngine>> {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getActionConfigGivenAction,
|
getActionConfigGivenAction,
|
||||||
} from '../utils/action.js';
|
} from '../utils/action.js';
|
||||||
import { getStreamCameraID } from '../utils/substream.js';
|
import { getStreamCameraID } from '../utils/substream.js';
|
||||||
|
import { generateViewContextForZoomChange } from '../components-lib/zoom/zoom-view-context.js';
|
||||||
import { CardActionsManagerAPI } from './types.js';
|
import { CardActionsManagerAPI } from './types.js';
|
||||||
|
|
||||||
const interactionSchema = z.object({
|
const interactionSchema = z.object({
|
||||||
@@ -263,9 +264,19 @@ export class ActionsManager {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'show_ptz':
|
case 'show_ptz':
|
||||||
this._api
|
this._api.getViewManager().setViewWithMergedContext({
|
||||||
.getViewManager()
|
live: { ptzVisible: frigateCardAction.show_ptz },
|
||||||
.setViewWithNewContext({ live: { ptzVisible: frigateCardAction.show_ptz } });
|
});
|
||||||
|
break;
|
||||||
|
case 'change_zoom':
|
||||||
|
this._api.getViewManager().setViewWithMergedContext(
|
||||||
|
generateViewContextForZoomChange(frigateCardAction.target_id, {
|
||||||
|
zoom: {
|
||||||
|
pan: frigateCardAction.pan,
|
||||||
|
zoom: frigateCardAction.zoom,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(`Frigate card received unknown card action: ${action}`);
|
console.warn(`Frigate card received unknown card action: ${action}`);
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ export class CardElementManager {
|
|||||||
// Manually call the location change handler as the card will be
|
// Manually call the location change handler as the card will be
|
||||||
// disconnected/reconnected when dashboard 'tab' changes happen within HA.
|
// disconnected/reconnected when dashboard 'tab' changes happen within HA.
|
||||||
this._api.getQueryStringManager().executeAll();
|
this._api.getQueryStringManager().executeAll();
|
||||||
|
|
||||||
|
// Make sure reconnections call the initialization code.
|
||||||
|
this._element.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public elementDisconnected(): void {
|
public elementDisconnected(): void {
|
||||||
@@ -108,15 +111,10 @@ export class CardElementManager {
|
|||||||
this._api.getMediaLoadedInfoManager().clear();
|
this._api.getMediaLoadedInfoManager().clear();
|
||||||
this._api.getFullscreenManager().disconnect();
|
this._api.getFullscreenManager().disconnect();
|
||||||
|
|
||||||
// Reset and uninitialize cameras to cause them to reinitialize on
|
// Uninitialize cameras to cause them to reinitialize on
|
||||||
// reconnection, to ensure the state subscription/unsubscription works
|
// reconnection, to ensure the state subscription/unsubscription works
|
||||||
// correctly for triggers.
|
// correctly for triggers.
|
||||||
this._api
|
|
||||||
.getCameraManager()
|
|
||||||
.reset()
|
|
||||||
.then(() =>
|
|
||||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS),
|
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS),
|
||||||
);
|
|
||||||
|
|
||||||
this._element.removeEventListener(
|
this._element.removeEventListener(
|
||||||
'mousemove',
|
'mousemove',
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export class ConditionsManager {
|
|||||||
const config = this._api.getConfigManager().getConfig();
|
const config = this._api.getConfigManager().getConfig();
|
||||||
const conditions: FrigateCardCondition[] = [];
|
const conditions: FrigateCardCondition[] = [];
|
||||||
config?.overrides?.forEach((override) => conditions.push(...override.conditions));
|
config?.overrides?.forEach((override) => conditions.push(...override.conditions));
|
||||||
|
config?.automations?.forEach((automation) => conditions.push(...automation.conditions));
|
||||||
|
|
||||||
// Element conditions can be arbitrarily nested underneath conditionals and
|
// Element conditions can be arbitrarily nested underneath conditionals and
|
||||||
// custom elements that this card may not known. Here we recursively parse
|
// custom elements that this card may not known. Here we recursively parse
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export class InitializationManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this._api.getMessageManager().hasMessage()) {
|
if (!this._api.getMessageManager().hasMessage()) {
|
||||||
|
if (!this._api.getViewManager().hasView()) {
|
||||||
// Set a view on initial load. However, if the query string contains a
|
// Set a view on initial load. However, if the query string contains a
|
||||||
// view related action, we don't set any view here and allow that content
|
// view related action, we don't set any view here and allow that content
|
||||||
// to be triggered by the firstUpdated() call that runs query string
|
// to be triggered by the firstUpdated() call that runs query string
|
||||||
@@ -100,6 +101,11 @@ export class InitializationManager {
|
|||||||
} else {
|
} else {
|
||||||
this._api.getViewManager().setViewDefault({ failSafe: true });
|
this._api.getViewManager().setViewDefault({ failSafe: true });
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// If we already have a view something (e.g. cameras) may have been
|
||||||
|
// reinitialized, be sure to ask for an update.
|
||||||
|
this._api.getCardElementManager().update();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export class ViewManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setViewWithNewContext(context: ViewContext): void {
|
public setViewWithMergedContext(context: ViewContext | null): void {
|
||||||
if (this._view) {
|
if (this._view) {
|
||||||
return this._setView(this._view?.clone().mergeInContext(context));
|
return this._setView(this._view?.clone().mergeInContext(context));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,8 +269,8 @@ class FrigateCard extends LitElement {
|
|||||||
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
|
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
|
||||||
@frigate-card:view:change=${(ev: CustomEvent<View>) =>
|
@frigate-card:view:change=${(ev: CustomEvent<View>) =>
|
||||||
this._controller.getViewManager().setView(ev.detail)}
|
this._controller.getViewManager().setView(ev.detail)}
|
||||||
@frigate-card:view:change-context=${(ev: CustomEvent<ViewContext>) =>
|
@frigate-card:view:change-context=${(ev: CustomEvent<ViewContext | null>) =>
|
||||||
this._controller.getViewManager().setViewWithNewContext(ev.detail)}
|
this._controller.getViewManager().setViewWithMergedContext(ev.detail)}
|
||||||
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
|
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
|
||||||
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
|
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
|
||||||
@frigate-card:media:unloaded=${() =>
|
@frigate-card:media:unloaded=${() =>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { MediaPlayerManager } from '../card-controller/media-player-manager';
|
|||||||
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
||||||
import { ViewManager } from '../card-controller/view-manager';
|
import { ViewManager } from '../card-controller/view-manager';
|
||||||
import {
|
import {
|
||||||
|
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||||
FrigateCardConfig,
|
FrigateCardConfig,
|
||||||
FrigateCardCustomAction,
|
FrigateCardCustomAction,
|
||||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
|
||||||
MenuItem,
|
MenuItem,
|
||||||
} from '../config/types';
|
} from '../config/types';
|
||||||
import { FRIGATE_BUTTON_MENU_ICON } from '../const';
|
import { FRIGATE_BUTTON_MENU_ICON } from '../const';
|
||||||
@@ -15,6 +15,7 @@ import { localize } from '../localize/localize.js';
|
|||||||
import { MediaLoadedInfo } from '../types';
|
import { MediaLoadedInfo } from '../types';
|
||||||
import {
|
import {
|
||||||
createFrigateCardCameraAction,
|
createFrigateCardCameraAction,
|
||||||
|
createFrigateCardChangeZoomAction,
|
||||||
createFrigateCardDisplayModeAction,
|
createFrigateCardDisplayModeAction,
|
||||||
createFrigateCardMediaPlayerAction,
|
createFrigateCardMediaPlayerAction,
|
||||||
createFrigateCardShowPTZAction,
|
createFrigateCardShowPTZAction,
|
||||||
@@ -95,6 +96,7 @@ export class MenuButtonController {
|
|||||||
this._getScreenshotButton(config, options?.currentMediaLoadedInfo),
|
this._getScreenshotButton(config, options?.currentMediaLoadedInfo),
|
||||||
this._getDisplayModeButton(config, cameraManager, view),
|
this._getDisplayModeButton(config, cameraManager, view),
|
||||||
this._getPTZButton(config, cameraManager, view),
|
this._getPTZButton(config, cameraManager, view),
|
||||||
|
this._getDefaultZoomButton(config, view),
|
||||||
|
|
||||||
...this._dynamicMenuButtons.map((button) => ({
|
...this._dynamicMenuButtons.map((button) => ({
|
||||||
style: this._getStyleFromActions(config, view, button, options),
|
style: this._getStyleFromActions(config, view, button, options),
|
||||||
@@ -582,7 +584,9 @@ export class MenuButtonController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected _getSubstreamAwareCameraID(view: View): string {
|
protected _getSubstreamAwareCameraID(view: View): string {
|
||||||
return view.context?.live?.overrides?.get(view.camera) ?? view.camera;
|
return view.is('live')
|
||||||
|
? view.context?.live?.overrides?.get(view.camera) ?? view.camera
|
||||||
|
: view.camera;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _getPTZButton(
|
protected _getPTZButton(
|
||||||
@@ -614,6 +618,27 @@ export class MenuButtonController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _getDefaultZoomButton(
|
||||||
|
config: FrigateCardConfig,
|
||||||
|
view: View,
|
||||||
|
): MenuItem | null {
|
||||||
|
const targetID = view.isViewerView()
|
||||||
|
? view.queryResults?.getSelectedResult()?.getID() ?? null
|
||||||
|
: this._getSubstreamAwareCameraID(view);
|
||||||
|
|
||||||
|
if (!targetID || (view.context?.zoom?.[targetID]?.isDefault ?? true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: 'mdi:magnify-close',
|
||||||
|
...config.menu.buttons.default_zoom,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.default_zoom'),
|
||||||
|
tap_action: createFrigateCardChangeZoomAction(targetID) as FrigateCardCustomAction,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the style of emphasized menu items.
|
* Get the style of emphasized menu items.
|
||||||
* @returns A StyleInfo.
|
* @returns A StyleInfo.
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
|
|
||||||
import round from 'lodash-es/round';
|
|
||||||
import { dispatchFrigateCardEvent, isHoverableDevice } from '../utils/basic';
|
|
||||||
|
|
||||||
export class ZoomController {
|
|
||||||
constructor(element: HTMLElement) {
|
|
||||||
this._element = element;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _element: HTMLElement;
|
|
||||||
protected _panzoom?: PanzoomObject;
|
|
||||||
protected _zoomed = false;
|
|
||||||
protected _allowClick = true;
|
|
||||||
|
|
||||||
protected _events = isHoverableDevice()
|
|
||||||
? {
|
|
||||||
down: ['pointerdown'],
|
|
||||||
move: ['pointermove'],
|
|
||||||
up: ['pointerup', 'pointerleave', 'pointercancel'],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
down: ['touchstart'],
|
|
||||||
move: ['touchmove'],
|
|
||||||
up: ['touchend', 'touchcancel'],
|
|
||||||
};
|
|
||||||
|
|
||||||
protected _downHandler = (ev: Event) => {
|
|
||||||
if (this._shouldZoomOrPan(ev)) {
|
|
||||||
this._panzoom?.handleDown(ev as PointerEvent);
|
|
||||||
ev.stopPropagation();
|
|
||||||
|
|
||||||
// If we do not prevent default here, the media carousels scroll.
|
|
||||||
ev.preventDefault();
|
|
||||||
this._allowClick = false;
|
|
||||||
} else {
|
|
||||||
this._allowClick = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
protected _clickHandler = (ev: Event) => {
|
|
||||||
// When mouse clicking is used to pan, need to avoid that causing a click
|
|
||||||
// handler elsewhere in the card being called. Example: Viewing a snapshot,
|
|
||||||
// and panning within it should not cause a related clip to play (the click
|
|
||||||
// handler in the viewer).
|
|
||||||
if (!this._allowClick) {
|
|
||||||
ev.stopPropagation();
|
|
||||||
}
|
|
||||||
this._allowClick = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
protected _moveHandler = (ev: Event) => {
|
|
||||||
if (this._shouldZoomOrPan(ev)) {
|
|
||||||
this._panzoom?.handleMove(ev as PointerEvent);
|
|
||||||
ev.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
protected _upHandler = (ev: Event) => {
|
|
||||||
if (this._shouldZoomOrPan(ev)) {
|
|
||||||
this._panzoom?.handleUp(ev as PointerEvent);
|
|
||||||
ev.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
protected _wheelHandler = (ev: Event) => {
|
|
||||||
if (ev instanceof WheelEvent && this._shouldZoomOrPan(ev)) {
|
|
||||||
this._panzoom?.zoomWithWheel(ev);
|
|
||||||
ev.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
protected _isScaleNormal(scale?: number): boolean {
|
|
||||||
// Floating point arithmetic warning: comparing floating point numbers,
|
|
||||||
// round them first.
|
|
||||||
return scale !== undefined && round(scale, 4) <= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _shouldZoomOrPan(ev: Event): boolean {
|
|
||||||
return (
|
|
||||||
!this._isScaleNormal(this._panzoom?.getScale()) ||
|
|
||||||
// TouchEvent does not exist on Firefox on non-touch events.
|
|
||||||
// See: https://github.com/dermotduffy/frigate-hass-card/issues/1174
|
|
||||||
(window.TouchEvent && ev instanceof TouchEvent && ev.touches.length > 1) ||
|
|
||||||
(ev instanceof WheelEvent && ev.ctrlKey)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _setTouchAction(touchEnabled: boolean): void {
|
|
||||||
this._element.style.touchAction = touchEnabled ? '' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
public activate(): void {
|
|
||||||
this._panzoom = Panzoom(this._element, {
|
|
||||||
contain: 'outside',
|
|
||||||
maxScale: 10,
|
|
||||||
minScale: 1,
|
|
||||||
noBind: true,
|
|
||||||
// Do not force the cursor style (by default it will always show the
|
|
||||||
// 'move' type cursor whether or not it is zoomed in).
|
|
||||||
cursor: undefined,
|
|
||||||
|
|
||||||
// Disable automatic touchAction setting from Panzoom() as otherwise it
|
|
||||||
// effectively disables dashboard scrolling.
|
|
||||||
// See: https://github.com/dermotduffy/frigate-hass-card/issues/1181
|
|
||||||
touchAction: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const registerListeners = (
|
|
||||||
events: string[],
|
|
||||||
func: (ev: Event) => void,
|
|
||||||
options?: AddEventListenerOptions,
|
|
||||||
) => {
|
|
||||||
events.forEach((eventName) => {
|
|
||||||
this._element.addEventListener(eventName, func, options);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
registerListeners(this._events['down'], this._downHandler, { capture: true });
|
|
||||||
registerListeners(this._events['move'], this._moveHandler, { capture: true });
|
|
||||||
registerListeners(this._events['up'], this._upHandler, { capture: true });
|
|
||||||
registerListeners(['wheel'], this._wheelHandler);
|
|
||||||
registerListeners(['click'], this._clickHandler, { capture: true });
|
|
||||||
|
|
||||||
this._element.addEventListener('panzoomzoom', (ev: Event) => {
|
|
||||||
// Take care here to only dispatch the zoomed/unzoomed events when the
|
|
||||||
// absolute state changes (rather than on every single zoom adjustment).
|
|
||||||
if (this._isScaleNormal((<CustomEvent<PanzoomEventDetail>>ev).detail.scale)) {
|
|
||||||
if (this._zoomed) {
|
|
||||||
this._setTouchAction(true);
|
|
||||||
dispatchFrigateCardEvent(this._element, 'zoom:unzoomed');
|
|
||||||
}
|
|
||||||
this._zoomed = false;
|
|
||||||
} else {
|
|
||||||
if (!this._zoomed) {
|
|
||||||
this._setTouchAction(false);
|
|
||||||
dispatchFrigateCardEvent(this._element, 'zoom:zoomed');
|
|
||||||
}
|
|
||||||
this._zoomed = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public deactivate(): void {
|
|
||||||
const unregisterListener = (
|
|
||||||
events: string[],
|
|
||||||
func: (ev: Event) => void,
|
|
||||||
options?: EventListenerOptions,
|
|
||||||
) => {
|
|
||||||
events.forEach((eventName) => {
|
|
||||||
this._element.removeEventListener(eventName, func, options);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
unregisterListener(this._events['down'], this._downHandler, { capture: true });
|
|
||||||
unregisterListener(this._events['move'], this._moveHandler, { capture: true });
|
|
||||||
unregisterListener(this._events['up'], this._upHandler, { capture: true });
|
|
||||||
unregisterListener(['wheel'], this._wheelHandler);
|
|
||||||
unregisterListener(['click'], this._clickHandler, { capture: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export interface ZoomConfig {
|
||||||
|
pan?: {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
};
|
||||||
|
zoom?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoomDefault {
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
|
||||||
|
import debounce from 'lodash-es/debounce';
|
||||||
|
import round from 'lodash-es/round';
|
||||||
|
import {
|
||||||
|
arefloatsApproximatelyEqual,
|
||||||
|
dispatchFrigateCardEvent,
|
||||||
|
isHoverableDevice,
|
||||||
|
} from '../../utils/basic';
|
||||||
|
import { ZoomConfig } from './types';
|
||||||
|
|
||||||
|
const ZOOM_DEFAULT_PAN_X = 50;
|
||||||
|
const ZOOM_DEFAULT_PAN_Y = 50;
|
||||||
|
const ZOOM_DEFAULT_SCALE = 1;
|
||||||
|
const ZOOM_PRECISION = 4;
|
||||||
|
|
||||||
|
export class ZoomController {
|
||||||
|
protected _element: HTMLElement;
|
||||||
|
protected _panzoom?: PanzoomObject;
|
||||||
|
|
||||||
|
// Is the controller zoomed in at all?
|
||||||
|
protected _zoomed = false;
|
||||||
|
|
||||||
|
// Is the controller set to the default zoom/pan settings?
|
||||||
|
protected _default = true;
|
||||||
|
|
||||||
|
// Should clicks be allowed to propagate, or consumed as a pan/zoom action?
|
||||||
|
protected _allowClick = true;
|
||||||
|
|
||||||
|
protected _defaultConfig: ZoomConfig | null;
|
||||||
|
protected _config: ZoomConfig | null;
|
||||||
|
|
||||||
|
// When the user pans/zooms changes may be created at a very high rate.
|
||||||
|
protected _debouncedChangeHandler = debounce(this._changeHandler.bind(this), 200);
|
||||||
|
|
||||||
|
// Multiple calls to setConfig() or setDefaultConfig() or resizes should only
|
||||||
|
// update once.
|
||||||
|
protected _debouncedUpdater = debounce(this._updateBasedOnConfig.bind(this), 200);
|
||||||
|
|
||||||
|
protected _resizeObserver = new ResizeObserver(this._debouncedUpdater);
|
||||||
|
|
||||||
|
protected _events = isHoverableDevice()
|
||||||
|
? {
|
||||||
|
down: ['pointerdown'],
|
||||||
|
move: ['pointermove'],
|
||||||
|
up: ['pointerup', 'pointerleave', 'pointercancel'],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
down: ['touchstart'],
|
||||||
|
move: ['touchmove'],
|
||||||
|
up: ['touchend', 'touchcancel'],
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _downHandler = (ev: Event) => {
|
||||||
|
if (this._shouldZoomOrPan(ev)) {
|
||||||
|
this._panzoom?.handleDown(ev as PointerEvent);
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
// If we do not prevent default here, the media carousels scroll.
|
||||||
|
ev.preventDefault();
|
||||||
|
this._allowClick = false;
|
||||||
|
} else {
|
||||||
|
this._allowClick = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _clickHandler = (ev: Event) => {
|
||||||
|
// When mouse clicking is used to pan, need to avoid that causing a click
|
||||||
|
// handler elsewhere in the card being called. Example: Viewing a snapshot,
|
||||||
|
// and panning within it should not cause a related clip to play (the click
|
||||||
|
// handler in the viewer).
|
||||||
|
if (!this._allowClick) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
this._allowClick = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _moveHandler = (ev: Event) => {
|
||||||
|
if (this._shouldZoomOrPan(ev)) {
|
||||||
|
this._panzoom?.handleMove(ev as PointerEvent);
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _upHandler = (ev: Event) => {
|
||||||
|
if (this._shouldZoomOrPan(ev)) {
|
||||||
|
this._panzoom?.handleUp(ev as PointerEvent);
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _wheelHandler = (ev: Event) => {
|
||||||
|
if (ev instanceof WheelEvent && this._shouldZoomOrPan(ev)) {
|
||||||
|
this._panzoom?.zoomWithWheel(ev);
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
element: HTMLElement,
|
||||||
|
options?: { config?: ZoomConfig | null; defaultConfig?: ZoomConfig | null },
|
||||||
|
) {
|
||||||
|
this._element = element;
|
||||||
|
this._config = options?.config ?? null;
|
||||||
|
this._defaultConfig = options?.defaultConfig ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public activate(): void {
|
||||||
|
const config = this._getConfigToUse();
|
||||||
|
const converted = this._convertPercentToXYPan(
|
||||||
|
config?.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||||
|
config?.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||||
|
config?.zoom ?? ZOOM_DEFAULT_SCALE,
|
||||||
|
);
|
||||||
|
|
||||||
|
this._panzoom = Panzoom(this._element, {
|
||||||
|
contain: 'outside',
|
||||||
|
maxScale: 10,
|
||||||
|
minScale: 1,
|
||||||
|
noBind: true,
|
||||||
|
// Do not force the cursor style (by default it will always show the
|
||||||
|
// 'move' type cursor whether or not it is zoomed in).
|
||||||
|
cursor: undefined,
|
||||||
|
|
||||||
|
// Disable automatic touchAction setting from Panzoom() as otherwise it
|
||||||
|
// effectively disables dashboard scrolling. See:
|
||||||
|
// https://github.com/dermotduffy/frigate-hass-card/issues/1181
|
||||||
|
touchAction: '',
|
||||||
|
|
||||||
|
// Set the initial pan/zoom values to avoid an initial unzoomed view.
|
||||||
|
...(config && converted && { startX: converted.x }),
|
||||||
|
...(config && converted && { startY: converted.y }),
|
||||||
|
...(config &&
|
||||||
|
converted && {
|
||||||
|
startScale:
|
||||||
|
config.zoom ??
|
||||||
|
// This is not reachable as without a zoom value, a default of 1 is
|
||||||
|
// assumed in _convertPercentToXYPan, which will return null @
|
||||||
|
// default zoom, and so this cannot be reached in practice.
|
||||||
|
/* istanbul ignore next @preserve */
|
||||||
|
ZOOM_DEFAULT_SCALE,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerListeners = (
|
||||||
|
events: string[],
|
||||||
|
func: (ev: Event) => void,
|
||||||
|
options?: AddEventListenerOptions,
|
||||||
|
) => {
|
||||||
|
events.forEach((eventName) => {
|
||||||
|
this._element.addEventListener(eventName, func, options);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
registerListeners(this._events['down'], this._downHandler, { capture: true });
|
||||||
|
registerListeners(this._events['move'], this._moveHandler, { capture: true });
|
||||||
|
registerListeners(this._events['up'], this._upHandler, { capture: true });
|
||||||
|
registerListeners(['wheel'], this._wheelHandler);
|
||||||
|
registerListeners(['click'], this._clickHandler, { capture: true });
|
||||||
|
|
||||||
|
this._resizeObserver.observe(this._element);
|
||||||
|
this._element.addEventListener('panzoomchange', this._debouncedChangeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public deactivate(): void {
|
||||||
|
const unregisterListener = (
|
||||||
|
events: string[],
|
||||||
|
func: (ev: Event) => void,
|
||||||
|
options?: EventListenerOptions,
|
||||||
|
) => {
|
||||||
|
events.forEach((eventName) => {
|
||||||
|
this._element.removeEventListener(eventName, func, options);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
unregisterListener(this._events['down'], this._downHandler, { capture: true });
|
||||||
|
unregisterListener(this._events['move'], this._moveHandler, { capture: true });
|
||||||
|
unregisterListener(this._events['up'], this._upHandler, { capture: true });
|
||||||
|
unregisterListener(['wheel'], this._wheelHandler);
|
||||||
|
unregisterListener(['click'], this._clickHandler, { capture: true });
|
||||||
|
|
||||||
|
this._resizeObserver.disconnect();
|
||||||
|
this._element.removeEventListener('panzoomchange', this._debouncedChangeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setDefaultConfig(config: ZoomConfig | null): void {
|
||||||
|
this._defaultConfig = config;
|
||||||
|
this._debouncedUpdater();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setConfig(config: ZoomConfig): void {
|
||||||
|
this._config = config;
|
||||||
|
this._debouncedUpdater();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _changeHandler(ev: Event): void {
|
||||||
|
const pz = (<CustomEvent<PanzoomEventDetail>>ev).detail;
|
||||||
|
|
||||||
|
const isUnzoomed = this._isUnzoomed(pz.scale);
|
||||||
|
const isAtDefault = this._isAtDefaultZoomAndPan(pz.x, pz.y, pz.scale);
|
||||||
|
|
||||||
|
// Take care here to only dispatch the zoomed/unzoomed events when the
|
||||||
|
// absolute state changes (rather than on every single zoom adjustment).
|
||||||
|
if (isUnzoomed && this._zoomed) {
|
||||||
|
this._zoomed = false;
|
||||||
|
this._setTouchAction(true);
|
||||||
|
dispatchFrigateCardEvent(this._element, 'zoom:unzoomed');
|
||||||
|
} else if (!isUnzoomed && !this._zoomed) {
|
||||||
|
this._zoomed = true;
|
||||||
|
this._setTouchAction(false);
|
||||||
|
dispatchFrigateCardEvent(this._element, 'zoom:zoomed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAtDefault && !this._default) {
|
||||||
|
this._default = true;
|
||||||
|
dispatchFrigateCardEvent(this._element, 'zoom:default', { isDefault: true });
|
||||||
|
} else if (!isAtDefault && this._default) {
|
||||||
|
this._default = false;
|
||||||
|
dispatchFrigateCardEvent(this._element, 'zoom:default', { isDefault: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _isZoomEqual(a: ZoomConfig, b: ZoomConfig): boolean {
|
||||||
|
// The ?? clauses below cannot be reached since this function is only ever
|
||||||
|
// used fully specified by this object. It's kept as-is for completeness.
|
||||||
|
return (
|
||||||
|
arefloatsApproximatelyEqual(
|
||||||
|
/* istanbul ignore next @preserve */
|
||||||
|
a.zoom ?? ZOOM_DEFAULT_SCALE,
|
||||||
|
/* istanbul ignore next @preserve */
|
||||||
|
b.zoom ?? ZOOM_DEFAULT_SCALE,
|
||||||
|
ZOOM_PRECISION,
|
||||||
|
) &&
|
||||||
|
arefloatsApproximatelyEqual(
|
||||||
|
/* istanbul ignore next @preserve */
|
||||||
|
a.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||||
|
/* istanbul ignore next @preserve */
|
||||||
|
b.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||||
|
ZOOM_PRECISION,
|
||||||
|
) &&
|
||||||
|
arefloatsApproximatelyEqual(
|
||||||
|
/* istanbul ignore next @preserve */
|
||||||
|
a.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||||
|
/* istanbul ignore next @preserve */
|
||||||
|
b.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||||
|
ZOOM_PRECISION,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _isZoomEmpty(config?: ZoomConfig | null): boolean {
|
||||||
|
return (
|
||||||
|
config?.pan?.x === undefined &&
|
||||||
|
config?.pan?.y === undefined &&
|
||||||
|
config?.zoom === undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _getConfigToUse(): ZoomConfig | null {
|
||||||
|
return this._isZoomEmpty(this._config) ? this._defaultConfig : this._config;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _updateBasedOnConfig(): void {
|
||||||
|
if (!this._panzoom) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = this._getConfigToUse();
|
||||||
|
const desiredScale = config?.zoom ?? ZOOM_DEFAULT_SCALE;
|
||||||
|
|
||||||
|
// Transform won't exist (will be null) if the element has no dimensions, or
|
||||||
|
// if the desired scale has no zoom (i.e. is 1).
|
||||||
|
const converted = this._convertPercentToXYPan(
|
||||||
|
config?.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||||
|
config?.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||||
|
desiredScale,
|
||||||
|
);
|
||||||
|
|
||||||
|
const x = converted?.x ?? 0;
|
||||||
|
const y = converted?.y ?? 0;
|
||||||
|
|
||||||
|
// Verify there is a material change in the pan/zoom settings before acting.
|
||||||
|
if (
|
||||||
|
this._isZoomEqual(
|
||||||
|
{ zoom: desiredScale, pan: { x: x, y: y } },
|
||||||
|
{
|
||||||
|
zoom: this._panzoom.getScale(),
|
||||||
|
pan: this._panzoom.getPan(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._panzoom.zoom(desiredScale, {
|
||||||
|
animate: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Panzoom must allow the browser to paint the zoomed image in order to
|
||||||
|
// "contain" the pan within the parent, this creates somewhat of an async
|
||||||
|
// situation where we need to ensure the zoom completes first. Using
|
||||||
|
// `requestAnimationFrame` appears to reliably allow the zoom to finish
|
||||||
|
// rendering first, before the pain is applied.
|
||||||
|
// See: https://github.com/timmywil/panzoom?tab=readme-ov-file#a-note-on-the-async-nature-of-panzoom
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
this._panzoom?.pan(x, y, {
|
||||||
|
animate: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert from Frigate card pan % values to Panzoom X/Y transformation
|
||||||
|
* coordinates.
|
||||||
|
* @param x The x translation value.
|
||||||
|
* @param y The y translation value.
|
||||||
|
* @param scale The desired (not current) scale.
|
||||||
|
* @returns An object with x/y pan % values or null on error.
|
||||||
|
*/
|
||||||
|
protected _convertPercentToXYPan(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
scale: number,
|
||||||
|
): { x: number; y: number } | null {
|
||||||
|
const minMax = this._getTransformMinMax(scale, this._panzoom?.getScale());
|
||||||
|
if (minMax === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: minMax.minX + (minMax.maxX - minMax.minX) * (x / 100),
|
||||||
|
y: minMax.minY + (minMax.maxY - minMax.minY) * (y / 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _getTransformMinMax(
|
||||||
|
desiredScale: number,
|
||||||
|
currentScale?: number,
|
||||||
|
): {
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minY: number;
|
||||||
|
maxY: number;
|
||||||
|
} | null {
|
||||||
|
const rendered = this._getRenderedSize(currentScale);
|
||||||
|
|
||||||
|
if (!rendered.width || !rendered.height) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minX = (rendered.width * (desiredScale - 1)) / desiredScale / 2;
|
||||||
|
const minY = (rendered.height * (desiredScale - 1)) / desiredScale / 2;
|
||||||
|
|
||||||
|
if (arefloatsApproximatelyEqual(minX, 0) || arefloatsApproximatelyEqual(minY, 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: minX,
|
||||||
|
maxX: -minX,
|
||||||
|
minY: minY,
|
||||||
|
maxY: -minY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _getRenderedSize(scale?: number): { width: number; height: number } {
|
||||||
|
const rect = this._element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
width: rect.width / (scale ?? ZOOM_DEFAULT_SCALE),
|
||||||
|
height: rect.height / (scale ?? ZOOM_DEFAULT_SCALE),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _isUnzoomed(scale?: number): boolean {
|
||||||
|
return scale !== undefined && round(scale, ZOOM_PRECISION) <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _isAtDefaultZoomAndPan(x: number, y: number, scale: number): boolean {
|
||||||
|
if (!this._defaultConfig) {
|
||||||
|
return this._isUnzoomed(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertedDefault = this._convertPercentToXYPan(
|
||||||
|
this._defaultConfig.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||||
|
this._defaultConfig.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||||
|
this._defaultConfig.zoom ?? ZOOM_DEFAULT_SCALE,
|
||||||
|
);
|
||||||
|
if (!convertedDefault) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
arefloatsApproximatelyEqual(x, convertedDefault.x) &&
|
||||||
|
arefloatsApproximatelyEqual(y, convertedDefault.y) &&
|
||||||
|
arefloatsApproximatelyEqual(
|
||||||
|
scale,
|
||||||
|
this._defaultConfig.zoom ??
|
||||||
|
// The ZOOM_DEFAULT_SCALE clause below cannot be reached since when
|
||||||
|
// this._defaultConfig.zoom is undefined, convertedDefault will end up
|
||||||
|
// null above and this function will have already returned.
|
||||||
|
/* istanbul ignore next @preserve */
|
||||||
|
ZOOM_DEFAULT_SCALE,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _shouldZoomOrPan(ev: Event): boolean {
|
||||||
|
return (
|
||||||
|
!this._isUnzoomed(this._panzoom?.getScale()) ||
|
||||||
|
// TouchEvent does not exist on Firefox on non-touch events. See:
|
||||||
|
// https://github.com/dermotduffy/frigate-hass-card/issues/1174
|
||||||
|
(window.TouchEvent && ev instanceof TouchEvent && ev.touches.length > 1) ||
|
||||||
|
(ev instanceof WheelEvent && ev.ctrlKey)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _setTouchAction(touchEnabled: boolean): void {
|
||||||
|
this._element.style.touchAction = touchEnabled ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { ViewContext } from 'view';
|
||||||
|
import { ZoomConfig, ZoomDefault } from './types.js';
|
||||||
|
import { dispatchViewContextChangeEvent } from '../../view/view.js';
|
||||||
|
|
||||||
|
interface ZoomViewContext {
|
||||||
|
// Populate this to request zoom to a particular scale/x/y. An empty object
|
||||||
|
// will reset to default, null will make no change.
|
||||||
|
zoom?: ZoomConfig | null;
|
||||||
|
|
||||||
|
// This will be populated with whether or not the current zoom is at the
|
||||||
|
// default level.
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZoomsViewContext {
|
||||||
|
[targetID: string]: ZoomViewContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'view' {
|
||||||
|
interface ViewContext {
|
||||||
|
zoom?: ZoomsViewContext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateViewContextForZoomChange = (
|
||||||
|
targetID: string,
|
||||||
|
options?: {
|
||||||
|
zoom?: ZoomConfig | null;
|
||||||
|
isDefault?: boolean;
|
||||||
|
},
|
||||||
|
): ViewContext | null => {
|
||||||
|
return {
|
||||||
|
zoom: {
|
||||||
|
[targetID]: {
|
||||||
|
zoom: options?.zoom ?? null,
|
||||||
|
...(options?.isDefault !== undefined && { isDefault: options.isDefault }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience wrapper to convert a zoom default into a dispatched view context
|
||||||
|
* change.
|
||||||
|
*/
|
||||||
|
export const handleZoomDefaultEvent = (
|
||||||
|
element: EventTarget,
|
||||||
|
ev: CustomEvent<ZoomDefault>,
|
||||||
|
targetID?: string,
|
||||||
|
): void => {
|
||||||
|
targetID && dispatchViewContextChangeEvent(
|
||||||
|
element,
|
||||||
|
generateViewContextForZoomChange(targetID, {
|
||||||
|
isDefault: ev.detail.isDefault,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,10 +9,13 @@ import {
|
|||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { guard } from 'lit/directives/guard.js';
|
||||||
import { live } from 'lit/directives/live.js';
|
import { live } from 'lit/directives/live.js';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
import isEqual from 'lodash-es/isEqual';
|
import isEqual from 'lodash-es/isEqual';
|
||||||
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
||||||
|
import { ZoomDefault } from '../components-lib/zoom/types.js';
|
||||||
|
import { handleZoomDefaultEvent } from '../components-lib/zoom/zoom-view-context.js';
|
||||||
import { CameraConfig, ImageViewConfig } from '../config/types.js';
|
import { CameraConfig, ImageViewConfig } from '../config/types.js';
|
||||||
import defaultImage from '../images/frigate-bird-in-sky.jpg';
|
import defaultImage from '../images/frigate-bird-in-sky.jpg';
|
||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
@@ -277,8 +280,25 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
||||||
|
const targetID = this.cameraConfig?.id;
|
||||||
|
const zoomConfig = targetID ? this.view?.context?.zoom?.[targetID]?.zoom : undefined;
|
||||||
|
|
||||||
return this.imageConfig?.zoomable
|
return this.imageConfig?.zoomable
|
||||||
? html` <frigate-card-zoomer> ${template} </frigate-card-zoomer>`
|
? html` <frigate-card-zoomer
|
||||||
|
.defaultConfig=${guard([this.cameraConfig?.dimensions?.layout], () =>
|
||||||
|
this.cameraConfig?.dimensions?.layout
|
||||||
|
? {
|
||||||
|
pan: this.cameraConfig.dimensions.layout.pan,
|
||||||
|
zoom: this.cameraConfig.dimensions.layout.zoom,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
)}
|
||||||
|
.config=${zoomConfig}
|
||||||
|
@frigate-card:zoom:default=${(ev: CustomEvent<ZoomDefault>) =>
|
||||||
|
handleZoomDefaultEvent(this, ev, targetID)}
|
||||||
|
>
|
||||||
|
${template}
|
||||||
|
</frigate-card-zoomer>`
|
||||||
: template;
|
: template;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
|
|||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
import { CameraConfig } from '../../config/types';
|
import { CameraConfig } from '../../config/types';
|
||||||
import { localize } from '../../localize/localize';
|
import basicBlockStyle from '../../scss/basic-block.scss';
|
||||||
import liveImageStyle from '../../scss/live-image.scss';
|
|
||||||
import { FrigateCardMediaPlayer } from '../../types.js';
|
import { FrigateCardMediaPlayer } from '../../types.js';
|
||||||
import { getStateObjOrDispatchError } from '../../utils/get-state-obj';
|
import { getStateObjOrDispatchError } from '../../utils/get-state-obj';
|
||||||
import '../image.js';
|
import '../image.js';
|
||||||
@@ -17,9 +16,6 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraConfig?: CameraConfig;
|
public cameraConfig?: CameraConfig;
|
||||||
|
|
||||||
@property({ attribute: true })
|
|
||||||
public watermark?: string;
|
|
||||||
|
|
||||||
protected _refImage: Ref<Element & FrigateCardMediaPlayer> = createRef();
|
protected _refImage: Ref<Element & FrigateCardMediaPlayer> = createRef();
|
||||||
|
|
||||||
public async play(): Promise<void> {
|
public async play(): Promise<void> {
|
||||||
@@ -80,17 +76,11 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
|||||||
.cameraConfig=${this.cameraConfig}
|
.cameraConfig=${this.cameraConfig}
|
||||||
>
|
>
|
||||||
</frigate-card-image>
|
</frigate-card-image>
|
||||||
${this.watermark
|
|
||||||
? html`<ha-icon
|
|
||||||
title=${localize('error.awaiting_live')}
|
|
||||||
icon="${this.watermark}"
|
|
||||||
></ha-icon>`
|
|
||||||
: ''}
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(liveImageStyle);
|
return unsafeCSS(basicBlockStyle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js';
|
import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js';
|
||||||
import { LiveController } from '../../components-lib/live/live-controller.js';
|
import { LiveController } from '../../components-lib/live/live-controller.js';
|
||||||
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
|
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
|
||||||
|
import { ZoomConfig, ZoomDefault } from '../../components-lib/zoom/types.js';
|
||||||
|
import { handleZoomDefaultEvent } from '../../components-lib/zoom/zoom-view-context.js';
|
||||||
import {
|
import {
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
CardWideConfig,
|
CardWideConfig,
|
||||||
@@ -501,6 +503,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
.liveConfig=${liveConfig}
|
.liveConfig=${liveConfig}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
|
.zoomConfig=${this.view?.context?.zoom?.[cameraID]?.zoom}
|
||||||
|
@frigate-card:zoom:default=${(ev: CustomEvent<ZoomDefault>) =>
|
||||||
|
handleZoomDefaultEvent(this, ev, cameraID)}
|
||||||
>
|
>
|
||||||
</frigate-card-live-provider>
|
</frigate-card-live-provider>
|
||||||
</div>
|
</div>
|
||||||
@@ -688,6 +693,9 @@ export class FrigateCardLiveProvider
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public microphoneStream?: MediaStream;
|
public microphoneStream?: MediaStream;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public zoomConfig?: ZoomConfig | null;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
protected _isVideoMediaLoaded = false;
|
protected _isVideoMediaLoaded = false;
|
||||||
|
|
||||||
@@ -793,9 +801,6 @@ export class FrigateCardLiveProvider
|
|||||||
this._isVideoMediaLoaded = false;
|
this._isVideoMediaLoaded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Record that video media is being shown.
|
|
||||||
*/
|
|
||||||
protected _videoMediaShowHandler(): void {
|
protected _videoMediaShowHandler(): void {
|
||||||
this._isVideoMediaLoaded = true;
|
this._isVideoMediaLoaded = true;
|
||||||
}
|
}
|
||||||
@@ -852,6 +857,15 @@ export class FrigateCardLiveProvider
|
|||||||
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
||||||
return this.liveConfig?.zoomable
|
return this.liveConfig?.zoomable
|
||||||
? html` <frigate-card-zoomer
|
? html` <frigate-card-zoomer
|
||||||
|
.defaultConfig=${guard([this.cameraConfig?.dimensions?.layout], () =>
|
||||||
|
this.cameraConfig?.dimensions?.layout
|
||||||
|
? {
|
||||||
|
pan: this.cameraConfig.dimensions.layout.pan,
|
||||||
|
zoom: this.cameraConfig.dimensions.layout.zoom,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
)}
|
||||||
|
.config=${this.zoomConfig}
|
||||||
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
|
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
|
||||||
@frigate-card:zoom:unzoomed=${() => this.setControls()}
|
@frigate-card:zoom:unzoomed=${() => this.setControls()}
|
||||||
>
|
>
|
||||||
@@ -899,15 +913,12 @@ export class FrigateCardLiveProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this._useZoomIfRequired(html`
|
return html`${this._useZoomIfRequired(html`
|
||||||
${showImageDuringLoading || provider === 'image'
|
${showImageDuringLoading || provider === 'image'
|
||||||
? html` <frigate-card-live-image
|
? html` <frigate-card-live-image
|
||||||
${ref(this._refProvider)}
|
${ref(this._refProvider)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraConfig=${this.cameraConfig}
|
.cameraConfig=${this.cameraConfig}
|
||||||
watermark=${ifDefined(
|
|
||||||
showImageDuringLoading ? 'mdi:progress-helper' : undefined,
|
|
||||||
)}
|
|
||||||
@frigate-card:media:loaded=${(ev: Event) => {
|
@frigate-card:media:loaded=${(ev: Event) => {
|
||||||
if (provider === 'image') {
|
if (provider === 'image') {
|
||||||
// Only count the media has loaded if the required provider is
|
// Only count the media has loaded if the required provider is
|
||||||
@@ -968,7 +979,13 @@ export class FrigateCardLiveProvider
|
|||||||
>
|
>
|
||||||
</frigate-card-live-jsmpeg>`
|
</frigate-card-live-jsmpeg>`
|
||||||
: html``}
|
: html``}
|
||||||
`);
|
`)}
|
||||||
|
${showImageDuringLoading && !this._isVideoMediaLoaded
|
||||||
|
? html`<ha-icon
|
||||||
|
title=${localize('error.awaiting_live')}
|
||||||
|
icon="mdi:progress-helper"
|
||||||
|
></ha-icon>`
|
||||||
|
: ''} `;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { ifDefined } from 'lit/directives/if-defined.js';
|
|||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
import { CameraManager } from '../camera-manager/manager.js';
|
import { CameraManager } from '../camera-manager/manager.js';
|
||||||
import { MediaGridSelected } from '../components-lib/media-grid-controller.js';
|
import { MediaGridSelected } from '../components-lib/media-grid-controller.js';
|
||||||
|
import { ZoomDefault } from '../components-lib/zoom/types.js';
|
||||||
|
import { handleZoomDefaultEvent } from '../components-lib/zoom/zoom-view-context.js';
|
||||||
import {
|
import {
|
||||||
dispatchMessageEvent,
|
dispatchMessageEvent,
|
||||||
renderMessage,
|
renderMessage,
|
||||||
@@ -484,7 +486,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
}}
|
}}
|
||||||
></frigate-card-next-previous-control>
|
></frigate-card-next-previous-control>
|
||||||
${guard(this._media, () => this._getSlides())}
|
${guard([this._media, this.view], () => this._getSlides())}
|
||||||
<frigate-card-next-previous-control
|
<frigate-card-next-previous-control
|
||||||
slot="next"
|
slot="next"
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
@@ -865,10 +867,28 @@ export class FrigateCardViewerProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
||||||
|
if (!this.media) {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
const cameraID = this.media.getCameraID();
|
||||||
|
const mediaID = this.media.getID() ?? undefined;
|
||||||
|
const cameraConfig = this.cameraManager?.getStore().getCameraConfig(cameraID);
|
||||||
|
|
||||||
return this.viewerConfig?.zoomable
|
return this.viewerConfig?.zoomable
|
||||||
? html` <frigate-card-zoomer
|
? html` <frigate-card-zoomer
|
||||||
|
.defaultConfig=${guard([cameraConfig?.dimensions?.layout], () =>
|
||||||
|
cameraConfig?.dimensions?.layout
|
||||||
|
? {
|
||||||
|
pan: cameraConfig.dimensions.layout.pan,
|
||||||
|
zoom: cameraConfig.dimensions.layout.zoom,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
)}
|
||||||
|
.config=${mediaID ? this.view?.context?.zoom?.[mediaID]?.zoom : undefined}
|
||||||
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
|
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
|
||||||
@frigate-card:zoom:unzoomed=${() => this.setControls()}
|
@frigate-card:zoom:unzoomed=${() => this.setControls()}
|
||||||
|
@frigate-card:zoom:default=${(ev: CustomEvent<ZoomDefault>) =>
|
||||||
|
handleZoomDefaultEvent(this, ev, mediaID)}
|
||||||
>
|
>
|
||||||
${template}
|
${template}
|
||||||
</frigate-card-zoomer>`
|
</frigate-card-zoomer>`
|
||||||
|
|||||||
@@ -6,13 +6,20 @@ import {
|
|||||||
PropertyValues,
|
PropertyValues,
|
||||||
TemplateResult,
|
TemplateResult,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { ZoomController } from '../components-lib/zoom-controller.js';
|
import { ZoomConfig } from '../components-lib/zoom/types.js';
|
||||||
|
import { ZoomController } from '../components-lib/zoom/zoom-controller.js';
|
||||||
import { setOrRemoveAttribute } from '../utils/basic.js';
|
import { setOrRemoveAttribute } from '../utils/basic.js';
|
||||||
|
|
||||||
@customElement('frigate-card-zoomer')
|
@customElement('frigate-card-zoomer')
|
||||||
export class FrigateCardZoomer extends LitElement {
|
export class FrigateCardZoomer extends LitElement {
|
||||||
protected _zoom = new ZoomController(this);
|
protected _zoom: ZoomController | null = null;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public defaultConfig?: ZoomConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public config?: ZoomConfig | null;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
protected _zoomed = false;
|
protected _zoomed = false;
|
||||||
@@ -24,11 +31,13 @@ export class FrigateCardZoomer extends LitElement {
|
|||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.addEventListener('frigate-card:zoom:zoomed', this._zoomHandler);
|
this.addEventListener('frigate-card:zoom:zoomed', this._zoomHandler);
|
||||||
this.addEventListener('frigate-card:zoom:unzoomed', this._unzoomHandler);
|
this.addEventListener('frigate-card:zoom:unzoomed', this._unzoomHandler);
|
||||||
this._zoom.activate();
|
|
||||||
|
// Call for an update to activate.
|
||||||
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
this._zoom.deactivate();
|
this._zoom?.deactivate();
|
||||||
this.removeEventListener('frigate-card:zoom:zoomed', this._zoomHandler);
|
this.removeEventListener('frigate-card:zoom:zoomed', this._zoomHandler);
|
||||||
this.removeEventListener('frigate-card:zoom:unzoomed', this._unzoomHandler);
|
this.removeEventListener('frigate-card:zoom:unzoomed', this._unzoomHandler);
|
||||||
}
|
}
|
||||||
@@ -37,7 +46,26 @@ export class FrigateCardZoomer extends LitElement {
|
|||||||
if (changedProps.has('_zoomed')) {
|
if (changedProps.has('_zoomed')) {
|
||||||
setOrRemoveAttribute(this, this._zoomed, 'zoomed');
|
setOrRemoveAttribute(this, this._zoomed, 'zoomed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._zoom) {
|
||||||
|
if (changedProps.has('defaultConfig')) {
|
||||||
|
this._zoom.setDefaultConfig(this.defaultConfig ?? null);
|
||||||
}
|
}
|
||||||
|
// If config is null, make no change to the zoom.
|
||||||
|
if (changedProps.has('config') && this.config) {
|
||||||
|
this._zoom.setConfig(this.config);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ensure that the configuration will be set before activation (vs
|
||||||
|
// activating in `connectedCallback`).
|
||||||
|
this._zoom = new ZoomController(this, {
|
||||||
|
config: this.config,
|
||||||
|
defaultConfig: this.defaultConfig,
|
||||||
|
});
|
||||||
|
this._zoom.activate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
return html` <slot></slot> `;
|
return html` <slot></slot> `;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,16 @@ const CAMERA_TRIGGER_EVENT_TYPES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type CameraTriggerEventType = (typeof CAMERA_TRIGGER_EVENT_TYPES)[number];
|
export type CameraTriggerEventType = (typeof CAMERA_TRIGGER_EVENT_TYPES)[number];
|
||||||
|
|
||||||
|
// *************************************************************************
|
||||||
|
// Pan / Zoom
|
||||||
|
// *************************************************************************
|
||||||
|
|
||||||
|
const panSchema = z.object({
|
||||||
|
x: z.number().min(0).max(100).optional(),
|
||||||
|
y: z.number().min(0).max(100).optional(),
|
||||||
|
});
|
||||||
|
const zoomSchema = z.number().min(1).max(10);
|
||||||
|
|
||||||
// *************************************************************************
|
// *************************************************************************
|
||||||
// View Display Mode
|
// View Display Mode
|
||||||
// *************************************************************************
|
// *************************************************************************
|
||||||
@@ -289,15 +299,23 @@ const frigateCardShowPTZActionSchema = frigateCardCustomActionsBaseSchema.extend
|
|||||||
show_ptz: z.boolean(),
|
show_ptz: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const frigateCardChangeZoomActionSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||||
|
frigate_card_action: z.literal('change_zoom'),
|
||||||
|
target_id: z.string(),
|
||||||
|
zoom: zoomSchema.optional(),
|
||||||
|
pan: panSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const frigateCardCustomActionSchema = z.union([
|
export const frigateCardCustomActionSchema = z.union([
|
||||||
frigateCardViewActionSchema,
|
|
||||||
frigateCardGeneralActionSchema,
|
|
||||||
frigateCardCameraSelectActionSchema,
|
frigateCardCameraSelectActionSchema,
|
||||||
|
frigateCardChangeZoomActionSchema,
|
||||||
|
frigateCardGeneralActionSchema,
|
||||||
frigateCardLiveDependencySelectActionSchema,
|
frigateCardLiveDependencySelectActionSchema,
|
||||||
frigateCardMediaPlayerActionSchema,
|
frigateCardMediaPlayerActionSchema,
|
||||||
frigateCardViewDisplayModeActionSchema,
|
|
||||||
frigateCardPTZActionSchema,
|
frigateCardPTZActionSchema,
|
||||||
frigateCardShowPTZActionSchema,
|
frigateCardShowPTZActionSchema,
|
||||||
|
frigateCardViewActionSchema,
|
||||||
|
frigateCardViewDisplayModeActionSchema,
|
||||||
]);
|
]);
|
||||||
export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>;
|
export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>;
|
||||||
|
|
||||||
@@ -626,6 +644,8 @@ const mediaLayoutConfigSchema = z.object({
|
|||||||
top: z.number().min(0).max(100).optional().default(0),
|
top: z.number().min(0).max(100).optional().default(0),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
pan: panSchema.optional(),
|
||||||
|
zoom: zoomSchema.optional(),
|
||||||
});
|
});
|
||||||
export type MediaLayoutConfig = z.infer<typeof mediaLayoutConfigSchema>;
|
export type MediaLayoutConfig = z.infer<typeof mediaLayoutConfigSchema>;
|
||||||
|
|
||||||
@@ -1345,22 +1365,20 @@ const hiddenButtonDefault = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const menuConfigDefault = {
|
const menuConfigDefault = {
|
||||||
style: 'hidden' as const,
|
|
||||||
position: 'top' as const,
|
|
||||||
alignment: 'left' as const,
|
alignment: 'left' as const,
|
||||||
|
button_size: 40,
|
||||||
buttons: {
|
buttons: {
|
||||||
frigate: visibleButtonDefault,
|
|
||||||
cameras: visibleButtonDefault,
|
|
||||||
substreams: visibleButtonDefault,
|
|
||||||
live: visibleButtonDefault,
|
|
||||||
clips: visibleButtonDefault,
|
|
||||||
snapshots: visibleButtonDefault,
|
|
||||||
image: hiddenButtonDefault,
|
|
||||||
timeline: visibleButtonDefault,
|
|
||||||
download: visibleButtonDefault,
|
|
||||||
camera_ui: visibleButtonDefault,
|
camera_ui: visibleButtonDefault,
|
||||||
fullscreen: visibleButtonDefault,
|
cameras: visibleButtonDefault,
|
||||||
|
clips: visibleButtonDefault,
|
||||||
|
default_zoom: visibleButtonDefault,
|
||||||
|
display_mode: visibleButtonDefault,
|
||||||
|
download: visibleButtonDefault,
|
||||||
expand: hiddenButtonDefault,
|
expand: hiddenButtonDefault,
|
||||||
|
frigate: visibleButtonDefault,
|
||||||
|
fullscreen: visibleButtonDefault,
|
||||||
|
image: hiddenButtonDefault,
|
||||||
|
live: visibleButtonDefault,
|
||||||
media_player: visibleButtonDefault,
|
media_player: visibleButtonDefault,
|
||||||
microphone: {
|
microphone: {
|
||||||
...hiddenButtonDefault,
|
...hiddenButtonDefault,
|
||||||
@@ -1368,12 +1386,15 @@ const menuConfigDefault = {
|
|||||||
},
|
},
|
||||||
mute: hiddenButtonDefault,
|
mute: hiddenButtonDefault,
|
||||||
play: hiddenButtonDefault,
|
play: hiddenButtonDefault,
|
||||||
|
ptz: hiddenButtonDefault,
|
||||||
recordings: hiddenButtonDefault,
|
recordings: hiddenButtonDefault,
|
||||||
screenshot: hiddenButtonDefault,
|
screenshot: hiddenButtonDefault,
|
||||||
display_mode: visibleButtonDefault,
|
snapshots: visibleButtonDefault,
|
||||||
ptz: hiddenButtonDefault,
|
substreams: visibleButtonDefault,
|
||||||
|
timeline: visibleButtonDefault,
|
||||||
},
|
},
|
||||||
button_size: 40,
|
position: 'top' as const,
|
||||||
|
style: 'hidden' as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
const visibleButtonSchema = menuBaseSchema.extend({
|
const visibleButtonSchema = menuBaseSchema.extend({
|
||||||
@@ -1393,18 +1414,21 @@ export const menuConfigSchema = z
|
|||||||
alignment: z.enum(FRIGATE_MENU_ALIGNMENTS).default(menuConfigDefault.alignment),
|
alignment: z.enum(FRIGATE_MENU_ALIGNMENTS).default(menuConfigDefault.alignment),
|
||||||
buttons: z
|
buttons: z
|
||||||
.object({
|
.object({
|
||||||
frigate: visibleButtonSchema.default(menuConfigDefault.buttons.frigate),
|
|
||||||
cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras),
|
|
||||||
substreams: visibleButtonSchema.default(menuConfigDefault.buttons.substreams),
|
|
||||||
live: visibleButtonSchema.default(menuConfigDefault.buttons.live),
|
|
||||||
clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips),
|
|
||||||
snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots),
|
|
||||||
image: hiddenButtonSchema.default(menuConfigDefault.buttons.image),
|
|
||||||
timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline),
|
|
||||||
download: visibleButtonSchema.default(menuConfigDefault.buttons.download),
|
|
||||||
camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui),
|
camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui),
|
||||||
fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen),
|
cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras),
|
||||||
|
clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips),
|
||||||
|
default_zoom: visibleButtonSchema.default(
|
||||||
|
menuConfigDefault.buttons.default_zoom,
|
||||||
|
),
|
||||||
|
display_mode: visibleButtonSchema.default(
|
||||||
|
menuConfigDefault.buttons.display_mode,
|
||||||
|
),
|
||||||
|
download: visibleButtonSchema.default(menuConfigDefault.buttons.download),
|
||||||
expand: hiddenButtonSchema.default(menuConfigDefault.buttons.expand),
|
expand: hiddenButtonSchema.default(menuConfigDefault.buttons.expand),
|
||||||
|
frigate: visibleButtonSchema.default(menuConfigDefault.buttons.frigate),
|
||||||
|
fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen),
|
||||||
|
image: hiddenButtonSchema.default(menuConfigDefault.buttons.image),
|
||||||
|
live: visibleButtonSchema.default(menuConfigDefault.buttons.live),
|
||||||
media_player: visibleButtonSchema.default(
|
media_player: visibleButtonSchema.default(
|
||||||
menuConfigDefault.buttons.media_player,
|
menuConfigDefault.buttons.media_player,
|
||||||
),
|
),
|
||||||
@@ -1415,14 +1439,14 @@ export const menuConfigSchema = z
|
|||||||
.default(menuConfigDefault.buttons.microphone.type),
|
.default(menuConfigDefault.buttons.microphone.type),
|
||||||
})
|
})
|
||||||
.default(menuConfigDefault.buttons.microphone),
|
.default(menuConfigDefault.buttons.microphone),
|
||||||
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
|
||||||
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
|
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
|
||||||
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
||||||
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
|
|
||||||
display_mode: visibleButtonSchema.default(
|
|
||||||
menuConfigDefault.buttons.display_mode,
|
|
||||||
),
|
|
||||||
ptz: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz),
|
ptz: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz),
|
||||||
|
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
||||||
|
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
|
||||||
|
snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots),
|
||||||
|
substreams: visibleButtonSchema.default(menuConfigDefault.buttons.substreams),
|
||||||
|
timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline),
|
||||||
})
|
})
|
||||||
.default(menuConfigDefault.buttons),
|
.default(menuConfigDefault.buttons),
|
||||||
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
|
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ export const CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS =
|
|||||||
`${CONF_CAMERAS}.#.dependencies.all_cameras` as const;
|
`${CONF_CAMERAS}.#.dependencies.all_cameras` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT =
|
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT =
|
||||||
`${CONF_CAMERAS}.#.dimensions.layout.fit` as const;
|
`${CONF_CAMERAS}.#.dimensions.layout.fit` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X =
|
||||||
|
`${CONF_CAMERAS}.#.dimensions.layout.pan.x` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_Y =
|
||||||
|
`${CONF_CAMERAS}.#.dimensions.layout.pan.y` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X =
|
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X =
|
||||||
`${CONF_CAMERAS}.#.dimensions.layout.position.x` as const;
|
`${CONF_CAMERAS}.#.dimensions.layout.position.x` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y =
|
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y =
|
||||||
@@ -69,6 +73,8 @@ export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_LEFT =
|
|||||||
`${CONF_CAMERAS}.#.dimensions.layout.view_box.left` as const;
|
`${CONF_CAMERAS}.#.dimensions.layout.view_box.left` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT =
|
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT =
|
||||||
`${CONF_CAMERAS}.#.dimensions.layout.view_box.right` as const;
|
`${CONF_CAMERAS}.#.dimensions.layout.view_box.right` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR =
|
||||||
|
`${CONF_CAMERAS}.#.dimensions.layout.zoom` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION =
|
export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION =
|
||||||
`${CONF_CAMERAS}.#.triggers.motion` as const;
|
`${CONF_CAMERAS}.#.triggers.motion` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
|
export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
|
||||||
|
|||||||
@@ -44,12 +44,15 @@ import {
|
|||||||
CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS,
|
CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS,
|
||||||
CONF_CAMERAS_ARRAY_DIMENSIONS_ASPECT_RATIO,
|
CONF_CAMERAS_ARRAY_DIMENSIONS_ASPECT_RATIO,
|
||||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT,
|
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT,
|
||||||
|
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X,
|
||||||
|
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_Y,
|
||||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X,
|
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X,
|
||||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y,
|
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y,
|
||||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_BOTTOM,
|
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_BOTTOM,
|
||||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_LEFT,
|
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_LEFT,
|
||||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT,
|
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT,
|
||||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_TOP,
|
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_TOP,
|
||||||
|
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR,
|
||||||
CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME,
|
CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME,
|
||||||
CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID,
|
CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID,
|
||||||
CONF_CAMERAS_ARRAY_FRIGATE_LABELS,
|
CONF_CAMERAS_ARRAY_FRIGATE_LABELS,
|
||||||
@@ -817,9 +820,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Called before each update.
|
|
||||||
*/
|
|
||||||
protected willUpdate(): void {
|
protected willUpdate(): void {
|
||||||
if (!this._initialized) {
|
if (!this._initialized) {
|
||||||
sideLoadHomeAssistantElements().then((success) => {
|
sideLoadHomeAssistantElements().then((success) => {
|
||||||
@@ -981,6 +981,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
max?: number;
|
max?: number;
|
||||||
label?: string;
|
label?: string;
|
||||||
default?: number;
|
default?: number;
|
||||||
|
step?: number;
|
||||||
},
|
},
|
||||||
): TemplateResult | void {
|
): TemplateResult | void {
|
||||||
if (!this._config) {
|
if (!this._config) {
|
||||||
@@ -992,7 +993,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
return html`
|
return html`
|
||||||
<ha-selector
|
<ha-selector
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.selector=${{ number: { min: params?.min || 0, max: params?.max, mode: mode } }}
|
.selector=${{
|
||||||
|
number: {
|
||||||
|
min: params?.min || 0,
|
||||||
|
max: params?.max,
|
||||||
|
mode: mode,
|
||||||
|
step: params?.step,
|
||||||
|
},
|
||||||
|
}}
|
||||||
.label=${params?.label || this._getLabel(configPath)}
|
.label=${params?.label || this._getLabel(configPath)}
|
||||||
.value=${value ?? params?.default}
|
.value=${value ?? params?.default}
|
||||||
.required=${false}
|
.required=${false}
|
||||||
@@ -1230,10 +1238,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
configPathFit: string,
|
configPathFit: string,
|
||||||
configPathPositionX: string,
|
configPathPositionX: string,
|
||||||
configPathPositionY: string,
|
configPathPositionY: string,
|
||||||
configPathPositionTop: string,
|
configPathViewBoxTop: string,
|
||||||
configPathPositionBottom: string,
|
configPathViewBoxBottom: string,
|
||||||
configPathPositionLeft: string,
|
configPathViewBoxLeft: string,
|
||||||
configPathPositionRight: string,
|
configPathViewBoxRight: string,
|
||||||
|
configPathZoom: string,
|
||||||
|
configPathPanX: string,
|
||||||
|
configPathPanY: string,
|
||||||
): TemplateResult | void {
|
): TemplateResult | void {
|
||||||
return this._putInSubmenu(
|
return this._putInSubmenu(
|
||||||
domain,
|
domain,
|
||||||
@@ -1241,6 +1252,22 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
labelPath,
|
labelPath,
|
||||||
{ name: 'mdi:page-layout-body' },
|
{ name: 'mdi:page-layout-body' },
|
||||||
html`
|
html`
|
||||||
|
${this._renderNumberInput(configPathZoom, {
|
||||||
|
min: 0,
|
||||||
|
max: 10,
|
||||||
|
label: localize('config.cameras.dimensions.layout.zoom'),
|
||||||
|
step: 0.1,
|
||||||
|
})}
|
||||||
|
${this._renderNumberInput(configPathPanX, {
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
label: localize('config.cameras.dimensions.layout.pan.x'),
|
||||||
|
})}
|
||||||
|
${this._renderNumberInput(configPathPanY, {
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
label: localize('config.cameras.dimensions.layout.pan.y'),
|
||||||
|
})}
|
||||||
${this._renderOptionSelector(configPathFit, this._layoutFits, {
|
${this._renderOptionSelector(configPathFit, this._layoutFits, {
|
||||||
label: localize('config.cameras.dimensions.layout.fit'),
|
label: localize('config.cameras.dimensions.layout.fit'),
|
||||||
})}
|
})}
|
||||||
@@ -1266,22 +1293,22 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
'config.cameras.dimensions.layout.view_box.editor_label',
|
'config.cameras.dimensions.layout.view_box.editor_label',
|
||||||
{ name: 'mdi:crop' },
|
{ name: 'mdi:crop' },
|
||||||
html`
|
html`
|
||||||
${this._renderNumberInput(configPathPositionTop, {
|
${this._renderNumberInput(configPathViewBoxTop, {
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
label: localize('config.cameras.dimensions.layout.view_box.top'),
|
label: localize('config.cameras.dimensions.layout.view_box.top'),
|
||||||
})}
|
})}
|
||||||
${this._renderNumberInput(configPathPositionBottom, {
|
${this._renderNumberInput(configPathViewBoxBottom, {
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
label: localize('config.cameras.dimensions.layout.view_box.bottom'),
|
label: localize('config.cameras.dimensions.layout.view_box.bottom'),
|
||||||
})}
|
})}
|
||||||
${this._renderNumberInput(configPathPositionLeft, {
|
${this._renderNumberInput(configPathViewBoxLeft, {
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
label: localize('config.cameras.dimensions.layout.view_box.left'),
|
label: localize('config.cameras.dimensions.layout.view_box.left'),
|
||||||
})}
|
})}
|
||||||
${this._renderNumberInput(configPathPositionRight, {
|
${this._renderNumberInput(configPathViewBoxRight, {
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
label: localize('config.cameras.dimensions.layout.view_box.right'),
|
label: localize('config.cameras.dimensions.layout.view_box.right'),
|
||||||
@@ -2041,6 +2068,18 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT,
|
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT,
|
||||||
cameraIndex,
|
cameraIndex,
|
||||||
),
|
),
|
||||||
|
getArrayConfigPath(
|
||||||
|
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR,
|
||||||
|
cameraIndex,
|
||||||
|
),
|
||||||
|
getArrayConfigPath(
|
||||||
|
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X,
|
||||||
|
cameraIndex,
|
||||||
|
),
|
||||||
|
getArrayConfigPath(
|
||||||
|
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_Y,
|
||||||
|
cameraIndex,
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
@@ -2273,6 +2312,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
${this._renderMenuButton('screenshot')}
|
${this._renderMenuButton('screenshot')}
|
||||||
${this._renderMenuButton('display_mode')}
|
${this._renderMenuButton('display_mode')}
|
||||||
${this._renderMenuButton('ptz')}
|
${this._renderMenuButton('ptz')}
|
||||||
|
${this._renderMenuButton('default_zoom')}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''}
|
: ''}
|
||||||
|
|||||||
@@ -55,6 +55,10 @@
|
|||||||
"cover": "El suport s'expandeix proporcionalment per cobrir la targeta",
|
"cover": "El suport s'expandeix proporcionalment per cobrir la targeta",
|
||||||
"fill": "El suport s'estira per omplir la targeta"
|
"fill": "El suport s'estira per omplir la targeta"
|
||||||
},
|
},
|
||||||
|
"pan": {
|
||||||
|
"x": "",
|
||||||
|
"y": ""
|
||||||
|
},
|
||||||
"position": {
|
"position": {
|
||||||
"editor_label": "",
|
"editor_label": "",
|
||||||
"x": "Percentatge d'emplaçament horitzontal",
|
"x": "Percentatge d'emplaçament horitzontal",
|
||||||
@@ -66,7 +70,8 @@
|
|||||||
"left": "",
|
"left": "",
|
||||||
"right": "",
|
"right": "",
|
||||||
"top": ""
|
"top": ""
|
||||||
}
|
},
|
||||||
|
"zoom": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -357,6 +362,7 @@
|
|||||||
"camera_ui": "Interfície d'usuari de la càmera",
|
"camera_ui": "Interfície d'usuari de la càmera",
|
||||||
"cameras": "Càmeres",
|
"cameras": "Càmeres",
|
||||||
"clips": "Clips",
|
"clips": "Clips",
|
||||||
|
"default_zoom": "",
|
||||||
"display_mode": "Mode de visualització",
|
"display_mode": "Mode de visualització",
|
||||||
"download": "Descarregar",
|
"download": "Descarregar",
|
||||||
"enabled": "Botó habilitat",
|
"enabled": "Botó habilitat",
|
||||||
|
|||||||
@@ -55,6 +55,10 @@
|
|||||||
"cover": "Media expands proportionally to cover the card",
|
"cover": "Media expands proportionally to cover the card",
|
||||||
"fill": "Media is stretched to fill the card"
|
"fill": "Media is stretched to fill the card"
|
||||||
},
|
},
|
||||||
|
"pan": {
|
||||||
|
"x": "Pan horizontal percentage",
|
||||||
|
"y": "Pan vertical percentage"
|
||||||
|
},
|
||||||
"position": {
|
"position": {
|
||||||
"editor_label": "Layout Cover Position",
|
"editor_label": "Layout Cover Position",
|
||||||
"x": "Horizontal placement percentage",
|
"x": "Horizontal placement percentage",
|
||||||
@@ -66,7 +70,8 @@
|
|||||||
"left": "Left inset percentage",
|
"left": "Left inset percentage",
|
||||||
"right": "Right inset percentage",
|
"right": "Right inset percentage",
|
||||||
"top": "Top inset percentage"
|
"top": "Top inset percentage"
|
||||||
}
|
},
|
||||||
|
"zoom": "Zoom factor"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -357,6 +362,7 @@
|
|||||||
"camera_ui": "Camera user interface",
|
"camera_ui": "Camera user interface",
|
||||||
"cameras": "Cameras",
|
"cameras": "Cameras",
|
||||||
"clips": "Clips",
|
"clips": "Clips",
|
||||||
|
"default_zoom": "Zoom to default",
|
||||||
"display_mode": "Display mode",
|
"display_mode": "Display mode",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"enabled": "Button enabled",
|
"enabled": "Button enabled",
|
||||||
|
|||||||
@@ -55,6 +55,10 @@
|
|||||||
"cover": "Il supporto si espande proporzionalmente per coprire la scheda",
|
"cover": "Il supporto si espande proporzionalmente per coprire la scheda",
|
||||||
"fill": "Il supporto viene allungato per riempire la scheda"
|
"fill": "Il supporto viene allungato per riempire la scheda"
|
||||||
},
|
},
|
||||||
|
"pan": {
|
||||||
|
"x": "",
|
||||||
|
"y": ""
|
||||||
|
},
|
||||||
"position": {
|
"position": {
|
||||||
"editor_label": "",
|
"editor_label": "",
|
||||||
"x": "Percentuale di posizionamento orizzontale",
|
"x": "Percentuale di posizionamento orizzontale",
|
||||||
@@ -66,7 +70,8 @@
|
|||||||
"left": "",
|
"left": "",
|
||||||
"right": "",
|
"right": "",
|
||||||
"top": ""
|
"top": ""
|
||||||
}
|
},
|
||||||
|
"zoom": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -357,6 +362,7 @@
|
|||||||
"camera_ui": "Interface utilisateur de la caméra",
|
"camera_ui": "Interface utilisateur de la caméra",
|
||||||
"cameras": "Appareils photo",
|
"cameras": "Appareils photo",
|
||||||
"clips": "Extraits",
|
"clips": "Extraits",
|
||||||
|
"default_zoom": "",
|
||||||
"display_mode": "",
|
"display_mode": "",
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"enabled": "Bouton activé",
|
"enabled": "Bouton activé",
|
||||||
|
|||||||
@@ -55,6 +55,10 @@
|
|||||||
"cover": "Il supporto si espande proporzionalmente per coprire la scheda",
|
"cover": "Il supporto si espande proporzionalmente per coprire la scheda",
|
||||||
"fill": "Il supporto viene allungato per riempire la scheda"
|
"fill": "Il supporto viene allungato per riempire la scheda"
|
||||||
},
|
},
|
||||||
|
"pan": {
|
||||||
|
"x": "",
|
||||||
|
"y": ""
|
||||||
|
},
|
||||||
"position": {
|
"position": {
|
||||||
"editor_label": "",
|
"editor_label": "",
|
||||||
"x": "Percentuale di posizionamento orizzontale",
|
"x": "Percentuale di posizionamento orizzontale",
|
||||||
@@ -66,7 +70,8 @@
|
|||||||
"left": "",
|
"left": "",
|
||||||
"right": "",
|
"right": "",
|
||||||
"top": ""
|
"top": ""
|
||||||
}
|
},
|
||||||
|
"zoom": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -357,6 +362,7 @@
|
|||||||
"camera_ui": "Interfaccia utente della fotocamera",
|
"camera_ui": "Interfaccia utente della fotocamera",
|
||||||
"cameras": "Telecamere",
|
"cameras": "Telecamere",
|
||||||
"clips": "Clip",
|
"clips": "Clip",
|
||||||
|
"default_zoom": "",
|
||||||
"display_mode": "",
|
"display_mode": "",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"enabled": "Pulsante abilitato",
|
"enabled": "Pulsante abilitato",
|
||||||
|
|||||||
@@ -55,6 +55,10 @@
|
|||||||
"cover": "A mídia se expande proporcionalmente para cobrir o cartão",
|
"cover": "A mídia se expande proporcionalmente para cobrir o cartão",
|
||||||
"fill": "A mídia é esticada para preencher o cartão"
|
"fill": "A mídia é esticada para preencher o cartão"
|
||||||
},
|
},
|
||||||
|
"pan": {
|
||||||
|
"x": "",
|
||||||
|
"y": ""
|
||||||
|
},
|
||||||
"position": {
|
"position": {
|
||||||
"editor_label": "",
|
"editor_label": "",
|
||||||
"x": "Porcentagem do posicionamento horizontal",
|
"x": "Porcentagem do posicionamento horizontal",
|
||||||
@@ -66,7 +70,8 @@
|
|||||||
"left": "",
|
"left": "",
|
||||||
"right": "",
|
"right": "",
|
||||||
"top": ""
|
"top": ""
|
||||||
}
|
},
|
||||||
|
"zoom": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -357,6 +362,7 @@
|
|||||||
"camera_ui": "Interface de usuário da câmera",
|
"camera_ui": "Interface de usuário da câmera",
|
||||||
"cameras": "Selecionar câmera",
|
"cameras": "Selecionar câmera",
|
||||||
"clips": "Clipes",
|
"clips": "Clipes",
|
||||||
|
"default_zoom": "",
|
||||||
"display_mode": "",
|
"display_mode": "",
|
||||||
"download": "Baixe a mídia do evento",
|
"download": "Baixe a mídia do evento",
|
||||||
"enabled": "Botão ativado",
|
"enabled": "Botão ativado",
|
||||||
|
|||||||
@@ -55,6 +55,10 @@
|
|||||||
"cover": "Tapar",
|
"cover": "Tapar",
|
||||||
"fill": "Preencher"
|
"fill": "Preencher"
|
||||||
},
|
},
|
||||||
|
"pan": {
|
||||||
|
"x": "",
|
||||||
|
"y": ""
|
||||||
|
},
|
||||||
"position": {
|
"position": {
|
||||||
"editor_label": "",
|
"editor_label": "",
|
||||||
"x": "Percentagem da localização horizontal",
|
"x": "Percentagem da localização horizontal",
|
||||||
@@ -66,7 +70,8 @@
|
|||||||
"left": "",
|
"left": "",
|
||||||
"right": "",
|
"right": "",
|
||||||
"top": ""
|
"top": ""
|
||||||
}
|
},
|
||||||
|
"zoom": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -357,6 +362,7 @@
|
|||||||
"camera_ui": "Camera",
|
"camera_ui": "Camera",
|
||||||
"cameras": "Selecionar câmera",
|
"cameras": "Selecionar câmera",
|
||||||
"clips": "Clipes",
|
"clips": "Clipes",
|
||||||
|
"default_zoom": "",
|
||||||
"display_mode": "",
|
"display_mode": "",
|
||||||
"download": "Descarregar mídia do evento",
|
"download": "Descarregar mídia do evento",
|
||||||
"enabled": "Botão ativado",
|
"enabled": "Botão ativado",
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
:host {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
ha-icon {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,16 @@
|
|||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ha-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,6 +102,27 @@ export function createFrigateCardShowPTZAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createFrigateCardChangeZoomAction(
|
||||||
|
targetID: string,
|
||||||
|
options?: {
|
||||||
|
cardID?: string;
|
||||||
|
pan?: {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
};
|
||||||
|
zoom?: number;
|
||||||
|
},
|
||||||
|
): FrigateCardCustomAction {
|
||||||
|
return {
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
frigate_card_action: 'change_zoom',
|
||||||
|
target_id: targetID,
|
||||||
|
...(options?.cardID && { card_id: options.cardID }),
|
||||||
|
...(options?.pan && { pan: options.pan }),
|
||||||
|
...(options?.zoom && { zoom: options.zoom }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an action configuration given a config and an interaction (e.g. 'tap').
|
* Get an action configuration given a config and an interaction (e.g. 'tap').
|
||||||
* @param interaction The interaction: `tap`, `hold` or `double_tap`
|
* @param interaction The interaction: `tap`, `hold` or `double_tap`
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import differenceInMinutes from 'date-fns/differenceInMinutes';
|
|||||||
import differenceInSeconds from 'date-fns/differenceInSeconds';
|
import differenceInSeconds from 'date-fns/differenceInSeconds';
|
||||||
import format from 'date-fns/format';
|
import format from 'date-fns/format';
|
||||||
import { StyleInfo } from 'lit/directives/style-map';
|
import { StyleInfo } from 'lit/directives/style-map';
|
||||||
import isEqual from 'lodash-es/isEqual';
|
import { round } from 'lodash-es';
|
||||||
|
import isEqualWith from 'lodash-es/isEqualWith';
|
||||||
import mergeWith from 'lodash-es/mergeWith';
|
import mergeWith from 'lodash-es/mergeWith';
|
||||||
import { FrigateCardError } from '../types';
|
import { FrigateCardError } from '../types';
|
||||||
|
|
||||||
@@ -87,8 +88,12 @@ export const setify = <T>(value: T | T[] | Set<T>): Set<T> => {
|
|||||||
* @param o The old value.
|
* @param o The old value.
|
||||||
* @returns `true` is the contents have changed.
|
* @returns `true` is the contents have changed.
|
||||||
*/
|
*/
|
||||||
export function contentsChanged(n: unknown, o: unknown): boolean {
|
export function contentsChanged(
|
||||||
return !isEqual(n, o);
|
n: unknown,
|
||||||
|
o: unknown,
|
||||||
|
customizer?: (a: unknown, b: unknown) => boolean | undefined,
|
||||||
|
): boolean {
|
||||||
|
return !isEqualWith(n, o, customizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -286,3 +291,24 @@ export const desparsifyArrays = <T>(data: T): T => {
|
|||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const arefloatsApproximatelyEqual = (
|
||||||
|
a: number,
|
||||||
|
b: number,
|
||||||
|
precision?: number,
|
||||||
|
): boolean => {
|
||||||
|
return round(a, precision) === round(b, precision);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a lodash isEqualsWith customizer that can compare floats.
|
||||||
|
*/
|
||||||
|
export const generateFloatApproximatelyEqualsCustomizer = (
|
||||||
|
precision: number,
|
||||||
|
): ((a: unknown, b: unknown) => boolean | undefined) => {
|
||||||
|
return (a: unknown, b: unknown) => {
|
||||||
|
return typeof a === 'number' && typeof b === 'number'
|
||||||
|
? arefloatsApproximatelyEqual(a, b, precision)
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export class View {
|
|||||||
* @param context The context to merge in.
|
* @param context The context to merge in.
|
||||||
* @returns This view.
|
* @returns This view.
|
||||||
*/
|
*/
|
||||||
public mergeInContext(context?: ViewContext): View {
|
public mergeInContext(context?: ViewContext | null): View {
|
||||||
this.context = merge({}, this.context, context);
|
this.context = merge({}, this.context, context);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -306,7 +306,7 @@ export interface FrigateCardViewChangeEventTarget extends EventTarget {
|
|||||||
*/
|
*/
|
||||||
export const dispatchViewContextChangeEvent = (
|
export const dispatchViewContextChangeEvent = (
|
||||||
target: EventTarget,
|
target: EventTarget,
|
||||||
context: ViewContext,
|
context: ViewContext | null,
|
||||||
): void => {
|
): void => {
|
||||||
dispatchFrigateCardEvent(target, 'view:change-context', context);
|
dispatchFrigateCardEvent(target, 'view:change-context', context);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -259,16 +259,9 @@ describe('CameraManager', async () => {
|
|||||||
return new CameraManager(api, { factory: mockFactory });
|
return new CameraManager(api, { factory: mockFactory });
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should reset', async () => {
|
it('should construct', async () => {
|
||||||
const store = new CameraManagerStore();
|
const manager = new CameraManager(createCardAPI());
|
||||||
store.addCamera(
|
expect(manager.getStore()).toBeTruthy();
|
||||||
new Camera(createCameraConfig({ id: 'id' }), mock<CameraManagerEngine>()),
|
|
||||||
);
|
|
||||||
const manager = new CameraManager(createCardAPI(), { store: store });
|
|
||||||
|
|
||||||
expect(manager.getStore().getCameraCount()).toBe(1);
|
|
||||||
await manager.reset();
|
|
||||||
expect(manager.getStore().getCameraCount()).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should initialize cameras from config', () => {
|
describe('should initialize cameras from config', () => {
|
||||||
|
|||||||
@@ -196,7 +196,6 @@ describe('ActionsManager.handleInteraction', () => {
|
|||||||
|
|
||||||
manager.handleInteractionEvent(
|
manager.handleInteractionEvent(
|
||||||
new CustomEvent<Interaction>('event', {
|
new CustomEvent<Interaction>('event', {
|
||||||
|
|
||||||
// Malformed interaction type.
|
// Malformed interaction type.
|
||||||
detail: { action: 'double_finger_snap' } as unknown as Interaction,
|
detail: { action: 'double_finger_snap' } as unknown as Interaction,
|
||||||
}),
|
}),
|
||||||
@@ -800,11 +799,68 @@ describe('ActionsManager.executeAction', () => {
|
|||||||
})!,
|
})!,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(api.getViewManager().setViewWithNewContext).toBeCalledWith(
|
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith(
|
||||||
expect.objectContaining({ live: { ptzVisible: true } }),
|
expect.objectContaining({ live: { ptzVisible: true } }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('should handle change_zoom action', () => {
|
||||||
|
it('default', async () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new ActionsManager(api);
|
||||||
|
|
||||||
|
await manager.executeFrigateAction(
|
||||||
|
createAction({
|
||||||
|
frigate_card_action: 'change_zoom',
|
||||||
|
target_id: 'camera.office',
|
||||||
|
})!,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
zoom: {
|
||||||
|
'camera.office': {
|
||||||
|
zoom: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-default', async () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new ActionsManager(api);
|
||||||
|
|
||||||
|
await manager.executeFrigateAction(
|
||||||
|
createAction({
|
||||||
|
frigate_card_action: 'change_zoom',
|
||||||
|
target_id: 'camera.office',
|
||||||
|
zoom: 2,
|
||||||
|
pan: {
|
||||||
|
x: 3,
|
||||||
|
y: 4,
|
||||||
|
},
|
||||||
|
})!,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
zoom: {
|
||||||
|
'camera.office': {
|
||||||
|
zoom: {
|
||||||
|
zoom: 2,
|
||||||
|
pan: {
|
||||||
|
x: 3,
|
||||||
|
y: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle unknown action', async () => {
|
it('should handle unknown action', async () => {
|
||||||
const manager = new ActionsManager(createCardAPI());
|
const manager = new ActionsManager(createCardAPI());
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,6 @@ describe('CardElementManager', () => {
|
|||||||
element.removeEventListener = removeEventListener;
|
element.removeEventListener = removeEventListener;
|
||||||
|
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
vi.mocked(api.getCameraManager().reset).mockResolvedValue();
|
|
||||||
|
|
||||||
const manager = new CardElementManager(
|
const manager = new CardElementManager(
|
||||||
api,
|
api,
|
||||||
@@ -186,6 +185,6 @@ describe('CardElementManager', () => {
|
|||||||
);
|
);
|
||||||
expect(windowRemoveEventListener).toBeCalledWith('popstate', expect.anything());
|
expect(windowRemoveEventListener).toBeCalledWith('popstate', expect.anything());
|
||||||
|
|
||||||
expect(api.getCameraManager().reset).toBeCalled();
|
expect(api.getInitializationManager().uninitialize).toBeCalledWith('cameras');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -525,6 +525,29 @@ describe('ConditionsManager', () => {
|
|||||||
|
|
||||||
expect(manager.hasHAStateConditions()).toBeTruthy();
|
expect(manager.hasHAStateConditions()).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('with automations', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const userConfig = createConfig({
|
||||||
|
automations: [
|
||||||
|
{
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
condition: 'state' as const,
|
||||||
|
entity: 'binary_sensor.foo',
|
||||||
|
state: 'on',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(userConfig);
|
||||||
|
const manager = new ConditionsManager(api);
|
||||||
|
manager.setConditionsFromConfig();
|
||||||
|
|
||||||
|
expect(manager.hasHAStateConditions()).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should evaluate conditions', () => {
|
describe('should evaluate conditions', () => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
import { loadLanguages } from '../../src/localize/localize';
|
|
||||||
import {
|
import {
|
||||||
InitializationAspect,
|
InitializationAspect,
|
||||||
InitializationManager,
|
InitializationManager,
|
||||||
} from '../../src/card-controller/initialization-manager';
|
} from '../../src/card-controller/initialization-manager';
|
||||||
|
import { loadLanguages } from '../../src/localize/localize';
|
||||||
import { sideLoadHomeAssistantElements } from '../../src/utils/ha';
|
import { sideLoadHomeAssistantElements } from '../../src/utils/ha';
|
||||||
import { Initializer } from '../../src/utils/initializer/initializer';
|
import { Initializer } from '../../src/utils/initializer/initializer';
|
||||||
import { createCardAPI, createConfig, createHASS } from '../test-utils';
|
import { createCardAPI, createConfig, createHASS } from '../test-utils';
|
||||||
@@ -184,6 +184,22 @@ describe('InitializationManager', () => {
|
|||||||
|
|
||||||
expect(await manager.initializeMandatory()).toBeFalsy();
|
expect(await manager.initializeMandatory()).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('with existing view', async () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||||
|
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||||
|
vi.mocked(api.getViewManager().hasView).mockReturnValue(true);
|
||||||
|
|
||||||
|
const initializer = mock<Initializer>();
|
||||||
|
const manager = new InitializationManager(api, initializer);
|
||||||
|
initializer.initializeMultipleIfNecessary
|
||||||
|
.mockResolvedValueOnce(true)
|
||||||
|
.mockResolvedValueOnce(true);
|
||||||
|
|
||||||
|
expect(await manager.initializeMandatory()).toBeTruthy();
|
||||||
|
expect(api.getCardElementManager().update).toBeCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should initialize background', () => {
|
describe('should initialize background', () => {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ describe('ViewManager.setView', () => {
|
|||||||
const context = { live: { fetchThumbnails: false } };
|
const context = { live: { fetchThumbnails: false } };
|
||||||
|
|
||||||
// Setting context with no existing view does nothing.
|
// Setting context with no existing view does nothing.
|
||||||
manager.setViewWithNewContext(context);
|
manager.setViewWithMergedContext(context);
|
||||||
expect(manager.getView()).toBeNull();
|
expect(manager.getView()).toBeNull();
|
||||||
|
|
||||||
const view = createView({
|
const view = createView({
|
||||||
@@ -89,7 +89,7 @@ describe('ViewManager.setView', () => {
|
|||||||
camera: 'camera',
|
camera: 'camera',
|
||||||
});
|
});
|
||||||
manager.setView(view);
|
manager.setView(view);
|
||||||
manager.setViewWithNewContext(context);
|
manager.setViewWithMergedContext(context);
|
||||||
|
|
||||||
expect(manager.getView()?.camera).toBe('camera');
|
expect(manager.getView()?.camera).toBe('camera');
|
||||||
expect(manager.getView()?.view).toBe('live');
|
expect(manager.getView()?.view).toBe('live');
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import {
|
|||||||
MenuButtonController,
|
MenuButtonController,
|
||||||
MenuButtonControllerOptions,
|
MenuButtonControllerOptions,
|
||||||
} from '../../src/components-lib/menu-button-controller';
|
} from '../../src/components-lib/menu-button-controller';
|
||||||
import { FrigateCardConfig, MenuItem, ViewDisplayMode } from '../../src/config/types';
|
import {
|
||||||
|
FrigateCardConfig,
|
||||||
|
FrigateCardView,
|
||||||
|
MenuItem,
|
||||||
|
ViewDisplayMode,
|
||||||
|
} from '../../src/config/types';
|
||||||
import { FrigateCardMediaPlayer } from '../../src/types';
|
import { FrigateCardMediaPlayer } from '../../src/types';
|
||||||
import { createFrigateCardSimpleAction } from '../../src/utils/action';
|
import { createFrigateCardSimpleAction } from '../../src/utils/action';
|
||||||
import { ViewMedia } from '../../src/view/media';
|
import { ViewMedia } from '../../src/view/media';
|
||||||
@@ -29,6 +34,7 @@ import {
|
|||||||
createStateEntity,
|
createStateEntity,
|
||||||
createStore,
|
createStore,
|
||||||
createView,
|
createView,
|
||||||
|
TestViewMedia,
|
||||||
} from '../test-utils';
|
} from '../test-utils';
|
||||||
|
|
||||||
vi.mock('../../src/utils/media-player-controller.js');
|
vi.mock('../../src/utils/media-player-controller.js');
|
||||||
@@ -1487,6 +1493,69 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('should have change zoom button', () => {
|
||||||
|
it.each([
|
||||||
|
['live' as const, true, false],
|
||||||
|
['live' as const, undefined, false],
|
||||||
|
['live' as const, false, true],
|
||||||
|
['media' as const, true, false],
|
||||||
|
['media' as const, undefined, false],
|
||||||
|
['media' as const, false, true],
|
||||||
|
])(
|
||||||
|
'%s view with isDefault %s',
|
||||||
|
(
|
||||||
|
viewName: FrigateCardView,
|
||||||
|
isDefault: boolean | undefined,
|
||||||
|
expectedResult: boolean,
|
||||||
|
) => {
|
||||||
|
const cameraManager = createCameraManager();
|
||||||
|
vi.mocked(cameraManager.getStore).mockReturnValue(
|
||||||
|
createStore([{ cameraID: 'camera-1' }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetID = viewName === 'live' ? 'camera-1' : 'media-1';
|
||||||
|
const view = createView({
|
||||||
|
view: viewName,
|
||||||
|
camera: 'camera-1',
|
||||||
|
queryResults: new MediaQueriesResults({
|
||||||
|
results: [new TestViewMedia({ id: 'media-1' })],
|
||||||
|
selectedIndex: 0,
|
||||||
|
}),
|
||||||
|
context: {
|
||||||
|
zoom: {
|
||||||
|
[targetID]: {
|
||||||
|
isDefault: isDefault,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const buttons = calculateButtons(controller, {
|
||||||
|
cameraManager: cameraManager,
|
||||||
|
view: view,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (expectedResult) {
|
||||||
|
expect(buttons).toContainEqual({
|
||||||
|
enabled: true,
|
||||||
|
icon: 'mdi:magnify-close',
|
||||||
|
priority: 50,
|
||||||
|
tap_action: {
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
frigate_card_action: 'change_zoom',
|
||||||
|
target_id: targetID,
|
||||||
|
},
|
||||||
|
title: 'Zoom to default',
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
expect(buttons).not.toContainEqual(
|
||||||
|
expect.objectContaining({ title: 'Set zoom to default' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe('should handle dynamic buttons', () => {
|
describe('should handle dynamic buttons', () => {
|
||||||
it('successfully', () => {
|
it('successfully', () => {
|
||||||
const button: MenuItem = {
|
const button: MenuItem = {
|
||||||
|
|||||||
@@ -1,321 +0,0 @@
|
|||||||
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { mock } from 'vitest-mock-extended';
|
|
||||||
import { ZoomController } from '../../src/components-lib/zoom-controller';
|
|
||||||
|
|
||||||
vi.mock('@dermotduffy/panzoom');
|
|
||||||
|
|
||||||
// https://github.com/jsdom/jsdom/issues/2527
|
|
||||||
(window as any).PointerEvent = MouseEvent;
|
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
|
||||||
describe('ZoomController', () => {
|
|
||||||
const mediaMediSpy = vi.spyOn(window, 'matchMedia');
|
|
||||||
|
|
||||||
const createMockPanZoom = (): PanzoomObject => {
|
|
||||||
const panzoom = mock<PanzoomObject>();
|
|
||||||
panzoom.getScale.mockReturnValue(1.0);
|
|
||||||
return panzoom;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createAndRegisterZoom = (element: HTMLElement): ZoomController => {
|
|
||||||
const zoom = new ZoomController(element);
|
|
||||||
zoom.activate();
|
|
||||||
return zoom;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createTouch = (target: HTMLElement): Touch => {
|
|
||||||
return {
|
|
||||||
clientX: 0,
|
|
||||||
clientY: 0,
|
|
||||||
force: 0,
|
|
||||||
identifier: 0,
|
|
||||||
pageX: 0,
|
|
||||||
pageY: 0,
|
|
||||||
radiusX: 0,
|
|
||||||
radiusY: 0,
|
|
||||||
rotationAngle: 0,
|
|
||||||
screenX: 0,
|
|
||||||
screenY: 0,
|
|
||||||
target: target,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mediaMediSpy.mockReturnValue(<MediaQueryList>{ matches: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be creatable', () => {
|
|
||||||
const element = document.createElement('div');
|
|
||||||
const zoom = new ZoomController(element);
|
|
||||||
expect(zoom).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should respond with pointer', () => {
|
|
||||||
const element = document.createElement('div');
|
|
||||||
|
|
||||||
const panzoom = createMockPanZoom();
|
|
||||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
|
||||||
|
|
||||||
createAndRegisterZoom(element);
|
|
||||||
|
|
||||||
// Won't zoom without control key.
|
|
||||||
const ev_1 = new WheelEvent('wheel', { bubbles: false, deltaY: -120 });
|
|
||||||
element.dispatchEvent(ev_1);
|
|
||||||
expect(panzoom.zoomWithWheel).not.toBeCalled();
|
|
||||||
|
|
||||||
const ev_2 = new WheelEvent('wheel', {
|
|
||||||
bubbles: false,
|
|
||||||
deltaY: -120,
|
|
||||||
ctrlKey: true,
|
|
||||||
});
|
|
||||||
element.dispatchEvent(ev_2);
|
|
||||||
expect(panzoom.zoomWithWheel).toBeCalledWith(ev_2);
|
|
||||||
|
|
||||||
panzoom.getScale = vi.fn().mockReturnValue(1.2);
|
|
||||||
|
|
||||||
const ev_3 = new PointerEvent('pointerdown');
|
|
||||||
element.dispatchEvent(ev_3);
|
|
||||||
expect(panzoom.handleDown).toBeCalledWith(ev_3);
|
|
||||||
|
|
||||||
const ev_4 = new PointerEvent('pointermove');
|
|
||||||
element.dispatchEvent(ev_4);
|
|
||||||
expect(panzoom.handleMove).toBeCalledWith(ev_4);
|
|
||||||
|
|
||||||
const ev_5 = new PointerEvent('pointerup');
|
|
||||||
element.dispatchEvent(ev_5);
|
|
||||||
expect(panzoom.handleUp).toBeCalledWith(ev_5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not respond to pointer when not zoomed', () => {
|
|
||||||
const element = document.createElement('div');
|
|
||||||
|
|
||||||
const panzoom = createMockPanZoom();
|
|
||||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
|
||||||
|
|
||||||
createAndRegisterZoom(element);
|
|
||||||
|
|
||||||
const ev_1 = new PointerEvent('pointerdown');
|
|
||||||
element.dispatchEvent(ev_1);
|
|
||||||
expect(panzoom.handleDown).not.toBeCalledWith(ev_1);
|
|
||||||
|
|
||||||
const ev_2 = new PointerEvent('pointermove');
|
|
||||||
element.dispatchEvent(ev_2);
|
|
||||||
expect(panzoom.handleDown).not.toBeCalledWith(ev_2);
|
|
||||||
|
|
||||||
const ev_3 = new PointerEvent('pointerup');
|
|
||||||
element.dispatchEvent(ev_3);
|
|
||||||
expect(panzoom.handleDown).not.toBeCalledWith(ev_3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should respond with touch', () => {
|
|
||||||
mediaMediSpy.mockReturnValue(<MediaQueryList>{ matches: false });
|
|
||||||
|
|
||||||
const element = document.createElement('div');
|
|
||||||
|
|
||||||
const panzoom = createMockPanZoom();
|
|
||||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
|
||||||
|
|
||||||
createAndRegisterZoom(element);
|
|
||||||
|
|
||||||
const ev_1 = new TouchEvent('touchstart', {
|
|
||||||
bubbles: false,
|
|
||||||
touches: [createTouch(element), createTouch(element)],
|
|
||||||
});
|
|
||||||
element.dispatchEvent(ev_1);
|
|
||||||
expect(panzoom.handleDown).toBeCalledWith(ev_1);
|
|
||||||
|
|
||||||
panzoom.getScale = vi.fn().mockReturnValue(1.2);
|
|
||||||
|
|
||||||
const ev_3 = new TouchEvent('touchstart');
|
|
||||||
element.dispatchEvent(ev_3);
|
|
||||||
expect(panzoom.handleDown).toBeCalledWith(ev_3);
|
|
||||||
|
|
||||||
const ev_4 = new TouchEvent('touchmove');
|
|
||||||
element.dispatchEvent(ev_4);
|
|
||||||
expect(panzoom.handleMove).toBeCalledWith(ev_4);
|
|
||||||
|
|
||||||
const ev_5 = new TouchEvent('touchend');
|
|
||||||
element.dispatchEvent(ev_5);
|
|
||||||
expect(panzoom.handleUp).toBeCalledWith(ev_5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should ignore click after pointerdown', () => {
|
|
||||||
const outer = document.createElement('div');
|
|
||||||
const inner = document.createElement('div');
|
|
||||||
outer.appendChild(inner);
|
|
||||||
const clickHandler = vi.fn();
|
|
||||||
outer.addEventListener('click', clickHandler);
|
|
||||||
|
|
||||||
const panzoom = createMockPanZoom();
|
|
||||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
|
||||||
|
|
||||||
createAndRegisterZoom(inner);
|
|
||||||
|
|
||||||
// Simulate being zoomed in.
|
|
||||||
panzoom.getScale = vi.fn().mockReturnValue(1.2);
|
|
||||||
|
|
||||||
// A click on its own will be fine.
|
|
||||||
const click_1 = new MouseEvent('click', { bubbles: true });
|
|
||||||
inner.dispatchEvent(click_1);
|
|
||||||
expect(clickHandler).toBeCalledTimes(1);
|
|
||||||
|
|
||||||
// A click after a pointerdown will be ignored.
|
|
||||||
const pointerdown_1 = new PointerEvent('pointerdown');
|
|
||||||
inner.dispatchEvent(pointerdown_1);
|
|
||||||
|
|
||||||
const click_2 = new MouseEvent('click', { bubbles: true });
|
|
||||||
inner.dispatchEvent(click_2);
|
|
||||||
|
|
||||||
// Click will have been ignored.
|
|
||||||
expect(clickHandler).toBeCalledTimes(1);
|
|
||||||
|
|
||||||
// Simulate being zoomed out.
|
|
||||||
panzoom.getScale = vi.fn().mockReturnValue(1.0);
|
|
||||||
const pointerdown_2 = new PointerEvent('pointerdown');
|
|
||||||
inner.dispatchEvent(pointerdown_2);
|
|
||||||
|
|
||||||
const click_3 = new MouseEvent('click', { bubbles: true });
|
|
||||||
inner.dispatchEvent(click_3);
|
|
||||||
|
|
||||||
// Click will have been processed.
|
|
||||||
expect(clickHandler).toBeCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deactivate should remove event handlers', () => {
|
|
||||||
const element = document.createElement('div');
|
|
||||||
|
|
||||||
const panzoom = createMockPanZoom();
|
|
||||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
|
||||||
|
|
||||||
createAndRegisterZoom(element).deactivate();
|
|
||||||
|
|
||||||
const ev_1 = new WheelEvent('wheel', {
|
|
||||||
bubbles: false,
|
|
||||||
deltaY: -120,
|
|
||||||
ctrlKey: true,
|
|
||||||
});
|
|
||||||
element.dispatchEvent(ev_1);
|
|
||||||
expect(panzoom.zoomWithWheel).not.toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fire frigate cards on zoom/unzoom', () => {
|
|
||||||
const element = document.createElement('div');
|
|
||||||
|
|
||||||
const zoomedFunc = vi.fn();
|
|
||||||
const unzoomedFunc = vi.fn();
|
|
||||||
|
|
||||||
element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc);
|
|
||||||
element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc);
|
|
||||||
|
|
||||||
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
|
||||||
|
|
||||||
createAndRegisterZoom(element);
|
|
||||||
|
|
||||||
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
|
||||||
detail: {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
scale: 1.2,
|
|
||||||
isSVG: false,
|
|
||||||
originalEvent: new PointerEvent('pointermove'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
element.dispatchEvent(ev_1);
|
|
||||||
expect(zoomedFunc).toBeCalled();
|
|
||||||
expect(unzoomedFunc).not.toBeCalled();
|
|
||||||
|
|
||||||
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
|
||||||
detail: {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
isSVG: false,
|
|
||||||
originalEvent: new PointerEvent('pointermove'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
element.dispatchEvent(ev_2);
|
|
||||||
expect(unzoomedFunc).toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set touch action on zoom/unzoom', () => {
|
|
||||||
const element = document.createElement('div');
|
|
||||||
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
|
||||||
|
|
||||||
createAndRegisterZoom(element);
|
|
||||||
|
|
||||||
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
|
||||||
detail: {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
scale: 1.2,
|
|
||||||
isSVG: false,
|
|
||||||
originalEvent: new PointerEvent('pointermove'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
element.dispatchEvent(ev_1);
|
|
||||||
expect(element.style.touchAction).toBe('none');
|
|
||||||
|
|
||||||
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
|
||||||
detail: {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
isSVG: false,
|
|
||||||
originalEvent: new PointerEvent('pointermove'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
element.dispatchEvent(ev_2);
|
|
||||||
expect(element.style.touchAction).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not fire frigate cards when state has not changed or spurious events received', () => {
|
|
||||||
const element = document.createElement('div');
|
|
||||||
|
|
||||||
const zoomedFunc = vi.fn();
|
|
||||||
const unzoomedFunc = vi.fn();
|
|
||||||
|
|
||||||
element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc);
|
|
||||||
element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc);
|
|
||||||
|
|
||||||
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
|
||||||
|
|
||||||
createAndRegisterZoom(element);
|
|
||||||
|
|
||||||
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
|
||||||
detail: {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
isSVG: false,
|
|
||||||
originalEvent: new PointerEvent('pointermove'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
element.dispatchEvent(ev_1);
|
|
||||||
|
|
||||||
// Unzoomed event with scale === 1, this._zoomed will already be false.
|
|
||||||
expect(unzoomedFunc).not.toBeCalled();
|
|
||||||
expect(zoomedFunc).not.toBeCalled();
|
|
||||||
|
|
||||||
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
|
||||||
detail: {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
scale: 1.2,
|
|
||||||
isSVG: false,
|
|
||||||
originalEvent: new PointerEvent('pointermove'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
element.dispatchEvent(ev_2);
|
|
||||||
expect(zoomedFunc).toBeCalledTimes(1);
|
|
||||||
expect(unzoomedFunc).not.toBeCalled();
|
|
||||||
|
|
||||||
// Another call when already zoomed will be ignored.
|
|
||||||
element.dispatchEvent(ev_2);
|
|
||||||
expect(zoomedFunc).toBeCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
|
||||||
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mock, mockClear } from 'vitest-mock-extended';
|
||||||
|
import { ZoomController } from '../../../src/components-lib/zoom/zoom-controller';
|
||||||
|
import { ResizeObserverMock, requestAnimationFrameMock } from '../../test-utils';
|
||||||
|
|
||||||
|
vi.mock('@dermotduffy/panzoom');
|
||||||
|
vi.mock('lodash-es/debounce', () => ({
|
||||||
|
default: vi.fn((fn) => fn),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// https://github.com/jsdom/jsdom/issues/2527
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(window as any).PointerEvent = MouseEvent;
|
||||||
|
|
||||||
|
const triggerResizeObserver = (): void => {
|
||||||
|
const resizeObserverTrigger = vi.mocked(global.ResizeObserver).mock.calls[0][0];
|
||||||
|
resizeObserverTrigger([], mock<ResizeObserver>());
|
||||||
|
};
|
||||||
|
|
||||||
|
const setElementToDefaultCardSize = (element: HTMLElement, multiple?: number): void => {
|
||||||
|
element.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||||
|
width: 492 * (multiple ?? 1),
|
||||||
|
height: 276.75 * (multiple ?? 1),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('ZoomController', () => {
|
||||||
|
const mediaSpy = vi.spyOn(window, 'matchMedia');
|
||||||
|
|
||||||
|
const createMockPanZoom = (): PanzoomObject => {
|
||||||
|
const panzoom = mock<PanzoomObject>();
|
||||||
|
panzoom.getScale.mockReturnValue(1.0);
|
||||||
|
panzoom.getPan.mockReturnValue({ x: 0, y: 0 });
|
||||||
|
return panzoom;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAndRegisterZoom = (element: HTMLElement): ZoomController => {
|
||||||
|
const zoom = new ZoomController(element);
|
||||||
|
zoom.activate();
|
||||||
|
return zoom;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTouch = (target: HTMLElement): Touch => {
|
||||||
|
return {
|
||||||
|
clientX: 0,
|
||||||
|
clientY: 0,
|
||||||
|
force: 0,
|
||||||
|
identifier: 0,
|
||||||
|
pageX: 0,
|
||||||
|
pageY: 0,
|
||||||
|
radiusX: 0,
|
||||||
|
radiusY: 0,
|
||||||
|
rotationAngle: 0,
|
||||||
|
screenX: 0,
|
||||||
|
screenY: 0,
|
||||||
|
target: target,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
||||||
|
window.requestAnimationFrame = requestAnimationFrameMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(Panzoom).mockReset();
|
||||||
|
vi.mocked(global.ResizeObserver).mockClear();
|
||||||
|
mediaSpy.mockReturnValue(<MediaQueryList>{ matches: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be creatable', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const zoom = new ZoomController(element);
|
||||||
|
expect(zoom).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should pan and zoom', () => {
|
||||||
|
it('should respond with pointer', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
createAndRegisterZoom(element);
|
||||||
|
|
||||||
|
// Won't zoom without control key.
|
||||||
|
const ev_1 = new WheelEvent('wheel', { bubbles: false, deltaY: -120 });
|
||||||
|
element.dispatchEvent(ev_1);
|
||||||
|
expect(panzoom.zoomWithWheel).not.toBeCalled();
|
||||||
|
|
||||||
|
const ev_2 = new WheelEvent('wheel', {
|
||||||
|
bubbles: false,
|
||||||
|
deltaY: -120,
|
||||||
|
ctrlKey: true,
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_2);
|
||||||
|
expect(panzoom.zoomWithWheel).toBeCalledWith(ev_2);
|
||||||
|
|
||||||
|
panzoom.getScale = vi.fn().mockReturnValue(1.2);
|
||||||
|
|
||||||
|
const ev_3 = new PointerEvent('pointerdown');
|
||||||
|
element.dispatchEvent(ev_3);
|
||||||
|
expect(panzoom.handleDown).toBeCalledWith(ev_3);
|
||||||
|
|
||||||
|
const ev_4 = new PointerEvent('pointermove');
|
||||||
|
element.dispatchEvent(ev_4);
|
||||||
|
expect(panzoom.handleMove).toBeCalledWith(ev_4);
|
||||||
|
|
||||||
|
const ev_5 = new PointerEvent('pointerup');
|
||||||
|
element.dispatchEvent(ev_5);
|
||||||
|
expect(panzoom.handleUp).toBeCalledWith(ev_5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not respond to pointer when not zoomed', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
createAndRegisterZoom(element);
|
||||||
|
|
||||||
|
const ev_1 = new PointerEvent('pointerdown');
|
||||||
|
element.dispatchEvent(ev_1);
|
||||||
|
expect(panzoom.handleDown).not.toBeCalledWith(ev_1);
|
||||||
|
|
||||||
|
const ev_2 = new PointerEvent('pointermove');
|
||||||
|
element.dispatchEvent(ev_2);
|
||||||
|
expect(panzoom.handleDown).not.toBeCalledWith(ev_2);
|
||||||
|
|
||||||
|
const ev_3 = new PointerEvent('pointerup');
|
||||||
|
element.dispatchEvent(ev_3);
|
||||||
|
expect(panzoom.handleDown).not.toBeCalledWith(ev_3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respond with touch', () => {
|
||||||
|
mediaSpy.mockReturnValue(<MediaQueryList>{ matches: false });
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
createAndRegisterZoom(element);
|
||||||
|
|
||||||
|
const ev_1 = new TouchEvent('touchstart', {
|
||||||
|
bubbles: false,
|
||||||
|
touches: [createTouch(element), createTouch(element)],
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_1);
|
||||||
|
expect(panzoom.handleDown).toBeCalledWith(ev_1);
|
||||||
|
|
||||||
|
panzoom.getScale = vi.fn().mockReturnValue(1.2);
|
||||||
|
|
||||||
|
const ev_3 = new TouchEvent('touchstart');
|
||||||
|
element.dispatchEvent(ev_3);
|
||||||
|
expect(panzoom.handleDown).toBeCalledWith(ev_3);
|
||||||
|
|
||||||
|
const ev_4 = new TouchEvent('touchmove');
|
||||||
|
element.dispatchEvent(ev_4);
|
||||||
|
expect(panzoom.handleMove).toBeCalledWith(ev_4);
|
||||||
|
|
||||||
|
const ev_5 = new TouchEvent('touchend');
|
||||||
|
element.dispatchEvent(ev_5);
|
||||||
|
expect(panzoom.handleUp).toBeCalledWith(ev_5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore click after pointerdown', () => {
|
||||||
|
const outer = document.createElement('div');
|
||||||
|
const inner = document.createElement('div');
|
||||||
|
outer.appendChild(inner);
|
||||||
|
const clickHandler = vi.fn();
|
||||||
|
outer.addEventListener('click', clickHandler);
|
||||||
|
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
createAndRegisterZoom(inner);
|
||||||
|
|
||||||
|
// Simulate being zoomed in.
|
||||||
|
panzoom.getScale = vi.fn().mockReturnValue(1.2);
|
||||||
|
|
||||||
|
// A click on its own will be fine.
|
||||||
|
const click_1 = new MouseEvent('click', { bubbles: true });
|
||||||
|
inner.dispatchEvent(click_1);
|
||||||
|
expect(clickHandler).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
// A click after a pointerdown will be ignored.
|
||||||
|
const pointerdown_1 = new PointerEvent('pointerdown');
|
||||||
|
inner.dispatchEvent(pointerdown_1);
|
||||||
|
|
||||||
|
const click_2 = new MouseEvent('click', { bubbles: true });
|
||||||
|
inner.dispatchEvent(click_2);
|
||||||
|
|
||||||
|
// Click will have been ignored.
|
||||||
|
//expect(clickHandler).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
// Simulate being zoomed out.
|
||||||
|
panzoom.getScale = vi.fn().mockReturnValue(1.0);
|
||||||
|
const pointerdown_2 = new PointerEvent('pointerdown');
|
||||||
|
inner.dispatchEvent(pointerdown_2);
|
||||||
|
|
||||||
|
const click_3 = new MouseEvent('click', { bubbles: true });
|
||||||
|
inner.dispatchEvent(click_3);
|
||||||
|
|
||||||
|
// Click will have been processed.
|
||||||
|
expect(clickHandler).toBeCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deactivate should remove event handlers', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
createAndRegisterZoom(element).deactivate();
|
||||||
|
|
||||||
|
const ev_1 = new WheelEvent('wheel', {
|
||||||
|
bubbles: false,
|
||||||
|
deltaY: -120,
|
||||||
|
ctrlKey: true,
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_1);
|
||||||
|
expect(panzoom.zoomWithWheel).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should fire events', () => {
|
||||||
|
it('on zoom/unzoom', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const zoomedFunc = vi.fn();
|
||||||
|
const unzoomedFunc = vi.fn();
|
||||||
|
element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc);
|
||||||
|
element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc);
|
||||||
|
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||||
|
createAndRegisterZoom(element);
|
||||||
|
|
||||||
|
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||||
|
detail: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1.2,
|
||||||
|
isSVG: false,
|
||||||
|
originalEvent: new PointerEvent('pointermove'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_1);
|
||||||
|
expect(zoomedFunc).toBeCalled();
|
||||||
|
expect(unzoomedFunc).not.toBeCalled();
|
||||||
|
|
||||||
|
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||||
|
detail: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
isSVG: false,
|
||||||
|
originalEvent: new PointerEvent('pointermove'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_2);
|
||||||
|
expect(unzoomedFunc).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when state has not changed or spurious events received', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const zoomedFunc = vi.fn();
|
||||||
|
const unzoomedFunc = vi.fn();
|
||||||
|
element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc);
|
||||||
|
element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc);
|
||||||
|
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||||
|
createAndRegisterZoom(element);
|
||||||
|
|
||||||
|
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||||
|
detail: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
isSVG: false,
|
||||||
|
originalEvent: new PointerEvent('pointermove'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_1);
|
||||||
|
|
||||||
|
// Unzoomed event with scale === 1, this._zoomed will already be false.
|
||||||
|
expect(unzoomedFunc).not.toBeCalled();
|
||||||
|
expect(zoomedFunc).not.toBeCalled();
|
||||||
|
|
||||||
|
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||||
|
detail: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1.2,
|
||||||
|
isSVG: false,
|
||||||
|
originalEvent: new PointerEvent('pointermove'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_2);
|
||||||
|
expect(zoomedFunc).toBeCalledTimes(1);
|
||||||
|
expect(unzoomedFunc).not.toBeCalled();
|
||||||
|
|
||||||
|
// Another call when already zoomed will be ignored.
|
||||||
|
element.dispatchEvent(ev_2);
|
||||||
|
expect(zoomedFunc).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('on default/non-default', () => {
|
||||||
|
it('without explicit default', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
setElementToDefaultCardSize(element);
|
||||||
|
|
||||||
|
const defaultFunc = vi.fn();
|
||||||
|
element.addEventListener('frigate-card:zoom:default', defaultFunc);
|
||||||
|
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||||
|
createAndRegisterZoom(element);
|
||||||
|
|
||||||
|
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||||
|
detail: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1.2,
|
||||||
|
isSVG: false,
|
||||||
|
originalEvent: new PointerEvent('pointermove'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_1);
|
||||||
|
expect(defaultFunc).toBeCalledWith(
|
||||||
|
expect.objectContaining({ detail: { isDefault: false } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||||
|
detail: {
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
scale: 1,
|
||||||
|
isSVG: false,
|
||||||
|
originalEvent: new PointerEvent('pointermove'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_2);
|
||||||
|
expect(defaultFunc).toBeCalledWith(
|
||||||
|
expect.objectContaining({ detail: { isDefault: true } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with complete explicit default', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
setElementToDefaultCardSize(element);
|
||||||
|
|
||||||
|
const defaultFunc = vi.fn();
|
||||||
|
element.addEventListener('frigate-card:zoom:default', defaultFunc);
|
||||||
|
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||||
|
const controller = createAndRegisterZoom(element);
|
||||||
|
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
|
||||||
|
|
||||||
|
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||||
|
detail: {
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
scale: 1,
|
||||||
|
isSVG: false,
|
||||||
|
originalEvent: new PointerEvent('pointermove'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_1);
|
||||||
|
expect(defaultFunc).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ detail: { isDefault: false } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||||
|
detail: {
|
||||||
|
x: 115.62,
|
||||||
|
y: 63.6525,
|
||||||
|
scale: 2,
|
||||||
|
isSVG: false,
|
||||||
|
originalEvent: new PointerEvent('pointermove'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_2);
|
||||||
|
expect(defaultFunc).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ detail: { isDefault: true } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with partial explicit default', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
setElementToDefaultCardSize(element);
|
||||||
|
|
||||||
|
const defaultFunc = vi.fn();
|
||||||
|
element.addEventListener('frigate-card:zoom:default', defaultFunc);
|
||||||
|
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||||
|
const controller = createAndRegisterZoom(element);
|
||||||
|
|
||||||
|
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||||
|
detail: {
|
||||||
|
x: 51,
|
||||||
|
y: 51,
|
||||||
|
scale: 2,
|
||||||
|
isSVG: false,
|
||||||
|
originalEvent: new PointerEvent('pointermove'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_1);
|
||||||
|
expect(defaultFunc).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ detail: { isDefault: false } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.setDefaultConfig({});
|
||||||
|
|
||||||
|
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||||
|
detail: {
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
scale: 1,
|
||||||
|
isSVG: false,
|
||||||
|
originalEvent: new PointerEvent('pointermove'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_2);
|
||||||
|
expect(defaultFunc).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ detail: { isDefault: true } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should automatically set correct zoom', () => {
|
||||||
|
it('with start', () => {
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
setElementToDefaultCardSize(element);
|
||||||
|
|
||||||
|
const controller = new ZoomController(element);
|
||||||
|
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
|
||||||
|
|
||||||
|
// Controller was not activated, config setting will not update pan/zoom.
|
||||||
|
expect(panzoom.zoom).not.toBeCalled();
|
||||||
|
expect(panzoom.pan).not.toBeCalled();
|
||||||
|
|
||||||
|
controller.activate();
|
||||||
|
expect(Panzoom).toBeCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
contain: "outside",
|
||||||
|
cursor: undefined,
|
||||||
|
maxScale: 10,
|
||||||
|
minScale: 1,
|
||||||
|
noBind: true,
|
||||||
|
touchAction: "",
|
||||||
|
startScale: 2,
|
||||||
|
startX: 115.62,
|
||||||
|
startY: 63.6525,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with set of default config', () => {
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
setElementToDefaultCardSize(element);
|
||||||
|
|
||||||
|
const controller = createAndRegisterZoom(element);
|
||||||
|
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
|
||||||
|
|
||||||
|
triggerResizeObserver();
|
||||||
|
|
||||||
|
expect(panzoom.zoom).toBeCalledWith(2, { animate: false });
|
||||||
|
expect(panzoom.pan).toBeCalledWith(115.62, 63.6525, { animate: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with set of config when a default is already set', () => {
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
setElementToDefaultCardSize(element);
|
||||||
|
|
||||||
|
const controller = createAndRegisterZoom(element);
|
||||||
|
|
||||||
|
// This call will do nothing since this is what zoom/pan already are.
|
||||||
|
controller.setDefaultConfig({ zoom: 1, pan: { x: 0, y: 0 } });
|
||||||
|
|
||||||
|
expect(panzoom.zoom).not.toBeCalled();
|
||||||
|
expect(panzoom.pan).not.toBeCalled();
|
||||||
|
|
||||||
|
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
|
||||||
|
|
||||||
|
expect(panzoom.zoom).toHaveBeenNthCalledWith(1, 2, { animate: false });
|
||||||
|
expect(panzoom.pan).toHaveBeenNthCalledWith(1, 115.62, 63.6525, {
|
||||||
|
animate: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.setConfig({ zoom: 3, pan: { x: 5, y: 6 } });
|
||||||
|
|
||||||
|
expect(panzoom.zoom).toHaveBeenNthCalledWith(2, 3, { animate: false });
|
||||||
|
expect(panzoom.pan).toHaveBeenNthCalledWith(2, 147.6, 81.18, {
|
||||||
|
animate: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with repeated calls with same values', () => {
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
setElementToDefaultCardSize(element);
|
||||||
|
|
||||||
|
const controller = createAndRegisterZoom(element);
|
||||||
|
|
||||||
|
controller.setConfig({ zoom: 1 });
|
||||||
|
expect(panzoom.zoom).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
controller.setConfig({ pan: { x: 50, y: 50 } });
|
||||||
|
expect(panzoom.zoom).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
controller.setConfig({ zoom: 1, pan: { x: 50, y: 50 } });
|
||||||
|
expect(panzoom.zoom).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
controller.setConfig({});
|
||||||
|
expect(panzoom.zoom).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
controller.setConfig({ zoom: 2 });
|
||||||
|
|
||||||
|
expect(panzoom.zoom).toBeCalledTimes(1);
|
||||||
|
expect(panzoom.pan).toBeCalledTimes(1);
|
||||||
|
expect(panzoom.zoom).toHaveBeenNthCalledWith(1, 2, { animate: false });
|
||||||
|
expect(panzoom.pan).toHaveBeenNthCalledWith(1, 0, 0, {
|
||||||
|
animate: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(panzoom.getScale).mockReturnValue(2);
|
||||||
|
vi.mocked(panzoom.getPan).mockReturnValue({ x: 0, y: 0 });
|
||||||
|
controller.setConfig({ zoom: 2 });
|
||||||
|
|
||||||
|
expect(panzoom.zoom).toBeCalledTimes(1);
|
||||||
|
expect(panzoom.pan).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when config is set to empty', () => {
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
setElementToDefaultCardSize(element);
|
||||||
|
|
||||||
|
const controller = createAndRegisterZoom(element);
|
||||||
|
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
|
||||||
|
mockClear(panzoom);
|
||||||
|
|
||||||
|
controller.setConfig({});
|
||||||
|
|
||||||
|
// Should fall back to default.
|
||||||
|
expect(panzoom.zoom).toBeCalledWith(2, { animate: false });
|
||||||
|
expect(panzoom.pan).toBeCalledWith(115.62, 63.6525, {
|
||||||
|
animate: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when resized', () => {
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
setElementToDefaultCardSize(element);
|
||||||
|
|
||||||
|
const controller = createAndRegisterZoom(element);
|
||||||
|
controller.setConfig({ zoom: 2, pan: { x: 3, y: 4 } });
|
||||||
|
|
||||||
|
expect(panzoom.zoom).toHaveBeenNthCalledWith(1, 2, { animate: false });
|
||||||
|
expect(panzoom.pan).toHaveBeenNthCalledWith(1, 115.62, 63.6525, {
|
||||||
|
animate: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(panzoom.getScale).mockReturnValue(2);
|
||||||
|
vi.mocked(panzoom.getPan).mockReturnValue({ x: 3, y: 4 });
|
||||||
|
|
||||||
|
setElementToDefaultCardSize(element);
|
||||||
|
triggerResizeObserver();
|
||||||
|
|
||||||
|
expect(panzoom.zoom).toHaveBeenNthCalledWith(2, 2, { animate: false });
|
||||||
|
expect(panzoom.pan).toHaveBeenNthCalledWith(2, 57.81, 31.82625, {
|
||||||
|
animate: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when not yet activated', () => {
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
setElementToDefaultCardSize(element);
|
||||||
|
|
||||||
|
new ZoomController(element);
|
||||||
|
|
||||||
|
triggerResizeObserver();
|
||||||
|
|
||||||
|
expect(panzoom.zoom).not.toBeCalled();
|
||||||
|
expect(panzoom.pan).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when element has no size', () => {
|
||||||
|
const panzoom = createMockPanZoom();
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
createAndRegisterZoom(element);
|
||||||
|
|
||||||
|
triggerResizeObserver();
|
||||||
|
|
||||||
|
expect(panzoom.zoom).not.toBeCalled();
|
||||||
|
expect(panzoom.pan).not.toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set touch action on zoom/unzoom', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||||
|
|
||||||
|
createAndRegisterZoom(element);
|
||||||
|
|
||||||
|
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||||
|
detail: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1.2,
|
||||||
|
isSVG: false,
|
||||||
|
originalEvent: new PointerEvent('pointermove'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_1);
|
||||||
|
expect(element.style.touchAction).toBe('none');
|
||||||
|
|
||||||
|
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||||
|
detail: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
isSVG: false,
|
||||||
|
originalEvent: new PointerEvent('pointermove'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.dispatchEvent(ev_2);
|
||||||
|
expect(element.style.touchAction).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
generateViewContextForZoomChange,
|
||||||
|
handleZoomDefaultEvent,
|
||||||
|
} from '../../../src/components-lib/zoom/zoom-view-context';
|
||||||
|
|
||||||
|
describe('generateViewContextForZoomChangeRequest', () => {
|
||||||
|
it('with config', () => {
|
||||||
|
expect(
|
||||||
|
generateViewContextForZoomChange('target', {
|
||||||
|
zoom: {
|
||||||
|
pan: { x: 1, y: 2 },
|
||||||
|
zoom: 3,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
zoom: {
|
||||||
|
target: {
|
||||||
|
zoom: {
|
||||||
|
pan: { x: 1, y: 2 },
|
||||||
|
zoom: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without config', () => {
|
||||||
|
expect(generateViewContextForZoomChange('target')).toEqual({
|
||||||
|
zoom: {
|
||||||
|
target: {
|
||||||
|
zoom: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateViewContextForZoomDefault', () => {
|
||||||
|
it('default', () => {
|
||||||
|
expect(generateViewContextForZoomChange('target', { isDefault: true })).toEqual({
|
||||||
|
zoom: {
|
||||||
|
target: {
|
||||||
|
isDefault: true,
|
||||||
|
zoom: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('not default', () => {
|
||||||
|
expect(generateViewContextForZoomChange('target', { isDefault: false })).toEqual({
|
||||||
|
zoom: {
|
||||||
|
target: {
|
||||||
|
isDefault: false,
|
||||||
|
zoom: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
it('handleZoomDefaultEvent', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const callback = vi.fn();
|
||||||
|
element.addEventListener('frigate-card:view:change-context', callback);
|
||||||
|
handleZoomDefaultEvent(
|
||||||
|
element,
|
||||||
|
new CustomEvent('frigate-card:zoom:default', { detail: { isDefault: true } }),
|
||||||
|
'target',
|
||||||
|
);
|
||||||
|
expect(callback).toBeCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
detail: {
|
||||||
|
zoom: {
|
||||||
|
target: {
|
||||||
|
zoom: null,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -175,6 +175,10 @@ describe('config defaults', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
},
|
},
|
||||||
|
default_zoom: {
|
||||||
|
enabled: true,
|
||||||
|
priority: 50,
|
||||||
|
},
|
||||||
display_mode: {
|
display_mode: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { actionSchema } from '../../src/config/types';
|
|||||||
import {
|
import {
|
||||||
convertActionToFrigateCardCustomAction,
|
convertActionToFrigateCardCustomAction,
|
||||||
createFrigateCardCameraAction,
|
createFrigateCardCameraAction,
|
||||||
|
createFrigateCardChangeZoomAction,
|
||||||
createFrigateCardDisplayModeAction,
|
createFrigateCardDisplayModeAction,
|
||||||
createFrigateCardMediaPlayerAction,
|
createFrigateCardMediaPlayerAction,
|
||||||
createFrigateCardShowPTZAction,
|
createFrigateCardShowPTZAction,
|
||||||
@@ -114,6 +115,38 @@ describe('createFrigateCardShowPTZAction', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createFrigateCardChangeZoomAction', () => {
|
||||||
|
it('should create change zoom default action', () => {
|
||||||
|
expect(
|
||||||
|
createFrigateCardChangeZoomAction('target_id', {
|
||||||
|
cardID: 'card_id',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
frigate_card_action: 'change_zoom',
|
||||||
|
card_id: 'card_id',
|
||||||
|
target_id: 'target_id',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create change zoom specific action', () => {
|
||||||
|
expect(
|
||||||
|
createFrigateCardChangeZoomAction('target_id', {
|
||||||
|
cardID: 'card_id',
|
||||||
|
pan: { x: 1, y: 2 },
|
||||||
|
zoom: 3,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
frigate_card_action: 'change_zoom',
|
||||||
|
card_id: 'card_id',
|
||||||
|
target_id: 'target_id',
|
||||||
|
pan: { x: 1, y: 2 },
|
||||||
|
zoom: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getActionConfigGivenAction', () => {
|
describe('getActionConfigGivenAction', () => {
|
||||||
const action = actionSchema.parse({
|
const action = actionSchema.parse({
|
||||||
action: 'fire-dom-event',
|
action: 'fire-dom-event',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterAll, describe, expect, it, vi } from 'vitest';
|
|||||||
import { FrigateCardError } from '../../src/types';
|
import { FrigateCardError } from '../../src/types';
|
||||||
import {
|
import {
|
||||||
allPromises,
|
allPromises,
|
||||||
|
arefloatsApproximatelyEqual,
|
||||||
arrayify,
|
arrayify,
|
||||||
arrayMove,
|
arrayMove,
|
||||||
aspectRatioToStyle,
|
aspectRatioToStyle,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
errorToConsole,
|
errorToConsole,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDateAndTime,
|
formatDateAndTime,
|
||||||
|
generateFloatApproximatelyEqualsCustomizer,
|
||||||
getChildrenFromElement,
|
getChildrenFromElement,
|
||||||
getDurationString,
|
getDurationString,
|
||||||
isHoverableDevice,
|
isHoverableDevice,
|
||||||
@@ -406,3 +408,54 @@ describe('desparsifyArrays', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('arefloatsApproximatelyEqual', () => {
|
||||||
|
describe('without precision', () => {
|
||||||
|
it('equals', () => {
|
||||||
|
expect(arefloatsApproximatelyEqual(1.1, 1.2)).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('not equals', () => {
|
||||||
|
expect(arefloatsApproximatelyEqual(0.5, 1.5)).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('with precision', () => {
|
||||||
|
it('equals', () => {
|
||||||
|
expect(arefloatsApproximatelyEqual(1.00001, 1.00002, 4)).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('not equals', () => {
|
||||||
|
expect(arefloatsApproximatelyEqual(0.5, 1.5, 4)).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateFloatApproximatelyEqualsCustomizer', () => {
|
||||||
|
describe('with incorrect types', () => {
|
||||||
|
it('undefined a', () => {
|
||||||
|
expect(
|
||||||
|
generateFloatApproximatelyEqualsCustomizer(4)(undefined, 1.2),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('undefined b', () => {
|
||||||
|
expect(
|
||||||
|
generateFloatApproximatelyEqualsCustomizer(4)(1.2, undefined),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('strings', () => {
|
||||||
|
expect(
|
||||||
|
generateFloatApproximatelyEqualsCustomizer(4)('foo', 'bar'),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('with correct types', () => {
|
||||||
|
it('equals', () => {
|
||||||
|
expect(
|
||||||
|
generateFloatApproximatelyEqualsCustomizer(4)(1.00001, 1.00002),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('not equals', () => {
|
||||||
|
expect(
|
||||||
|
generateFloatApproximatelyEqualsCustomizer(5)(1.00001, 1.00002),
|
||||||
|
).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [
|
|||||||
'components-lib/menu-button-controller.ts',
|
'components-lib/menu-button-controller.ts',
|
||||||
'components-lib/menu-controller.ts',
|
'components-lib/menu-controller.ts',
|
||||||
'components-lib/ptz-controller.ts',
|
'components-lib/ptz-controller.ts',
|
||||||
'components-lib/zoom-controller.ts',
|
'components-lib/zoom/*.ts',
|
||||||
'config/**/*.ts',
|
'config/**/*.ts',
|
||||||
'const.ts',
|
'const.ts',
|
||||||
'types.ts',
|
'types.ts',
|
||||||
|
|||||||