diff --git a/docs/configuration/actions/custom/README.md b/docs/configuration/actions/custom/README.md index 0150b555..5834fb1d 100644 --- a/docs/configuration/actions/custom/README.md +++ b/docs/configuration/actions/custom/README.md @@ -388,7 +388,7 @@ advanced_camera_card_action: ptz ## `ptz_controls` -Show or hide the PTZ controls. +Show, hide, or change the type of the PTZ controls. ```yaml action: custom:advanced-camera-card-action @@ -396,11 +396,12 @@ advanced_camera_card_action: ptz_controls # [...] ``` -| Parameter | Description | -| ----------------------------- | -------------------------------------------------------- | -| `action` | Must be `custom:advanced-camera-card-action`. | -| `advanced_camera_card_action` | Must be `ptz_controls`. | -| `show` | If `true` shows the PTZ controls, if `false` hides them. | +| Parameter | Description | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `action` | Must be `custom:advanced-camera-card-action`. | +| `advanced_camera_card_action` | Must be `ptz_controls`. | +| `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` diff --git a/docs/configuration/live.md b/docs/configuration/live.md index a7b5d70d..dda4cc2d 100644 --- a/docs/configuration/live.md +++ b/docs/configuration/live.md @@ -74,11 +74,16 @@ live: | --------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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_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 | | `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. | | `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%` | +| `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). @@ -214,6 +219,8 @@ live: hide_pan_tilt: false hide_zoom: false hide_home: false + hide_type: false + type: buttons style: # Optionally override the default style. right: 5% diff --git a/package.json b/package.json index 574300dc..681dcfc6 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@graphiteds/core": "^1.9.21", "@lit-labs/scoped-registry-mixin": "^1.0.3", "@lit-labs/task": "^1.1.3", + "@use-gesture/vanilla": "^10.3.1", "any-date-parser": "^2.2.0", "component-emitter": "^1.3.1", "compute-scroll-into-view": "^3.1.1", diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 5ce3e118..4a919b5d 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -5,7 +5,6 @@ import { EqualityMap } from '../cache/equality-map.js'; import { CardCameraAPI } from '../card-controller/types.js'; import { sortItems } from '../card-controller/view/sort.js'; import { - PTZ_PAN_TILT_ACTIONS, PTZAction, PTZActionPhase, PTZPanTiltAction, @@ -910,21 +909,22 @@ export class CameraManager { * For example: with 90° rotation, pressing "left" should send "down" to camera. */ private _rotatePTZAction(action: PTZAction, rotation?: Rotation): PTZAction { - if (!rotation) { + if ( + !rotation || + action === 'preset' || + action === 'zoom_in' || + action === 'zoom_out' + ) { return action; } - // Pan/tilt directions in clockwise order for rotation calculation - const index = PTZ_PAN_TILT_ACTIONS.indexOf(action as PTZPanTiltAction); - - if (index === -1) { - // Not a directional action (e.g., zoom_in, zoom_out, preset) - return action; - } + // Directions in clockwise order for rotation calculation. + const CLOCKWISE: PTZPanTiltAction[] = ['up', 'right', 'down', 'left']; + const index = CLOCKWISE.indexOf(action); // Each 90° rotation shifts the direction index counter-clockwise. const shift = (4 - rotation / 90) % 4; - return PTZ_PAN_TILT_ACTIONS[(index + shift) % 4]; + return CLOCKWISE[(index + shift) % 4]; } public async executePTZAction( diff --git a/src/card-controller/actions/actions/ptz-controls.ts b/src/card-controller/actions/actions/ptz-controls.ts index ef8a465a..cfbe9cc5 100644 --- a/src/card-controller/actions/actions/ptz-controls.ts +++ b/src/card-controller/actions/actions/ptz-controls.ts @@ -6,8 +6,25 @@ export class PTZControlsAction extends AdvancedCameraCardAction { 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({ - ptzControls: { enabled: this._action.enabled }, + ptzControls: { + ...(enabled !== undefined && { enabled }), + ...(this._action.type && { type: this._action.type }), + }, }); } } diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 494f8902..1597a068 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -717,7 +717,7 @@ export class MenuButtonController { style: isOn ? this._getEmphasizedStyle() : {}, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.menu.buttons.ptz_controls'), - tap_action: createPTZControlsAction(!isOn), + tap_action: createPTZControlsAction({ enabled: !isOn }), }; } return null; diff --git a/src/components-lib/ptz/drag-controller.ts b/src/components-lib/ptz/drag-controller.ts new file mode 100644 index 00000000..41fdb55e --- /dev/null +++ b/src/components-lib/ptz/drag-controller.ts @@ -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> | 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; + } +} diff --git a/src/components-lib/ptz/ptz-controller.ts b/src/components-lib/ptz/ptz-controller.ts index a220c80a..18d71f2c 100644 --- a/src/components-lib/ptz/ptz-controller.ts +++ b/src/components-lib/ptz/ptz-controller.ts @@ -3,10 +3,17 @@ import { dispatchActionExecutionRequest } from '../../card-controller/actions/ut import { SubmenuInteraction } from '../../components/submenu/types.js'; import { PTZAction } from '../../config/schema/actions/custom/ptz.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 { Interaction } from '../../types.js'; -import { createPTZMultiAction, getActionConfigGivenAction } from '../../utils/action.js'; +import { + createPTZControlsAction, + createPTZMultiAction, + getActionConfigGivenAction, +} from '../../utils/action.js'; import { PTZControllerActions } from './types'; 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 { return this._forceVisibility !== undefined ? this._forceVisibility : this._config?.mode === 'auto' - ? !!this._cameraID && - !!this._cameraManager - ?.getCameraCapabilities(this._cameraID) - ?.hasPTZCapability() + ? this.hasPhysicalPTZ() : this._config?.mode === 'on'; } diff --git a/src/components-lib/ptz/types.ts b/src/components-lib/ptz/types.ts index 910798ab..96ee4ea4 100644 --- a/src/components-lib/ptz/types.ts +++ b/src/components-lib/ptz/types.ts @@ -1,8 +1,10 @@ import { PTZControlAction } from '../../config/schema/actions/custom/ptz'; import { Actions } from '../../config/schema/actions/types'; +import { PTZControlType } from '../../config/schema/common/controls/ptz'; interface PTZControlsViewContext { enabled?: boolean; + type?: PTZControlType; } declare module 'view' { interface ViewContext { diff --git a/src/components-lib/zoom/zoom-controller.ts b/src/components-lib/zoom/zoom-controller.ts index b8aba3db..39abce9b 100644 --- a/src/components-lib/zoom/zoom-controller.ts +++ b/src/components-lib/zoom/zoom-controller.ts @@ -19,12 +19,13 @@ export class ZoomController { // Is the controller zoomed in at all? 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? 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 _settings: PartialZoomSettings | null; @@ -198,6 +199,14 @@ export class ZoomController { this._debouncedUpdater(); } + public isActivated(): boolean { + return !!this._panzoom; + } + + public setZoom(value: boolean): void { + this._zoom = value; + } + private _changeHandler(ev: Event): void { const pz = (>ev).detail; const unzoomed = this._isUnzoomed(pz.scale); @@ -439,6 +448,9 @@ export class ZoomController { } private _shouldZoomOrPan(ev: Event): boolean { + if (!this._zoom) { + return false; + } return ( !this._isUnzoomed(this._panzoom?.getScale()) || // TouchEvent does not exist on Firefox on non-touch events. See: diff --git a/src/components/live/carousel.ts b/src/components/live/carousel.ts index 3da43fdf..a40e9c11 100644 --- a/src/components/live/carousel.ts +++ b/src/components/live/carousel.ts @@ -15,8 +15,13 @@ import { MicrophoneState } from '../../card-controller/types.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { MediaActionsController } from '../../components-lib/media-actions-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 { 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 { LiveConfig } from '../../config/schema/live.js'; import { CardWideConfig, configDefaults } from '../../config/schema/types.js'; @@ -32,7 +37,6 @@ import '../carousel'; import { EmblaCarouselPlugins } from '../carousel.js'; import '../next-prev-control.js'; import '../ptz.js'; -import { AdvancedCameraCardPTZ } from '../ptz.js'; import './provider.js'; const ADVANCED_CAMERA_CARD_LIVE_PROVIDER = 'advanced-camera-card-live-provider'; @@ -70,14 +74,13 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { @property({ attribute: false }) public viewFilterCameraID?: string; - // Index between camera name and slide number. - private _cameraToSlide: Record = {}; - private _refPTZControl: Ref = createRef(); private _refCarousel: Ref = createRef(); private _mediaActionsController = new MediaActionsController(); private _mediaHeightController = new MediaHeightController(this, '.embla__slide'); + private _ptzDragController = new PTZDragController(this); + @state() private _mediaHasLoaded = false; @@ -96,10 +99,38 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { super.disconnectedCallback(); } - private _getTransitionEffect(): TransitionEffect { - return this.liveConfig?.transition_effect ?? configDefaults.live.transition_effect; + private _getDisplayPTZType(cameraID: string | null): PTZControlType { + 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 { if (this.viewFilterCameraID) { // If the carousel is limited to a single cameraID, the first (only) @@ -144,21 +175,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { return [AutoMediaLoadedInfo()]; } - /** - * 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] { + private _getSlides(): TemplateResult[] { if (!this.cameraManager) { - return [[], {}]; + return []; } const view = this.viewManagerEpoch?.manager.getView(); @@ -167,16 +186,13 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { : this.cameraManager?.getStore().getCameraIDsWithCapability('live'); const slides: TemplateResult[] = []; - const cameraToSlide: Record = {}; - for (const cameraID of cameraIDs ?? []) { const slide = this._renderLive(this._getSubstreamCameraID(cameraID, view)); if (slide) { - cameraToSlide[cameraID] = slides.length; slides.push(slide); } } - return [slides, cameraToSlide]; + return slides; } private _setViewHandler(ev: CustomEvent): void { @@ -221,6 +237,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { .hass=${this.hass} .cardWideConfig=${this.cardWideConfig} .zoomSettings=${view?.context?.zoom?.[cameraID]?.requested} + .zoom=${!this._isGesturesPTZActive(view, cameraID)} @advanced-camera-card:zoom:change=${(ev: CustomEvent) => handleZoomSettingsObservedEvent( ev, @@ -312,8 +329,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { return; } - const [slides, cameraToSlide] = this._getSlides(); - this._cameraToSlide = cameraToSlide; + const slides = this._getSlides(); if (!slides.length) { return; } @@ -321,6 +337,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { const hasMultipleCameras = slides.length > 1; const neighbors = this._getCameraNeighbors(); + const streamAwareCameraID = getStreamCameraID(view, this.viewFilterCameraID); + const gesturesPTZActive = this._isGesturesPTZActive(view, streamAwareCameraID); + const forcePTZVisibility = !this._mediaHasLoaded || (!!this.viewFilterCameraID && this.viewFilterCameraID !== view.camera) || @@ -328,6 +347,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { ? false : view.context?.ptzControls?.enabled; + const dragEnabled = + hasMultipleCameras && this.liveConfig?.draggable && !gesturesPTZActive; + // Notes on the below: // - guard() is used to avoid reseting the carousel unless the // options/plugins actually change. @@ -336,7 +358,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { `; @@ -402,6 +425,18 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { if (rootChanged || changedProperties.has('viewManagerEpoch')) { 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 { diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index 4cec5f2e..47714329 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -61,9 +61,15 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP @property({ attribute: false }) public zoomSettings?: PartialZoomSettings | null; + @property({ attribute: false }) + public zoom = true; + @state() private _isVideoMediaLoaded = false; + @state() + private _zoomed = false; + @state() private _hasProviderError = false; @@ -177,6 +183,16 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP 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 { const config = this.camera?.getConfig(); const intermediateTemplate = html` - (await this.getMediaPlayerController())?.setControls(false)} - @advanced-camera-card:zoom:unzoomed=${async () => - (await this.getMediaPlayerController())?.setControls()} + .zoom=${this.zoom} + @advanced-camera-card:zoom:zoomed=${() => (this._zoomed = true)} + @advanced-camera-card:zoom:unzoomed=${() => (this._zoomed = false)} > ${intermediateTemplate} ` @@ -303,7 +318,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP class=${classMap(classes)} .hass=${this.hass} .cameraConfig=${cameraConfig} - ?controls=${this.liveConfig.controls.builtin} + ?controls=${this._getEffectiveBuiltinControls()} @advanced-camera-card:live:error=${() => this._providerErrorHandler()} > ` @@ -316,7 +331,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP .cameraEndpoints=${this.cameraEndpoints} .microphoneState=${this.microphoneState} .microphoneConfig=${this.liveConfig.microphone} - ?controls=${this.liveConfig.controls.builtin} + ?controls=${this._getEffectiveBuiltinControls()} @advanced-camera-card:live:error=${() => this._providerErrorHandler()} > ` @@ -328,7 +343,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP .cameraConfig=${cameraConfig} .cameraEndpoints=${this.cameraEndpoints} .cardWideConfig=${this.cardWideConfig} - ?controls=${this.liveConfig.controls.builtin} + ?controls=${this._getEffectiveBuiltinControls()} @advanced-camera-card:live:error=${() => this._providerErrorHandler()} > ` diff --git a/src/components/ptz.ts b/src/components/ptz.ts index 873e1315..1eb4dff0 100644 --- a/src/components/ptz.ts +++ b/src/components/ptz.ts @@ -1,9 +1,9 @@ import { CSSResultGroup, + html, LitElement, PropertyValues, TemplateResult, - html, unsafeCSS, } from 'lit'; 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 { PTZControllerActions } from '../components-lib/ptz/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 { localize } from '../localize/localize.js'; import ptzStyle from '../scss/ptz.scss'; @@ -40,6 +43,9 @@ export class AdvancedCameraCardPTZ extends LitElement { @property({ attribute: false }) public forceVisibility?: boolean; + @property({ attribute: false }) + public type?: PTZControlType; + private _controller = new PTZController(this); private _actions: PTZControllerActions | null = null; @@ -106,8 +112,10 @@ export class AdvancedCameraCardPTZ extends LitElement { : null; const config = this._controller.getConfig(); + const isGestures = this.type === 'gestures'; return html`
- ${!config?.hide_pan_tilt && + ${!isGestures && + !config?.hide_pan_tilt && (this._actions?.left || this._actions?.right || this._actions?.up || @@ -119,13 +127,17 @@ export class AdvancedCameraCardPTZ extends LitElement { ${renderIcon('down', 'mdi:arrow-down', { actions: this._actions?.down })}
` : ''} - ${!config?.hide_zoom && (this._actions?.zoom_in || this._actions?.zoom_out) + ${!isGestures && + !config?.hide_zoom && + (this._actions?.zoom_in || this._actions?.zoom_out) ? html`
${renderIcon('zoom_in', 'mdi:plus', { actions: this._actions.zoom_in })} ${renderIcon('zoom_out', 'mdi:minus', { actions: this._actions.zoom_out })}
` : html``} - ${!config?.hide_home && (this._actions?.home || presetSubmenuItems?.length) + ${!isGestures && + !config?.hide_home && + (this._actions?.home || presetSubmenuItems?.length) ? html`
${renderIcon('home', 'mdi:home', { actions: this._actions?.home })} ${presetSubmenuItems?.length @@ -149,6 +161,26 @@ export class AdvancedCameraCardPTZ extends LitElement { : ''}
` : ''} + ${ + // 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`
this._controller.toggleTypeHandler(ev, this.type)} + > + +
` + : '' + } `; } diff --git a/src/components/zoomer.ts b/src/components/zoomer.ts index 9ba06131..e2daa9fa 100644 --- a/src/components/zoomer.ts +++ b/src/components/zoomer.ts @@ -13,17 +13,20 @@ import { PartialZoomSettings } from '../components-lib/zoom/types.js'; @customElement('advanced-camera-card-zoomer') export class AdvancedCameraCardZoomer extends LitElement { - private _zoom: ZoomController | null = null; - @property({ attribute: false }) public defaultSettings?: PartialZoomSettings; @property({ attribute: false }) public settings?: PartialZoomSettings | null; + @property({ attribute: false }) + public zoom = true; + @state() private _zoomed = false; + private _zoomController = new ZoomController(this); + private _zoomHandler = () => (this._zoomed = true); private _unzoomHandler = () => (this._zoomed = false); @@ -37,7 +40,7 @@ export class AdvancedCameraCardZoomer extends LitElement { } disconnectedCallback(): void { - this._zoom?.deactivate(); + this._zoomController.deactivate(); this.removeEventListener('advanced-camera-card:zoom:zoomed', this._zoomHandler); this.removeEventListener('advanced-camera-card:zoom:unzoomed', this._unzoomHandler); super.disconnectedCallback(); @@ -48,22 +51,19 @@ export class AdvancedCameraCardZoomer extends LitElement { setOrRemoveAttribute(this, this._zoomed, 'zoomed'); } - if (this._zoom) { - if (changedProps.has('defaultSettings')) { - this._zoom.setDefaultSettings(this.defaultSettings ?? null); - } - // If config is null, make no change to the zoom. - if (changedProps.has('settings') && this.settings) { - this._zoom.setSettings(this.settings); - } - } else { - // Ensure that the configuration will be set before activation (vs - // activating in `connectedCallback`). - this._zoom = new ZoomController(this, { - config: this.settings, - defaultConfig: this.defaultSettings, - }); - this._zoom.activate(); + if (changedProps.has('zoom')) { + this._zoomController.setZoom(this.zoom); + } + if (changedProps.has('defaultSettings')) { + this._zoomController.setDefaultSettings(this.defaultSettings ?? null); + } + // If config is null, make no change to the zoom. + if (changedProps.has('settings') && this.settings) { + this._zoomController.setSettings(this.settings); + } + + if (!this._zoomController.isActivated()) { + this._zoomController.activate(); } } @@ -77,7 +77,6 @@ export class AdvancedCameraCardZoomer extends LitElement { width: 100%; height: 100%; display: block; - cursor: auto; } :host([zoomed]) { cursor: move; diff --git a/src/config/management.ts b/src/config/management.ts index ac71c3a8..44e790f9 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -633,7 +633,9 @@ const ptzControlSettingsTransform = (data: unknown): unknown => { 'hide_pan_tilt', 'hide_zoom', 'hide_home', + 'hide_type', 'style', + 'type', ]; const keys = Object.keys(data); diff --git a/src/config/schema/actions/custom/ptz-controls.ts b/src/config/schema/actions/custom/ptz-controls.ts index 227100a7..f8ef5307 100644 --- a/src/config/schema/actions/custom/ptz-controls.ts +++ b/src/config/schema/actions/custom/ptz-controls.ts @@ -1,9 +1,11 @@ import { z } from 'zod'; +import { PTZ_CONTROL_TYPES } from '../../common/controls/ptz'; import { advancedCameraCardCustomActionsBaseSchema } from './base'; export const ptzControlsActionConfigSchema = advancedCameraCardCustomActionsBaseSchema.extend({ 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; diff --git a/src/config/schema/actions/custom/ptz.ts b/src/config/schema/actions/custom/ptz.ts index 1b8e7b08..bcbdaadd 100644 --- a/src/config/schema/actions/custom/ptz.ts +++ b/src/config/schema/actions/custom/ptz.ts @@ -1,10 +1,17 @@ import { z } from 'zod'; 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]; 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; export type PTZBaseAction = (typeof PTZ_BASE_ACTIONS)[number]; diff --git a/src/config/schema/common/controls/ptz.ts b/src/config/schema/common/controls/ptz.ts index 57917f19..df250732 100644 --- a/src/config/schema/common/controls/ptz.ts +++ b/src/config/schema/common/controls/ptz.ts @@ -1,12 +1,17 @@ import { z } from 'zod'; +export const PTZ_CONTROL_TYPES = ['buttons', 'gestures'] as const; +export type PTZControlType = (typeof PTZ_CONTROL_TYPES)[number]; + export const ptzControlsDefaults = { orientation: 'horizontal' as const, mode: 'auto' as const, hide_pan_tilt: false, hide_zoom: false, hide_home: false, + hide_type: false, position: 'bottom-right' as const, + type: 'buttons' as const, }; 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_zoom: z.boolean().default(ptzControlsDefaults.hide_zoom), 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(), }); diff --git a/src/const.ts b/src/const.ts index b763f909..3a4f020c 100644 --- a/src/const.ts +++ b/src/const.ts @@ -290,6 +290,8 @@ export const CONF_LIVE_CONTROLS_PTZ_HIDE_HOME = `${CONF_LIVE}.controls.ptz.hide_home` as const; export const CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT = `${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 = `${CONF_LIVE}.controls.ptz.hide_zoom` 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; export const CONF_LIVE_CONTROLS_PTZ_POSITION = `${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_THUMBNAILS_MODE = diff --git a/src/editor.ts b/src/editor.ts index e96019d4..e8d5ee11 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -121,10 +121,12 @@ import { CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_LIVE_CONTROLS_PTZ_HIDE_HOME, CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT, + CONF_LIVE_CONTROLS_PTZ_HIDE_TYPE, CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM, CONF_LIVE_CONTROLS_PTZ_MODE, CONF_LIVE_CONTROLS_PTZ_ORIENTATION, CONF_LIVE_CONTROLS_PTZ_POSITION, + CONF_LIVE_CONTROLS_PTZ_TYPE, CONF_LIVE_CONTROLS_THUMBNAILS_MODE, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, 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[] = [ { value: '', label: '' }, { @@ -3225,6 +3239,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard CONF_LIVE_CONTROLS_PTZ_MODE, this._ptzModes, )} + ${this._renderOptionSelector( + CONF_LIVE_CONTROLS_PTZ_TYPE, + this._ptzTypes, + )} ${this._renderOptionSelector( CONF_LIVE_CONTROLS_PTZ_POSITION, this._ptzPositions, @@ -3254,6 +3272,13 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard 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'), + }, + )} `, )} `, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 4d40c565..0f2d24bb 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -47,8 +47,8 @@ }, "disable": "Disable", "disable_except": "Disable except", - "force": "Force", - "editor_label": "Camera capabilities" + "editor_label": "Camera capabilities", + "force": "Force" }, "cast": { "dashboard": { @@ -388,6 +388,7 @@ "editor_label": "PTZ", "hide_home": "Hide home & preset controls", "hide_pan_tilt": "Hide pan & tilt control", + "hide_type": "Hide type toggle button", "hide_zoom": "Hide zoom control", "mode": "Mode", "modes": { @@ -405,6 +406,11 @@ "bottom-right": "Bottom right", "top-left": "Top left", "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", "delete": "Delete", "dimensions": "Card dimensions", - "docs": "Documentation", "dimensions_secondary": "Card dimensions & shape options", + "docs": "Documentation", "folders": "Folders", "folders_secondary": "What folders to render on this card", "image": "Image", @@ -692,11 +698,11 @@ "profiles_secondary": "Choose pre-configured sets of defaults", "remote_control": "Remote Control", "remote_control_secondary": "Options for remote controlling the card", - "toggle_diagnostics": "Toggle diagnostics", "status_bar": "Status bar", "status_bar_secondary": "Status bar look & feel options", "timeline": "Timeline", "timeline_secondary": "Event timeline options", + "toggle_diagnostics": "Toggle diagnostics", "upgrade": "Automatic Upgrade", "upgrade_available": "An automatic card configuration upgrade is available", "view": "View", @@ -709,6 +715,7 @@ "left": "Left", "presets": "Presets", "right": "Right", + "type": "PTZ Type", "up": "Up", "zoom_in": "Zoom In", "zoom_out": "Zoom Out" diff --git a/src/scss/ptz.scss b/src/scss/ptz.scss index 0f00c6f7..34c8285c 100644 --- a/src/scss/ptz.scss +++ b/src/scss/ptz.scss @@ -46,7 +46,8 @@ .ptz-move, .ptz-zoom, -.ptz-presets { +.ptz-presets, +.ptz-type { position: relative; transition: @@ -70,16 +71,19 @@ } :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); } :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); } .ptz-zoom, -.ptz-presets { +.ptz-presets, +.ptz-type { border-radius: var(--advanced-camera-card-border-radius-final); } @@ -119,16 +123,27 @@ advanced-camera-card-submenu:not(.disabled) { } .ptz-presets, -.ptz-zoom { +.ptz-zoom, +.ptz-type { display: flex; align-items: center; justify-content: space-evenly; } :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; } :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; } + +.ptz-type { + cursor: pointer; + + advanced-camera-card-icon.selected { + color: var(--advanced-camera-card-ptz-color-selected); + } +} diff --git a/src/scss/themes/base.scss b/src/scss/themes/base.scss index 054cebca..02339617 100644 --- a/src/scss/themes/base.scss +++ b/src/scss/themes/base.scss @@ -280,6 +280,11 @@ var(--advanced-camera-card-ptz-background), 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 diff --git a/src/utils/action.ts b/src/utils/action.ts index e68809c9..767aad22 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -34,6 +34,7 @@ import { AdvancedCameraCardCustomActionConfig, } from '../config/schema/actions/types.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 type { EffectName } from '../types.js'; import { arrayify } from './basic.js'; @@ -111,16 +112,16 @@ export function createDisplayModeAction( }; } -export function createPTZControlsAction( - enabled: boolean, - options?: { - cardID?: string; - }, -): PTZControlsActionConfig { +export function createPTZControlsAction(options?: { + cardID?: string; + enabled?: boolean; + type?: PTZControlType; +}): PTZControlsActionConfig { return { action: 'fire-dom-event', 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 }), }; } diff --git a/tests/card-controller/actions/actions/ptz-controls.test.ts b/tests/card-controller/actions/actions/ptz-controls.test.ts index 9cc22469..51c5ad03 100644 --- a/tests/card-controller/actions/actions/ptz-controls.test.ts +++ b/tests/card-controller/actions/actions/ptz-controls.test.ts @@ -1,21 +1,106 @@ -import { expect, it } from 'vitest'; -import { createCardAPI } from '../../../test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; 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 () => { - const api = createCardAPI(); - const action = new PTZControlsAction( - {}, - { - action: 'fire-dom-event', - advanced_camera_card_action: 'ptz_controls', - enabled: true, - }, - ); +describe('PTZControlsAction', () => { + it('should set enabled explicitly', async () => { + const api = createCardAPI(); + const action = new PTZControlsAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_controls', + enabled: true, + type: 'buttons', + }, + ); - await action.execute(api); + await action.execute(api); - expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith( - expect.objectContaining({ ptzControls: { enabled: true } }), - ); + expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({ + ptzControls: { enabled: true, type: 'buttons' }, + }); + }); + + it('should toggle enabled when not specified', async () => { + const api = createCardAPI(); + const view = mock(); + 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.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' }, + }); + }); }); diff --git a/tests/components-lib/ptz/drag-controller.test.ts b/tests/components-lib/ptz/drag-controller.test.ts new file mode 100644 index 00000000..f5e4537d --- /dev/null +++ b/tests/components-lib/ptz/drag-controller.test.ts @@ -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'); + }); + }); +}); diff --git a/tests/components-lib/ptz/ptz-controller.test.ts b/tests/components-lib/ptz/ptz-controller.test.ts index ecd8e3d3..f55a7b61 100644 --- a/tests/components-lib/ptz/ptz-controller.test.ts +++ b/tests/components-lib/ptz/ptz-controller.test.ts @@ -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', () => { it.each([ ['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', () => { it('successfully', () => { const action = { diff --git a/tests/components-lib/zoom/zoom-controller.test.ts b/tests/components-lib/zoom/zoom-controller.test.ts index 97ca3a48..d89a2ee6 100644 --- a/tests/components-lib/zoom/zoom-controller.test.ts +++ b/tests/components-lib/zoom/zoom-controller.test.ts @@ -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', () => { const element = document.createElement('div'); vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom()); diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index 3ae236c0..6a66b177 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -977,6 +977,25 @@ describe('should handle version specific upgrades', () => { }); 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', () => { diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 8bc9930e..c2e161bc 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -92,8 +92,10 @@ describe('config defaults', () => { style: 'chevrons', }, ptz: { + type: 'buttons', hide_home: false, hide_pan_tilt: false, + hide_type: false, hide_zoom: false, mode: 'auto', orientation: 'horizontal', @@ -163,8 +165,10 @@ describe('config defaults', () => { style: 'thumbnails', }, ptz: { + type: 'buttons', hide_home: false, hide_pan_tilt: false, + hide_type: false, hide_zoom: false, mode: 'off', orientation: 'horizontal', @@ -1072,7 +1076,6 @@ describe('config defaults', () => { { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'ptz_controls', - enabled: true, }, { action: 'custom:advanced-camera-card-action', diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index adb97a80..f53e75ca 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -110,9 +110,10 @@ describe('createDisplayModeAction', () => { }); describe('createPTZControlsAction', () => { - it('should create PTZ controls action', () => { + it('should create PTZ controls action with enabled', () => { expect( - createPTZControlsAction(true, { + createPTZControlsAction({ + enabled: true, cardID: 'card_id', }), ).toEqual({ @@ -122,6 +123,18 @@ describe('createPTZControlsAction', () => { 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', () => { diff --git a/yarn.lock b/yarn.lock index 51e5c877..bcb55f31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2308,6 +2308,22 @@ __metadata: languageName: node 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": version: 1.6.0 resolution: "@vitest/coverage-istanbul@npm:1.6.0" @@ -2491,6 +2507,7 @@ __metadata: "@types/masonry-layout": "npm:^4.2.8" "@typescript-eslint/eslint-plugin": "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" any-date-parser: "npm:^2.2.0" component-emitter: "npm:^1.3.1"