Add support for pre-defined zoom/pan settings.

This commit is contained in:
Dermot Duffy
2024-04-27 14:55:38 -07:00
parent 246dd7ff54
commit 7917e5c537
59 changed files with 1981 additions and 638 deletions
+2
View File
@@ -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
## Installation
+1 -1
View File
@@ -1,6 +1,6 @@
![logo](images/frigate-logo.svg)
# 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.
+21 -3
View File
@@ -118,15 +118,24 @@ frigate_card_action: camera_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
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) to adjust the pan/zoom of, or a media ID (e.g. `frigate` event ID) to zoom in on specific media items. |
| `zoom` | How much to zoom-in. See the [camera zoom parameter](cameras/README.md?id=layout-configuration). |
| `pan` | How much to pan-x/y. See the [camera pan parameter](cameras/README.md?id=layout-configuration). |
### `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`
Change to the specified view.
@@ -136,6 +145,15 @@ action: custom:frigate-card-action
frigate_card_action: [view]
```
### `default`
Change to the default view.
```yaml
action: custom:frigate-card-action
frigate_card_action: default
```
### `download`
Download the displayed media.
+16 -2
View File
@@ -144,10 +144,12 @@ cameras:
| 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. |
| `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. |
| `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): ![](../../images/browsers/chrome_16x16.png "Google Chrome :no-zoom") ![](../../images/browsers/chromium_16x16.png "Chromium :no-zoom") ![](../../images/browsers/edge_16x16.png "Microsoft Edge :no-zoom") |
| `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).
@@ -169,6 +171,11 @@ See [media layout examples](../../examples.md?id=media-layout).
![](../../images/media_layout/view-box.png "Media Layout Position: Taller than wider :size=400")
#### `pan` and `zoom`: Predefined panning and zooming
![](../../images/media_layout/pan-zoom.png "Panning and zooming :size=400")
## `triggers`
The `triggers` block configures what triggers a camera. Triggering can be used
@@ -300,6 +307,13 @@ cameras:
movies:
directory_pattern: '%Y-%m-%d'
file_pattern: '%H-%M-%S'
- camera_entity: camera.zoomed
dimensions:
layout:
zoom: 2.0
pan:
x: 50
y: 50
cameras_global:
live_provider: ha
```
+5
View File
@@ -164,6 +164,11 @@ conditions:
| `condition` | Must be `view`. |
| `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
[](common/expanded-warning.md ':include')
+8 -1
View File
@@ -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).|
| `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. |
| `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. |
| `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. |
@@ -53,7 +54,7 @@ menu:
| 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`. |
| `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`. |
| `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
alignment: matching
icon: mdi:video-switch
default_zoom:
priority: 50
enabled: true
alignment: matching
icon: mdi:magnify-close
substreams:
priority: 50
enabled: true
alignment: matching
icon: mdi:video-input-component
live:
priority: 50
+60
View File
@@ -997,3 +997,63 @@ cameras:
webrtc_card:
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
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 62 KiB

+4 -2
View File
@@ -1,16 +1,18 @@
#!/bin/bash
WIDTH=800
WIDTH=1200
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_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_PAN_ZOOM="https://docs.google.com/drawings/d/e/2PACX-1vTcbuMiqKo7-w0my3jaht1xdEFUhLSur1nSxhkxbuX0eagwuisaNCBfKvDjFY4hzNVGRZoBy7YehaMn/pub?w=$WIDTH"
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/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/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"
+5 -5
View File
@@ -128,10 +128,6 @@ export class CameraManager {
this._store = options?.store ?? new CameraManagerStore();
}
public async reset(): Promise<void> {
await this._store.reset();
}
public async initializeCamerasFromConfig(): Promise<boolean> {
const config = this._api.getConfigManager().getConfig();
const hass = this._api.getHASSManager().getHASS();
@@ -149,7 +145,7 @@ export class CameraManager {
);
const resetAndInitialize = async () => {
await this.reset();
await this._reset();
await this._initializeCameras(cameras);
};
@@ -165,6 +161,10 @@ export class CameraManager {
return true;
}
protected async _reset(): Promise<void> {
await this._store.reset();
}
protected async _getEnginesForCameras(
camerasConfig: CamerasConfig,
): Promise<Map<CameraConfig, CameraManagerEngine>> {
+14 -3
View File
@@ -12,6 +12,7 @@ import {
getActionConfigGivenAction,
} from '../utils/action.js';
import { getStreamCameraID } from '../utils/substream.js';
import { generateViewContextForZoomChange } from '../components-lib/zoom/zoom-view-context.js';
import { CardActionsManagerAPI } from './types.js';
const interactionSchema = z.object({
@@ -263,9 +264,19 @@ export class ActionsManager {
}
break;
case 'show_ptz':
this._api
.getViewManager()
.setViewWithNewContext({ live: { ptzVisible: frigateCardAction.show_ptz } });
this._api.getViewManager().setViewWithMergedContext({
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;
default:
console.warn(`Frigate card received unknown card action: ${action}`);
+5 -7
View File
@@ -99,6 +99,9 @@ export class CardElementManager {
// Manually call the location change handler as the card will be
// disconnected/reconnected when dashboard 'tab' changes happen within HA.
this._api.getQueryStringManager().executeAll();
// Make sure reconnections call the initialization code.
this._element.requestUpdate();
}
public elementDisconnected(): void {
@@ -108,15 +111,10 @@ export class CardElementManager {
this._api.getMediaLoadedInfoManager().clear();
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
// correctly for triggers.
this._api
.getCameraManager()
.reset()
.then(() =>
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS),
);
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS),
this._element.removeEventListener(
'mousemove',
+18 -12
View File
@@ -86,19 +86,25 @@ export class InitializationManager {
}
if (!this._api.getMessageManager().hasMessage()) {
// 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
// to be triggered by the firstUpdated() call that runs query string
// actions. To do otherwise may cause a race condition between the default
// view and the querystring view, see:
// https://github.com/dermotduffy/frigate-hass-card/issues/1200
const hasViewRelatedActions = this._api
.getQueryStringManager()
.hasViewRelatedActions();
if (hasViewRelatedActions) {
this._api.getQueryStringManager().executeViewRelated();
if (!this._api.getViewManager().hasView()) {
// 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
// to be triggered by the firstUpdated() call that runs query string
// actions. To do otherwise may cause a race condition between the default
// view and the querystring view, see:
// https://github.com/dermotduffy/frigate-hass-card/issues/1200
const hasViewRelatedActions = this._api
.getQueryStringManager()
.hasViewRelatedActions();
if (hasViewRelatedActions) {
this._api.getQueryStringManager().executeViewRelated();
} else {
this._api.getViewManager().setViewDefault({ failSafe: true });
}
} else {
this._api.getViewManager().setViewDefault({ failSafe: true });
// 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();
}
}
+1 -1
View File
@@ -161,7 +161,7 @@ export class ViewManager {
}
}
public setViewWithNewContext(context: ViewContext): void {
public setViewWithMergedContext(context: ViewContext | null): void {
if (this._view) {
return this._setView(this._view?.clone().mergeInContext(context));
}
+2 -2
View File
@@ -269,8 +269,8 @@ class FrigateCard extends LitElement {
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
@frigate-card:view:change=${(ev: CustomEvent<View>) =>
this._controller.getViewManager().setView(ev.detail)}
@frigate-card:view:change-context=${(ev: CustomEvent<ViewContext>) =>
this._controller.getViewManager().setViewWithNewContext(ev.detail)}
@frigate-card:view:change-context=${(ev: CustomEvent<ViewContext | null>) =>
this._controller.getViewManager().setViewWithMergedContext(ev.detail)}
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
@frigate-card:media:unloaded=${() =>
+27 -2
View File
@@ -5,9 +5,9 @@ import { MediaPlayerManager } from '../card-controller/media-player-manager';
import { MicrophoneManager } from '../card-controller/microphone-manager';
import { ViewManager } from '../card-controller/view-manager';
import {
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
FrigateCardConfig,
FrigateCardCustomAction,
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
MenuItem,
} from '../config/types';
import { FRIGATE_BUTTON_MENU_ICON } from '../const';
@@ -15,6 +15,7 @@ import { localize } from '../localize/localize.js';
import { MediaLoadedInfo } from '../types';
import {
createFrigateCardCameraAction,
createFrigateCardChangeZoomAction,
createFrigateCardDisplayModeAction,
createFrigateCardMediaPlayerAction,
createFrigateCardShowPTZAction,
@@ -95,6 +96,7 @@ export class MenuButtonController {
this._getScreenshotButton(config, options?.currentMediaLoadedInfo),
this._getDisplayModeButton(config, cameraManager, view),
this._getPTZButton(config, cameraManager, view),
this._getDefaultZoomButton(config, view),
...this._dynamicMenuButtons.map((button) => ({
style: this._getStyleFromActions(config, view, button, options),
@@ -582,7 +584,9 @@ export class MenuButtonController {
}
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(
@@ -614,6 +618,27 @@ export class MenuButtonController {
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.
* @returns A StyleInfo.
-160
View File
@@ -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 });
}
}
+11
View File
@@ -0,0 +1,11 @@
export interface ZoomConfig {
pan?: {
x?: number;
y?: number;
};
zoom?: number;
}
export interface ZoomDefault {
isDefault: boolean;
}
+419
View File
@@ -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,
}),
);
};
+21 -1
View File
@@ -9,10 +9,13 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.js';
import { live } from 'lit/directives/live.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import isEqual from 'lodash-es/isEqual';
import { CachedValueController } from '../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 defaultImage from '../images/frigate-bird-in-sky.jpg';
import { localize } from '../localize/localize.js';
@@ -277,8 +280,25 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
}
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
const targetID = this.cameraConfig?.id;
const zoomConfig = targetID ? this.view?.context?.zoom?.[targetID]?.zoom : undefined;
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;
}
+2 -12
View File
@@ -3,8 +3,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { CameraConfig } from '../../config/types';
import { localize } from '../../localize/localize';
import liveImageStyle from '../../scss/live-image.scss';
import basicBlockStyle from '../../scss/basic-block.scss';
import { FrigateCardMediaPlayer } from '../../types.js';
import { getStateObjOrDispatchError } from '../../utils/get-state-obj';
import '../image.js';
@@ -17,9 +16,6 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@property({ attribute: true })
public watermark?: string;
protected _refImage: Ref<Element & FrigateCardMediaPlayer> = createRef();
public async play(): Promise<void> {
@@ -80,17 +76,11 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
.cameraConfig=${this.cameraConfig}
>
</frigate-card-image>
${this.watermark
? html`<ha-icon
title=${localize('error.awaiting_live')}
icon="${this.watermark}"
></ha-icon>`
: ''}
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(liveImageStyle);
return unsafeCSS(basicBlockStyle);
}
}
+25 -8
View File
@@ -21,6 +21,8 @@ import {
import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js';
import { LiveController } from '../../components-lib/live/live-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 {
CameraConfig,
CardWideConfig,
@@ -501,6 +503,9 @@ export class FrigateCardLiveCarousel extends LitElement {
.liveConfig=${liveConfig}
.hass=${this.hass}
.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>
</div>
@@ -688,6 +693,9 @@ export class FrigateCardLiveProvider
@property({ attribute: false })
public microphoneStream?: MediaStream;
@property({ attribute: false })
public zoomConfig?: ZoomConfig | null;
@state()
protected _isVideoMediaLoaded = false;
@@ -793,9 +801,6 @@ export class FrigateCardLiveProvider
this._isVideoMediaLoaded = false;
}
/**
* Record that video media is being shown.
*/
protected _videoMediaShowHandler(): void {
this._isVideoMediaLoaded = true;
}
@@ -852,6 +857,15 @@ export class FrigateCardLiveProvider
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
return this.liveConfig?.zoomable
? 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:unzoomed=${() => this.setControls()}
>
@@ -899,15 +913,12 @@ export class FrigateCardLiveProvider
}
}
return this._useZoomIfRequired(html`
return html`${this._useZoomIfRequired(html`
${showImageDuringLoading || provider === 'image'
? html` <frigate-card-live-image
${ref(this._refProvider)}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
watermark=${ifDefined(
showImageDuringLoading ? 'mdi:progress-helper' : undefined,
)}
@frigate-card:media:loaded=${(ev: Event) => {
if (provider === 'image') {
// Only count the media has loaded if the required provider is
@@ -968,7 +979,13 @@ export class FrigateCardLiveProvider
>
</frigate-card-live-jsmpeg>`
: html``}
`);
`)}
${showImageDuringLoading && !this._isVideoMediaLoaded
? html`<ha-icon
title=${localize('error.awaiting_live')}
icon="mdi:progress-helper"
></ha-icon>`
: ''} `;
}
static get styles(): CSSResultGroup {
+21 -1
View File
@@ -12,6 +12,8 @@ import { ifDefined } from 'lit/directives/if-defined.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../camera-manager/manager.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 {
dispatchMessageEvent,
renderMessage,
@@ -484,7 +486,7 @@ export class FrigateCardViewerCarousel extends LitElement {
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>
${guard(this._media, () => this._getSlides())}
${guard([this._media, this.view], () => this._getSlides())}
<frigate-card-next-previous-control
slot="next"
.hass=${this.hass}
@@ -865,10 +867,28 @@ export class FrigateCardViewerProvider
}
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
? 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:unzoomed=${() => this.setControls()}
@frigate-card:zoom:default=${(ev: CustomEvent<ZoomDefault>) =>
handleZoomDefaultEvent(this, ev, mediaID)}
>
${template}
</frigate-card-zoomer>`
+33 -5
View File
@@ -6,13 +6,20 @@ import {
PropertyValues,
TemplateResult,
} from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { ZoomController } from '../components-lib/zoom-controller.js';
import { customElement, property, state } from 'lit/decorators.js';
import { ZoomConfig } from '../components-lib/zoom/types.js';
import { ZoomController } from '../components-lib/zoom/zoom-controller.js';
import { setOrRemoveAttribute } from '../utils/basic.js';
@customElement('frigate-card-zoomer')
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()
protected _zoomed = false;
@@ -24,11 +31,13 @@ export class FrigateCardZoomer extends LitElement {
super.connectedCallback();
this.addEventListener('frigate-card:zoom:zoomed', this._zoomHandler);
this.addEventListener('frigate-card:zoom:unzoomed', this._unzoomHandler);
this._zoom.activate();
// Call for an update to activate.
this.requestUpdate();
}
disconnectedCallback(): void {
this._zoom.deactivate();
this._zoom?.deactivate();
this.removeEventListener('frigate-card:zoom:zoomed', this._zoomHandler);
this.removeEventListener('frigate-card:zoom:unzoomed', this._unzoomHandler);
}
@@ -37,7 +46,26 @@ export class FrigateCardZoomer extends LitElement {
if (changedProps.has('_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 {
return html` <slot></slot> `;
}
+57 -33
View File
@@ -90,6 +90,16 @@ const CAMERA_TRIGGER_EVENT_TYPES = [
] as const;
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
// *************************************************************************
@@ -289,15 +299,23 @@ const frigateCardShowPTZActionSchema = frigateCardCustomActionsBaseSchema.extend
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([
frigateCardViewActionSchema,
frigateCardGeneralActionSchema,
frigateCardCameraSelectActionSchema,
frigateCardChangeZoomActionSchema,
frigateCardGeneralActionSchema,
frigateCardLiveDependencySelectActionSchema,
frigateCardMediaPlayerActionSchema,
frigateCardViewDisplayModeActionSchema,
frigateCardPTZActionSchema,
frigateCardShowPTZActionSchema,
frigateCardViewActionSchema,
frigateCardViewDisplayModeActionSchema,
]);
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),
})
.optional(),
pan: panSchema.optional(),
zoom: zoomSchema.optional(),
});
export type MediaLayoutConfig = z.infer<typeof mediaLayoutConfigSchema>;
@@ -1345,22 +1365,20 @@ const hiddenButtonDefault = {
};
const menuConfigDefault = {
style: 'hidden' as const,
position: 'top' as const,
alignment: 'left' as const,
button_size: 40,
buttons: {
frigate: visibleButtonDefault,
cameras: visibleButtonDefault,
substreams: visibleButtonDefault,
live: visibleButtonDefault,
clips: visibleButtonDefault,
snapshots: visibleButtonDefault,
image: hiddenButtonDefault,
timeline: visibleButtonDefault,
download: visibleButtonDefault,
camera_ui: visibleButtonDefault,
fullscreen: visibleButtonDefault,
cameras: visibleButtonDefault,
clips: visibleButtonDefault,
default_zoom: visibleButtonDefault,
display_mode: visibleButtonDefault,
download: visibleButtonDefault,
expand: hiddenButtonDefault,
frigate: visibleButtonDefault,
fullscreen: visibleButtonDefault,
image: hiddenButtonDefault,
live: visibleButtonDefault,
media_player: visibleButtonDefault,
microphone: {
...hiddenButtonDefault,
@@ -1368,12 +1386,15 @@ const menuConfigDefault = {
},
mute: hiddenButtonDefault,
play: hiddenButtonDefault,
ptz: hiddenButtonDefault,
recordings: hiddenButtonDefault,
screenshot: hiddenButtonDefault,
display_mode: visibleButtonDefault,
ptz: hiddenButtonDefault,
snapshots: visibleButtonDefault,
substreams: visibleButtonDefault,
timeline: visibleButtonDefault,
},
button_size: 40,
position: 'top' as const,
style: 'hidden' as const,
};
const visibleButtonSchema = menuBaseSchema.extend({
@@ -1393,18 +1414,21 @@ export const menuConfigSchema = z
alignment: z.enum(FRIGATE_MENU_ALIGNMENTS).default(menuConfigDefault.alignment),
buttons: z
.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),
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),
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(
menuConfigDefault.buttons.media_player,
),
@@ -1415,14 +1439,14 @@ export const menuConfigSchema = z
.default(menuConfigDefault.buttons.microphone.type),
})
.default(menuConfigDefault.buttons.microphone),
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
display_mode: visibleButtonSchema.default(
menuConfigDefault.buttons.display_mode,
),
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),
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
+6
View File
@@ -57,6 +57,10 @@ export const CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS =
`${CONF_CAMERAS}.#.dependencies.all_cameras` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT =
`${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 =
`${CONF_CAMERAS}.#.dimensions.layout.position.x` as const;
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;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT =
`${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 =
`${CONF_CAMERAS}.#.triggers.motion` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
+52 -12
View File
@@ -44,12 +44,15 @@ import {
CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS,
CONF_CAMERAS_ARRAY_DIMENSIONS_ASPECT_RATIO,
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_Y,
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_BOTTOM,
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_LEFT,
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT,
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_CLIENT_ID,
CONF_CAMERAS_ARRAY_FRIGATE_LABELS,
@@ -817,9 +820,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
}
}
/**
* Called before each update.
*/
protected willUpdate(): void {
if (!this._initialized) {
sideLoadHomeAssistantElements().then((success) => {
@@ -981,6 +981,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
max?: number;
label?: string;
default?: number;
step?: number;
},
): TemplateResult | void {
if (!this._config) {
@@ -992,7 +993,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
return html`
<ha-selector
.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)}
.value=${value ?? params?.default}
.required=${false}
@@ -1230,10 +1238,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
configPathFit: string,
configPathPositionX: string,
configPathPositionY: string,
configPathPositionTop: string,
configPathPositionBottom: string,
configPathPositionLeft: string,
configPathPositionRight: string,
configPathViewBoxTop: string,
configPathViewBoxBottom: string,
configPathViewBoxLeft: string,
configPathViewBoxRight: string,
configPathZoom: string,
configPathPanX: string,
configPathPanY: string,
): TemplateResult | void {
return this._putInSubmenu(
domain,
@@ -1241,6 +1252,22 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
labelPath,
{ name: 'mdi:page-layout-body' },
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, {
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',
{ name: 'mdi:crop' },
html`
${this._renderNumberInput(configPathPositionTop, {
${this._renderNumberInput(configPathViewBoxTop, {
min: 0,
max: 100,
label: localize('config.cameras.dimensions.layout.view_box.top'),
})}
${this._renderNumberInput(configPathPositionBottom, {
${this._renderNumberInput(configPathViewBoxBottom, {
min: 0,
max: 100,
label: localize('config.cameras.dimensions.layout.view_box.bottom'),
})}
${this._renderNumberInput(configPathPositionLeft, {
${this._renderNumberInput(configPathViewBoxLeft, {
min: 0,
max: 100,
label: localize('config.cameras.dimensions.layout.view_box.left'),
})}
${this._renderNumberInput(configPathPositionRight, {
${this._renderNumberInput(configPathViewBoxRight, {
min: 0,
max: 100,
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,
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('display_mode')}
${this._renderMenuButton('ptz')}
${this._renderMenuButton('default_zoom')}
</div>
`
: ''}
+7 -1
View File
@@ -55,6 +55,10 @@
"cover": "El suport s'expandeix proporcionalment per cobrir la targeta",
"fill": "El suport s'estira per omplir la targeta"
},
"pan": {
"x": "",
"y": ""
},
"position": {
"editor_label": "",
"x": "Percentatge d'emplaçament horitzontal",
@@ -66,7 +70,8 @@
"left": "",
"right": "",
"top": ""
}
},
"zoom": ""
}
},
"engines": {
@@ -357,6 +362,7 @@
"camera_ui": "Interfície d'usuari de la càmera",
"cameras": "Càmeres",
"clips": "Clips",
"default_zoom": "",
"display_mode": "Mode de visualització",
"download": "Descarregar",
"enabled": "Botó habilitat",
+7 -1
View File
@@ -55,6 +55,10 @@
"cover": "Media expands proportionally to cover the card",
"fill": "Media is stretched to fill the card"
},
"pan": {
"x": "Pan horizontal percentage",
"y": "Pan vertical percentage"
},
"position": {
"editor_label": "Layout Cover Position",
"x": "Horizontal placement percentage",
@@ -66,7 +70,8 @@
"left": "Left inset percentage",
"right": "Right inset percentage",
"top": "Top inset percentage"
}
},
"zoom": "Zoom factor"
}
},
"engines": {
@@ -357,6 +362,7 @@
"camera_ui": "Camera user interface",
"cameras": "Cameras",
"clips": "Clips",
"default_zoom": "Zoom to default",
"display_mode": "Display mode",
"download": "Download",
"enabled": "Button enabled",
+7 -1
View File
@@ -55,6 +55,10 @@
"cover": "Il supporto si espande proporzionalmente per coprire la scheda",
"fill": "Il supporto viene allungato per riempire la scheda"
},
"pan": {
"x": "",
"y": ""
},
"position": {
"editor_label": "",
"x": "Percentuale di posizionamento orizzontale",
@@ -66,7 +70,8 @@
"left": "",
"right": "",
"top": ""
}
},
"zoom": ""
}
},
"engines": {
@@ -357,6 +362,7 @@
"camera_ui": "Interface utilisateur de la caméra",
"cameras": "Appareils photo",
"clips": "Extraits",
"default_zoom": "",
"display_mode": "",
"download": "Télécharger",
"enabled": "Bouton activé",
+7 -1
View File
@@ -55,6 +55,10 @@
"cover": "Il supporto si espande proporzionalmente per coprire la scheda",
"fill": "Il supporto viene allungato per riempire la scheda"
},
"pan": {
"x": "",
"y": ""
},
"position": {
"editor_label": "",
"x": "Percentuale di posizionamento orizzontale",
@@ -66,7 +70,8 @@
"left": "",
"right": "",
"top": ""
}
},
"zoom": ""
}
},
"engines": {
@@ -357,6 +362,7 @@
"camera_ui": "Interfaccia utente della fotocamera",
"cameras": "Telecamere",
"clips": "Clip",
"default_zoom": "",
"display_mode": "",
"download": "Download",
"enabled": "Pulsante abilitato",
+7 -1
View File
@@ -55,6 +55,10 @@
"cover": "A mídia se expande proporcionalmente para cobrir o cartão",
"fill": "A mídia é esticada para preencher o cartão"
},
"pan": {
"x": "",
"y": ""
},
"position": {
"editor_label": "",
"x": "Porcentagem do posicionamento horizontal",
@@ -66,7 +70,8 @@
"left": "",
"right": "",
"top": ""
}
},
"zoom": ""
}
},
"engines": {
@@ -357,6 +362,7 @@
"camera_ui": "Interface de usuário da câmera",
"cameras": "Selecionar câmera",
"clips": "Clipes",
"default_zoom": "",
"display_mode": "",
"download": "Baixe a mídia do evento",
"enabled": "Botão ativado",
+7 -1
View File
@@ -55,6 +55,10 @@
"cover": "Tapar",
"fill": "Preencher"
},
"pan": {
"x": "",
"y": ""
},
"position": {
"editor_label": "",
"x": "Percentagem da localização horizontal",
@@ -66,7 +70,8 @@
"left": "",
"right": "",
"top": ""
}
},
"zoom": ""
}
},
"engines": {
@@ -357,6 +362,7 @@
"camera_ui": "Camera",
"cameras": "Selecionar câmera",
"clips": "Clipes",
"default_zoom": "",
"display_mode": "",
"download": "Descarregar mídia do evento",
"enabled": "Botão ativado",
-13
View File
@@ -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);
}
+8
View File
@@ -2,8 +2,16 @@
display: block;
height: 100%;
width: 100%;
position: relative;
}
.hidden {
display: none;
}
ha-icon {
position: absolute;
top: 10px;
right: 10px;
color: var(--primary-color);
}
+21
View File
@@ -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').
* @param interaction The interaction: `tap`, `hold` or `double_tap`
+29 -3
View File
@@ -3,7 +3,8 @@ import differenceInMinutes from 'date-fns/differenceInMinutes';
import differenceInSeconds from 'date-fns/differenceInSeconds';
import format from 'date-fns/format';
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 { FrigateCardError } from '../types';
@@ -87,8 +88,12 @@ export const setify = <T>(value: T | T[] | Set<T>): Set<T> => {
* @param o The old value.
* @returns `true` is the contents have changed.
*/
export function contentsChanged(n: unknown, o: unknown): boolean {
return !isEqual(n, o);
export function contentsChanged(
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;
};
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;
};
};
+2 -2
View File
@@ -177,7 +177,7 @@ export class View {
* @param context The context to merge in.
* @returns This view.
*/
public mergeInContext(context?: ViewContext): View {
public mergeInContext(context?: ViewContext | null): View {
this.context = merge({}, this.context, context);
return this;
}
@@ -306,7 +306,7 @@ export interface FrigateCardViewChangeEventTarget extends EventTarget {
*/
export const dispatchViewContextChangeEvent = (
target: EventTarget,
context: ViewContext,
context: ViewContext | null,
): void => {
dispatchFrigateCardEvent(target, 'view:change-context', context);
};
+3 -10
View File
@@ -259,16 +259,9 @@ describe('CameraManager', async () => {
return new CameraManager(api, { factory: mockFactory });
};
it('should reset', async () => {
const store = new CameraManagerStore();
store.addCamera(
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);
it('should construct', async () => {
const manager = new CameraManager(createCardAPI());
expect(manager.getStore()).toBeTruthy();
});
describe('should initialize cameras from config', () => {
+58 -2
View File
@@ -196,7 +196,6 @@ describe('ActionsManager.handleInteraction', () => {
manager.handleInteractionEvent(
new CustomEvent<Interaction>('event', {
// Malformed interaction type.
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 } }),
);
});
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 () => {
const manager = new ActionsManager(createCardAPI());
@@ -149,7 +149,6 @@ describe('CardElementManager', () => {
element.removeEventListener = removeEventListener;
const api = createCardAPI();
vi.mocked(api.getCameraManager().reset).mockResolvedValue();
const manager = new CardElementManager(
api,
@@ -186,6 +185,6 @@ describe('CardElementManager', () => {
);
expect(windowRemoveEventListener).toBeCalledWith('popstate', expect.anything());
expect(api.getCameraManager().reset).toBeCalled();
expect(api.getInitializationManager().uninitialize).toBeCalledWith('cameras');
});
});
@@ -1,10 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { loadLanguages } from '../../src/localize/localize';
import {
InitializationAspect,
InitializationManager,
} from '../../src/card-controller/initialization-manager';
import { loadLanguages } from '../../src/localize/localize';
import { sideLoadHomeAssistantElements } from '../../src/utils/ha';
import { Initializer } from '../../src/utils/initializer/initializer';
import { createCardAPI, createConfig, createHASS } from '../test-utils';
@@ -184,6 +184,22 @@ describe('InitializationManager', () => {
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', () => {
+2 -2
View File
@@ -81,7 +81,7 @@ describe('ViewManager.setView', () => {
const context = { live: { fetchThumbnails: false } };
// Setting context with no existing view does nothing.
manager.setViewWithNewContext(context);
manager.setViewWithMergedContext(context);
expect(manager.getView()).toBeNull();
const view = createView({
@@ -89,7 +89,7 @@ describe('ViewManager.setView', () => {
camera: 'camera',
});
manager.setView(view);
manager.setViewWithNewContext(context);
manager.setViewWithMergedContext(context);
expect(manager.getView()?.camera).toBe('camera');
expect(manager.getView()?.view).toBe('live');
@@ -11,7 +11,12 @@ import {
MenuButtonController,
MenuButtonControllerOptions,
} 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 { createFrigateCardSimpleAction } from '../../src/utils/action';
import { ViewMedia } from '../../src/view/media';
@@ -29,6 +34,7 @@ import {
createStateEntity,
createStore,
createView,
TestViewMedia,
} from '../test-utils';
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', () => {
it('successfully', () => {
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,
},
},
},
}),
);
});
+4
View File
@@ -175,6 +175,10 @@ describe('config defaults', () => {
enabled: true,
priority: 50,
},
default_zoom: {
enabled: true,
priority: 50,
},
display_mode: {
enabled: true,
priority: 50,
+33
View File
@@ -5,6 +5,7 @@ import { actionSchema } from '../../src/config/types';
import {
convertActionToFrigateCardCustomAction,
createFrigateCardCameraAction,
createFrigateCardChangeZoomAction,
createFrigateCardDisplayModeAction,
createFrigateCardMediaPlayerAction,
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', () => {
const action = actionSchema.parse({
action: 'fire-dom-event',
+53
View File
@@ -2,6 +2,7 @@ import { afterAll, describe, expect, it, vi } from 'vitest';
import { FrigateCardError } from '../../src/types';
import {
allPromises,
arefloatsApproximatelyEqual,
arrayify,
arrayMove,
aspectRatioToStyle,
@@ -12,6 +13,7 @@ import {
errorToConsole,
formatDate,
formatDateAndTime,
generateFloatApproximatelyEqualsCustomizer,
getChildrenFromElement,
getDurationString,
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();
});
});
});
+1 -1
View File
@@ -18,7 +18,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [
'components-lib/menu-button-controller.ts',
'components-lib/menu-controller.ts',
'components-lib/ptz-controller.ts',
'components-lib/zoom-controller.ts',
'components-lib/zoom/*.ts',
'config/**/*.ts',
'const.ts',
'types.ts',