feat: Implement support for gesture-based PTZ control (#2378)

- Closes: #1839
This commit is contained in:
Dermot Duffy
2026-02-28 20:36:53 -08:00
committed by GitHub
parent 40ad3b4460
commit ea86ad0016
32 changed files with 1714 additions and 126 deletions
+7 -6
View File
@@ -388,7 +388,7 @@ advanced_camera_card_action: ptz
## `ptz_controls` ## `ptz_controls`
Show or hide the PTZ controls. Show, hide, or change the type of the PTZ controls.
```yaml ```yaml
action: custom:advanced-camera-card-action action: custom:advanced-camera-card-action
@@ -396,11 +396,12 @@ advanced_camera_card_action: ptz_controls
# [...] # [...]
``` ```
| Parameter | Description | | Parameter | Description |
| ----------------------------- | -------------------------------------------------------- | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `action` | Must be `custom:advanced-camera-card-action`. | | `action` | Must be `custom:advanced-camera-card-action`. |
| `advanced_camera_card_action` | Must be `ptz_controls`. | | `advanced_camera_card_action` | Must be `ptz_controls`. |
| `show` | If `true` shows the PTZ controls, if `false` hides them. | | `enabled` | If `true` shows the PTZ controls, if `false` hides them. If omitted and `type` is also omitted, toggles the current visibility. |
| `type` | Set the PTZ control type to `buttons` or `gestures`. Setting `type` alone does not affect the `enabled`/visibility state of the controls. |
## `ptz_digital` ## `ptz_digital`
+7
View File
@@ -74,11 +74,16 @@ live:
| --------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `hide_home` | `false` | When `true` the Home and Presets buttons of the control are hidden | | `hide_home` | `false` | When `true` the Home and Presets buttons of the control are hidden |
| `hide_pan_tilt` | `false` | When `true` the Pan & Tilt buttons of the control is hidden | | `hide_pan_tilt` | `false` | When `true` the Pan & Tilt buttons of the control is hidden |
| `hide_type` | `false` | When `true` the button that switches between `buttons` and `gestures` PTZ control types is hidden. This button is automatically hidden on cameras without physical PTZ support. |
| `hide_zoom` | `false` | When `true` the Zoom button of the control is hidden | | `hide_zoom` | `false` | When `true` the Zoom button of the control is hidden |
| `mode` | `auto` | If `on` or `off`, by default will always or never show PTZ controls respectively, if `auto` will show PTZ controls only if the camera supports real PTZ. | | `mode` | `auto` | If `on` or `off`, by default will always or never show PTZ controls respectively, if `auto` will show PTZ controls only if the camera supports real PTZ. |
| `orientation` | `horizontal` | Whether to show a `vertical` or `horizontal` PTZ control. | | `orientation` | `horizontal` | Whether to show a `vertical` or `horizontal` PTZ control. |
| `position` | `bottom-right` | Whether to position the control on the `top-left`, `top-right`, `bottom-left` or `bottom-right`. This may be overridden by using the `style` parameter to precisely control placement. | | `position` | `bottom-right` | Whether to position the control on the `top-left`, `top-right`, `bottom-left` or `bottom-right`. This may be overridden by using the `style` parameter to precisely control placement. |
| `style` | | Optionally position and style the element using CSS. Similar to [Picture Element styling](https://www.home-assistant.io/dashboards/picture-elements/#how-to-use-the-style-object), except without any default, e.g. `left: 42%` | | `style` | | Optionally position and style the element using CSS. Similar to [Picture Element styling](https://www.home-assistant.io/dashboards/picture-elements/#how-to-use-the-style-object), except without any default, e.g. `left: 42%` |
| `type` | `buttons` | The PTZ control type: `buttons` shows directional button controls, `gestures` enables drag, pinch, and scroll wheel to pan, tilt, and zoom. Gesture controls only function on cameras with physical PTZ support. |
> [!WARNING]
> PTZ control precision is affected by latency at multiple points: the command round-trip to the camera, the camera's own response time, and the delay before movement is visible in the live stream. Additionally, cameras that only support relative movement may move in non-smooth discrete steps, while continuous movement cameras may overshoot the intended position before a stop command arrives.
To configure the PTZ _actions_ taken for a particular camera, see [Camera PTZ Settings](./cameras/README.md?id=ptz). To configure the PTZ _actions_ taken for a particular camera, see [Camera PTZ Settings](./cameras/README.md?id=ptz).
@@ -214,6 +219,8 @@ live:
hide_pan_tilt: false hide_pan_tilt: false
hide_zoom: false hide_zoom: false
hide_home: false hide_home: false
hide_type: false
type: buttons
style: style:
# Optionally override the default style. # Optionally override the default style.
right: 5% right: 5%
+1
View File
@@ -24,6 +24,7 @@
"@graphiteds/core": "^1.9.21", "@graphiteds/core": "^1.9.21",
"@lit-labs/scoped-registry-mixin": "^1.0.3", "@lit-labs/scoped-registry-mixin": "^1.0.3",
"@lit-labs/task": "^1.1.3", "@lit-labs/task": "^1.1.3",
"@use-gesture/vanilla": "^10.3.1",
"any-date-parser": "^2.2.0", "any-date-parser": "^2.2.0",
"component-emitter": "^1.3.1", "component-emitter": "^1.3.1",
"compute-scroll-into-view": "^3.1.1", "compute-scroll-into-view": "^3.1.1",
+10 -10
View File
@@ -5,7 +5,6 @@ import { EqualityMap } from '../cache/equality-map.js';
import { CardCameraAPI } from '../card-controller/types.js'; import { CardCameraAPI } from '../card-controller/types.js';
import { sortItems } from '../card-controller/view/sort.js'; import { sortItems } from '../card-controller/view/sort.js';
import { import {
PTZ_PAN_TILT_ACTIONS,
PTZAction, PTZAction,
PTZActionPhase, PTZActionPhase,
PTZPanTiltAction, PTZPanTiltAction,
@@ -910,21 +909,22 @@ export class CameraManager {
* For example: with 90° rotation, pressing "left" should send "down" to camera. * For example: with 90° rotation, pressing "left" should send "down" to camera.
*/ */
private _rotatePTZAction(action: PTZAction, rotation?: Rotation): PTZAction { private _rotatePTZAction(action: PTZAction, rotation?: Rotation): PTZAction {
if (!rotation) { if (
!rotation ||
action === 'preset' ||
action === 'zoom_in' ||
action === 'zoom_out'
) {
return action; return action;
} }
// Pan/tilt directions in clockwise order for rotation calculation // Directions in clockwise order for rotation calculation.
const index = PTZ_PAN_TILT_ACTIONS.indexOf(action as PTZPanTiltAction); const CLOCKWISE: PTZPanTiltAction[] = ['up', 'right', 'down', 'left'];
const index = CLOCKWISE.indexOf(action);
if (index === -1) {
// Not a directional action (e.g., zoom_in, zoom_out, preset)
return action;
}
// Each 90° rotation shifts the direction index counter-clockwise. // Each 90° rotation shifts the direction index counter-clockwise.
const shift = (4 - rotation / 90) % 4; const shift = (4 - rotation / 90) % 4;
return PTZ_PAN_TILT_ACTIONS[(index + shift) % 4]; return CLOCKWISE[(index + shift) % 4];
} }
public async executePTZAction( public async executePTZAction(
@@ -6,8 +6,25 @@ export class PTZControlsAction extends AdvancedCameraCardAction<PTZControlsActio
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
const currentEnabled = api.getViewManager().getView()?.context?.ptzControls?.enabled;
// If `enabled` is explicit, use it. If only `type` is being changed, leave
// `enabled` untouched (undefined = no change). Otherwise (neither set),
// toggle the current enabled value — this is the menu-button show/hide use
// case.
const enabled =
this._action.enabled ??
(this._action.type
? undefined
: currentEnabled === undefined
? undefined
: !currentEnabled);
api.getViewManager().setViewWithMergedContext({ api.getViewManager().setViewWithMergedContext({
ptzControls: { enabled: this._action.enabled }, ptzControls: {
...(enabled !== undefined && { enabled }),
...(this._action.type && { type: this._action.type }),
},
}); });
} }
} }
+1 -1
View File
@@ -717,7 +717,7 @@ export class MenuButtonController {
style: isOn ? this._getEmphasizedStyle() : {}, style: isOn ? this._getEmphasizedStyle() : {},
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.menu.buttons.ptz_controls'), title: localize('config.menu.buttons.ptz_controls'),
tap_action: createPTZControlsAction(!isOn), tap_action: createPTZControlsAction({ enabled: !isOn }),
}; };
} }
return null; return null;
+285
View File
@@ -0,0 +1,285 @@
import type { Handler } from '@use-gesture/vanilla';
import {
createGesture,
dragAction,
pinchAction,
wheelAction,
} from '@use-gesture/vanilla';
import { ReactiveController, ReactiveControllerHost } from 'lit';
import { dispatchActionExecutionRequest } from '../../card-controller/actions/utils/execution-request.js';
import {
PTZAction,
PTZActionPhase,
PTZPanAction,
PTZTiltAction,
PTZZoomAction,
} from '../../config/schema/actions/custom/ptz.js';
import { createPTZAction } from '../../utils/action.js';
// Minimum drag distance (px) before the gesture is recognized as a drag.
const DRAG_THRESHOLD = 50;
// Drag distance (px) at which continuous PTZ start/stop begins. Below this
// threshold a single relative "nudge" is dispatched on release instead.
const CONTINUOUS_THRESHOLD = 100;
const CURSOR_GRAB = 'grab';
const CURSOR_GRABBING = 'grabbing';
export class PTZDragController implements ReactiveController {
private _host: ReactiveControllerHost & HTMLElement;
// The DOM element gestures bind to. Distinct from _host because the host's
// shadow DOM may contain siblings (e.g. PTZ overlay) that should not
// receive gesture events.
private _gestureElement: HTMLElement | null = null;
// The @use-gesture Recognizer instance.
private _gesture: ReturnType<ReturnType<typeof createGesture>> | null = null;
// Whether gesture handling is active (triggers host re-render on change).
private _active = false;
// Currently running continuous pan/tilt directions.
private _activeX: PTZPanAction | null = null;
private _activeY: PTZTiltAction | null = null;
// Whether the current drag has crossed CONTINUOUS_THRESHOLD and entered
// continuous mode. Below that threshold, drag-end dispatches relative.
private _continuous = false;
// When a pinch occurs mid-drag, the drag is "poisoned" — all remaining
// drag events for that gesture are ignored to prevent stray pan/tilt.
private _dragCancelledByPinch = false;
// Currently running continuous zoom (pinch).
private _activeZoom: PTZZoomAction | null = null;
// Original element styles restored on deactivate.
private _savedTouchAction = '';
private _savedCursor = '';
constructor(host: ReactiveControllerHost & HTMLElement) {
this._host = host;
this._host.addController(this);
}
public hostDisconnected(): void {
this.deactivateIfNecessary();
}
public activateIfNecessary(element: HTMLElement): void {
if (this._active) {
return;
}
this._gestureElement = element;
this._applyGestureStyles(element);
const gesture = createGesture([dragAction, pinchAction, wheelAction]);
this._gesture = gesture(
element,
{
onDrag: this._onDrag,
onPinch: this._onPinch,
onWheel: this._onWheel,
onPointerDown: () => this._setCursor(true),
onPointerUp: () => this._setCursor(false),
onPointerLeave: () => this._setCursor(false),
onPointerCancel: () => this._setCursor(false),
},
{
drag: {
threshold: DRAG_THRESHOLD,
filterTaps: true,
},
},
);
this._active = true;
this._host.requestUpdate();
}
public deactivateIfNecessary(): void {
if (!this._active) {
return;
}
this._gesture?.destroy();
this._gesture = null;
this._restoreGestureStyles();
this._gestureElement = null;
this._stopAllDirections();
this._stopZoom();
this._continuous = false;
this._dragCancelledByPinch = false;
this._active = false;
this._host.requestUpdate();
}
private _applyGestureStyles(element: HTMLElement): void {
this._savedCursor = element.style.cursor;
this._savedTouchAction = element.style.touchAction;
element.style.cursor = CURSOR_GRAB;
element.style.touchAction = 'none';
}
private _restoreGestureStyles(): void {
/* istanbul ignore next: only called when gesture is active -- @preserve */
if (this._gestureElement) {
this._gestureElement.style.cursor = this._savedCursor;
this._gestureElement.style.touchAction = this._savedTouchAction;
}
}
private _setCursor(grabbing: boolean): void {
/* istanbul ignore next: only called when gesture is active -- @preserve */
if (this._gestureElement) {
this._gestureElement.style.cursor = grabbing ? CURSOR_GRABBING : CURSOR_GRAB;
}
}
private _onDrag: Handler<'drag'> = (state) => {
if (state.pinching) {
this._stopAllDirections();
this._dragCancelledByPinch = true;
return;
}
if (this._dragCancelledByPinch) {
if (state.last) {
this._dragCancelledByPinch = false;
}
return;
}
const [mx, my] = state.movement;
if (state.last) {
if (this._continuous) {
this._stopAllDirections();
this._continuous = false;
} else {
// Short drag: dispatch a single relative action per axis.
this._dispatchRelative(mx, my);
}
return;
}
const distance = Math.sqrt(mx * mx + my * my);
if (!this._continuous && distance >= CONTINUOUS_THRESHOLD) {
this._continuous = true;
}
if (!this._continuous) {
return;
}
// Inverted: dragging right sends PTZ left ("grab the scene").
const wantX: PTZPanAction | null = mx > 0 ? 'left' : mx < 0 ? 'right' : null;
const wantY: PTZTiltAction | null = my > 0 ? 'up' : my < 0 ? 'down' : null;
if (wantX !== this._activeX) {
if (this._activeX) {
this._dispatch(this._activeX, 'stop');
}
this._activeX = wantX;
if (wantX) {
this._dispatch(wantX, 'start');
}
}
if (wantY !== this._activeY) {
if (this._activeY) {
this._dispatch(this._activeY, 'stop');
}
this._activeY = wantY;
if (wantY) {
this._dispatch(wantY, 'start');
}
}
};
private _onPinch: Handler<'pinch'> = (state) => {
const direction = state.direction[0];
if (state.last) {
this._stopZoom();
return;
}
if (direction === 0) {
return;
}
const action: PTZZoomAction = direction > 0 ? 'zoom_in' : 'zoom_out';
if (action !== this._activeZoom) {
this._stopZoom();
this._activeZoom = action;
this._dispatch(action, 'start');
}
};
private _onWheel: Handler<'wheel'> = (state) => {
// delta[1] is the vertical (Y-axis) scroll amount.
const dy = state.delta[1];
if (dy === 0) {
return;
}
this._dispatch(dy > 0 ? 'zoom_out' : 'zoom_in');
};
private _dispatch(action: PTZAction | null, phase?: PTZActionPhase): void {
/* istanbul ignore next: all call sites guard against this -- @preserve */
if (!action) {
return;
}
dispatchActionExecutionRequest(this._host, {
actions: createPTZAction({
ptzAction: action,
...(phase && { ptzPhase: phase }),
}),
});
}
// Dispatch relative actions from movement values. Inverted: positive X (drag
// right) sends PTZ left.
private _dispatchRelative(mx: number, my: number): void {
if (mx > 0) {
this._dispatch('left');
} else if (mx < 0) {
this._dispatch('right');
}
if (my > 0) {
this._dispatch('up');
} else if (my < 0) {
this._dispatch('down');
}
}
private _stopAllDirections(): void {
if (this._activeX) {
this._dispatch(this._activeX, 'stop');
this._activeX = null;
}
if (this._activeY) {
this._dispatch(this._activeY, 'stop');
this._activeY = null;
}
}
private _stopZoom(): void {
if (!this._activeZoom) {
return;
}
this._dispatch(this._activeZoom, 'stop');
this._activeZoom = null;
}
}
+27 -6
View File
@@ -3,10 +3,17 @@ import { dispatchActionExecutionRequest } from '../../card-controller/actions/ut
import { SubmenuInteraction } from '../../components/submenu/types.js'; import { SubmenuInteraction } from '../../components/submenu/types.js';
import { PTZAction } from '../../config/schema/actions/custom/ptz.js'; import { PTZAction } from '../../config/schema/actions/custom/ptz.js';
import { Actions, ActionsConfig } from '../../config/schema/actions/types.js'; import { Actions, ActionsConfig } from '../../config/schema/actions/types.js';
import { PTZControlsConfig } from '../../config/schema/common/controls/ptz.js'; import {
PTZControlsConfig,
PTZControlType,
} from '../../config/schema/common/controls/ptz.js';
import { HomeAssistant } from '../../ha/types.js'; import { HomeAssistant } from '../../ha/types.js';
import { Interaction } from '../../types.js'; import { Interaction } from '../../types.js';
import { createPTZMultiAction, getActionConfigGivenAction } from '../../utils/action.js'; import {
createPTZControlsAction,
createPTZMultiAction,
getActionConfigGivenAction,
} from '../../utils/action.js';
import { PTZControllerActions } from './types'; import { PTZControllerActions } from './types';
export class PTZController { export class PTZController {
@@ -69,14 +76,28 @@ export class PTZController {
} }
} }
public toggleTypeHandler(ev: Event, currentType?: PTZControlType): void {
ev.stopPropagation();
dispatchActionExecutionRequest(this._host, {
actions: createPTZControlsAction({
type: currentType === 'gestures' ? 'buttons' : 'gestures',
}),
});
}
public hasPhysicalPTZ(): boolean {
return (
!!this._cameraID &&
!!this._cameraManager?.getCameraCapabilities(this._cameraID)?.hasPTZCapability()
);
}
public shouldDisplay(): boolean { public shouldDisplay(): boolean {
return this._forceVisibility !== undefined return this._forceVisibility !== undefined
? this._forceVisibility ? this._forceVisibility
: this._config?.mode === 'auto' : this._config?.mode === 'auto'
? !!this._cameraID && ? this.hasPhysicalPTZ()
!!this._cameraManager
?.getCameraCapabilities(this._cameraID)
?.hasPTZCapability()
: this._config?.mode === 'on'; : this._config?.mode === 'on';
} }
+2
View File
@@ -1,8 +1,10 @@
import { PTZControlAction } from '../../config/schema/actions/custom/ptz'; import { PTZControlAction } from '../../config/schema/actions/custom/ptz';
import { Actions } from '../../config/schema/actions/types'; import { Actions } from '../../config/schema/actions/types';
import { PTZControlType } from '../../config/schema/common/controls/ptz';
interface PTZControlsViewContext { interface PTZControlsViewContext {
enabled?: boolean; enabled?: boolean;
type?: PTZControlType;
} }
declare module 'view' { declare module 'view' {
interface ViewContext { interface ViewContext {
+15 -3
View File
@@ -19,12 +19,13 @@ export class ZoomController {
// Is the controller zoomed in at all? // Is the controller zoomed in at all?
private _zoomed = false; private _zoomed = false;
// Is the controller set to the default zoom/pan settings?
private _default = true;
// Should clicks be allowed to propagate, or consumed as a pan/zoom action? // Should clicks be allowed to propagate, or consumed as a pan/zoom action?
private _allowClick = true; private _allowClick = true;
// Whether zoom/pan gestures are active. When `false`, all gesture events pass
// through untouched (used to yield to PTZ gesture mode).
private _zoom = true;
private _defaultSettings: PartialZoomSettings | null; private _defaultSettings: PartialZoomSettings | null;
private _settings: PartialZoomSettings | null; private _settings: PartialZoomSettings | null;
@@ -198,6 +199,14 @@ export class ZoomController {
this._debouncedUpdater(); this._debouncedUpdater();
} }
public isActivated(): boolean {
return !!this._panzoom;
}
public setZoom(value: boolean): void {
this._zoom = value;
}
private _changeHandler(ev: Event): void { private _changeHandler(ev: Event): void {
const pz = (<CustomEvent<PanzoomEventDetail>>ev).detail; const pz = (<CustomEvent<PanzoomEventDetail>>ev).detail;
const unzoomed = this._isUnzoomed(pz.scale); const unzoomed = this._isUnzoomed(pz.scale);
@@ -439,6 +448,9 @@ export class ZoomController {
} }
private _shouldZoomOrPan(ev: Event): boolean { private _shouldZoomOrPan(ev: Event): boolean {
if (!this._zoom) {
return false;
}
return ( return (
!this._isUnzoomed(this._panzoom?.getScale()) || !this._isUnzoomed(this._panzoom?.getScale()) ||
// TouchEvent does not exist on Firefox on non-touch events. See: // TouchEvent does not exist on Firefox on non-touch events. See:
+63 -28
View File
@@ -15,8 +15,13 @@ import { MicrophoneState } from '../../card-controller/types.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { MediaActionsController } from '../../components-lib/media-actions-controller.js'; import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
import { MediaHeightController } from '../../components-lib/media-height-controller.js'; import { MediaHeightController } from '../../components-lib/media-height-controller.js';
import { PTZDragController } from '../../components-lib/ptz/drag-controller.js';
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js'; import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js'; import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
import {
ptzControlsDefaults,
PTZControlType,
} from '../../config/schema/common/controls/ptz.js';
import { TransitionEffect } from '../../config/schema/common/transition-effect.js'; import { TransitionEffect } from '../../config/schema/common/transition-effect.js';
import { LiveConfig } from '../../config/schema/live.js'; import { LiveConfig } from '../../config/schema/live.js';
import { CardWideConfig, configDefaults } from '../../config/schema/types.js'; import { CardWideConfig, configDefaults } from '../../config/schema/types.js';
@@ -32,7 +37,6 @@ import '../carousel';
import { EmblaCarouselPlugins } from '../carousel.js'; import { EmblaCarouselPlugins } from '../carousel.js';
import '../next-prev-control.js'; import '../next-prev-control.js';
import '../ptz.js'; import '../ptz.js';
import { AdvancedCameraCardPTZ } from '../ptz.js';
import './provider.js'; import './provider.js';
const ADVANCED_CAMERA_CARD_LIVE_PROVIDER = 'advanced-camera-card-live-provider'; const ADVANCED_CAMERA_CARD_LIVE_PROVIDER = 'advanced-camera-card-live-provider';
@@ -70,14 +74,13 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public viewFilterCameraID?: string; public viewFilterCameraID?: string;
// Index between camera name and slide number.
private _cameraToSlide: Record<string, number> = {};
private _refPTZControl: Ref<AdvancedCameraCardPTZ> = createRef();
private _refCarousel: Ref<HTMLElement> = createRef(); private _refCarousel: Ref<HTMLElement> = createRef();
private _mediaActionsController = new MediaActionsController(); private _mediaActionsController = new MediaActionsController();
private _mediaHeightController = new MediaHeightController(this, '.embla__slide'); private _mediaHeightController = new MediaHeightController(this, '.embla__slide');
private _ptzDragController = new PTZDragController(this);
@state() @state()
private _mediaHasLoaded = false; private _mediaHasLoaded = false;
@@ -96,10 +99,38 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
super.disconnectedCallback(); super.disconnectedCallback();
} }
private _getTransitionEffect(): TransitionEffect { private _getDisplayPTZType(cameraID: string | null): PTZControlType {
return this.liveConfig?.transition_effect ?? configDefaults.live.transition_effect; if (
!cameraID ||
// For cameras without physical PTZ, always display buttons so the user
// can control digital zoom. Gesture type controls have no effect on those
// cameras.
!this.cameraManager?.getCameraCapabilities(cameraID)?.hasPTZCapability()
) {
return 'buttons';
}
return (
this.viewManagerEpoch?.manager.getView()?.context?.ptzControls?.type ??
this.liveConfig?.controls.ptz.type ??
ptzControlsDefaults.type
);
} }
private _isGesturesPTZActive(
view: View | null | undefined,
cameraID: string | null,
): boolean {
// _getDisplayPTZType returns 'buttons' for digital-only cameras, so this
// implicitly guards against cameras without physical PTZ capability.
return (
this._getDisplayPTZType(cameraID) === 'gestures' &&
view?.context?.ptzControls?.enabled !== false
);
}
private _getTransitionEffect = (): TransitionEffect =>
this.liveConfig?.transition_effect ?? configDefaults.live.transition_effect;
private _getSelectedCameraIndex(): number { private _getSelectedCameraIndex(): number {
if (this.viewFilterCameraID) { if (this.viewFilterCameraID) {
// If the carousel is limited to a single cameraID, the first (only) // If the carousel is limited to a single cameraID, the first (only)
@@ -144,21 +175,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
return [AutoMediaLoadedInfo()]; return [AutoMediaLoadedInfo()];
} }
/** private _getSlides(): TemplateResult[] {
* Returns the number of slides to lazily load. 0 means all slides are lazy
* loaded, 1 means that 1 slide on each side of the currently selected slide
* should lazy load, etc. `null` means lazy loading is disabled and everything
* should load simultaneously.
* @returns
*/
private _getLazyLoadCount(): number | null {
// Defaults to fully-lazy loading.
return this.liveConfig?.lazy_load === false ? null : 0;
}
private _getSlides(): [TemplateResult[], Record<string, number>] {
if (!this.cameraManager) { if (!this.cameraManager) {
return [[], {}]; return [];
} }
const view = this.viewManagerEpoch?.manager.getView(); const view = this.viewManagerEpoch?.manager.getView();
@@ -167,16 +186,13 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
: this.cameraManager?.getStore().getCameraIDsWithCapability('live'); : this.cameraManager?.getStore().getCameraIDsWithCapability('live');
const slides: TemplateResult[] = []; const slides: TemplateResult[] = [];
const cameraToSlide: Record<string, number> = {};
for (const cameraID of cameraIDs ?? []) { for (const cameraID of cameraIDs ?? []) {
const slide = this._renderLive(this._getSubstreamCameraID(cameraID, view)); const slide = this._renderLive(this._getSubstreamCameraID(cameraID, view));
if (slide) { if (slide) {
cameraToSlide[cameraID] = slides.length;
slides.push(slide); slides.push(slide);
} }
} }
return [slides, cameraToSlide]; return slides;
} }
private _setViewHandler(ev: CustomEvent<CarouselSelected>): void { private _setViewHandler(ev: CustomEvent<CarouselSelected>): void {
@@ -221,6 +237,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
.zoomSettings=${view?.context?.zoom?.[cameraID]?.requested} .zoomSettings=${view?.context?.zoom?.[cameraID]?.requested}
.zoom=${!this._isGesturesPTZActive(view, cameraID)}
@advanced-camera-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) => @advanced-camera-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
handleZoomSettingsObservedEvent( handleZoomSettingsObservedEvent(
ev, ev,
@@ -312,8 +329,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
return; return;
} }
const [slides, cameraToSlide] = this._getSlides(); const slides = this._getSlides();
this._cameraToSlide = cameraToSlide;
if (!slides.length) { if (!slides.length) {
return; return;
} }
@@ -321,6 +337,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
const hasMultipleCameras = slides.length > 1; const hasMultipleCameras = slides.length > 1;
const neighbors = this._getCameraNeighbors(); const neighbors = this._getCameraNeighbors();
const streamAwareCameraID = getStreamCameraID(view, this.viewFilterCameraID);
const gesturesPTZActive = this._isGesturesPTZActive(view, streamAwareCameraID);
const forcePTZVisibility = const forcePTZVisibility =
!this._mediaHasLoaded || !this._mediaHasLoaded ||
(!!this.viewFilterCameraID && this.viewFilterCameraID !== view.camera) || (!!this.viewFilterCameraID && this.viewFilterCameraID !== view.camera) ||
@@ -328,6 +347,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
? false ? false
: view.context?.ptzControls?.enabled; : view.context?.ptzControls?.enabled;
const dragEnabled =
hasMultipleCameras && this.liveConfig?.draggable && !gesturesPTZActive;
// Notes on the below: // Notes on the below:
// - guard() is used to avoid reseting the carousel unless the // - guard() is used to avoid reseting the carousel unless the
// options/plugins actually change. // options/plugins actually change.
@@ -336,7 +358,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
<advanced-camera-card-carousel <advanced-camera-card-carousel
${ref(this._refCarousel)} ${ref(this._refCarousel)}
.loop=${hasMultipleCameras} .loop=${hasMultipleCameras}
.dragEnabled=${hasMultipleCameras && this.liveConfig?.draggable} .dragEnabled=${dragEnabled}
.plugins=${guard( .plugins=${guard(
[this.cameraManager, this.liveConfig], [this.cameraManager, this.liveConfig],
this._getPlugins.bind(this), this._getPlugins.bind(this),
@@ -363,8 +385,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.config=${this.liveConfig.controls.ptz} .config=${this.liveConfig.controls.ptz}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.cameraID=${getStreamCameraID(view, this.viewFilterCameraID)} .cameraID=${streamAwareCameraID}
.forceVisibility=${forcePTZVisibility} .forceVisibility=${forcePTZVisibility}
.type=${this._getDisplayPTZType(streamAwareCameraID)}
> >
</advanced-camera-card-ptz> </advanced-camera-card-ptz>
`; `;
@@ -402,6 +425,18 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
if (rootChanged || changedProperties.has('viewManagerEpoch')) { if (rootChanged || changedProperties.has('viewManagerEpoch')) {
this._setMediaTarget(); this._setMediaTarget();
} }
const carouselEl = this._refCarousel.value;
const view = this.viewManagerEpoch?.manager.getView();
const streamAwareCameraID = view
? getStreamCameraID(view, this.viewFilterCameraID)
: null;
if (this._isGesturesPTZActive(view, streamAwareCameraID) && carouselEl) {
this._ptzDragController.activateIfNecessary(carouselEl);
} else {
this._ptzDragController.deactivateIfNecessary();
}
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
+22 -7
View File
@@ -61,9 +61,15 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
@property({ attribute: false }) @property({ attribute: false })
public zoomSettings?: PartialZoomSettings | null; public zoomSettings?: PartialZoomSettings | null;
@property({ attribute: false })
public zoom = true;
@state() @state()
private _isVideoMediaLoaded = false; private _isVideoMediaLoaded = false;
@state()
private _zoomed = false;
@state() @state()
private _hasProviderError = false; private _hasProviderError = false;
@@ -177,6 +183,16 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
return result; return result;
} }
// Builtin (native) video controls require all three conditions:
// - controls.builtin: user config enables native controls.
// - zoom: Whether digital zoom/panning is allowed (this will be false when a
// 'gesture' type PTZ control is active).
// - !_zoomed: the user has not actually digital zoomed in (when zoomed, we
// want to hide the controls).
private _getEffectiveBuiltinControls(): boolean {
return !!this.liveConfig?.controls.builtin && this.zoom && !this._zoomed;
}
private _renderContainer(template: TemplateResult): TemplateResult { private _renderContainer(template: TemplateResult): TemplateResult {
const config = this.camera?.getConfig(); const config = this.camera?.getConfig();
const intermediateTemplate = html` <advanced-camera-card-media-dimensions-container const intermediateTemplate = html` <advanced-camera-card-media-dimensions-container
@@ -203,10 +219,9 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
: undefined, : undefined,
)} )}
.settings=${this.zoomSettings} .settings=${this.zoomSettings}
@advanced-camera-card:zoom:zoomed=${async () => .zoom=${this.zoom}
(await this.getMediaPlayerController())?.setControls(false)} @advanced-camera-card:zoom:zoomed=${() => (this._zoomed = true)}
@advanced-camera-card:zoom:unzoomed=${async () => @advanced-camera-card:zoom:unzoomed=${() => (this._zoomed = false)}
(await this.getMediaPlayerController())?.setControls()}
> >
${intermediateTemplate} ${intermediateTemplate}
</advanced-camera-card-zoomer>` </advanced-camera-card-zoomer>`
@@ -303,7 +318,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
class=${classMap(classes)} class=${classMap(classes)}
.hass=${this.hass} .hass=${this.hass}
.cameraConfig=${cameraConfig} .cameraConfig=${cameraConfig}
?controls=${this.liveConfig.controls.builtin} ?controls=${this._getEffectiveBuiltinControls()}
@advanced-camera-card:live:error=${() => this._providerErrorHandler()} @advanced-camera-card:live:error=${() => this._providerErrorHandler()}
> >
</advanced-camera-card-live-ha>` </advanced-camera-card-live-ha>`
@@ -316,7 +331,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
.cameraEndpoints=${this.cameraEndpoints} .cameraEndpoints=${this.cameraEndpoints}
.microphoneState=${this.microphoneState} .microphoneState=${this.microphoneState}
.microphoneConfig=${this.liveConfig.microphone} .microphoneConfig=${this.liveConfig.microphone}
?controls=${this.liveConfig.controls.builtin} ?controls=${this._getEffectiveBuiltinControls()}
@advanced-camera-card:live:error=${() => this._providerErrorHandler()} @advanced-camera-card:live:error=${() => this._providerErrorHandler()}
> >
</advanced-camera-card-live-go2rtc>` </advanced-camera-card-live-go2rtc>`
@@ -328,7 +343,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
.cameraConfig=${cameraConfig} .cameraConfig=${cameraConfig}
.cameraEndpoints=${this.cameraEndpoints} .cameraEndpoints=${this.cameraEndpoints}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
?controls=${this.liveConfig.controls.builtin} ?controls=${this._getEffectiveBuiltinControls()}
@advanced-camera-card:live:error=${() => this._providerErrorHandler()} @advanced-camera-card:live:error=${() => this._providerErrorHandler()}
> >
</advanced-camera-card-live-webrtc-card>` </advanced-camera-card-live-webrtc-card>`
+37 -5
View File
@@ -1,9 +1,9 @@
import { import {
CSSResultGroup, CSSResultGroup,
html,
LitElement, LitElement,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
html,
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
@@ -13,7 +13,10 @@ import { CameraManager } from '../camera-manager/manager.js';
import { PTZController } from '../components-lib/ptz/ptz-controller.js'; import { PTZController } from '../components-lib/ptz/ptz-controller.js';
import { PTZControllerActions } from '../components-lib/ptz/types.js'; import { PTZControllerActions } from '../components-lib/ptz/types.js';
import { Actions } from '../config/schema/actions/types.js'; import { Actions } from '../config/schema/actions/types.js';
import { PTZControlsConfig } from '../config/schema/common/controls/ptz.js'; import {
PTZControlsConfig,
PTZControlType,
} from '../config/schema/common/controls/ptz.js';
import { HomeAssistant } from '../ha/types.js'; import { HomeAssistant } from '../ha/types.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import ptzStyle from '../scss/ptz.scss'; import ptzStyle from '../scss/ptz.scss';
@@ -40,6 +43,9 @@ export class AdvancedCameraCardPTZ extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public forceVisibility?: boolean; public forceVisibility?: boolean;
@property({ attribute: false })
public type?: PTZControlType;
private _controller = new PTZController(this); private _controller = new PTZController(this);
private _actions: PTZControllerActions | null = null; private _actions: PTZControllerActions | null = null;
@@ -106,8 +112,10 @@ export class AdvancedCameraCardPTZ extends LitElement {
: null; : null;
const config = this._controller.getConfig(); const config = this._controller.getConfig();
const isGestures = this.type === 'gestures';
return html` <div class="ptz"> return html` <div class="ptz">
${!config?.hide_pan_tilt && ${!isGestures &&
!config?.hide_pan_tilt &&
(this._actions?.left || (this._actions?.left ||
this._actions?.right || this._actions?.right ||
this._actions?.up || this._actions?.up ||
@@ -119,13 +127,17 @@ export class AdvancedCameraCardPTZ extends LitElement {
${renderIcon('down', 'mdi:arrow-down', { actions: this._actions?.down })} ${renderIcon('down', 'mdi:arrow-down', { actions: this._actions?.down })}
</div>` </div>`
: ''} : ''}
${!config?.hide_zoom && (this._actions?.zoom_in || this._actions?.zoom_out) ${!isGestures &&
!config?.hide_zoom &&
(this._actions?.zoom_in || this._actions?.zoom_out)
? html` <div class="ptz-zoom"> ? html` <div class="ptz-zoom">
${renderIcon('zoom_in', 'mdi:plus', { actions: this._actions.zoom_in })} ${renderIcon('zoom_in', 'mdi:plus', { actions: this._actions.zoom_in })}
${renderIcon('zoom_out', 'mdi:minus', { actions: this._actions.zoom_out })} ${renderIcon('zoom_out', 'mdi:minus', { actions: this._actions.zoom_out })}
</div>` </div>`
: html``} : html``}
${!config?.hide_home && (this._actions?.home || presetSubmenuItems?.length) ${!isGestures &&
!config?.hide_home &&
(this._actions?.home || presetSubmenuItems?.length)
? html`<div class="ptz-presets"> ? html`<div class="ptz-presets">
${renderIcon('home', 'mdi:home', { actions: this._actions?.home })} ${renderIcon('home', 'mdi:home', { actions: this._actions?.home })}
${presetSubmenuItems?.length ${presetSubmenuItems?.length
@@ -149,6 +161,26 @@ export class AdvancedCameraCardPTZ extends LitElement {
: ''} : ''}
</div>` </div>`
: ''} : ''}
${
// The type toggle switches between buttons and gestures. On
// digital-only cameras, gestures have no effect (the drag controller
// never activates), so the toggle is meaningless and hidden in that
// case.
!config?.hide_type && this._controller.hasPhysicalPTZ()
? html`<div
class="ptz-type"
@click=${(ev: Event) => this._controller.toggleTypeHandler(ev, this.type)}
>
<advanced-camera-card-icon
class=${classMap({
selected: this.type === 'gestures',
})}
.icon=${{ icon: 'mdi:cursor-pointer' }}
.title=${localize('elements.ptz.type')}
></advanced-camera-card-icon>
</div>`
: ''
}
</div>`; </div>`;
} }
+19 -20
View File
@@ -13,17 +13,20 @@ import { PartialZoomSettings } from '../components-lib/zoom/types.js';
@customElement('advanced-camera-card-zoomer') @customElement('advanced-camera-card-zoomer')
export class AdvancedCameraCardZoomer extends LitElement { export class AdvancedCameraCardZoomer extends LitElement {
private _zoom: ZoomController | null = null;
@property({ attribute: false }) @property({ attribute: false })
public defaultSettings?: PartialZoomSettings; public defaultSettings?: PartialZoomSettings;
@property({ attribute: false }) @property({ attribute: false })
public settings?: PartialZoomSettings | null; public settings?: PartialZoomSettings | null;
@property({ attribute: false })
public zoom = true;
@state() @state()
private _zoomed = false; private _zoomed = false;
private _zoomController = new ZoomController(this);
private _zoomHandler = () => (this._zoomed = true); private _zoomHandler = () => (this._zoomed = true);
private _unzoomHandler = () => (this._zoomed = false); private _unzoomHandler = () => (this._zoomed = false);
@@ -37,7 +40,7 @@ export class AdvancedCameraCardZoomer extends LitElement {
} }
disconnectedCallback(): void { disconnectedCallback(): void {
this._zoom?.deactivate(); this._zoomController.deactivate();
this.removeEventListener('advanced-camera-card:zoom:zoomed', this._zoomHandler); this.removeEventListener('advanced-camera-card:zoom:zoomed', this._zoomHandler);
this.removeEventListener('advanced-camera-card:zoom:unzoomed', this._unzoomHandler); this.removeEventListener('advanced-camera-card:zoom:unzoomed', this._unzoomHandler);
super.disconnectedCallback(); super.disconnectedCallback();
@@ -48,22 +51,19 @@ export class AdvancedCameraCardZoomer extends LitElement {
setOrRemoveAttribute(this, this._zoomed, 'zoomed'); setOrRemoveAttribute(this, this._zoomed, 'zoomed');
} }
if (this._zoom) { if (changedProps.has('zoom')) {
if (changedProps.has('defaultSettings')) { this._zoomController.setZoom(this.zoom);
this._zoom.setDefaultSettings(this.defaultSettings ?? null); }
} if (changedProps.has('defaultSettings')) {
// If config is null, make no change to the zoom. this._zoomController.setDefaultSettings(this.defaultSettings ?? null);
if (changedProps.has('settings') && this.settings) { }
this._zoom.setSettings(this.settings); // If config is null, make no change to the zoom.
} if (changedProps.has('settings') && this.settings) {
} else { this._zoomController.setSettings(this.settings);
// Ensure that the configuration will be set before activation (vs }
// activating in `connectedCallback`).
this._zoom = new ZoomController(this, { if (!this._zoomController.isActivated()) {
config: this.settings, this._zoomController.activate();
defaultConfig: this.defaultSettings,
});
this._zoom.activate();
} }
} }
@@ -77,7 +77,6 @@ export class AdvancedCameraCardZoomer extends LitElement {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
cursor: auto;
} }
:host([zoomed]) { :host([zoomed]) {
cursor: move; cursor: move;
+2
View File
@@ -633,7 +633,9 @@ const ptzControlSettingsTransform = (data: unknown): unknown => {
'hide_pan_tilt', 'hide_pan_tilt',
'hide_zoom', 'hide_zoom',
'hide_home', 'hide_home',
'hide_type',
'style', 'style',
'type',
]; ];
const keys = Object.keys(data); const keys = Object.keys(data);
@@ -1,9 +1,11 @@
import { z } from 'zod'; import { z } from 'zod';
import { PTZ_CONTROL_TYPES } from '../../common/controls/ptz';
import { advancedCameraCardCustomActionsBaseSchema } from './base'; import { advancedCameraCardCustomActionsBaseSchema } from './base';
export const ptzControlsActionConfigSchema = export const ptzControlsActionConfigSchema =
advancedCameraCardCustomActionsBaseSchema.extend({ advancedCameraCardCustomActionsBaseSchema.extend({
advanced_camera_card_action: z.literal('ptz_controls'), advanced_camera_card_action: z.literal('ptz_controls'),
enabled: z.boolean(), enabled: z.boolean().optional(),
type: z.enum(PTZ_CONTROL_TYPES).optional(),
}); });
export type PTZControlsActionConfig = z.infer<typeof ptzControlsActionConfigSchema>; export type PTZControlsActionConfig = z.infer<typeof ptzControlsActionConfigSchema>;
+8 -1
View File
@@ -1,10 +1,17 @@
import { z } from 'zod'; import { z } from 'zod';
import { advancedCameraCardCustomActionsBaseSchema } from './base'; import { advancedCameraCardCustomActionsBaseSchema } from './base';
export const PTZ_PAN_TILT_ACTIONS = ['up', 'right', 'down', 'left'] as const; const PTZ_PAN_ACTIONS = ['left', 'right'] as const;
export type PTZPanAction = (typeof PTZ_PAN_ACTIONS)[number];
const PTZ_TILT_ACTIONS = ['up', 'down'] as const;
export type PTZTiltAction = (typeof PTZ_TILT_ACTIONS)[number];
const PTZ_PAN_TILT_ACTIONS = [...PTZ_PAN_ACTIONS, ...PTZ_TILT_ACTIONS] as const;
export type PTZPanTiltAction = (typeof PTZ_PAN_TILT_ACTIONS)[number]; export type PTZPanTiltAction = (typeof PTZ_PAN_TILT_ACTIONS)[number];
const PTZ_ZOOM_ACTIONS = ['zoom_in', 'zoom_out'] as const; const PTZ_ZOOM_ACTIONS = ['zoom_in', 'zoom_out'] as const;
export type PTZZoomAction = (typeof PTZ_ZOOM_ACTIONS)[number];
const PTZ_BASE_ACTIONS = [...PTZ_PAN_TILT_ACTIONS, ...PTZ_ZOOM_ACTIONS] as const; const PTZ_BASE_ACTIONS = [...PTZ_PAN_TILT_ACTIONS, ...PTZ_ZOOM_ACTIONS] as const;
export type PTZBaseAction = (typeof PTZ_BASE_ACTIONS)[number]; export type PTZBaseAction = (typeof PTZ_BASE_ACTIONS)[number];
+8
View File
@@ -1,12 +1,17 @@
import { z } from 'zod'; import { z } from 'zod';
export const PTZ_CONTROL_TYPES = ['buttons', 'gestures'] as const;
export type PTZControlType = (typeof PTZ_CONTROL_TYPES)[number];
export const ptzControlsDefaults = { export const ptzControlsDefaults = {
orientation: 'horizontal' as const, orientation: 'horizontal' as const,
mode: 'auto' as const, mode: 'auto' as const,
hide_pan_tilt: false, hide_pan_tilt: false,
hide_zoom: false, hide_zoom: false,
hide_home: false, hide_home: false,
hide_type: false,
position: 'bottom-right' as const, position: 'bottom-right' as const,
type: 'buttons' as const,
}; };
export const ptzControlsConfigSchema = z.object({ export const ptzControlsConfigSchema = z.object({
@@ -21,6 +26,9 @@ export const ptzControlsConfigSchema = z.object({
hide_pan_tilt: z.boolean().default(ptzControlsDefaults.hide_pan_tilt), hide_pan_tilt: z.boolean().default(ptzControlsDefaults.hide_pan_tilt),
hide_zoom: z.boolean().default(ptzControlsDefaults.hide_zoom), hide_zoom: z.boolean().default(ptzControlsDefaults.hide_zoom),
hide_home: z.boolean().default(ptzControlsDefaults.hide_home), hide_home: z.boolean().default(ptzControlsDefaults.hide_home),
hide_type: z.boolean().default(ptzControlsDefaults.hide_type),
type: z.enum(PTZ_CONTROL_TYPES).default(ptzControlsDefaults.type),
style: z.looseObject({}).optional(), style: z.looseObject({}).optional(),
}); });
+3
View File
@@ -290,6 +290,8 @@ export const CONF_LIVE_CONTROLS_PTZ_HIDE_HOME =
`${CONF_LIVE}.controls.ptz.hide_home` as const; `${CONF_LIVE}.controls.ptz.hide_home` as const;
export const CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT = export const CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT =
`${CONF_LIVE}.controls.ptz.hide_pan_tilt` as const; `${CONF_LIVE}.controls.ptz.hide_pan_tilt` as const;
export const CONF_LIVE_CONTROLS_PTZ_HIDE_TYPE =
`${CONF_LIVE}.controls.ptz.hide_type` as const;
export const CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM = export const CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM =
`${CONF_LIVE}.controls.ptz.hide_zoom` as const; `${CONF_LIVE}.controls.ptz.hide_zoom` as const;
export const CONF_LIVE_CONTROLS_PTZ_MODE = `${CONF_LIVE}.controls.ptz.mode` as const; export const CONF_LIVE_CONTROLS_PTZ_MODE = `${CONF_LIVE}.controls.ptz.mode` as const;
@@ -297,6 +299,7 @@ export const CONF_LIVE_CONTROLS_PTZ_ORIENTATION =
`${CONF_LIVE}.controls.ptz.orientation` as const; `${CONF_LIVE}.controls.ptz.orientation` as const;
export const CONF_LIVE_CONTROLS_PTZ_POSITION = export const CONF_LIVE_CONTROLS_PTZ_POSITION =
`${CONF_LIVE}.controls.ptz.position` as const; `${CONF_LIVE}.controls.ptz.position` as const;
export const CONF_LIVE_CONTROLS_PTZ_TYPE = `${CONF_LIVE}.controls.ptz.type` as const;
export const CONF_LIVE_CONTROLS_WHEEL = `${CONF_LIVE}.controls.wheel` as const; export const CONF_LIVE_CONTROLS_WHEEL = `${CONF_LIVE}.controls.wheel` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE = export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE =
+25
View File
@@ -121,10 +121,12 @@ import {
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
CONF_LIVE_CONTROLS_PTZ_HIDE_HOME, CONF_LIVE_CONTROLS_PTZ_HIDE_HOME,
CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT, CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT,
CONF_LIVE_CONTROLS_PTZ_HIDE_TYPE,
CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM, CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM,
CONF_LIVE_CONTROLS_PTZ_MODE, CONF_LIVE_CONTROLS_PTZ_MODE,
CONF_LIVE_CONTROLS_PTZ_ORIENTATION, CONF_LIVE_CONTROLS_PTZ_ORIENTATION,
CONF_LIVE_CONTROLS_PTZ_POSITION, CONF_LIVE_CONTROLS_PTZ_POSITION,
CONF_LIVE_CONTROLS_PTZ_TYPE,
CONF_LIVE_CONTROLS_THUMBNAILS_MODE, CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
@@ -860,6 +862,18 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
}, },
]; ];
private _ptzTypes: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'buttons',
label: localize('config.live.controls.ptz.types.buttons'),
},
{
value: 'gestures',
label: localize('config.live.controls.ptz.types.gestures'),
},
];
private _ptzPositions: EditorSelectOption[] = [ private _ptzPositions: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ {
@@ -3225,6 +3239,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
CONF_LIVE_CONTROLS_PTZ_MODE, CONF_LIVE_CONTROLS_PTZ_MODE,
this._ptzModes, this._ptzModes,
)} )}
${this._renderOptionSelector(
CONF_LIVE_CONTROLS_PTZ_TYPE,
this._ptzTypes,
)}
${this._renderOptionSelector( ${this._renderOptionSelector(
CONF_LIVE_CONTROLS_PTZ_POSITION, CONF_LIVE_CONTROLS_PTZ_POSITION,
this._ptzPositions, this._ptzPositions,
@@ -3254,6 +3272,13 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
label: localize('config.live.controls.ptz.hide_home'), label: localize('config.live.controls.ptz.hide_home'),
}, },
)} )}
${this._renderSwitch(
CONF_LIVE_CONTROLS_PTZ_HIDE_TYPE,
this._defaults.live.controls.ptz.hide_type,
{
label: localize('config.live.controls.ptz.hide_type'),
},
)}
`, `,
)} )}
`, `,
+11 -4
View File
@@ -47,8 +47,8 @@
}, },
"disable": "Disable", "disable": "Disable",
"disable_except": "Disable except", "disable_except": "Disable except",
"force": "Force", "editor_label": "Camera capabilities",
"editor_label": "Camera capabilities" "force": "Force"
}, },
"cast": { "cast": {
"dashboard": { "dashboard": {
@@ -388,6 +388,7 @@
"editor_label": "PTZ", "editor_label": "PTZ",
"hide_home": "Hide home & preset controls", "hide_home": "Hide home & preset controls",
"hide_pan_tilt": "Hide pan & tilt control", "hide_pan_tilt": "Hide pan & tilt control",
"hide_type": "Hide type toggle button",
"hide_zoom": "Hide zoom control", "hide_zoom": "Hide zoom control",
"mode": "Mode", "mode": "Mode",
"modes": { "modes": {
@@ -405,6 +406,11 @@
"bottom-right": "Bottom right", "bottom-right": "Bottom right",
"top-left": "Top left", "top-left": "Top left",
"top-right": "Top right" "top-right": "Top right"
},
"type": "Default type",
"types": {
"buttons": "Buttons",
"gestures": "Gestures"
} }
} }
}, },
@@ -668,8 +674,8 @@
"cameras_secondary": "What cameras to render on this card", "cameras_secondary": "What cameras to render on this card",
"delete": "Delete", "delete": "Delete",
"dimensions": "Card dimensions", "dimensions": "Card dimensions",
"docs": "Documentation",
"dimensions_secondary": "Card dimensions & shape options", "dimensions_secondary": "Card dimensions & shape options",
"docs": "Documentation",
"folders": "Folders", "folders": "Folders",
"folders_secondary": "What folders to render on this card", "folders_secondary": "What folders to render on this card",
"image": "Image", "image": "Image",
@@ -692,11 +698,11 @@
"profiles_secondary": "Choose pre-configured sets of defaults", "profiles_secondary": "Choose pre-configured sets of defaults",
"remote_control": "Remote Control", "remote_control": "Remote Control",
"remote_control_secondary": "Options for remote controlling the card", "remote_control_secondary": "Options for remote controlling the card",
"toggle_diagnostics": "Toggle diagnostics",
"status_bar": "Status bar", "status_bar": "Status bar",
"status_bar_secondary": "Status bar look & feel options", "status_bar_secondary": "Status bar look & feel options",
"timeline": "Timeline", "timeline": "Timeline",
"timeline_secondary": "Event timeline options", "timeline_secondary": "Event timeline options",
"toggle_diagnostics": "Toggle diagnostics",
"upgrade": "Automatic Upgrade", "upgrade": "Automatic Upgrade",
"upgrade_available": "An automatic card configuration upgrade is available", "upgrade_available": "An automatic card configuration upgrade is available",
"view": "View", "view": "View",
@@ -709,6 +715,7 @@
"left": "Left", "left": "Left",
"presets": "Presets", "presets": "Presets",
"right": "Right", "right": "Right",
"type": "PTZ Type",
"up": "Up", "up": "Up",
"zoom_in": "Zoom In", "zoom_in": "Zoom In",
"zoom_out": "Zoom Out" "zoom_out": "Zoom Out"
+22 -7
View File
@@ -46,7 +46,8 @@
.ptz-move, .ptz-move,
.ptz-zoom, .ptz-zoom,
.ptz-presets { .ptz-presets,
.ptz-type {
position: relative; position: relative;
transition: transition:
@@ -70,16 +71,19 @@
} }
:host([data-orientation='horizontal']) .ptz .ptz-zoom, :host([data-orientation='horizontal']) .ptz .ptz-zoom,
:host([data-orientation='horizontal']) .ptz .ptz-presets { :host([data-orientation='horizontal']) .ptz .ptz-presets,
:host([data-orientation='horizontal']) .ptz .ptz-type {
width: calc(var(--advanced-camera-card-ptz-icon-size) * 1.5); width: calc(var(--advanced-camera-card-ptz-icon-size) * 1.5);
} }
:host([data-orientation='vertical']) .ptz .ptz-zoom, :host([data-orientation='vertical']) .ptz .ptz-zoom,
:host([data-orientation='vertical']) .ptz .ptz-presets { :host([data-orientation='vertical']) .ptz .ptz-presets,
:host([data-orientation='vertical']) .ptz .ptz-type {
height: calc(var(--advanced-camera-card-ptz-icon-size) * 1.5); height: calc(var(--advanced-camera-card-ptz-icon-size) * 1.5);
} }
.ptz-zoom, .ptz-zoom,
.ptz-presets { .ptz-presets,
.ptz-type {
border-radius: var(--advanced-camera-card-border-radius-final); border-radius: var(--advanced-camera-card-border-radius-final);
} }
@@ -119,16 +123,27 @@ advanced-camera-card-submenu:not(.disabled) {
} }
.ptz-presets, .ptz-presets,
.ptz-zoom { .ptz-zoom,
.ptz-type {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-evenly; justify-content: space-evenly;
} }
:host([data-orientation='vertical']) .ptz-presets, :host([data-orientation='vertical']) .ptz-presets,
:host([data-orientation='vertical']) .ptz-zoom { :host([data-orientation='vertical']) .ptz-zoom,
:host([data-orientation='vertical']) .ptz-type {
flex-direction: row; flex-direction: row;
} }
:host([data-orientation='horizontal']) .ptz-presets, :host([data-orientation='horizontal']) .ptz-presets,
:host([data-orientation='horizontal']) .ptz-zoom { :host([data-orientation='horizontal']) .ptz-zoom,
:host([data-orientation='horizontal']) .ptz-type {
flex-direction: column; flex-direction: column;
} }
.ptz-type {
cursor: pointer;
advanced-camera-card-icon.selected {
color: var(--advanced-camera-card-ptz-color-selected);
}
}
+5
View File
@@ -280,6 +280,11 @@
var(--advanced-camera-card-ptz-background), var(--advanced-camera-card-ptz-background),
transparent var(--advanced-camera-card-control-background-opacity) transparent var(--advanced-camera-card-control-background-opacity)
); );
--advanced-camera-card-ptz-color-selected: color-mix(
in oklab,
var(--advanced-camera-card-foreground-primary),
transparent 20%
);
/****************** /******************
* Overlay Message * Overlay Message
+8 -7
View File
@@ -34,6 +34,7 @@ import {
AdvancedCameraCardCustomActionConfig, AdvancedCameraCardCustomActionConfig,
} from '../config/schema/actions/types.js'; } from '../config/schema/actions/types.js';
import { AdvancedCameraCardUserSpecifiedView } from '../config/schema/common/const.js'; import { AdvancedCameraCardUserSpecifiedView } from '../config/schema/common/const.js';
import { PTZControlType } from '../config/schema/common/controls/ptz.js';
import { ServiceCallRequest } from '../ha/types.js'; import { ServiceCallRequest } from '../ha/types.js';
import type { EffectName } from '../types.js'; import type { EffectName } from '../types.js';
import { arrayify } from './basic.js'; import { arrayify } from './basic.js';
@@ -111,16 +112,16 @@ export function createDisplayModeAction(
}; };
} }
export function createPTZControlsAction( export function createPTZControlsAction(options?: {
enabled: boolean, cardID?: string;
options?: { enabled?: boolean;
cardID?: string; type?: PTZControlType;
}, }): PTZControlsActionConfig {
): PTZControlsActionConfig {
return { return {
action: 'fire-dom-event', action: 'fire-dom-event',
advanced_camera_card_action: 'ptz_controls', advanced_camera_card_action: 'ptz_controls',
enabled: enabled, ...(options?.enabled !== undefined && { enabled: options.enabled }),
...(options?.type && { type: options.type }),
...(options?.cardID && { card_id: options.cardID }), ...(options?.cardID && { card_id: options.cardID }),
}; };
} }
@@ -1,21 +1,106 @@
import { expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { createCardAPI } from '../../../test-utils'; import { mock } from 'vitest-mock-extended';
import { PTZControlsAction } from '../../../../src/card-controller/actions/actions/ptz-controls'; import { PTZControlsAction } from '../../../../src/card-controller/actions/actions/ptz-controls';
import { View } from '../../../../src/view/view';
import { createCardAPI } from '../../../test-utils';
it('should handle ptz_controls action', async () => { describe('PTZControlsAction', () => {
const api = createCardAPI(); it('should set enabled explicitly', async () => {
const action = new PTZControlsAction( const api = createCardAPI();
{}, const action = new PTZControlsAction(
{ {},
action: 'fire-dom-event', {
advanced_camera_card_action: 'ptz_controls', action: 'fire-dom-event',
enabled: true, advanced_camera_card_action: 'ptz_controls',
}, enabled: true,
); type: 'buttons',
},
);
await action.execute(api); await action.execute(api);
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith( expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({
expect.objectContaining({ ptzControls: { enabled: true } }), ptzControls: { enabled: true, type: 'buttons' },
); });
});
it('should toggle enabled when not specified', async () => {
const api = createCardAPI();
const view = mock<View>();
view.context = { ptzControls: { enabled: true } };
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const action = new PTZControlsAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz_controls',
},
);
await action.execute(api);
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({
ptzControls: { enabled: false },
});
});
it('should not set enabled when currently undefined and not specified', async () => {
const api = createCardAPI();
const action = new PTZControlsAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz_controls',
},
);
await action.execute(api);
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({
ptzControls: {},
});
});
it('should set type without affecting enabled when not specified', async () => {
const api = createCardAPI();
const action = new PTZControlsAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz_controls',
type: 'gestures',
},
);
await action.execute(api);
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({
ptzControls: { type: 'gestures' },
});
});
it('should set type only leaving enabled unchanged', async () => {
const api = createCardAPI();
const view = mock<View>();
view.context = { ptzControls: { enabled: true } };
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const action = new PTZControlsAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz_controls',
type: 'buttons',
},
);
await action.execute(api);
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({
ptzControls: { type: 'buttons' },
});
});
}); });
@@ -0,0 +1,818 @@
import { createGesture } from '@use-gesture/vanilla';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { dispatchActionExecutionRequest } from '../../../src/card-controller/actions/utils/execution-request.js';
import { PTZDragController } from '../../../src/components-lib/ptz/drag-controller';
import {
PTZAction,
PTZActionPhase,
} from '../../../src/config/schema/actions/custom/ptz';
import { createPTZAction } from '../../../src/utils/action';
import { createLitElement } from '../../test-utils';
vi.mock('@use-gesture/vanilla', () => ({
createGesture: vi.fn(),
dragAction: Symbol('dragAction'),
pinchAction: Symbol('pinchAction'),
wheelAction: Symbol('wheelAction'),
}));
vi.mock('../../../src/card-controller/actions/utils/execution-request.js', () => ({
dispatchActionExecutionRequest: vi.fn(),
}));
const ptzAction = (ptzAction: PTZAction, ptzPhase?: PTZActionPhase) => ({
actions: createPTZAction({ ptzAction, ptzPhase }),
});
// @vitest-environment jsdom
describe('PTZDragController', () => {
const destroy = vi.fn();
const gestureInit = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
gestureInit.mockReturnValue({ destroy });
vi.mocked(createGesture).mockReturnValue(gestureInit);
});
const getHandlers = () =>
gestureInit.mock.calls[0][1] as unknown as Record<
string,
(...args: unknown[]) => void
>;
it('should register as a controller on the host', () => {
const host = createLitElement();
new PTZDragController(host);
expect(host.addController).toBeCalled();
});
describe('activation', () => {
it('should activate and request host update', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
const element = document.createElement('div');
controller.activateIfNecessary(element);
expect(host.requestUpdate).toBeCalled();
});
it('should set cursor and touch-action styles on the element', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
const element = document.createElement('div');
controller.activateIfNecessary(element);
expect(element.style.cursor).toBe('grab');
expect(element.style.touchAction).toBe('none');
});
it('should preserve existing styles for restoration', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
const element = document.createElement('div');
element.style.cursor = 'pointer';
element.style.touchAction = 'auto';
controller.activateIfNecessary(element);
controller.deactivateIfNecessary();
expect(element.style.cursor).toBe('pointer');
expect(element.style.touchAction).toBe('auto');
});
it('should not activate twice', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
const element = document.createElement('div');
controller.activateIfNecessary(element);
controller.activateIfNecessary(element);
expect(createGesture).toBeCalledTimes(1);
});
it('should create gesture with drag, pinch, and wheel actions', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
expect(createGesture).toBeCalled();
});
});
describe('deactivation', () => {
it('should deactivate and request host update', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
const element = document.createElement('div');
controller.activateIfNecessary(element);
vi.mocked(host.requestUpdate).mockClear();
controller.deactivateIfNecessary();
expect(host.requestUpdate).toBeCalled();
});
it('should destroy the gesture recognizer', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
controller.deactivateIfNecessary();
expect(destroy).toBeCalled();
});
it('should not deactivate when not active', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.deactivateIfNecessary();
expect(host.requestUpdate).not.toBeCalled();
});
it('should stop active directions on deactivation', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [110, -110],
});
vi.mocked(dispatchActionExecutionRequest).mockClear();
controller.deactivateIfNecessary();
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('left', 'stop'),
);
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('down', 'stop'),
);
});
it('should stop active zoom on deactivation', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onPinch({ direction: [1], last: false });
vi.mocked(dispatchActionExecutionRequest).mockClear();
controller.deactivateIfNecessary();
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('zoom_in', 'stop'),
);
});
});
describe('hostDisconnected', () => {
it('should deactivate when host disconnects', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
controller.hostDisconnected();
expect(destroy).toBeCalled();
});
});
describe('drag', () => {
describe('continuous mode', () => {
it('should start continuous PTZ left when dragging right', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [110, 0],
});
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('left', 'start'),
);
});
it('should start continuous PTZ right when dragging left', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [-110, 0],
});
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('right', 'start'),
);
});
it('should start continuous PTZ up when dragging down', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [0, 110],
});
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('up', 'start'),
);
});
it('should start continuous PTZ down when dragging up', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [0, -110],
});
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('down', 'start'),
);
});
it('should start both axes for diagonal drag', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [110, -110],
});
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('left', 'start'),
);
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('down', 'start'),
);
});
it('should not re-dispatch when direction is unchanged', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [110, 0],
});
vi.mocked(dispatchActionExecutionRequest).mockClear();
getHandlers().onDrag({
pinching: false,
last: false,
movement: [120, 0],
});
expect(dispatchActionExecutionRequest).not.toBeCalled();
});
it('should stop old and start new on X direction reversal', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [110, 0],
});
vi.mocked(dispatchActionExecutionRequest).mockClear();
getHandlers().onDrag({
pinching: false,
last: false,
movement: [-110, 0],
});
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('left', 'stop'),
);
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('right', 'start'),
);
});
it('should stop old and start new on Y direction reversal', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [0, 110],
});
vi.mocked(dispatchActionExecutionRequest).mockClear();
getHandlers().onDrag({
pinching: false,
last: false,
movement: [0, -110],
});
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('up', 'stop'),
);
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('down', 'start'),
);
});
it('should stop direction when axis returns to zero', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [110, 110],
});
vi.mocked(dispatchActionExecutionRequest).mockClear();
// Movement returns to zero on both axes.
getHandlers().onDrag({
pinching: false,
last: false,
movement: [0, 0],
});
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('left', 'stop'),
);
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('up', 'stop'),
);
expect(dispatchActionExecutionRequest).toBeCalledTimes(2);
});
it('should stop active directions on drag end', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [110, -110],
});
vi.mocked(dispatchActionExecutionRequest).mockClear();
getHandlers().onDrag({
pinching: false,
last: true,
movement: [110, -110],
});
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('left', 'stop'),
);
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('down', 'stop'),
);
});
it('should not dispatch relative on drag end after continuous', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [110, 0],
});
vi.mocked(dispatchActionExecutionRequest).mockClear();
getHandlers().onDrag({
pinching: false,
last: true,
movement: [110, 0],
});
// Only the stop is dispatched, not a relative action.
expect(dispatchActionExecutionRequest).toBeCalledTimes(1);
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('left', 'stop'),
);
});
});
describe('relative mode', () => {
it('should not dispatch during small drag', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [30, -20],
});
expect(dispatchActionExecutionRequest).not.toBeCalled();
});
it('should dispatch relative left and down on small drag end', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [30, -20],
});
getHandlers().onDrag({
pinching: false,
last: true,
movement: [30, -20],
});
expect(dispatchActionExecutionRequest).toBeCalledWith(host, ptzAction('left'));
expect(dispatchActionExecutionRequest).toBeCalledWith(host, ptzAction('down'));
});
it('should dispatch relative right and up on small drag end', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [-30, 20],
});
getHandlers().onDrag({
pinching: false,
last: true,
movement: [-30, 20],
});
expect(dispatchActionExecutionRequest).toBeCalledWith(host, ptzAction('right'));
expect(dispatchActionExecutionRequest).toBeCalledWith(host, ptzAction('up'));
});
it('should not dispatch relative on zero movement', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: true,
movement: [0, 0],
});
expect(dispatchActionExecutionRequest).not.toBeCalled();
});
});
describe('pinch cancels drag', () => {
it('should ignore drag events while pinching', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: true,
last: false,
movement: [80, 80],
});
expect(dispatchActionExecutionRequest).not.toBeCalled();
});
it('should stop active continuous directions when pinch starts', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [110, 0],
});
vi.mocked(dispatchActionExecutionRequest).mockClear();
getHandlers().onDrag({
pinching: true,
last: false,
movement: [120, 0],
});
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('left', 'stop'),
);
});
it('should ignore drag events after pinch until gesture ends', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
// Pinch poisons the drag.
getHandlers().onDrag({
pinching: true,
last: false,
movement: [80, 0],
});
// Subsequent non-pinching drag is ignored.
getHandlers().onDrag({
pinching: false,
last: false,
movement: [110, 0],
});
expect(dispatchActionExecutionRequest).not.toBeCalled();
});
it('should resume drag handling after poisoned gesture ends', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
// Pinch poisons the drag.
getHandlers().onDrag({
pinching: true,
last: false,
movement: [80, 0],
});
// Gesture ends -- clears the flag.
getHandlers().onDrag({
pinching: false,
last: true,
movement: [80, 0],
});
expect(dispatchActionExecutionRequest).not.toBeCalled();
});
});
it('should not dispatch when movement is zero', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onDrag({
pinching: false,
last: false,
movement: [0, 0],
});
expect(dispatchActionExecutionRequest).not.toBeCalled();
});
});
describe('pinch', () => {
it('should start zoom_in on pinch out', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onPinch({ direction: [1], last: false });
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('zoom_in', 'start'),
);
});
it('should start zoom_out on pinch in', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onPinch({ direction: [-1], last: false });
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('zoom_out', 'start'),
);
});
it('should stop zoom and start new direction on pinch direction change', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onPinch({ direction: [1], last: false });
vi.mocked(dispatchActionExecutionRequest).mockClear();
getHandlers().onPinch({ direction: [-1], last: false });
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('zoom_in', 'stop'),
);
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('zoom_out', 'start'),
);
});
it('should stop zoom on pinch end', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onPinch({ direction: [1], last: false });
vi.mocked(dispatchActionExecutionRequest).mockClear();
getHandlers().onPinch({ direction: [1], last: true });
expect(dispatchActionExecutionRequest).toBeCalledWith(
host,
ptzAction('zoom_in', 'stop'),
);
});
it('should ignore zero direction', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onPinch({ direction: [0], last: false });
expect(dispatchActionExecutionRequest).not.toBeCalled();
});
it('should not re-dispatch when zoom direction is unchanged', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onPinch({ direction: [1], last: false });
vi.mocked(dispatchActionExecutionRequest).mockClear();
getHandlers().onPinch({ direction: [1], last: false });
expect(dispatchActionExecutionRequest).not.toBeCalled();
});
});
describe('wheel', () => {
it('should dispatch zoom_out on scroll down', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onWheel({ delta: [0, 100] });
expect(dispatchActionExecutionRequest).toBeCalledWith(host, ptzAction('zoom_out'));
});
it('should dispatch zoom_in on scroll up', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onWheel({ delta: [0, -100] });
expect(dispatchActionExecutionRequest).toBeCalledWith(host, ptzAction('zoom_in'));
});
it('should not dispatch on zero delta', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
controller.activateIfNecessary(document.createElement('div'));
getHandlers().onWheel({ delta: [0, 0] });
expect(dispatchActionExecutionRequest).not.toBeCalled();
});
});
describe('cursor', () => {
it('should set grabbing cursor on pointer down', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
const element = document.createElement('div');
controller.activateIfNecessary(element);
getHandlers().onPointerDown();
expect(element.style.cursor).toBe('grabbing');
});
it('should restore grab cursor on pointer up', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
const element = document.createElement('div');
controller.activateIfNecessary(element);
getHandlers().onPointerDown();
getHandlers().onPointerUp();
expect(element.style.cursor).toBe('grab');
});
it('should restore grab cursor on pointer leave', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
const element = document.createElement('div');
controller.activateIfNecessary(element);
getHandlers().onPointerDown();
getHandlers().onPointerLeave();
expect(element.style.cursor).toBe('grab');
});
it('should restore grab cursor on pointer cancel', () => {
const host = createLitElement();
const controller = new PTZDragController(host);
const element = document.createElement('div');
controller.activateIfNecessary(element);
getHandlers().onPointerDown();
getHandlers().onPointerCancel();
expect(element.style.cursor).toBe('grab');
});
});
});
@@ -142,6 +142,34 @@ describe('PTZController', () => {
}); });
}); });
describe('hasPhysicalPTZ', () => {
it('should return false without camera', () => {
const controller = new PTZController(document.createElement('div'));
expect(controller.hasPhysicalPTZ()).toBeFalsy();
});
it('should return false without PTZ capability', () => {
const controller = new PTZController(document.createElement('div'));
controller.setCamera(createCameraManager(), 'camera.office');
expect(controller.hasPhysicalPTZ()).toBeFalsy();
});
it('should return true with PTZ capability', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue(
createCapabilities({
ptz: {
left: [PTZMovementType.Relative],
},
}),
);
const controller = new PTZController(document.createElement('div'));
controller.setCamera(cameraManager, 'camera.office');
expect(controller.hasPhysicalPTZ()).toBeTruthy();
});
});
describe('should get PTZ actions', () => { describe('should get PTZ actions', () => {
it.each([ it.each([
['left' as const], ['left' as const],
@@ -364,6 +392,79 @@ describe('PTZController', () => {
}); });
}); });
describe('should toggle type', () => {
it('from gestures to buttons', () => {
const element = document.createElement('div');
const handler = vi.fn();
element.addEventListener('advanced-camera-card:action:execution-request', handler);
const controller = new PTZController(element);
const ev = new Event('click');
vi.spyOn(ev, 'stopPropagation');
controller.toggleTypeHandler(ev, 'gestures');
expect(ev.stopPropagation).toBeCalled();
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: {
actions: {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz_controls',
type: 'buttons',
},
},
}),
);
});
it('from buttons to gestures', () => {
const element = document.createElement('div');
const handler = vi.fn();
element.addEventListener('advanced-camera-card:action:execution-request', handler);
const controller = new PTZController(element);
const ev = new Event('click');
controller.toggleTypeHandler(ev, 'buttons');
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: {
actions: {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz_controls',
type: 'gestures',
},
},
}),
);
});
it('from undefined to gestures', () => {
const element = document.createElement('div');
const handler = vi.fn();
element.addEventListener('advanced-camera-card:action:execution-request', handler);
const controller = new PTZController(element);
const ev = new Event('click');
controller.toggleTypeHandler(ev);
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: {
actions: {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz_controls',
type: 'gestures',
},
},
}),
);
});
});
describe('should handle action', () => { describe('should handle action', () => {
it('successfully', () => { it('successfully', () => {
const action = { const action = {
@@ -640,6 +640,36 @@ describe('ZoomController', () => {
}); });
}); });
it('should report activation state', () => {
const element = document.createElement('div');
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
const controller = new ZoomController(element);
expect(controller.isActivated()).toBe(false);
controller.activate();
expect(controller.isActivated()).toBe(true);
});
it('should not zoom or pan when zoom is disabled', () => {
const element = document.createElement('div');
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
const controller = createAndRegisterZoom(element);
// Simulate being zoomed in.
panzoom.getScale = vi.fn().mockReturnValue(1.2);
controller.setZoom(false);
const ev = new PointerEvent('pointerdown');
element.dispatchEvent(ev);
expect(panzoom.handleDown).not.toBeCalled();
});
it('should set touch action on zoom/unzoom', () => { it('should set touch action on zoom/unzoom', () => {
const element = document.createElement('div'); const element = document.createElement('div');
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom()); vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
+19
View File
@@ -977,6 +977,25 @@ describe('should handle version specific upgrades', () => {
}); });
postUpgradeChecks(config); postUpgradeChecks(config);
}); });
it('should not upgrade ptz settings-only config', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
live: {
controls: {
ptz: {
type: 'gestures',
hide_type: true,
mode: 'on',
orientation: 'vertical',
position: 'bottom-right',
},
},
},
};
expect(upgradeConfig(config)).toBeFalsy();
});
}); });
it('should move view.timeout_seconds', () => { it('should move view.timeout_seconds', () => {
+4 -1
View File
@@ -92,8 +92,10 @@ describe('config defaults', () => {
style: 'chevrons', style: 'chevrons',
}, },
ptz: { ptz: {
type: 'buttons',
hide_home: false, hide_home: false,
hide_pan_tilt: false, hide_pan_tilt: false,
hide_type: false,
hide_zoom: false, hide_zoom: false,
mode: 'auto', mode: 'auto',
orientation: 'horizontal', orientation: 'horizontal',
@@ -163,8 +165,10 @@ describe('config defaults', () => {
style: 'thumbnails', style: 'thumbnails',
}, },
ptz: { ptz: {
type: 'buttons',
hide_home: false, hide_home: false,
hide_pan_tilt: false, hide_pan_tilt: false,
hide_type: false,
hide_zoom: false, hide_zoom: false,
mode: 'off', mode: 'off',
orientation: 'horizontal', orientation: 'horizontal',
@@ -1072,7 +1076,6 @@ describe('config defaults', () => {
{ {
action: 'custom:advanced-camera-card-action', action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'ptz_controls', advanced_camera_card_action: 'ptz_controls',
enabled: true,
}, },
{ {
action: 'custom:advanced-camera-card-action', action: 'custom:advanced-camera-card-action',
+15 -2
View File
@@ -110,9 +110,10 @@ describe('createDisplayModeAction', () => {
}); });
describe('createPTZControlsAction', () => { describe('createPTZControlsAction', () => {
it('should create PTZ controls action', () => { it('should create PTZ controls action with enabled', () => {
expect( expect(
createPTZControlsAction(true, { createPTZControlsAction({
enabled: true,
cardID: 'card_id', cardID: 'card_id',
}), }),
).toEqual({ ).toEqual({
@@ -122,6 +123,18 @@ describe('createPTZControlsAction', () => {
card_id: 'card_id', card_id: 'card_id',
}); });
}); });
it('should create PTZ controls action with type', () => {
expect(
createPTZControlsAction({
type: 'gestures',
}),
).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz_controls',
type: 'gestures',
});
});
}); });
describe('createPTZAction', () => { describe('createPTZAction', () => {
+17
View File
@@ -2308,6 +2308,22 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@use-gesture/core@npm:10.3.1":
version: 10.3.1
resolution: "@use-gesture/core@npm:10.3.1"
checksum: 10c0/2e3b5c0f7fe26cdb47be3a9c2a58a6a9edafc5b2895b07d2898eda9ab5a2b29fb0098b15597baa0856907b593075cd44cc69bba4785c9cfb7b6fabaa3b52cd3e
languageName: node
linkType: hard
"@use-gesture/vanilla@npm:^10.3.1":
version: 10.3.1
resolution: "@use-gesture/vanilla@npm:10.3.1"
dependencies:
"@use-gesture/core": "npm:10.3.1"
checksum: 10c0/b407eb9646f07281fdcaa71bb07f7ba2d3af62292c211dcc52fcf83452aff08027f9facd9f6c055e65609d289a34a69b6357363da08cde99bf969474d752f0cf
languageName: node
linkType: hard
"@vitest/coverage-istanbul@npm:^1.6.0": "@vitest/coverage-istanbul@npm:^1.6.0":
version: 1.6.0 version: 1.6.0
resolution: "@vitest/coverage-istanbul@npm:1.6.0" resolution: "@vitest/coverage-istanbul@npm:1.6.0"
@@ -2491,6 +2507,7 @@ __metadata:
"@types/masonry-layout": "npm:^4.2.8" "@types/masonry-layout": "npm:^4.2.8"
"@typescript-eslint/eslint-plugin": "npm:^8.30.1" "@typescript-eslint/eslint-plugin": "npm:^8.30.1"
"@typescript-eslint/parser": "npm:^8.30.1" "@typescript-eslint/parser": "npm:^8.30.1"
"@use-gesture/vanilla": "npm:^10.3.1"
"@vitest/coverage-istanbul": "npm:^1.6.0" "@vitest/coverage-istanbul": "npm:^1.6.0"
any-date-parser: "npm:^2.2.0" any-date-parser: "npm:^2.2.0"
component-emitter: "npm:^1.3.1" component-emitter: "npm:^1.3.1"