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
+10 -10
View File
@@ -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(
@@ -6,8 +6,25 @@ export class PTZControlsAction extends AdvancedCameraCardAction<PTZControlsActio
public async execute(api: CardActionsAPI): Promise<void> {
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 }),
},
});
}
}
+1 -1
View File
@@ -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;
+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 { 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';
}
+2
View File
@@ -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 {
+15 -3
View File
@@ -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 = (<CustomEvent<PanzoomEventDetail>>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:
+63 -28
View File
@@ -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<string, number> = {};
private _refPTZControl: Ref<AdvancedCameraCardPTZ> = createRef();
private _refCarousel: Ref<HTMLElement> = 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<string, number>] {
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<string, number> = {};
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<CarouselSelected>): 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<ZoomSettingsObserved>) =>
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 {
<advanced-camera-card-carousel
${ref(this._refCarousel)}
.loop=${hasMultipleCameras}
.dragEnabled=${hasMultipleCameras && this.liveConfig?.draggable}
.dragEnabled=${dragEnabled}
.plugins=${guard(
[this.cameraManager, this.liveConfig],
this._getPlugins.bind(this),
@@ -363,8 +385,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
.hass=${this.hass}
.config=${this.liveConfig.controls.ptz}
.cameraManager=${this.cameraManager}
.cameraID=${getStreamCameraID(view, this.viewFilterCameraID)}
.cameraID=${streamAwareCameraID}
.forceVisibility=${forcePTZVisibility}
.type=${this._getDisplayPTZType(streamAwareCameraID)}
>
</advanced-camera-card-ptz>
`;
@@ -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 {
+22 -7
View File
@@ -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` <advanced-camera-card-media-dimensions-container
@@ -203,10 +219,9 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
: undefined,
)}
.settings=${this.zoomSettings}
@advanced-camera-card:zoom:zoomed=${async () =>
(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}
</advanced-camera-card-zoomer>`
@@ -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()}
>
</advanced-camera-card-live-ha>`
@@ -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()}
>
</advanced-camera-card-live-go2rtc>`
@@ -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()}
>
</advanced-camera-card-live-webrtc-card>`
+37 -5
View File
@@ -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` <div class="ptz">
${!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 })}
</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">
${renderIcon('zoom_in', 'mdi:plus', { actions: this._actions.zoom_in })}
${renderIcon('zoom_out', 'mdi:minus', { actions: this._actions.zoom_out })}
</div>`
: html``}
${!config?.hide_home && (this._actions?.home || presetSubmenuItems?.length)
${!isGestures &&
!config?.hide_home &&
(this._actions?.home || presetSubmenuItems?.length)
? html`<div class="ptz-presets">
${renderIcon('home', 'mdi:home', { actions: this._actions?.home })}
${presetSubmenuItems?.length
@@ -149,6 +161,26 @@ export class AdvancedCameraCardPTZ extends LitElement {
: ''}
</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>`;
}
+19 -20
View File
@@ -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;
+2
View File
@@ -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);
@@ -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<typeof ptzControlsActionConfigSchema>;
+8 -1
View File
@@ -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];
+8
View File
@@ -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(),
});
+3
View File
@@ -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 =
+25
View File
@@ -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'),
},
)}
`,
)}
`,
+11 -4
View File
@@ -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"
+22 -7
View File
@@ -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);
}
}
+5
View File
@@ -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
+8 -7
View File
@@ -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 }),
};
}