diff --git a/src/panels/lovelace/components/hui-image.ts b/src/panels/lovelace/components/hui-image.ts
index e10366a7e5..067a420c86 100644
--- a/src/panels/lovelace/components/hui-image.ts
+++ b/src/panels/lovelace/components/hui-image.ts
@@ -6,7 +6,7 @@ import {
PropertyValues,
TemplateResult,
} from "lit";
-import { customElement, property, state, query } from "lit/decorators";
+import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { STATES_OFF } from "../../../common/const";
@@ -15,13 +15,19 @@ import "../../../components/ha-camera-stream";
import { CameraEntity, fetchThumbnailUrlWithCache } from "../../../data/camera";
import { UNAVAILABLE } from "../../../data/entity";
import { HomeAssistant } from "../../../types";
+import "../../../components/ha-circular-progress";
const UPDATE_INTERVAL = 10000;
const DEFAULT_FILTER = "grayscale(100%)";
const MAX_IMAGE_WIDTH = 640;
const ASPECT_RATIO_DEFAULT = 9 / 16;
-const SCALING_FACTOR = 2;
+
+enum LoadState {
+ Loading = 1,
+ Loaded = 2,
+ Error = 3,
+}
export interface StateSpecificConfig {
[state: string]: string;
@@ -35,7 +41,7 @@ export class HuiImage extends LitElement {
@property() public image?: string;
- @property() public stateImage?: StateSpecificConfig;
+ @property({ attribute: false }) public stateImage?: StateSpecificConfig;
@property() public cameraImage?: string;
@@ -45,39 +51,86 @@ export class HuiImage extends LitElement {
@property() public filter?: string;
- @property() public stateFilter?: StateSpecificConfig;
+ @property({ attribute: false }) public stateFilter?: StateSpecificConfig;
@property() public darkModeImage?: string;
@property() public darkModeFilter?: string;
- @state() private _loadError?: boolean;
+ @state() private _imageVisible? = false;
+
+ @state() private _loadState?: LoadState;
@state() private _cameraImageSrc?: string;
- @query("img") private _image!: HTMLImageElement;
+ @state() private _loadedImageSrc?: string;
+
+ private _intersectionObserver?: IntersectionObserver;
private _lastImageHeight?: number;
private _cameraUpdater?: number;
+ private _ratio: {
+ w: number;
+ h: number;
+ } | null = null;
+
public connectedCallback(): void {
super.connectedCallback();
+ if (this._loadState === undefined) {
+ this._loadState = LoadState.Loading;
+ }
if (this.cameraImage && this.cameraView !== "live") {
- this._startUpdateCameraInterval();
+ this._startIntersectionObserverOrUpdates();
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._stopUpdateCameraInterval();
+ this._stopIntersectionObserver();
+ this._imageVisible = undefined;
+ }
+
+ protected handleIntersectionCallback(entries: IntersectionObserverEntry[]) {
+ this._imageVisible = entries[0].isIntersecting;
+ }
+
+ public willUpdate(changedProps: PropertyValues): void {
+ if (changedProps.has("hass")) {
+ const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
+
+ if (this._shouldStartCameraUpdates(oldHass)) {
+ this._startIntersectionObserverOrUpdates();
+ } else if (!this.hass!.connected) {
+ this._stopUpdateCameraInterval();
+ this._stopIntersectionObserver();
+ }
+ }
+ if (changedProps.has("_imageVisible")) {
+ if (this._imageVisible) {
+ if (this._shouldStartCameraUpdates()) {
+ this._startUpdateCameraInterval();
+ }
+ } else {
+ this._stopUpdateCameraInterval();
+ }
+ }
+ if (changedProps.has("aspectRatio")) {
+ this._ratio = this.aspectRatio
+ ? parseAspectRatio(this.aspectRatio)
+ : null;
+ }
}
protected render(): TemplateResult {
if (!this.hass) {
return html``;
}
- const ratio = this.aspectRatio ? parseAspectRatio(this.aspectRatio) : null;
+ const useRatio = Boolean(
+ this._ratio && this._ratio.w > 0 && this._ratio.h > 0
+ );
const stateObj = this.entity ? this.hass.states[this.entity] : undefined;
const entityState = stateObj ? stateObj.state : UNAVAILABLE;
@@ -131,14 +184,21 @@ export class HuiImage extends LitElement {
return html`
0 && ratio.h > 0
- ? `${((100 * ratio.h) / ratio.w).toFixed(2)}%`
- : "",
- })}
- class=${classMap({
- ratio: Boolean(ratio && ratio.w > 0 && ratio.h > 0),
+ paddingBottom: useRatio
+ ? `${((100 * this._ratio!.h) / this._ratio!.w).toFixed(2)}%`
+ : undefined,
+ backgroundImage:
+ useRatio && this._loadedImageSrc
+ ? `url(${this._loadedImageSrc})`
+ : undefined,
+ filter:
+ this._loadState === LoadState.Loaded || this.cameraView === "live"
+ ? filter
+ : undefined,
})}
+ class="container ${classMap({
+ ratio: useRatio,
+ })}"
>
${this.cameraImage && this.cameraView === "live"
? html`
@@ -148,6 +208,8 @@ export class HuiImage extends LitElement {
.stateObj=${cameraObj}
>
`
+ : imageSrc === undefined
+ ? html``
: html`
![]()
`}
- ${this._loadError
+ ${this._loadState === LoadState.Error
? html`
`
+ : this.cameraView !== "live" &&
+ (imageSrc === undefined || this._loadState === LoadState.Loading)
+ ? html`
+
+
`
: ""}
`;
}
- protected updated(changedProps: PropertyValues): void {
- if (changedProps.has("hass")) {
- const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
- if (!oldHass || oldHass.connected !== this.hass!.connected) {
- if (this.hass!.connected && this.cameraView !== "live") {
- this._updateCameraImageSrc();
- this._startUpdateCameraInterval();
- } else if (!this.hass!.connected) {
- this._stopUpdateCameraInterval();
- this._cameraImageSrc = undefined;
- this._loadError = true;
- }
+ protected _shouldStartCameraUpdates(oldHass?: HomeAssistant): boolean {
+ return !!(
+ (!oldHass || oldHass.connected !== this.hass!.connected) &&
+ this.hass!.connected &&
+ this.cameraView !== "live"
+ );
+ }
+
+ private _startIntersectionObserverOrUpdates(): void {
+ if ("IntersectionObserver" in window) {
+ if (!this._intersectionObserver) {
+ this._intersectionObserver = new IntersectionObserver(
+ this.handleIntersectionCallback.bind(this)
+ );
}
- } else if (changedProps.has("cameraImage") && this.cameraView !== "live") {
- this._updateCameraImageSrc();
+ this._intersectionObserver.observe(this);
+ } else {
+ // No support for IntersectionObserver
+ // assume all images are visible
+ this._imageVisible = true;
this._startUpdateCameraInterval();
}
}
+ private _stopIntersectionObserver(): void {
+ if (this._intersectionObserver) {
+ this._intersectionObserver.disconnect();
+ }
+ }
+
private _startUpdateCameraInterval(): void {
this._stopUpdateCameraInterval();
+ this._updateCameraImageSrc();
if (this.cameraImage && this.isConnected) {
this._cameraUpdater = window.setInterval(
- () => this._updateCameraImageSrc(),
+ () => this._updateCameraImageSrcAtInterval(),
UPDATE_INTERVAL
);
}
@@ -209,13 +303,26 @@ export class HuiImage extends LitElement {
}
private _onImageError(): void {
- this._loadError = true;
+ this._loadState = LoadState.Error;
}
- private async _onImageLoad(): Promise {
- this._loadError = false;
+ private async _onImageLoad(ev: Event): Promise {
+ this._loadState = LoadState.Loaded;
+ const imgEl = ev.target as HTMLImageElement;
+ if (this._ratio && this._ratio.w > 0 && this._ratio.h > 0) {
+ this._loadedImageSrc = imgEl.src;
+ }
await this.updateComplete;
- this._lastImageHeight = this._image.offsetHeight;
+ this._lastImageHeight = imgEl.offsetHeight;
+ }
+
+ private async _updateCameraImageSrcAtInterval(): Promise {
+ // If we hit the interval and it was still loading
+ // it means we timed out so we should show the error.
+ if (this._loadState === LoadState.Loading) {
+ this._onImageError();
+ }
+ return this._updateCameraImageSrc();
}
private async _updateCameraImageSrc(): Promise {
@@ -232,41 +339,62 @@ export class HuiImage extends LitElement {
return;
}
- // One the first render we will not know the width
- const element_width = this._image.offsetWidth
- ? this._image.offsetWidth
- : MAX_IMAGE_WIDTH;
- // Because the aspect ratio might result in a smaller image,
- // we ask for 200% of what we need to make sure the image is
- // still clear. In practice, for 4k sources, this is still
- // an order of magnitude smaller.
- const width = Math.ceil(element_width * SCALING_FACTOR);
- // If the image has not rendered yet we may have a zero height
- const height = this._image.offsetHeight
- ? this._image.offsetHeight * SCALING_FACTOR
- : Math.ceil(element_width * SCALING_FACTOR * ASPECT_RATIO_DEFAULT);
-
+ const element_width = this.clientWidth || MAX_IMAGE_WIDTH;
+ let width = Math.ceil(element_width * devicePixelRatio);
+ let height: number;
+ // If the image has not rendered yet we have no height
+ if (!this._lastImageHeight) {
+ if (this._ratio && this._ratio.w > 0 && this._ratio.h > 0) {
+ height = Math.ceil(width * (this._ratio.h / this._ratio.w));
+ } else {
+ // If we don't have a ratio and we don't have a height
+ // we ask for 200% of what we need because the aspect
+ // ratio might result in a smaller image
+ width *= 2;
+ height = Math.ceil(width * ASPECT_RATIO_DEFAULT);
+ }
+ } else {
+ height = Math.ceil(this._lastImageHeight * devicePixelRatio);
+ }
this._cameraImageSrc = await fetchThumbnailUrlWithCache(
this.hass,
this.cameraImage,
width,
height
);
+ if (this._cameraImageSrc === undefined) {
+ this._onImageError();
+ }
}
static get styles(): CSSResultGroup {
return css`
+ :host {
+ display: block;
+ }
+
+ .container {
+ transition: filter 0.2s linear;
+ }
+
img {
display: block;
height: auto;
- transition: filter 0.2s linear;
width: 100%;
}
+ .progress-container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
.ratio {
position: relative;
width: 100%;
height: 0;
+ background-position: center;
+ background-size: cover;
}
.ratio img,
@@ -278,6 +406,10 @@ export class HuiImage extends LitElement {
height: 100%;
}
+ .ratio img {
+ visibility: hidden;
+ }
+
#brokenImage {
background: grey url("/static/images/image-broken.svg") center/36px
no-repeat;