Files
advanced-camera-card/src/components/image.ts
T

257 lines
8.5 KiB
TypeScript

import { HomeAssistant } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
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 { CachedValueController } from '../cached-value-controller.js';
import defaultImage from '../images/frigate-bird-in-sky.jpg';
import { localize } from '../localize/localize.js';
import imageStyle from '../scss/image.scss';
import { CameraConfig, ImageViewConfig } from '../types.js';
import { isHassDifferent } from '../utils/ha';
import { dispatchMediaShowEvent } from '../utils/media-info.js';
import { View } from '../view.js';
import { dispatchErrorMessageEvent } from './message.js';
// See: https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py#L101
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
@customElement('frigate-card-image')
export class FrigateCardImage extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@property({ attribute: false })
public imageConfig?: ImageViewConfig;
protected _refImage: Ref<HTMLImageElement> = createRef();
protected _cachedValueController?: CachedValueController<string>;
protected _boundVisibilityHandler = this._visibilityHandler.bind(this);
/**
* Get the camera entity for the current camera configuration.
* @returns The entity or undefined if no camera entity is available.
*/
protected _getCameraEntity(): string | null {
return (
(this.cameraConfig?.camera_entity || this.cameraConfig?.webrtc_card?.entity) ??
null
);
}
/**
* Determine whether the element should be updated.
* @param changedProps The changed properties if any.
* @returns `true` if the element should be updated.
*/
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (!this.hass || document.visibilityState !== 'visible') {
return false;
}
const cameraEntity = this._getCameraEntity();
if (
changedProps.has('hass') &&
changedProps.size == 1 &&
this.imageConfig?.mode === 'camera' &&
cameraEntity
) {
if (isHassDifferent(this.hass, changedProps.get('hass'), [cameraEntity])) {
// If the state of the camera entity has changed, remove the cached
// value (will be re-calculated in willUpdate). This is important to
// ensure a changed access token is immediately used.
this._cachedValueController?.clearValue();
return true;
}
return false;
}
return true;
}
/**
* Ensure there is a cached value before an update.
* @param _changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('imageConfig')) {
if (this._cachedValueController) {
this._cachedValueController.removeController();
}
if (this.imageConfig) {
this._cachedValueController = new CachedValueController(
this,
this.imageConfig.refresh_seconds,
this._getImageSource.bind(this),
);
}
}
// If the camera or view changed, immediately discard the old value (view to
// allow pressing of the image button to fetch a fresh image). Likewise, if
// the state is not acceptable, discard the old value (to allow a stock or
// backup image to be displayed).
if (
changedProps.has('cameraConfig') ||
changedProps.has('view') ||
(this.imageConfig?.mode === 'camera' &&
!this._getAcceptableState(this._getCameraEntity()))
) {
this._cachedValueController?.clearValue();
}
if (!this._cachedValueController?.value) {
this._cachedValueController?.updateValue();
}
}
/**
* Determine if a given entity is acceptable as the basis for an image render
* (detects old or disconnected states). Using an old state is problematic as
* it runs the risk that the JS has an old access token for the camera, and
* that results in a notification on the HA UI about a failed login. See:
* https://github.com/dermotduffy/frigate-hass-card/issues/398 .
* @param entity The entity.
* @returns The state or null if not acceptable.
*/
protected _getAcceptableState(entity: string | null): HassEntity | null {
const state = (entity ? this.hass?.states[entity] : null) ?? null;
return !!this.hass &&
this.hass.connected &&
!!state &&
Date.now() - Date.parse(state.last_updated) < HASS_REJECTION_CUTOFF_MS
? state
: null;
}
/**
* Component connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
document.addEventListener('visibilitychange', this._boundVisibilityHandler);
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
document.removeEventListener('visibilitychange', this._boundVisibilityHandler);
super.disconnectedCallback();
}
/**
* Handle document visibility changes.
*/
protected _visibilityHandler(): void {
if (!this._refImage.value) {
return;
}
if (document.visibilityState === 'hidden') {
// Set the image to default when the document is hidden. This is to avoid
// some browsers (e.g. Firefox) eagerly re-loading the old image when the
// document regains visibility -- for some images (e.g. camera mode) the
// image may be using an old-expired token and re-use prior to
// re-generation of a new URL would generate an unauthorized request
// (401), see:
// https://github.com/dermotduffy/frigate-hass-card/issues/398
this._cachedValueController?.clearValue();
this._forceSafeImage();
} else {
// If the document is freshly re-visible, immediately re-render it to
// restore the image src. If the HASS object is old (i.e. browser tab was
// inactive for some time) this update request may be (correctly)
// rejected.
this.requestUpdate();
}
}
/**
* Build a working absolute image URL that the browser will not cache.
* @param url An input URL (may be relative to document origin)
* @returns A new URL (absolute, will not be browser cached).
*/
protected _buildImageURL(url: string): string {
const urlObj = new URL(url, document.baseURI);
urlObj.searchParams.append('_t', String(Date.now()));
return urlObj.toString();
}
protected _getImageSource(): string {
if (this.hass && this.imageConfig?.mode === 'camera') {
const state = this._getAcceptableState(this._getCameraEntity());
if (state?.attributes.entity_picture) {
return this._buildImageURL(state.attributes.entity_picture);
}
}
if (this.imageConfig?.mode !== 'screensaver' && this.imageConfig?.url) {
return this._buildImageURL(this.imageConfig.url);
}
return defaultImage;
}
/**
* Force the img element to a safe image.
*/
protected _forceSafeImage(stockOnly?: boolean): void {
if (this._refImage.value) {
this._refImage.value.src =
!stockOnly && this.imageConfig?.url ? this.imageConfig.url : defaultImage;
}
}
protected render(): TemplateResult | void {
const src = this._cachedValueController?.value;
return src
? html` <img
${ref(this._refImage)}
src=${src}
@load=${(ev) => {
dispatchMediaShowEvent(this, ev);
}}
@error=${() => {
if (this.imageConfig?.mode === 'camera') {
// In camera mode, the user has likely not made an error, but HA
// may be unavailble, so show the stock image. Don't let the URL
// override the stock image in this case, as this could create an
// error loop if that URL subsequently failed to load.
this._forceSafeImage(true);
} else if (this.imageConfig?.mode === 'url') {
// In url mode, the user likely specified a URL that cannot be
// resolved. Show an error message.
dispatchErrorMessageEvent(
this,
localize('error.image_load_error'),
{ context: this.imageConfig },
);
}
}}
/>`
: html``;
}
static get styles(): CSSResultGroup {
return unsafeCSS(imageStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
"frigate-card-image": FrigateCardImage
}
}