diff --git a/docs/README.md b/docs/README.md index 7db2c4de..a6dc8d47 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/_coverpage.md b/docs/_coverpage.md index 777ac33d..ec27a485 100644 --- a/docs/_coverpage.md +++ b/docs/_coverpage.md @@ -1,8 +1,8 @@ ![logo](images/frigate-logo.svg) -# Frigate Card 6.0.0 *DEV* +# Frigate Card 6.0.0 _*DEV*_ > A comprehensive camera card for Home Assistant. [GitHub](https://github.com/dermotduffy/frigate-hass-card/) -[Documentation](README.md) \ No newline at end of file +[Documentation](README.md) diff --git a/docs/configuration/actions.md b/docs/configuration/actions.md index 2d0af42d..5b9af685 100644 --- a/docs/configuration/actions.md +++ b/docs/configuration/actions.md @@ -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. diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index 6aeef734..31124162 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -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 ``` diff --git a/docs/configuration/conditions.md b/docs/configuration/conditions.md index 23620cf2..40e59550 100644 --- a/docs/configuration/conditions.md +++ b/docs/configuration/conditions.md @@ -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') diff --git a/docs/configuration/menu.md b/docs/configuration/menu.md index 1348e516..093f8e11 100644 --- a/docs/configuration/menu.md +++ b/docs/configuration/menu.md @@ -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 diff --git a/docs/examples.md b/docs/examples.md index c9a47395..0165992c 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -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 +``` diff --git a/docs/images/browsers/chrome_16x16.png b/docs/images/browsers/chrome_16x16.png new file mode 100644 index 00000000..e4e2773f Binary files /dev/null and b/docs/images/browsers/chrome_16x16.png differ diff --git a/docs/images/browsers/chromium_16x16.png b/docs/images/browsers/chromium_16x16.png new file mode 100644 index 00000000..a3fd998d Binary files /dev/null and b/docs/images/browsers/chromium_16x16.png differ diff --git a/docs/images/browsers/edge_16x16.png b/docs/images/browsers/edge_16x16.png new file mode 100644 index 00000000..1f2b230f Binary files /dev/null and b/docs/images/browsers/edge_16x16.png differ diff --git a/docs/images/media_layout/fit.png b/docs/images/media_layout/fit.png index b47fe96a..a8ade712 100644 Binary files a/docs/images/media_layout/fit.png and b/docs/images/media_layout/fit.png differ diff --git a/docs/images/media_layout/pan-zoom.png b/docs/images/media_layout/pan-zoom.png new file mode 100644 index 00000000..4e25a979 Binary files /dev/null and b/docs/images/media_layout/pan-zoom.png differ diff --git a/docs/images/media_layout/position-shorter-than-height.png b/docs/images/media_layout/position-shorter-than-height.png index e4804d12..47857b48 100644 Binary files a/docs/images/media_layout/position-shorter-than-height.png and b/docs/images/media_layout/position-shorter-than-height.png differ diff --git a/docs/images/media_layout/position-thinner-than-width.png b/docs/images/media_layout/position-thinner-than-width.png index 2f171db2..0fe36f32 100644 Binary files a/docs/images/media_layout/position-thinner-than-width.png and b/docs/images/media_layout/position-thinner-than-width.png differ diff --git a/docs/images/media_layout/view-box.png b/docs/images/media_layout/view-box.png index 9a91d96a..4744ff4b 100644 Binary files a/docs/images/media_layout/view-box.png and b/docs/images/media_layout/view-box.png differ diff --git a/scripts/update-images.sh b/scripts/update-images.sh index 753c78be..cca096d5 100755 --- a/scripts/update-images.sh +++ b/scripts/update-images.sh @@ -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" diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index fa683cf8..780d0b28 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -128,10 +128,6 @@ export class CameraManager { this._store = options?.store ?? new CameraManagerStore(); } - public async reset(): Promise { - await this._store.reset(); - } - public async initializeCamerasFromConfig(): Promise { 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 { + await this._store.reset(); + } + protected async _getEnginesForCameras( camerasConfig: CamerasConfig, ): Promise> { diff --git a/src/card-controller/actions-manager.ts b/src/card-controller/actions-manager.ts index 69c54ab4..08eff63c 100644 --- a/src/card-controller/actions-manager.ts +++ b/src/card-controller/actions-manager.ts @@ -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}`); diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index 1b442f1e..c586173e 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -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', diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts index 3c640198..cbc23190 100644 --- a/src/card-controller/initialization-manager.ts +++ b/src/card-controller/initialization-manager.ts @@ -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(); } } diff --git a/src/card-controller/view-manager.ts b/src/card-controller/view-manager.ts index 2570fed9..5a369700 100644 --- a/src/card-controller/view-manager.ts +++ b/src/card-controller/view-manager.ts @@ -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)); } diff --git a/src/card.ts b/src/card.ts index 517f5def..c6b49571 100644 --- a/src/card.ts +++ b/src/card.ts @@ -269,8 +269,8 @@ class FrigateCard extends LitElement { this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)} @frigate-card:view:change=${(ev: CustomEvent) => this._controller.getViewManager().setView(ev.detail)} - @frigate-card:view:change-context=${(ev: CustomEvent) => - this._controller.getViewManager().setViewWithNewContext(ev.detail)} + @frigate-card:view:change-context=${(ev: CustomEvent) => + this._controller.getViewManager().setViewWithMergedContext(ev.detail)} @frigate-card:media:loaded=${(ev: CustomEvent) => this._controller.getMediaLoadedInfoManager().set(ev.detail)} @frigate-card:media:unloaded=${() => diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index eeaae499..5fa25a9a 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -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. diff --git a/src/components-lib/zoom-controller.ts b/src/components-lib/zoom-controller.ts deleted file mode 100644 index 07557136..00000000 --- a/src/components-lib/zoom-controller.ts +++ /dev/null @@ -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((>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 }); - } -} diff --git a/src/components-lib/zoom/types.ts b/src/components-lib/zoom/types.ts new file mode 100644 index 00000000..04f92855 --- /dev/null +++ b/src/components-lib/zoom/types.ts @@ -0,0 +1,11 @@ +export interface ZoomConfig { + pan?: { + x?: number; + y?: number; + }; + zoom?: number; +} + +export interface ZoomDefault { + isDefault: boolean; +} diff --git a/src/components-lib/zoom/zoom-controller.ts b/src/components-lib/zoom/zoom-controller.ts new file mode 100644 index 00000000..8a6aeec6 --- /dev/null +++ b/src/components-lib/zoom/zoom-controller.ts @@ -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 = (>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'; + } +} diff --git a/src/components-lib/zoom/zoom-view-context.ts b/src/components-lib/zoom/zoom-view-context.ts new file mode 100644 index 00000000..bd8b169a --- /dev/null +++ b/src/components-lib/zoom/zoom-view-context.ts @@ -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, + targetID?: string, +): void => { + targetID && dispatchViewContextChangeEvent( + element, + generateViewContextForZoomChange(targetID, { + isDefault: ev.detail.isDefault, + }), + ); +}; diff --git a/src/components/image.ts b/src/components/image.ts index 0ba8bbcf..5a1850d3 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -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` ${template} ` + ? html` + 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) => + handleZoomDefaultEvent(this, ev, targetID)} + > + ${template} + ` : template; } diff --git a/src/components/live/live-image.ts b/src/components/live/live-image.ts index 91a5351d..a4d20cbd 100644 --- a/src/components/live/live-image.ts +++ b/src/components/live/live-image.ts @@ -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 = createRef(); public async play(): Promise { @@ -80,17 +76,11 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia .cameraConfig=${this.cameraConfig} > - ${this.watermark - ? html`` - : ''} `; } static get styles(): CSSResultGroup { - return unsafeCSS(liveImageStyle); + return unsafeCSS(basicBlockStyle); } } diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 59a0bc70..8251fc07 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -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) => + handleZoomDefaultEvent(this, ev, cameraID)} > @@ -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` + 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` { if (provider === 'image') { // Only count the media has loaded if the required provider is @@ -968,7 +979,13 @@ export class FrigateCardLiveProvider > ` : html``} - `); + `)} + ${showImageDuringLoading && !this._isVideoMediaLoaded + ? html`` + : ''} `; } static get styles(): CSSResultGroup { diff --git a/src/components/viewer.ts b/src/components/viewer.ts index e37a7df0..1d3b4694 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -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); }} > - ${guard(this._media, () => this._getSlides())} + ${guard([this._media, this.view], () => this._getSlides())} + 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) => + handleZoomDefaultEvent(this, ev, mediaID)} > ${template} ` diff --git a/src/components/zoomer.ts b/src/components/zoomer.ts index 111c7bb5..7c690060 100644 --- a/src/components/zoomer.ts +++ b/src/components/zoomer.ts @@ -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` `; } diff --git a/src/config/types.ts b/src/config/types.ts index 9c08c061..6f2d6dd3 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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; @@ -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; @@ -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), diff --git a/src/const.ts b/src/const.ts index d39d45aa..b3d9c9ec 100644 --- a/src/const.ts +++ b/src/const.ts @@ -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 = diff --git a/src/editor.ts b/src/editor.ts index 0b9adc74..f79390e8 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -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` ` : ''} diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 7890348b..f6bd7c74 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -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", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 61b48940..d145acab 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -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", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index 806ed481..0c639c16 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -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é", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index b55a68e3..cf078f21 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -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", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index fb1fb510..ad57b80a 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -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", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 2bee1366..46cd0f18 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -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", diff --git a/src/scss/live-image.scss b/src/scss/live-image.scss deleted file mode 100644 index e3950e11..00000000 --- a/src/scss/live-image.scss +++ /dev/null @@ -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); -} diff --git a/src/scss/live-provider.scss b/src/scss/live-provider.scss index 15c43662..468bf011 100644 --- a/src/scss/live-provider.scss +++ b/src/scss/live-provider.scss @@ -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); +} diff --git a/src/utils/action.ts b/src/utils/action.ts index 01b39f78..cb4e2b18 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -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` diff --git a/src/utils/basic.ts b/src/utils/basic.ts index a31f1223..99f16855 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -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 = (value: T | T[] | Set): Set => { * @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 = (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; + }; +}; diff --git a/src/view/view.ts b/src/view/view.ts index 03dbfeec..9e9e54e6 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -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); }; diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 6a0a2258..6c69a6fb 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -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()), - ); - 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', () => { diff --git a/tests/card-controller/actions-manager.test.ts b/tests/card-controller/actions-manager.test.ts index 8b11b640..0da864a7 100644 --- a/tests/card-controller/actions-manager.test.ts +++ b/tests/card-controller/actions-manager.test.ts @@ -196,7 +196,6 @@ describe('ActionsManager.handleInteraction', () => { manager.handleInteractionEvent( new CustomEvent('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()); diff --git a/tests/card-controller/card-element-manager.test.ts b/tests/card-controller/card-element-manager.test.ts index 82f9392b..664c1faa 100644 --- a/tests/card-controller/card-element-manager.test.ts +++ b/tests/card-controller/card-element-manager.test.ts @@ -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'); }); }); diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts index 7abc77b0..6ba18553 100644 --- a/tests/card-controller/initialization-manager.test.ts +++ b/tests/card-controller/initialization-manager.test.ts @@ -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(); + 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', () => { diff --git a/tests/card-controller/view-manager.test.ts b/tests/card-controller/view-manager.test.ts index f1395938..c0bed650 100644 --- a/tests/card-controller/view-manager.test.ts +++ b/tests/card-controller/view-manager.test.ts @@ -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'); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index daba7957..f7bdc4e7 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -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 = { diff --git a/tests/components-lib/zoom-controller.test.ts b/tests/components-lib/zoom-controller.test.ts deleted file mode 100644 index e040e4d1..00000000 --- a/tests/components-lib/zoom-controller.test.ts +++ /dev/null @@ -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(); - 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({ 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({ 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('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('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('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('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('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('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); - }); -}); diff --git a/tests/components-lib/zoom/zoom-controller.test.ts b/tests/components-lib/zoom/zoom-controller.test.ts new file mode 100644 index 00000000..46fa22e2 --- /dev/null +++ b/tests/components-lib/zoom/zoom-controller.test.ts @@ -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()); +}; + +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(); + 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({ 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({ 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('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('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('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('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('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('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('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('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('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('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('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('panzoomchange', { + detail: { + x: 0, + y: 0, + scale: 1, + isSVG: false, + originalEvent: new PointerEvent('pointermove'), + }, + }); + element.dispatchEvent(ev_2); + expect(element.style.touchAction).toBeFalsy(); + }); +}); diff --git a/tests/components-lib/zoom/zoom-view-context.test.ts b/tests/components-lib/zoom/zoom-view-context.test.ts new file mode 100644 index 00000000..818cc569 --- /dev/null +++ b/tests/components-lib/zoom/zoom-view-context.test.ts @@ -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, + }, + }, + }, + }), + ); +}); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index e94ced6c..05342c9d 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -175,6 +175,10 @@ describe('config defaults', () => { enabled: true, priority: 50, }, + default_zoom: { + enabled: true, + priority: 50, + }, display_mode: { enabled: true, priority: 50, diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index d75389b6..1591b68b 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -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', diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts index 38c0add6..50fc0e60 100644 --- a/tests/utils/basic.test.ts +++ b/tests/utils/basic.test.ts @@ -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(); + }); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 6794fe02..25a3f0be 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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',