mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-23 09:16:38 +00:00
Only update cameras when they are visible (#9690)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
parent
f6d02d8fc6
commit
46c981103d
@ -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;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user