fix: Camera aspect-ratio should always apply (#2114)

- Closes #2073
This commit is contained in:
Dermot Duffy
2025-07-13 14:56:03 -07:00
committed by GitHub
parent d0bfb97843
commit c3e7cef9cf
29 changed files with 812 additions and 201 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ export const resolveImageMode = (options?: {
};
/**
* A media player to wrap a image that updates continuously.
* A media player to wrap an image that updates continuously.
*/
@customElement('advanced-camera-card-image-updating-player')
export class AdvancedCameraCardImageUpdatingPlayer
+41 -41
View File
@@ -11,6 +11,7 @@ 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';
@@ -19,8 +20,6 @@ import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const';
import { HomeAssistant } from '../ha/types';
import imageStyle from '../scss/image.scss';
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types.js';
import { aspectRatioToString } from '../utils/basic';
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
import './image-updating-player';
import { resolveImageMode } from './image-updating-player';
import './zoomer.js';
@@ -42,36 +41,29 @@ 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 _refImage: Ref<MediaPlayerElement> = createRef();
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('cameraConfig') || changedProps.has('imageConfig')) {
if (
this._dimensionsController.setCameraConfig(
resolveImageMode({
imageConfig: this.imageConfig,
cameraConfig: this.cameraConfig,
}) === 'camera'
) {
updateElementStyleFromMediaLayoutConfig(
this,
this.cameraConfig?.dimensions?.layout,
);
this.style.aspectRatio = aspectRatioToString({
ratio: this.cameraConfig?.dimensions?.aspect_ratio,
});
} else {
updateElementStyleFromMediaLayoutConfig(this);
this.style.removeProperty('aspect-ratio');
}
? this.cameraConfig?.dimensions
: undefined,
);
}
}
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
protected _renderContainer(template: TemplateResult): TemplateResult {
const zoomTarget = IMAGE_VIEW_ZOOM_TARGET_SENTINEL;
const view = this.viewManagerEpoch?.manager.getView();
const mode = resolveImageMode({
@@ -79,29 +71,33 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
cameraConfig: this.cameraConfig,
});
return 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,
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,
)}
>
${template}
</advanced-camera-card-zoomer>`
: template;
.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>`;
}
protected render(): TemplateResult | void {
@@ -109,7 +105,7 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
return;
}
return this._useZoomIfRequired(html`
return this._renderContainer(html`
<advanced-camera-card-image-updating-player
${ref(this._refImage)}
.hass=${this.hass}
@@ -121,6 +117,10 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
`);
}
public updated(): void {
this._dimensionsController.setContainer(this._refContainer.value);
}
static get styles(): CSSResultGroup {
return unsafeCSS(imageStyle);
}
-6
View File
@@ -40,12 +40,6 @@ export class AdvancedCameraCardLive extends LitElement {
return;
}
// Implementation notes:
// - See use of liveConfig and not config below -- the underlying carousel
// will independently override the liveConfig to reflect the camera in the
// carousel (not necessarily the selected camera).
// - Various events are captured to prevent them propagating upwards if the
// card is in the background.
return html`
<advanced-camera-card-live-grid
.hass=${this.hass}
+60 -44
View File
@@ -14,6 +14,7 @@ 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';
@@ -22,15 +23,15 @@ import { STREAM_TROUBLESHOOTING_URL } from '../../const.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import liveProviderStyle from '../../scss/live-provider.scss';
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js';
import { aspectRatioToString } from '../../utils/basic.js';
import {
MediaLoadedInfo,
MediaPlayer,
MediaPlayerController,
MediaPlayerElement,
} from '../../types.js';
import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
import '../icon.js';
import { renderMessage } from '../message.js';
import '../next-prev-control.js';
import '../ptz.js';
import '../surround.js';
@customElement('advanced-camera-card-live-provider')
export class AdvancedCameraCardLiveProvider extends LitElement implements MediaPlayer {
@@ -69,7 +70,9 @@ 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:
//
@@ -184,13 +187,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
this._importPromises.push(import('./providers/go2rtc/index.js'));
}
updateElementStyleFromMediaLayoutConfig(
this,
this.cameraConfig?.dimensions?.layout,
);
this.style.aspectRatio = aspectRatioToString({
ratio: this.cameraConfig?.dimensions?.aspect_ratio,
});
this._dimensionsController.setCameraConfig(this.cameraConfig?.dimensions);
}
}
@@ -203,26 +200,30 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
return result;
}
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
return 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;
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>`;
}
protected render(): TemplateResult | void {
@@ -240,11 +241,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
this.ariaLabel = this.label;
const provider = this._getResolvedProvider();
const showImageDuringLoading = this._shouldShowImageDuringLoading();
const showLoadingIcon = !this._isVideoMediaLoaded;
const providerClasses = {
hidden: showImageDuringLoading,
};
if (
provider === 'ha' ||
@@ -287,20 +283,36 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
}
}
return html`${this._useZoomIfRequired(html`
const showImageDuringLoading = this._shouldShowImageDuringLoading();
const showLoadingIcon = !this._isVideoMediaLoaded;
const classes = {
hidden: showImageDuringLoading,
};
return html`${this._renderContainer(html`
${showImageDuringLoading || provider === 'image'
? html` <advanced-camera-card-live-image
${ref(this._refProvider)}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
class=${classMap({
...classes,
// The image provider is providing the temporary loading image,
// so it should not be hidden.
hidden: false,
})}
@advanced-camera-card:live:error=${() => this._providerErrorHandler()}
@advanced-camera-card:media:loaded=${(ev: Event) => {
@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();
}
}}
@@ -310,7 +322,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
${provider === 'ha'
? html` <advanced-camera-card-live-ha
${ref(this._refProvider)}
class=${classMap(providerClasses)}
class=${classMap(classes)}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
?controls=${this.liveConfig.controls.builtin}
@@ -321,7 +333,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
: provider === 'go2rtc'
? html`<advanced-camera-card-live-go2rtc
${ref(this._refProvider)}
class=${classMap(providerClasses)}
class=${classMap(classes)}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
.cameraEndpoints=${this.cameraEndpoints}
@@ -337,7 +349,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
: provider === 'webrtc-card'
? html`<advanced-camera-card-live-webrtc-card
${ref(this._refProvider)}
class=${classMap(providerClasses)}
class=${classMap(classes)}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
.cameraEndpoints=${this.cameraEndpoints}
@@ -352,7 +364,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
: provider === 'jsmpeg'
? html` <advanced-camera-card-live-jsmpeg
${ref(this._refProvider)}
class=${classMap(providerClasses)}
class=${classMap(classes)}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
.cameraEndpoints=${this.cameraEndpoints}
@@ -390,6 +402,10 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
: ''}`;
}
public updated(): void {
this._dimensionsController.setContainer(this._refContainer.value);
}
static get styles(): CSSResultGroup {
return unsafeCSS(liveProviderStyle);
}
+1 -1
View File
@@ -13,8 +13,8 @@ import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { ViewerConfig } from '../../config/schema/viewer.js';
import { HomeAssistant } from '../../ha/types.js';
import { ResolvedMediaCache } from '../../ha/resolved-media.js';
import { HomeAssistant } from '../../ha/types.js';
import '../../patches/ha-hls-player.js';
import basicBlockStyle from '../../scss/basic-block.scss';
import './carousel';
+52 -34
View File
@@ -12,8 +12,10 @@ 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';
import { CardWideConfig } from '../../config/schema/types.js';
import { ViewerConfig } from '../../config/schema/viewer.js';
import { canonicalizeHAURL } from '../../ha/canonical-url.js';
@@ -29,8 +31,7 @@ import {
import '../../patches/ha-hls-player.js';
import viewerProviderStyle from '../../scss/viewer-provider.scss';
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js';
import { aspectRatioToString, errorToConsole } from '../../utils/basic.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
import { errorToConsole } from '../../utils/basic.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { VideoContentType, ViewMedia } from '../../view/item.js';
import { QueryClassifier } from '../../view/query-classifier.js';
@@ -62,7 +63,9 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
public cardWideConfig?: CardWideConfig;
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;
@@ -200,19 +203,20 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
}
if (changedProps.has('media') || changedProps.has('cameraManager')) {
const cameraID = this.media?.getCameraID();
const cameraConfig = cameraID
? this.cameraManager?.getStore().getCameraConfig(cameraID)
: null;
updateElementStyleFromMediaLayoutConfig(this, cameraConfig?.dimensions?.layout);
this.style.aspectRatio = aspectRatioToString({
ratio: cameraConfig?.dimensions?.aspect_ratio,
});
this._dimensionsController.setCameraConfig(
this._getRelevantCameraConfig()?.dimensions,
);
}
}
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
private _getRelevantCameraConfig(): CameraConfig | null {
const cameraID = this.media?.getCameraID();
return cameraID
? this.cameraManager?.getStore().getCameraConfig(cameraID) ?? null
: null;
}
protected _renderContainer(template: TemplateResult): TemplateResult {
if (!this.media) {
return template;
}
@@ -223,27 +227,37 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
: null;
const view = this.viewManagerEpoch?.manager.getView();
return this.viewerConfig?.zoomable
? html` <advanced-camera-card-zoomer
.defaultSettings=${guard([cameraConfig?.dimensions?.layout], () =>
cameraConfig?.dimensions?.layout
? {
pan: cameraConfig.dimensions.layout.pan,
zoom: cameraConfig.dimensions.layout.zoom,
}
: undefined,
)}
.settings=${mediaID ? view?.context?.zoom?.[mediaID]?.requested : undefined}
@advanced-camera-card:zoom:zoomed=${async () =>
(await this.getMediaPlayerController())?.setControls(false)}
@advanced-camera-card:zoom:unzoomed=${async () =>
(await this.getMediaPlayerController())?.setControls()}
@advanced-camera-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
handleZoomSettingsObservedEvent(ev, this.viewManagerEpoch?.manager, mediaID)}
>
${template}
</advanced-camera-card-zoomer>`
: template;
// 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.viewerConfig?.zoomable
? html`<advanced-camera-card-zoomer
.defaultSettings=${guard([cameraConfig?.dimensions?.layout], () =>
cameraConfig?.dimensions?.layout
? {
pan: cameraConfig.dimensions.layout.pan,
zoom: cameraConfig.dimensions.layout.zoom,
}
: undefined,
)}
.settings=${mediaID ? view?.context?.zoom?.[mediaID]?.requested : undefined}
@advanced-camera-card:zoom:zoomed=${async () =>
(await this.getMediaPlayerController())?.setControls(false)}
@advanced-camera-card:zoom:unzoomed=${async () =>
(await this.getMediaPlayerController())?.setControls()}
@advanced-camera-card:zoom:change=${(
ev: CustomEvent<ZoomSettingsObserved>,
) =>
handleZoomSettingsObservedEvent(
ev,
this.viewManagerEpoch?.manager,
mediaID,
)}
>
${template}
</advanced-camera-card-zoomer>`
: template}
</div>`;
}
protected render(): TemplateResult | void {
@@ -264,7 +278,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
// Note: crossorigin="anonymous" is required on <video> below in order to
// allow screenshot of motionEye videos which currently go cross-origin.
return this._useZoomIfRequired(html`
return this._renderContainer(html`
${ViewItemClassifier.isVideo(this.media)
? this.media.getVideoContentType() === VideoContentType.HLS
? html`<advanced-camera-card-ha-hls-player
@@ -305,6 +319,10 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
`);
}
public updated(): void {
this._dimensionsController.setContainer(this._refContainer.value);
}
static get styles(): CSSResultGroup {
return unsafeCSS(viewerProviderStyle);
}
+4
View File
@@ -12,6 +12,7 @@ import { CameraManager } from '../camera-manager/manager.js';
import { MicrophoneState } from '../card-controller/types.js';
import { ViewItemManager } from '../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js';
import { AdvancedCameraCardConfig, CardWideConfig } from '../config/schema/types.js';
import { RawAdvancedCameraCardConfig } from '../config/types.js';
import { DeviceRegistryManager } from '../ha/registry/device/index.js';
@@ -62,6 +63,9 @@ export class AdvancedCameraCardViews extends LitElement {
@property({ attribute: false })
public deviceRegistryManager?: DeviceRegistryManager;
@property({ attribute: false })
public conditionStateManager?: ConditionStateManagerReadonlyInterface;
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('viewManagerEpoch') || changedProps.has('config')) {
const view = this.viewManagerEpoch?.manager.getView();