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