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
+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 {