Only update cameras when they are visible (#9690)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
J. Nick Koston 2021-08-22 17:55:56 -05:00 committed by GitHub
parent f6d02d8fc6
commit 46c981103d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

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