Add support for pre-defined zoom/pan settings.
This commit is contained in:
@@ -128,10 +128,6 @@ export class CameraManager {
|
||||
this._store = options?.store ?? new CameraManagerStore();
|
||||
}
|
||||
|
||||
public async reset(): Promise<void> {
|
||||
await this._store.reset();
|
||||
}
|
||||
|
||||
public async initializeCamerasFromConfig(): Promise<boolean> {
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
@@ -149,7 +145,7 @@ export class CameraManager {
|
||||
);
|
||||
|
||||
const resetAndInitialize = async () => {
|
||||
await this.reset();
|
||||
await this._reset();
|
||||
await this._initializeCameras(cameras);
|
||||
};
|
||||
|
||||
@@ -165,6 +161,10 @@ export class CameraManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async _reset(): Promise<void> {
|
||||
await this._store.reset();
|
||||
}
|
||||
|
||||
protected async _getEnginesForCameras(
|
||||
camerasConfig: CamerasConfig,
|
||||
): Promise<Map<CameraConfig, CameraManagerEngine>> {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ export class ViewManager {
|
||||
}
|
||||
}
|
||||
|
||||
public setViewWithNewContext(context: ViewContext): void {
|
||||
public setViewWithMergedContext(context: ViewContext | null): void {
|
||||
if (this._view) {
|
||||
return this._setView(this._view?.clone().mergeInContext(context));
|
||||
}
|
||||
|
||||
+2
-2
@@ -269,8 +269,8 @@ class FrigateCard extends LitElement {
|
||||
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
|
||||
@frigate-card:view:change=${(ev: CustomEvent<View>) =>
|
||||
this._controller.getViewManager().setView(ev.detail)}
|
||||
@frigate-card:view:change-context=${(ev: CustomEvent<ViewContext>) =>
|
||||
this._controller.getViewManager().setViewWithNewContext(ev.detail)}
|
||||
@frigate-card:view:change-context=${(ev: CustomEvent<ViewContext | null>) =>
|
||||
this._controller.getViewManager().setViewWithMergedContext(ev.detail)}
|
||||
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
|
||||
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
|
||||
@frigate-card:media:unloaded=${() =>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
|
||||
import round from 'lodash-es/round';
|
||||
import { dispatchFrigateCardEvent, isHoverableDevice } from '../utils/basic';
|
||||
|
||||
export class ZoomController {
|
||||
constructor(element: HTMLElement) {
|
||||
this._element = element;
|
||||
}
|
||||
|
||||
protected _element: HTMLElement;
|
||||
protected _panzoom?: PanzoomObject;
|
||||
protected _zoomed = false;
|
||||
protected _allowClick = true;
|
||||
|
||||
protected _events = isHoverableDevice()
|
||||
? {
|
||||
down: ['pointerdown'],
|
||||
move: ['pointermove'],
|
||||
up: ['pointerup', 'pointerleave', 'pointercancel'],
|
||||
}
|
||||
: {
|
||||
down: ['touchstart'],
|
||||
move: ['touchmove'],
|
||||
up: ['touchend', 'touchcancel'],
|
||||
};
|
||||
|
||||
protected _downHandler = (ev: Event) => {
|
||||
if (this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.handleDown(ev as PointerEvent);
|
||||
ev.stopPropagation();
|
||||
|
||||
// If we do not prevent default here, the media carousels scroll.
|
||||
ev.preventDefault();
|
||||
this._allowClick = false;
|
||||
} else {
|
||||
this._allowClick = true;
|
||||
}
|
||||
};
|
||||
|
||||
protected _clickHandler = (ev: Event) => {
|
||||
// When mouse clicking is used to pan, need to avoid that causing a click
|
||||
// handler elsewhere in the card being called. Example: Viewing a snapshot,
|
||||
// and panning within it should not cause a related clip to play (the click
|
||||
// handler in the viewer).
|
||||
if (!this._allowClick) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
this._allowClick = true;
|
||||
};
|
||||
|
||||
protected _moveHandler = (ev: Event) => {
|
||||
if (this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.handleMove(ev as PointerEvent);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
protected _upHandler = (ev: Event) => {
|
||||
if (this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.handleUp(ev as PointerEvent);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
protected _wheelHandler = (ev: Event) => {
|
||||
if (ev instanceof WheelEvent && this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.zoomWithWheel(ev);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
protected _isScaleNormal(scale?: number): boolean {
|
||||
// Floating point arithmetic warning: comparing floating point numbers,
|
||||
// round them first.
|
||||
return scale !== undefined && round(scale, 4) <= 1;
|
||||
}
|
||||
|
||||
protected _shouldZoomOrPan(ev: Event): boolean {
|
||||
return (
|
||||
!this._isScaleNormal(this._panzoom?.getScale()) ||
|
||||
// TouchEvent does not exist on Firefox on non-touch events.
|
||||
// See: https://github.com/dermotduffy/frigate-hass-card/issues/1174
|
||||
(window.TouchEvent && ev instanceof TouchEvent && ev.touches.length > 1) ||
|
||||
(ev instanceof WheelEvent && ev.ctrlKey)
|
||||
);
|
||||
}
|
||||
|
||||
protected _setTouchAction(touchEnabled: boolean): void {
|
||||
this._element.style.touchAction = touchEnabled ? '' : 'none';
|
||||
}
|
||||
|
||||
public activate(): void {
|
||||
this._panzoom = Panzoom(this._element, {
|
||||
contain: 'outside',
|
||||
maxScale: 10,
|
||||
minScale: 1,
|
||||
noBind: true,
|
||||
// Do not force the cursor style (by default it will always show the
|
||||
// 'move' type cursor whether or not it is zoomed in).
|
||||
cursor: undefined,
|
||||
|
||||
// Disable automatic touchAction setting from Panzoom() as otherwise it
|
||||
// effectively disables dashboard scrolling.
|
||||
// See: https://github.com/dermotduffy/frigate-hass-card/issues/1181
|
||||
touchAction: '',
|
||||
});
|
||||
|
||||
const registerListeners = (
|
||||
events: string[],
|
||||
func: (ev: Event) => void,
|
||||
options?: AddEventListenerOptions,
|
||||
) => {
|
||||
events.forEach((eventName) => {
|
||||
this._element.addEventListener(eventName, func, options);
|
||||
});
|
||||
};
|
||||
|
||||
registerListeners(this._events['down'], this._downHandler, { capture: true });
|
||||
registerListeners(this._events['move'], this._moveHandler, { capture: true });
|
||||
registerListeners(this._events['up'], this._upHandler, { capture: true });
|
||||
registerListeners(['wheel'], this._wheelHandler);
|
||||
registerListeners(['click'], this._clickHandler, { capture: true });
|
||||
|
||||
this._element.addEventListener('panzoomzoom', (ev: Event) => {
|
||||
// Take care here to only dispatch the zoomed/unzoomed events when the
|
||||
// absolute state changes (rather than on every single zoom adjustment).
|
||||
if (this._isScaleNormal((<CustomEvent<PanzoomEventDetail>>ev).detail.scale)) {
|
||||
if (this._zoomed) {
|
||||
this._setTouchAction(true);
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:unzoomed');
|
||||
}
|
||||
this._zoomed = false;
|
||||
} else {
|
||||
if (!this._zoomed) {
|
||||
this._setTouchAction(false);
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:zoomed');
|
||||
}
|
||||
this._zoomed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public deactivate(): void {
|
||||
const unregisterListener = (
|
||||
events: string[],
|
||||
func: (ev: Event) => void,
|
||||
options?: EventListenerOptions,
|
||||
) => {
|
||||
events.forEach((eventName) => {
|
||||
this._element.removeEventListener(eventName, func, options);
|
||||
});
|
||||
};
|
||||
|
||||
unregisterListener(this._events['down'], this._downHandler, { capture: true });
|
||||
unregisterListener(this._events['move'], this._moveHandler, { capture: true });
|
||||
unregisterListener(this._events['up'], this._upHandler, { capture: true });
|
||||
unregisterListener(['wheel'], this._wheelHandler);
|
||||
unregisterListener(['click'], this._clickHandler, { capture: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface ZoomConfig {
|
||||
pan?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
export interface ZoomDefault {
|
||||
isDefault: boolean;
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import round from 'lodash-es/round';
|
||||
import {
|
||||
arefloatsApproximatelyEqual,
|
||||
dispatchFrigateCardEvent,
|
||||
isHoverableDevice,
|
||||
} from '../../utils/basic';
|
||||
import { ZoomConfig } from './types';
|
||||
|
||||
const ZOOM_DEFAULT_PAN_X = 50;
|
||||
const ZOOM_DEFAULT_PAN_Y = 50;
|
||||
const ZOOM_DEFAULT_SCALE = 1;
|
||||
const ZOOM_PRECISION = 4;
|
||||
|
||||
export class ZoomController {
|
||||
protected _element: HTMLElement;
|
||||
protected _panzoom?: PanzoomObject;
|
||||
|
||||
// Is the controller zoomed in at all?
|
||||
protected _zoomed = false;
|
||||
|
||||
// Is the controller set to the default zoom/pan settings?
|
||||
protected _default = true;
|
||||
|
||||
// Should clicks be allowed to propagate, or consumed as a pan/zoom action?
|
||||
protected _allowClick = true;
|
||||
|
||||
protected _defaultConfig: ZoomConfig | null;
|
||||
protected _config: ZoomConfig | null;
|
||||
|
||||
// When the user pans/zooms changes may be created at a very high rate.
|
||||
protected _debouncedChangeHandler = debounce(this._changeHandler.bind(this), 200);
|
||||
|
||||
// Multiple calls to setConfig() or setDefaultConfig() or resizes should only
|
||||
// update once.
|
||||
protected _debouncedUpdater = debounce(this._updateBasedOnConfig.bind(this), 200);
|
||||
|
||||
protected _resizeObserver = new ResizeObserver(this._debouncedUpdater);
|
||||
|
||||
protected _events = isHoverableDevice()
|
||||
? {
|
||||
down: ['pointerdown'],
|
||||
move: ['pointermove'],
|
||||
up: ['pointerup', 'pointerleave', 'pointercancel'],
|
||||
}
|
||||
: {
|
||||
down: ['touchstart'],
|
||||
move: ['touchmove'],
|
||||
up: ['touchend', 'touchcancel'],
|
||||
};
|
||||
|
||||
protected _downHandler = (ev: Event) => {
|
||||
if (this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.handleDown(ev as PointerEvent);
|
||||
ev.stopPropagation();
|
||||
|
||||
// If we do not prevent default here, the media carousels scroll.
|
||||
ev.preventDefault();
|
||||
this._allowClick = false;
|
||||
} else {
|
||||
this._allowClick = true;
|
||||
}
|
||||
};
|
||||
|
||||
protected _clickHandler = (ev: Event) => {
|
||||
// When mouse clicking is used to pan, need to avoid that causing a click
|
||||
// handler elsewhere in the card being called. Example: Viewing a snapshot,
|
||||
// and panning within it should not cause a related clip to play (the click
|
||||
// handler in the viewer).
|
||||
if (!this._allowClick) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
this._allowClick = true;
|
||||
};
|
||||
|
||||
protected _moveHandler = (ev: Event) => {
|
||||
if (this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.handleMove(ev as PointerEvent);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
protected _upHandler = (ev: Event) => {
|
||||
if (this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.handleUp(ev as PointerEvent);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
protected _wheelHandler = (ev: Event) => {
|
||||
if (ev instanceof WheelEvent && this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.zoomWithWheel(ev);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
constructor(
|
||||
element: HTMLElement,
|
||||
options?: { config?: ZoomConfig | null; defaultConfig?: ZoomConfig | null },
|
||||
) {
|
||||
this._element = element;
|
||||
this._config = options?.config ?? null;
|
||||
this._defaultConfig = options?.defaultConfig ?? null;
|
||||
}
|
||||
|
||||
public activate(): void {
|
||||
const config = this._getConfigToUse();
|
||||
const converted = this._convertPercentToXYPan(
|
||||
config?.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||
config?.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||
config?.zoom ?? ZOOM_DEFAULT_SCALE,
|
||||
);
|
||||
|
||||
this._panzoom = Panzoom(this._element, {
|
||||
contain: 'outside',
|
||||
maxScale: 10,
|
||||
minScale: 1,
|
||||
noBind: true,
|
||||
// Do not force the cursor style (by default it will always show the
|
||||
// 'move' type cursor whether or not it is zoomed in).
|
||||
cursor: undefined,
|
||||
|
||||
// Disable automatic touchAction setting from Panzoom() as otherwise it
|
||||
// effectively disables dashboard scrolling. See:
|
||||
// https://github.com/dermotduffy/frigate-hass-card/issues/1181
|
||||
touchAction: '',
|
||||
|
||||
// Set the initial pan/zoom values to avoid an initial unzoomed view.
|
||||
...(config && converted && { startX: converted.x }),
|
||||
...(config && converted && { startY: converted.y }),
|
||||
...(config &&
|
||||
converted && {
|
||||
startScale:
|
||||
config.zoom ??
|
||||
// This is not reachable as without a zoom value, a default of 1 is
|
||||
// assumed in _convertPercentToXYPan, which will return null @
|
||||
// default zoom, and so this cannot be reached in practice.
|
||||
/* istanbul ignore next @preserve */
|
||||
ZOOM_DEFAULT_SCALE,
|
||||
}),
|
||||
});
|
||||
|
||||
const registerListeners = (
|
||||
events: string[],
|
||||
func: (ev: Event) => void,
|
||||
options?: AddEventListenerOptions,
|
||||
) => {
|
||||
events.forEach((eventName) => {
|
||||
this._element.addEventListener(eventName, func, options);
|
||||
});
|
||||
};
|
||||
|
||||
registerListeners(this._events['down'], this._downHandler, { capture: true });
|
||||
registerListeners(this._events['move'], this._moveHandler, { capture: true });
|
||||
registerListeners(this._events['up'], this._upHandler, { capture: true });
|
||||
registerListeners(['wheel'], this._wheelHandler);
|
||||
registerListeners(['click'], this._clickHandler, { capture: true });
|
||||
|
||||
this._resizeObserver.observe(this._element);
|
||||
this._element.addEventListener('panzoomchange', this._debouncedChangeHandler);
|
||||
}
|
||||
|
||||
public deactivate(): void {
|
||||
const unregisterListener = (
|
||||
events: string[],
|
||||
func: (ev: Event) => void,
|
||||
options?: EventListenerOptions,
|
||||
) => {
|
||||
events.forEach((eventName) => {
|
||||
this._element.removeEventListener(eventName, func, options);
|
||||
});
|
||||
};
|
||||
|
||||
unregisterListener(this._events['down'], this._downHandler, { capture: true });
|
||||
unregisterListener(this._events['move'], this._moveHandler, { capture: true });
|
||||
unregisterListener(this._events['up'], this._upHandler, { capture: true });
|
||||
unregisterListener(['wheel'], this._wheelHandler);
|
||||
unregisterListener(['click'], this._clickHandler, { capture: true });
|
||||
|
||||
this._resizeObserver.disconnect();
|
||||
this._element.removeEventListener('panzoomchange', this._debouncedChangeHandler);
|
||||
}
|
||||
|
||||
public setDefaultConfig(config: ZoomConfig | null): void {
|
||||
this._defaultConfig = config;
|
||||
this._debouncedUpdater();
|
||||
}
|
||||
|
||||
public setConfig(config: ZoomConfig): void {
|
||||
this._config = config;
|
||||
this._debouncedUpdater();
|
||||
}
|
||||
|
||||
protected _changeHandler(ev: Event): void {
|
||||
const pz = (<CustomEvent<PanzoomEventDetail>>ev).detail;
|
||||
|
||||
const isUnzoomed = this._isUnzoomed(pz.scale);
|
||||
const isAtDefault = this._isAtDefaultZoomAndPan(pz.x, pz.y, pz.scale);
|
||||
|
||||
// Take care here to only dispatch the zoomed/unzoomed events when the
|
||||
// absolute state changes (rather than on every single zoom adjustment).
|
||||
if (isUnzoomed && this._zoomed) {
|
||||
this._zoomed = false;
|
||||
this._setTouchAction(true);
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:unzoomed');
|
||||
} else if (!isUnzoomed && !this._zoomed) {
|
||||
this._zoomed = true;
|
||||
this._setTouchAction(false);
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:zoomed');
|
||||
}
|
||||
|
||||
if (isAtDefault && !this._default) {
|
||||
this._default = true;
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:default', { isDefault: true });
|
||||
} else if (!isAtDefault && this._default) {
|
||||
this._default = false;
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:default', { isDefault: false });
|
||||
}
|
||||
}
|
||||
|
||||
protected _isZoomEqual(a: ZoomConfig, b: ZoomConfig): boolean {
|
||||
// The ?? clauses below cannot be reached since this function is only ever
|
||||
// used fully specified by this object. It's kept as-is for completeness.
|
||||
return (
|
||||
arefloatsApproximatelyEqual(
|
||||
/* istanbul ignore next @preserve */
|
||||
a.zoom ?? ZOOM_DEFAULT_SCALE,
|
||||
/* istanbul ignore next @preserve */
|
||||
b.zoom ?? ZOOM_DEFAULT_SCALE,
|
||||
ZOOM_PRECISION,
|
||||
) &&
|
||||
arefloatsApproximatelyEqual(
|
||||
/* istanbul ignore next @preserve */
|
||||
a.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||
/* istanbul ignore next @preserve */
|
||||
b.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||
ZOOM_PRECISION,
|
||||
) &&
|
||||
arefloatsApproximatelyEqual(
|
||||
/* istanbul ignore next @preserve */
|
||||
a.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||
/* istanbul ignore next @preserve */
|
||||
b.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||
ZOOM_PRECISION,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected _isZoomEmpty(config?: ZoomConfig | null): boolean {
|
||||
return (
|
||||
config?.pan?.x === undefined &&
|
||||
config?.pan?.y === undefined &&
|
||||
config?.zoom === undefined
|
||||
);
|
||||
}
|
||||
|
||||
protected _getConfigToUse(): ZoomConfig | null {
|
||||
return this._isZoomEmpty(this._config) ? this._defaultConfig : this._config;
|
||||
}
|
||||
|
||||
protected _updateBasedOnConfig(): void {
|
||||
if (!this._panzoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this._getConfigToUse();
|
||||
const desiredScale = config?.zoom ?? ZOOM_DEFAULT_SCALE;
|
||||
|
||||
// Transform won't exist (will be null) if the element has no dimensions, or
|
||||
// if the desired scale has no zoom (i.e. is 1).
|
||||
const converted = this._convertPercentToXYPan(
|
||||
config?.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||
config?.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||
desiredScale,
|
||||
);
|
||||
|
||||
const x = converted?.x ?? 0;
|
||||
const y = converted?.y ?? 0;
|
||||
|
||||
// Verify there is a material change in the pan/zoom settings before acting.
|
||||
if (
|
||||
this._isZoomEqual(
|
||||
{ zoom: desiredScale, pan: { x: x, y: y } },
|
||||
{
|
||||
zoom: this._panzoom.getScale(),
|
||||
pan: this._panzoom.getPan(),
|
||||
},
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._panzoom.zoom(desiredScale, {
|
||||
animate: false,
|
||||
});
|
||||
|
||||
// Panzoom must allow the browser to paint the zoomed image in order to
|
||||
// "contain" the pan within the parent, this creates somewhat of an async
|
||||
// situation where we need to ensure the zoom completes first. Using
|
||||
// `requestAnimationFrame` appears to reliably allow the zoom to finish
|
||||
// rendering first, before the pain is applied.
|
||||
// See: https://github.com/timmywil/panzoom?tab=readme-ov-file#a-note-on-the-async-nature-of-panzoom
|
||||
window.requestAnimationFrame(() => {
|
||||
this._panzoom?.pan(x, y, {
|
||||
animate: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert from Frigate card pan % values to Panzoom X/Y transformation
|
||||
* coordinates.
|
||||
* @param x The x translation value.
|
||||
* @param y The y translation value.
|
||||
* @param scale The desired (not current) scale.
|
||||
* @returns An object with x/y pan % values or null on error.
|
||||
*/
|
||||
protected _convertPercentToXYPan(
|
||||
x: number,
|
||||
y: number,
|
||||
scale: number,
|
||||
): { x: number; y: number } | null {
|
||||
const minMax = this._getTransformMinMax(scale, this._panzoom?.getScale());
|
||||
if (minMax === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: minMax.minX + (minMax.maxX - minMax.minX) * (x / 100),
|
||||
y: minMax.minY + (minMax.maxY - minMax.minY) * (y / 100),
|
||||
};
|
||||
}
|
||||
|
||||
protected _getTransformMinMax(
|
||||
desiredScale: number,
|
||||
currentScale?: number,
|
||||
): {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
} | null {
|
||||
const rendered = this._getRenderedSize(currentScale);
|
||||
|
||||
if (!rendered.width || !rendered.height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minX = (rendered.width * (desiredScale - 1)) / desiredScale / 2;
|
||||
const minY = (rendered.height * (desiredScale - 1)) / desiredScale / 2;
|
||||
|
||||
if (arefloatsApproximatelyEqual(minX, 0) || arefloatsApproximatelyEqual(minY, 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
minX: minX,
|
||||
maxX: -minX,
|
||||
minY: minY,
|
||||
maxY: -minY,
|
||||
};
|
||||
}
|
||||
|
||||
protected _getRenderedSize(scale?: number): { width: number; height: number } {
|
||||
const rect = this._element.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width / (scale ?? ZOOM_DEFAULT_SCALE),
|
||||
height: rect.height / (scale ?? ZOOM_DEFAULT_SCALE),
|
||||
};
|
||||
}
|
||||
|
||||
protected _isUnzoomed(scale?: number): boolean {
|
||||
return scale !== undefined && round(scale, ZOOM_PRECISION) <= 1;
|
||||
}
|
||||
|
||||
protected _isAtDefaultZoomAndPan(x: number, y: number, scale: number): boolean {
|
||||
if (!this._defaultConfig) {
|
||||
return this._isUnzoomed(scale);
|
||||
}
|
||||
|
||||
const convertedDefault = this._convertPercentToXYPan(
|
||||
this._defaultConfig.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||
this._defaultConfig.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||
this._defaultConfig.zoom ?? ZOOM_DEFAULT_SCALE,
|
||||
);
|
||||
if (!convertedDefault) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
arefloatsApproximatelyEqual(x, convertedDefault.x) &&
|
||||
arefloatsApproximatelyEqual(y, convertedDefault.y) &&
|
||||
arefloatsApproximatelyEqual(
|
||||
scale,
|
||||
this._defaultConfig.zoom ??
|
||||
// The ZOOM_DEFAULT_SCALE clause below cannot be reached since when
|
||||
// this._defaultConfig.zoom is undefined, convertedDefault will end up
|
||||
// null above and this function will have already returned.
|
||||
/* istanbul ignore next @preserve */
|
||||
ZOOM_DEFAULT_SCALE,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected _shouldZoomOrPan(ev: Event): boolean {
|
||||
return (
|
||||
!this._isUnzoomed(this._panzoom?.getScale()) ||
|
||||
// TouchEvent does not exist on Firefox on non-touch events. See:
|
||||
// https://github.com/dermotduffy/frigate-hass-card/issues/1174
|
||||
(window.TouchEvent && ev instanceof TouchEvent && ev.touches.length > 1) ||
|
||||
(ev instanceof WheelEvent && ev.ctrlKey)
|
||||
);
|
||||
}
|
||||
|
||||
protected _setTouchAction(touchEnabled: boolean): void {
|
||||
this._element.style.touchAction = touchEnabled ? '' : 'none';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ViewContext } from 'view';
|
||||
import { ZoomConfig, ZoomDefault } from './types.js';
|
||||
import { dispatchViewContextChangeEvent } from '../../view/view.js';
|
||||
|
||||
interface ZoomViewContext {
|
||||
// Populate this to request zoom to a particular scale/x/y. An empty object
|
||||
// will reset to default, null will make no change.
|
||||
zoom?: ZoomConfig | null;
|
||||
|
||||
// This will be populated with whether or not the current zoom is at the
|
||||
// default level.
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
interface ZoomsViewContext {
|
||||
[targetID: string]: ZoomViewContext;
|
||||
}
|
||||
|
||||
declare module 'view' {
|
||||
interface ViewContext {
|
||||
zoom?: ZoomsViewContext;
|
||||
}
|
||||
}
|
||||
|
||||
export const generateViewContextForZoomChange = (
|
||||
targetID: string,
|
||||
options?: {
|
||||
zoom?: ZoomConfig | null;
|
||||
isDefault?: boolean;
|
||||
},
|
||||
): ViewContext | null => {
|
||||
return {
|
||||
zoom: {
|
||||
[targetID]: {
|
||||
zoom: options?.zoom ?? null,
|
||||
...(options?.isDefault !== undefined && { isDefault: options.isDefault }),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience wrapper to convert a zoom default into a dispatched view context
|
||||
* change.
|
||||
*/
|
||||
export const handleZoomDefaultEvent = (
|
||||
element: EventTarget,
|
||||
ev: CustomEvent<ZoomDefault>,
|
||||
targetID?: string,
|
||||
): void => {
|
||||
targetID && dispatchViewContextChangeEvent(
|
||||
element,
|
||||
generateViewContextForZoomChange(targetID, {
|
||||
isDefault: ev.detail.isDefault,
|
||||
}),
|
||||
);
|
||||
};
|
||||
+21
-1
@@ -9,10 +9,13 @@ import {
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
||||
import { ZoomDefault } from '../components-lib/zoom/types.js';
|
||||
import { handleZoomDefaultEvent } from '../components-lib/zoom/zoom-view-context.js';
|
||||
import { CameraConfig, ImageViewConfig } from '../config/types.js';
|
||||
import defaultImage from '../images/frigate-bird-in-sky.jpg';
|
||||
import { localize } from '../localize/localize.js';
|
||||
@@ -277,8 +280,25 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
|
||||
}
|
||||
|
||||
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
||||
const targetID = this.cameraConfig?.id;
|
||||
const zoomConfig = targetID ? this.view?.context?.zoom?.[targetID]?.zoom : undefined;
|
||||
|
||||
return this.imageConfig?.zoomable
|
||||
? html` <frigate-card-zoomer> ${template} </frigate-card-zoomer>`
|
||||
? html` <frigate-card-zoomer
|
||||
.defaultConfig=${guard([this.cameraConfig?.dimensions?.layout], () =>
|
||||
this.cameraConfig?.dimensions?.layout
|
||||
? {
|
||||
pan: this.cameraConfig.dimensions.layout.pan,
|
||||
zoom: this.cameraConfig.dimensions.layout.zoom,
|
||||
}
|
||||
: undefined,
|
||||
)}
|
||||
.config=${zoomConfig}
|
||||
@frigate-card:zoom:default=${(ev: CustomEvent<ZoomDefault>) =>
|
||||
handleZoomDefaultEvent(this, ev, targetID)}
|
||||
>
|
||||
${template}
|
||||
</frigate-card-zoomer>`
|
||||
: template;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { CameraConfig } from '../../config/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import liveImageStyle from '../../scss/live-image.scss';
|
||||
import basicBlockStyle from '../../scss/basic-block.scss';
|
||||
import { FrigateCardMediaPlayer } from '../../types.js';
|
||||
import { getStateObjOrDispatchError } from '../../utils/get-state-obj';
|
||||
import '../image.js';
|
||||
@@ -17,9 +16,6 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
||||
@property({ attribute: false })
|
||||
public cameraConfig?: CameraConfig;
|
||||
|
||||
@property({ attribute: true })
|
||||
public watermark?: string;
|
||||
|
||||
protected _refImage: Ref<Element & FrigateCardMediaPlayer> = createRef();
|
||||
|
||||
public async play(): Promise<void> {
|
||||
@@ -80,17 +76,11 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
||||
.cameraConfig=${this.cameraConfig}
|
||||
>
|
||||
</frigate-card-image>
|
||||
${this.watermark
|
||||
? html`<ha-icon
|
||||
title=${localize('error.awaiting_live')}
|
||||
icon="${this.watermark}"
|
||||
></ha-icon>`
|
||||
: ''}
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(liveImageStyle);
|
||||
return unsafeCSS(basicBlockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js';
|
||||
import { LiveController } from '../../components-lib/live/live-controller.js';
|
||||
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
|
||||
import { ZoomConfig, ZoomDefault } from '../../components-lib/zoom/types.js';
|
||||
import { handleZoomDefaultEvent } from '../../components-lib/zoom/zoom-view-context.js';
|
||||
import {
|
||||
CameraConfig,
|
||||
CardWideConfig,
|
||||
@@ -501,6 +503,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
.liveConfig=${liveConfig}
|
||||
.hass=${this.hass}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.zoomConfig=${this.view?.context?.zoom?.[cameraID]?.zoom}
|
||||
@frigate-card:zoom:default=${(ev: CustomEvent<ZoomDefault>) =>
|
||||
handleZoomDefaultEvent(this, ev, cameraID)}
|
||||
>
|
||||
</frigate-card-live-provider>
|
||||
</div>
|
||||
@@ -688,6 +693,9 @@ export class FrigateCardLiveProvider
|
||||
@property({ attribute: false })
|
||||
public microphoneStream?: MediaStream;
|
||||
|
||||
@property({ attribute: false })
|
||||
public zoomConfig?: ZoomConfig | null;
|
||||
|
||||
@state()
|
||||
protected _isVideoMediaLoaded = false;
|
||||
|
||||
@@ -793,9 +801,6 @@ export class FrigateCardLiveProvider
|
||||
this._isVideoMediaLoaded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that video media is being shown.
|
||||
*/
|
||||
protected _videoMediaShowHandler(): void {
|
||||
this._isVideoMediaLoaded = true;
|
||||
}
|
||||
@@ -852,6 +857,15 @@ export class FrigateCardLiveProvider
|
||||
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
||||
return this.liveConfig?.zoomable
|
||||
? html` <frigate-card-zoomer
|
||||
.defaultConfig=${guard([this.cameraConfig?.dimensions?.layout], () =>
|
||||
this.cameraConfig?.dimensions?.layout
|
||||
? {
|
||||
pan: this.cameraConfig.dimensions.layout.pan,
|
||||
zoom: this.cameraConfig.dimensions.layout.zoom,
|
||||
}
|
||||
: undefined,
|
||||
)}
|
||||
.config=${this.zoomConfig}
|
||||
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
|
||||
@frigate-card:zoom:unzoomed=${() => this.setControls()}
|
||||
>
|
||||
@@ -899,15 +913,12 @@ export class FrigateCardLiveProvider
|
||||
}
|
||||
}
|
||||
|
||||
return this._useZoomIfRequired(html`
|
||||
return html`${this._useZoomIfRequired(html`
|
||||
${showImageDuringLoading || provider === 'image'
|
||||
? html` <frigate-card-live-image
|
||||
${ref(this._refProvider)}
|
||||
.hass=${this.hass}
|
||||
.cameraConfig=${this.cameraConfig}
|
||||
watermark=${ifDefined(
|
||||
showImageDuringLoading ? 'mdi:progress-helper' : undefined,
|
||||
)}
|
||||
@frigate-card:media:loaded=${(ev: Event) => {
|
||||
if (provider === 'image') {
|
||||
// Only count the media has loaded if the required provider is
|
||||
@@ -968,7 +979,13 @@ export class FrigateCardLiveProvider
|
||||
>
|
||||
</frigate-card-live-jsmpeg>`
|
||||
: html``}
|
||||
`);
|
||||
`)}
|
||||
${showImageDuringLoading && !this._isVideoMediaLoaded
|
||||
? html`<ha-icon
|
||||
title=${localize('error.awaiting_live')}
|
||||
icon="mdi:progress-helper"
|
||||
></ha-icon>`
|
||||
: ''} `;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { MediaGridSelected } from '../components-lib/media-grid-controller.js';
|
||||
import { ZoomDefault } from '../components-lib/zoom/types.js';
|
||||
import { handleZoomDefaultEvent } from '../components-lib/zoom/zoom-view-context.js';
|
||||
import {
|
||||
dispatchMessageEvent,
|
||||
renderMessage,
|
||||
@@ -484,7 +486,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
${guard(this._media, () => this._getSlides())}
|
||||
${guard([this._media, this.view], () => this._getSlides())}
|
||||
<frigate-card-next-previous-control
|
||||
slot="next"
|
||||
.hass=${this.hass}
|
||||
@@ -865,10 +867,28 @@ export class FrigateCardViewerProvider
|
||||
}
|
||||
|
||||
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
||||
if (!this.media) {
|
||||
return template;
|
||||
}
|
||||
const cameraID = this.media.getCameraID();
|
||||
const mediaID = this.media.getID() ?? undefined;
|
||||
const cameraConfig = this.cameraManager?.getStore().getCameraConfig(cameraID);
|
||||
|
||||
return this.viewerConfig?.zoomable
|
||||
? html` <frigate-card-zoomer
|
||||
.defaultConfig=${guard([cameraConfig?.dimensions?.layout], () =>
|
||||
cameraConfig?.dimensions?.layout
|
||||
? {
|
||||
pan: cameraConfig.dimensions.layout.pan,
|
||||
zoom: cameraConfig.dimensions.layout.zoom,
|
||||
}
|
||||
: undefined,
|
||||
)}
|
||||
.config=${mediaID ? this.view?.context?.zoom?.[mediaID]?.zoom : undefined}
|
||||
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
|
||||
@frigate-card:zoom:unzoomed=${() => this.setControls()}
|
||||
@frigate-card:zoom:default=${(ev: CustomEvent<ZoomDefault>) =>
|
||||
handleZoomDefaultEvent(this, ev, mediaID)}
|
||||
>
|
||||
${template}
|
||||
</frigate-card-zoomer>`
|
||||
|
||||
@@ -6,13 +6,20 @@ import {
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
} from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { ZoomController } from '../components-lib/zoom-controller.js';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { ZoomConfig } from '../components-lib/zoom/types.js';
|
||||
import { ZoomController } from '../components-lib/zoom/zoom-controller.js';
|
||||
import { setOrRemoveAttribute } from '../utils/basic.js';
|
||||
|
||||
@customElement('frigate-card-zoomer')
|
||||
export class FrigateCardZoomer extends LitElement {
|
||||
protected _zoom = new ZoomController(this);
|
||||
protected _zoom: ZoomController | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
public defaultConfig?: ZoomConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public config?: ZoomConfig | null;
|
||||
|
||||
@state()
|
||||
protected _zoomed = false;
|
||||
@@ -24,11 +31,13 @@ export class FrigateCardZoomer extends LitElement {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('frigate-card:zoom:zoomed', this._zoomHandler);
|
||||
this.addEventListener('frigate-card:zoom:unzoomed', this._unzoomHandler);
|
||||
this._zoom.activate();
|
||||
|
||||
// Call for an update to activate.
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this._zoom.deactivate();
|
||||
this._zoom?.deactivate();
|
||||
this.removeEventListener('frigate-card:zoom:zoomed', this._zoomHandler);
|
||||
this.removeEventListener('frigate-card:zoom:unzoomed', this._unzoomHandler);
|
||||
}
|
||||
@@ -37,7 +46,26 @@ export class FrigateCardZoomer extends LitElement {
|
||||
if (changedProps.has('_zoomed')) {
|
||||
setOrRemoveAttribute(this, this._zoomed, 'zoomed');
|
||||
}
|
||||
|
||||
if (this._zoom) {
|
||||
if (changedProps.has('defaultConfig')) {
|
||||
this._zoom.setDefaultConfig(this.defaultConfig ?? null);
|
||||
}
|
||||
// If config is null, make no change to the zoom.
|
||||
if (changedProps.has('config') && this.config) {
|
||||
this._zoom.setConfig(this.config);
|
||||
}
|
||||
} else {
|
||||
// Ensure that the configuration will be set before activation (vs
|
||||
// activating in `connectedCallback`).
|
||||
this._zoom = new ZoomController(this, {
|
||||
config: this.config,
|
||||
defaultConfig: this.defaultConfig,
|
||||
});
|
||||
this._zoom.activate();
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
return html` <slot></slot> `;
|
||||
}
|
||||
|
||||
+57
-33
@@ -90,6 +90,16 @@ const CAMERA_TRIGGER_EVENT_TYPES = [
|
||||
] as const;
|
||||
export type CameraTriggerEventType = (typeof CAMERA_TRIGGER_EVENT_TYPES)[number];
|
||||
|
||||
// *************************************************************************
|
||||
// Pan / Zoom
|
||||
// *************************************************************************
|
||||
|
||||
const panSchema = z.object({
|
||||
x: z.number().min(0).max(100).optional(),
|
||||
y: z.number().min(0).max(100).optional(),
|
||||
});
|
||||
const zoomSchema = z.number().min(1).max(10);
|
||||
|
||||
// *************************************************************************
|
||||
// View Display Mode
|
||||
// *************************************************************************
|
||||
@@ -289,15 +299,23 @@ const frigateCardShowPTZActionSchema = frigateCardCustomActionsBaseSchema.extend
|
||||
show_ptz: z.boolean(),
|
||||
});
|
||||
|
||||
const frigateCardChangeZoomActionSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.literal('change_zoom'),
|
||||
target_id: z.string(),
|
||||
zoom: zoomSchema.optional(),
|
||||
pan: panSchema.optional(),
|
||||
});
|
||||
|
||||
export const frigateCardCustomActionSchema = z.union([
|
||||
frigateCardViewActionSchema,
|
||||
frigateCardGeneralActionSchema,
|
||||
frigateCardCameraSelectActionSchema,
|
||||
frigateCardChangeZoomActionSchema,
|
||||
frigateCardGeneralActionSchema,
|
||||
frigateCardLiveDependencySelectActionSchema,
|
||||
frigateCardMediaPlayerActionSchema,
|
||||
frigateCardViewDisplayModeActionSchema,
|
||||
frigateCardPTZActionSchema,
|
||||
frigateCardShowPTZActionSchema,
|
||||
frigateCardViewActionSchema,
|
||||
frigateCardViewDisplayModeActionSchema,
|
||||
]);
|
||||
export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>;
|
||||
|
||||
@@ -626,6 +644,8 @@ const mediaLayoutConfigSchema = z.object({
|
||||
top: z.number().min(0).max(100).optional().default(0),
|
||||
})
|
||||
.optional(),
|
||||
pan: panSchema.optional(),
|
||||
zoom: zoomSchema.optional(),
|
||||
});
|
||||
export type MediaLayoutConfig = z.infer<typeof mediaLayoutConfigSchema>;
|
||||
|
||||
@@ -1345,22 +1365,20 @@ const hiddenButtonDefault = {
|
||||
};
|
||||
|
||||
const menuConfigDefault = {
|
||||
style: 'hidden' as const,
|
||||
position: 'top' as const,
|
||||
alignment: 'left' as const,
|
||||
button_size: 40,
|
||||
buttons: {
|
||||
frigate: visibleButtonDefault,
|
||||
cameras: visibleButtonDefault,
|
||||
substreams: visibleButtonDefault,
|
||||
live: visibleButtonDefault,
|
||||
clips: visibleButtonDefault,
|
||||
snapshots: visibleButtonDefault,
|
||||
image: hiddenButtonDefault,
|
||||
timeline: visibleButtonDefault,
|
||||
download: visibleButtonDefault,
|
||||
camera_ui: visibleButtonDefault,
|
||||
fullscreen: visibleButtonDefault,
|
||||
cameras: visibleButtonDefault,
|
||||
clips: visibleButtonDefault,
|
||||
default_zoom: visibleButtonDefault,
|
||||
display_mode: visibleButtonDefault,
|
||||
download: visibleButtonDefault,
|
||||
expand: hiddenButtonDefault,
|
||||
frigate: visibleButtonDefault,
|
||||
fullscreen: visibleButtonDefault,
|
||||
image: hiddenButtonDefault,
|
||||
live: visibleButtonDefault,
|
||||
media_player: visibleButtonDefault,
|
||||
microphone: {
|
||||
...hiddenButtonDefault,
|
||||
@@ -1368,12 +1386,15 @@ const menuConfigDefault = {
|
||||
},
|
||||
mute: hiddenButtonDefault,
|
||||
play: hiddenButtonDefault,
|
||||
ptz: hiddenButtonDefault,
|
||||
recordings: hiddenButtonDefault,
|
||||
screenshot: hiddenButtonDefault,
|
||||
display_mode: visibleButtonDefault,
|
||||
ptz: hiddenButtonDefault,
|
||||
snapshots: visibleButtonDefault,
|
||||
substreams: visibleButtonDefault,
|
||||
timeline: visibleButtonDefault,
|
||||
},
|
||||
button_size: 40,
|
||||
position: 'top' as const,
|
||||
style: 'hidden' as const,
|
||||
};
|
||||
|
||||
const visibleButtonSchema = menuBaseSchema.extend({
|
||||
@@ -1393,18 +1414,21 @@ export const menuConfigSchema = z
|
||||
alignment: z.enum(FRIGATE_MENU_ALIGNMENTS).default(menuConfigDefault.alignment),
|
||||
buttons: z
|
||||
.object({
|
||||
frigate: visibleButtonSchema.default(menuConfigDefault.buttons.frigate),
|
||||
cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras),
|
||||
substreams: visibleButtonSchema.default(menuConfigDefault.buttons.substreams),
|
||||
live: visibleButtonSchema.default(menuConfigDefault.buttons.live),
|
||||
clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips),
|
||||
snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots),
|
||||
image: hiddenButtonSchema.default(menuConfigDefault.buttons.image),
|
||||
timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline),
|
||||
download: visibleButtonSchema.default(menuConfigDefault.buttons.download),
|
||||
camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui),
|
||||
fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen),
|
||||
cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras),
|
||||
clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips),
|
||||
default_zoom: visibleButtonSchema.default(
|
||||
menuConfigDefault.buttons.default_zoom,
|
||||
),
|
||||
display_mode: visibleButtonSchema.default(
|
||||
menuConfigDefault.buttons.display_mode,
|
||||
),
|
||||
download: visibleButtonSchema.default(menuConfigDefault.buttons.download),
|
||||
expand: hiddenButtonSchema.default(menuConfigDefault.buttons.expand),
|
||||
frigate: visibleButtonSchema.default(menuConfigDefault.buttons.frigate),
|
||||
fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen),
|
||||
image: hiddenButtonSchema.default(menuConfigDefault.buttons.image),
|
||||
live: visibleButtonSchema.default(menuConfigDefault.buttons.live),
|
||||
media_player: visibleButtonSchema.default(
|
||||
menuConfigDefault.buttons.media_player,
|
||||
),
|
||||
@@ -1415,14 +1439,14 @@ export const menuConfigSchema = z
|
||||
.default(menuConfigDefault.buttons.microphone.type),
|
||||
})
|
||||
.default(menuConfigDefault.buttons.microphone),
|
||||
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
||||
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
|
||||
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
||||
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
|
||||
display_mode: visibleButtonSchema.default(
|
||||
menuConfigDefault.buttons.display_mode,
|
||||
),
|
||||
ptz: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz),
|
||||
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
||||
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
|
||||
snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots),
|
||||
substreams: visibleButtonSchema.default(menuConfigDefault.buttons.substreams),
|
||||
timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline),
|
||||
})
|
||||
.default(menuConfigDefault.buttons),
|
||||
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
|
||||
|
||||
@@ -57,6 +57,10 @@ export const CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS =
|
||||
`${CONF_CAMERAS}.#.dependencies.all_cameras` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.fit` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.pan.x` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_Y =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.pan.y` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.position.x` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y =
|
||||
@@ -69,6 +73,8 @@ export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_LEFT =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.view_box.left` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.view_box.right` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.zoom` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION =
|
||||
`${CONF_CAMERAS}.#.triggers.motion` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
|
||||
|
||||
+52
-12
@@ -44,12 +44,15 @@ import {
|
||||
CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_ASPECT_RATIO,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_Y,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_BOTTOM,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_LEFT,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_TOP,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR,
|
||||
CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME,
|
||||
CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID,
|
||||
CONF_CAMERAS_ARRAY_FRIGATE_LABELS,
|
||||
@@ -817,9 +820,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before each update.
|
||||
*/
|
||||
protected willUpdate(): void {
|
||||
if (!this._initialized) {
|
||||
sideLoadHomeAssistantElements().then((success) => {
|
||||
@@ -981,6 +981,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
max?: number;
|
||||
label?: string;
|
||||
default?: number;
|
||||
step?: number;
|
||||
},
|
||||
): TemplateResult | void {
|
||||
if (!this._config) {
|
||||
@@ -992,7 +993,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
return html`
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${{ number: { min: params?.min || 0, max: params?.max, mode: mode } }}
|
||||
.selector=${{
|
||||
number: {
|
||||
min: params?.min || 0,
|
||||
max: params?.max,
|
||||
mode: mode,
|
||||
step: params?.step,
|
||||
},
|
||||
}}
|
||||
.label=${params?.label || this._getLabel(configPath)}
|
||||
.value=${value ?? params?.default}
|
||||
.required=${false}
|
||||
@@ -1230,10 +1238,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
configPathFit: string,
|
||||
configPathPositionX: string,
|
||||
configPathPositionY: string,
|
||||
configPathPositionTop: string,
|
||||
configPathPositionBottom: string,
|
||||
configPathPositionLeft: string,
|
||||
configPathPositionRight: string,
|
||||
configPathViewBoxTop: string,
|
||||
configPathViewBoxBottom: string,
|
||||
configPathViewBoxLeft: string,
|
||||
configPathViewBoxRight: string,
|
||||
configPathZoom: string,
|
||||
configPathPanX: string,
|
||||
configPathPanY: string,
|
||||
): TemplateResult | void {
|
||||
return this._putInSubmenu(
|
||||
domain,
|
||||
@@ -1241,6 +1252,22 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
labelPath,
|
||||
{ name: 'mdi:page-layout-body' },
|
||||
html`
|
||||
${this._renderNumberInput(configPathZoom, {
|
||||
min: 0,
|
||||
max: 10,
|
||||
label: localize('config.cameras.dimensions.layout.zoom'),
|
||||
step: 0.1,
|
||||
})}
|
||||
${this._renderNumberInput(configPathPanX, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
label: localize('config.cameras.dimensions.layout.pan.x'),
|
||||
})}
|
||||
${this._renderNumberInput(configPathPanY, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
label: localize('config.cameras.dimensions.layout.pan.y'),
|
||||
})}
|
||||
${this._renderOptionSelector(configPathFit, this._layoutFits, {
|
||||
label: localize('config.cameras.dimensions.layout.fit'),
|
||||
})}
|
||||
@@ -1266,22 +1293,22 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
'config.cameras.dimensions.layout.view_box.editor_label',
|
||||
{ name: 'mdi:crop' },
|
||||
html`
|
||||
${this._renderNumberInput(configPathPositionTop, {
|
||||
${this._renderNumberInput(configPathViewBoxTop, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
label: localize('config.cameras.dimensions.layout.view_box.top'),
|
||||
})}
|
||||
${this._renderNumberInput(configPathPositionBottom, {
|
||||
${this._renderNumberInput(configPathViewBoxBottom, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
label: localize('config.cameras.dimensions.layout.view_box.bottom'),
|
||||
})}
|
||||
${this._renderNumberInput(configPathPositionLeft, {
|
||||
${this._renderNumberInput(configPathViewBoxLeft, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
label: localize('config.cameras.dimensions.layout.view_box.left'),
|
||||
})}
|
||||
${this._renderNumberInput(configPathPositionRight, {
|
||||
${this._renderNumberInput(configPathViewBoxRight, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
label: localize('config.cameras.dimensions.layout.view_box.right'),
|
||||
@@ -2041,6 +2068,18 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT,
|
||||
cameraIndex,
|
||||
),
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR,
|
||||
cameraIndex,
|
||||
),
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X,
|
||||
cameraIndex,
|
||||
),
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_Y,
|
||||
cameraIndex,
|
||||
),
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
@@ -2273,6 +2312,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
${this._renderMenuButton('screenshot')}
|
||||
${this._renderMenuButton('display_mode')}
|
||||
${this._renderMenuButton('ptz')}
|
||||
${this._renderMenuButton('default_zoom')}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ha-icon {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
@@ -2,8 +2,16 @@
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ha-icon {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,27 @@ export function createFrigateCardShowPTZAction(
|
||||
};
|
||||
}
|
||||
|
||||
export function createFrigateCardChangeZoomAction(
|
||||
targetID: string,
|
||||
options?: {
|
||||
cardID?: string;
|
||||
pan?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
zoom?: number;
|
||||
},
|
||||
): FrigateCardCustomAction {
|
||||
return {
|
||||
action: 'fire-dom-event',
|
||||
frigate_card_action: 'change_zoom',
|
||||
target_id: targetID,
|
||||
...(options?.cardID && { card_id: options.cardID }),
|
||||
...(options?.pan && { pan: options.pan }),
|
||||
...(options?.zoom && { zoom: options.zoom }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an action configuration given a config and an interaction (e.g. 'tap').
|
||||
* @param interaction The interaction: `tap`, `hold` or `double_tap`
|
||||
|
||||
+29
-3
@@ -3,7 +3,8 @@ import differenceInMinutes from 'date-fns/differenceInMinutes';
|
||||
import differenceInSeconds from 'date-fns/differenceInSeconds';
|
||||
import format from 'date-fns/format';
|
||||
import { StyleInfo } from 'lit/directives/style-map';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { round } from 'lodash-es';
|
||||
import isEqualWith from 'lodash-es/isEqualWith';
|
||||
import mergeWith from 'lodash-es/mergeWith';
|
||||
import { FrigateCardError } from '../types';
|
||||
|
||||
@@ -87,8 +88,12 @@ export const setify = <T>(value: T | T[] | Set<T>): Set<T> => {
|
||||
* @param o The old value.
|
||||
* @returns `true` is the contents have changed.
|
||||
*/
|
||||
export function contentsChanged(n: unknown, o: unknown): boolean {
|
||||
return !isEqual(n, o);
|
||||
export function contentsChanged(
|
||||
n: unknown,
|
||||
o: unknown,
|
||||
customizer?: (a: unknown, b: unknown) => boolean | undefined,
|
||||
): boolean {
|
||||
return !isEqualWith(n, o, customizer);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -286,3 +291,24 @@ export const desparsifyArrays = <T>(data: T): T => {
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export const arefloatsApproximatelyEqual = (
|
||||
a: number,
|
||||
b: number,
|
||||
precision?: number,
|
||||
): boolean => {
|
||||
return round(a, precision) === round(b, precision);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a lodash isEqualsWith customizer that can compare floats.
|
||||
*/
|
||||
export const generateFloatApproximatelyEqualsCustomizer = (
|
||||
precision: number,
|
||||
): ((a: unknown, b: unknown) => boolean | undefined) => {
|
||||
return (a: unknown, b: unknown) => {
|
||||
return typeof a === 'number' && typeof b === 'number'
|
||||
? arefloatsApproximatelyEqual(a, b, precision)
|
||||
: undefined;
|
||||
};
|
||||
};
|
||||
|
||||
+2
-2
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user