feat: Add support for rotating camera streams (#2149)

* Closes #1307
This commit is contained in:
Dermot Duffy
2025-08-23 17:30:53 -07:00
committed by GitHub
parent 69ae80d6f1
commit 4d4b64232a
25 changed files with 1393 additions and 764 deletions
+30 -54
View File
@@ -1,17 +1,9 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { CameraManager } from '../camera-manager/manager';
import { ViewManagerEpoch } from '../card-controller/view/types';
import { MediaProviderDimensionsController } from '../components-lib/media-provider-dimensions-controller';
import { ZoomSettingsObserved } from '../components-lib/zoom/types';
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context';
import { CameraConfig } from '../config/schema/cameras';
@@ -22,6 +14,7 @@ import imageStyle from '../scss/image.scss';
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types.js';
import './image-updating-player';
import { resolveImageMode } from './image-updating-player';
import './media-dimensions-container';
import './zoomer.js';
@customElement('advanced-camera-card-image')
@@ -41,28 +34,13 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
@property({ attribute: false })
public imageConfig?: ImageViewConfig;
protected _dimensionsController = new MediaProviderDimensionsController(this);
protected _refImage: Ref<MediaPlayerElement> = createRef();
protected _refContainer: Ref<HTMLElement> = createRef();
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
await this.updateComplete;
return (await this._refImage.value?.getMediaPlayerController()) ?? null;
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('cameraConfig') || changedProps.has('imageConfig')) {
this._dimensionsController.setCameraConfig(
resolveImageMode({
imageConfig: this.imageConfig,
cameraConfig: this.cameraConfig,
}) === 'camera'
? this.cameraConfig?.dimensions
: undefined,
);
}
}
protected _renderContainer(template: TemplateResult): TemplateResult {
const zoomTarget = IMAGE_VIEW_ZOOM_TARGET_SENTINEL;
const view = this.viewManagerEpoch?.manager.getView();
@@ -71,33 +49,35 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
cameraConfig: this.cameraConfig,
});
return html`<div class="container" ${ref(this._refContainer)}>
${this.imageConfig?.zoomable
? html`<advanced-camera-card-zoomer
.defaultSettings=${guard(
[this.imageConfig, this.cameraConfig?.dimensions?.layout],
() =>
mode === 'camera' && this.cameraConfig?.dimensions?.layout
? {
pan: this.cameraConfig.dimensions.layout.pan,
zoom: this.cameraConfig.dimensions.layout.zoom,
}
: undefined,
const intermediateTemplate = html` <advanced-camera-card-media-dimensions-container
.dimensionsConfig=${mode === 'camera' ? this.cameraConfig?.dimensions : undefined}
>
${template}
</advanced-camera-card-media-dimensions-container>`;
return html` ${this.imageConfig?.zoomable
? html`<advanced-camera-card-zoomer
.defaultSettings=${guard(
[this.imageConfig, this.cameraConfig?.dimensions?.layout],
() =>
mode === 'camera' && this.cameraConfig?.dimensions?.layout
? {
pan: this.cameraConfig.dimensions.layout.pan,
zoom: this.cameraConfig.dimensions.layout.zoom,
}
: undefined,
)}
.settings=${view?.context?.zoom?.[zoomTarget]?.requested}
@advanced-camera-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
handleZoomSettingsObservedEvent(
ev,
this.viewManagerEpoch?.manager,
zoomTarget,
)}
.settings=${view?.context?.zoom?.[zoomTarget]?.requested}
@advanced-camera-card:zoom:change=${(
ev: CustomEvent<ZoomSettingsObserved>,
) =>
handleZoomSettingsObservedEvent(
ev,
this.viewManagerEpoch?.manager,
zoomTarget,
)}
>
${template}
</advanced-camera-card-zoomer>`
: template}
</div>`;
>
${intermediateTemplate}
</advanced-camera-card-zoomer>`
: intermediateTemplate}`;
}
protected render(): TemplateResult | void {
@@ -117,10 +97,6 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
`);
}
public updated(): void {
this._dimensionsController.setContainer(this._refContainer.value);
}
static get styles(): CSSResultGroup {
return unsafeCSS(imageStyle);
}
+37 -55
View File
@@ -14,7 +14,6 @@ import { CameraEndpoints } from '../../camera-manager/types.js';
import { MicrophoneState } from '../../card-controller/types.js';
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
import { MediaProviderDimensionsController } from '../../components-lib/media-provider-dimensions-controller.js';
import { PartialZoomSettings } from '../../components-lib/zoom/types.js';
import { CameraConfig, LiveProvider } from '../../config/schema/cameras.js';
import { LiveConfig } from '../../config/schema/live.js';
@@ -32,6 +31,7 @@ import {
import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
import '../icon.js';
import { renderMessage } from '../message.js';
import './../media-dimensions-container';
@customElement('advanced-camera-card-live-provider')
export class AdvancedCameraCardLiveProvider extends LitElement implements MediaPlayer {
@@ -70,9 +70,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
protected _showStreamTroubleshooting = false;
protected _refProvider: Ref<MediaPlayerElement> = createRef();
protected _refContainer: Ref<HTMLElement> = createRef();
protected _lazyLoadController: LazyLoadController = new LazyLoadController(this);
protected _dimensionsController = new MediaProviderDimensionsController(this);
// A note on dynamic imports:
//
@@ -82,8 +81,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
// underlying code is not yet loaded.
//
// Test case: A card with a non-live view, but live pre-loaded, attempts to
// call mute() when the <advanced-camera-card-live> element first renders in the
// background. These calls fail without waiting for loading here.
// call mute() when the <advanced-camera-card-live> element first renders in
// the background. These calls fail without waiting for loading here.
protected _importPromises: Promise<unknown>[] = [];
constructor() {
@@ -186,8 +185,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
} else if (provider === 'go2rtc') {
this._importPromises.push(import('./providers/go2rtc/index.js'));
}
this._dimensionsController.setCameraConfig(this.cameraConfig?.dimensions);
}
}
@@ -201,29 +198,38 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
}
protected _renderContainer(template: TemplateResult): TemplateResult {
// Place the zoomer in a separate div, as the zoom library misinterprets the
// explicit width/height setting from the provider resizer as zooming.
return html`<div class="container" ${ref(this._refContainer)}>
${this.liveConfig?.zoomable
? html` <advanced-camera-card-zoomer
.defaultSettings=${guard([this.cameraConfig?.dimensions?.layout], () =>
this.cameraConfig?.dimensions?.layout
? {
pan: this.cameraConfig.dimensions.layout.pan,
zoom: this.cameraConfig.dimensions.layout.zoom,
}
: 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()}
>
${template}
</advanced-camera-card-zoomer>`
: template}
</div>`;
const intermediateTemplate = html` <advanced-camera-card-media-dimensions-container
.dimensionsConfig=${this.cameraConfig?.dimensions}
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
if (ev.detail.placeholder) {
ev.stopPropagation();
} else {
this._videoMediaShowHandler();
}
}}
>
${template}
</advanced-camera-card-media-dimensions-container>`;
return html` ${this.liveConfig?.zoomable
? html` <advanced-camera-card-zoomer
.defaultSettings=${guard([this.cameraConfig?.dimensions?.layout], () =>
this.cameraConfig?.dimensions?.layout
? {
pan: this.cameraConfig.dimensions.layout.pan,
zoom: this.cameraConfig.dimensions.layout.zoom,
}
: 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()}
>
${intermediateTemplate}
</advanced-camera-card-zoomer>`
: intermediateTemplate}`;
}
protected render(): TemplateResult | void {
@@ -304,17 +310,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
})}
@advanced-camera-card:live:error=${() => this._providerErrorHandler()}
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
if (provider === 'image') {
// Only count the media has loaded if the required provider is
// the image (not just the temporary image shown during
// loading).
this._videoMediaShowHandler();
} else {
// Manually call resize(), since the dimensions controller won't
// receive the after that stopPropagation().
this._dimensionsController.resize();
ev.stopPropagation();
}
ev.detail.placeholder = provider !== 'image';
}}
>
</advanced-camera-card-live-image>`
@@ -327,7 +323,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
.cameraConfig=${this.cameraConfig}
?controls=${this.liveConfig.controls.builtin}
@advanced-camera-card:live:error=${() => this._providerErrorHandler()}
@advanced-camera-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
>
</advanced-camera-card-live-ha>`
: provider === 'go2rtc'
@@ -341,9 +336,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
.microphoneConfig=${this.liveConfig.microphone}
?controls=${this.liveConfig.controls.builtin}
@advanced-camera-card:live:error=${() => this._providerErrorHandler()}
@advanced-camera-card:media:loaded=${this._videoMediaShowHandler.bind(
this,
)}
>
</advanced-camera-card-live-go2rtc>`
: provider === 'webrtc-card'
@@ -356,9 +348,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
.cardWideConfig=${this.cardWideConfig}
?controls=${this.liveConfig.controls.builtin}
@advanced-camera-card:live:error=${() => this._providerErrorHandler()}
@advanced-camera-card:media:loaded=${this._videoMediaShowHandler.bind(
this,
)}
>
</advanced-camera-card-live-webrtc-card>`
: provider === 'jsmpeg'
@@ -370,9 +359,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
.cameraEndpoints=${this.cameraEndpoints}
.cardWideConfig=${this.cardWideConfig}
@advanced-camera-card:live:error=${() => this._providerErrorHandler()}
@advanced-camera-card:media:loaded=${this._videoMediaShowHandler.bind(
this,
)}
>
</advanced-camera-card-live-jsmpeg>`
: html``}
@@ -402,10 +388,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
: ''}`;
}
public updated(): void {
this._dimensionsController.setContainer(this._refContainer.value);
}
static get styles(): CSSResultGroup {
return unsafeCSS(liveProviderStyle);
}
@@ -0,0 +1,57 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { MediaDimensionsContainerController } from '../components-lib/media-dimensions-container-controller.js';
import { CameraDimensionsConfig } from '../config/schema/cameras';
import mediaDimensionsContainerStyle from '../scss/media-dimensions-container.scss';
@customElement('advanced-camera-card-media-dimensions-container')
export class AdvancedCameraCardMediaDimensionsContainer extends LitElement {
@property({ attribute: false })
public dimensionsConfig?: CameraDimensionsConfig;
protected _controller = new MediaDimensionsContainerController(this);
protected _refInnerContainer: Ref<HTMLElement> = createRef();
protected _refOuterContainer: Ref<HTMLElement> = createRef();
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('dimensionsConfig')) {
this._controller.setConfig(this.dimensionsConfig);
}
}
protected render(): TemplateResult | void {
return html`
<div class="outer" ${ref(this._refOuterContainer)}>
<div class="inner" ${ref(this._refInnerContainer)}>
<slot></slot>
</div>
</div>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(mediaDimensionsContainerStyle);
}
public updated(): void {
this._controller.setContainers(
this._refInnerContainer.value,
this._refOuterContainer.value,
);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-media-dimensions-container': AdvancedCameraCardMediaDimensionsContainer;
}
}
+11 -18
View File
@@ -12,7 +12,6 @@ import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
import { MediaProviderDimensionsController } from '../../components-lib/media-provider-dimensions-controller.js';
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
import { CameraConfig } from '../../config/schema/cameras.js';
@@ -38,6 +37,7 @@ import { QueryClassifier } from '../../view/query-classifier.js';
import '../image-player.js';
import { renderProgressIndicator } from '../progress-indicator.js';
import '../video-player.js';
import './../media-dimensions-container';
@customElement('advanced-camera-card-viewer-provider')
export class AdvancedCameraCardViewerProvider extends LitElement implements MediaPlayer {
@@ -65,7 +65,6 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
protected _refProvider: Ref<MediaPlayerElement> = createRef();
protected _refContainer: Ref<HTMLElement> = createRef();
protected _lazyLoadController: LazyLoadController = new LazyLoadController(this);
protected _dimensionsController = new MediaProviderDimensionsController(this);
@state()
protected _url: string | null = null;
@@ -201,12 +200,6 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) {
import('../zoomer.js');
}
if (changedProps.has('media') || changedProps.has('cameraManager')) {
this._dimensionsController.setCameraConfig(
this._getRelevantCameraConfig()?.dimensions,
);
}
}
private _getRelevantCameraConfig(): CameraConfig | null {
@@ -227,9 +220,13 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
: null;
const view = this.viewManagerEpoch?.manager.getView();
// Place the zoomer in a separate div, as the zoom library misinterprets the
// explicit width/height setting from the provider resizer as zooming.
return html`<div class="container" ${ref(this._refContainer)}>
const intermediateTemplate = html` <advanced-camera-card-media-dimensions-container
.dimensionsConfig=${this._getRelevantCameraConfig()?.dimensions}
>
${template}
</advanced-camera-card-media-dimensions-container>`;
return html`
${this.viewerConfig?.zoomable
? html`<advanced-camera-card-zoomer
.defaultSettings=${guard([cameraConfig?.dimensions?.layout], () =>
@@ -254,10 +251,10 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
mediaID,
)}
>
${template}
${intermediateTemplate}
</advanced-camera-card-zoomer>`
: template}
</div>`;
: intermediateTemplate}
`;
}
protected render(): TemplateResult | void {
@@ -319,10 +316,6 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
`);
}
public updated(): void {
this._dimensionsController.setContainer(this._refContainer.value);
}
static get styles(): CSSResultGroup {
return unsafeCSS(viewerProviderStyle);
}