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

557 lines
20 KiB
TypeScript

import type { HassEntity } from 'home-assistant-js-websocket';
import {
html,
LitElement,
unsafeCSS,
type CSSResultGroup,
type PropertyValues,
type TemplateResult,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { live } from 'lit/directives/live.js';
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js';
import type { MediaUnavailableIssueReason } from '../card-controller/issues/issues/media-unavailable.js';
import { CachedValueController } from '../components-lib/cached-value-controller.js';
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
import { FRAME_STALL_SECONDS } from '../components-lib/media-player/frame-stall-watchdog.js';
import { ImageMediaPlayerController } from '../components-lib/media-player/image.js';
import { createMediaNotification } from '../components-lib/notification/media.js';
import {
getSignedURLErrorText,
SignedURLController,
} from '../components-lib/signed-url-controller.js';
import type { Notification } from '../config/schema/actions/types.js';
import type { CameraConfig } from '../config/schema/cameras.js';
import { type ImageBaseConfig, type ImageMode } from '../config/schema/common/image.js';
import type { EnabledProxyConfig } from '../config/schema/common/proxy.js';
import { isHassDifferent } from '../ha/is-hass-different.js';
import type { HomeAssistant } from '../ha/types.js';
import defaultImage from '../images/iris-screensaver.jpg';
import { localize } from '../localize/localize.js';
import imageUpdatingPlayerStyle from '../scss/image-updating-player.scss?inline';
import type { MediaPlayer, MediaPlayerController } from '../types.js';
import { contentsChanged } from '../utils/basic.js';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
import {
createMediaLoadedInfo,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
} from '../utils/media-info.js';
import type { View } from '../view/view.js';
import { renderNotificationBlock } from './notification/block.js';
declare global {
interface HTMLElementEventMap {
// A private signal to the immediate parent that the media failed.
// Non-bubbling.
'advanced-camera-card:image-updating-player:error': CustomEvent<MediaUnavailableIssueReason>;
}
}
// See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py .
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
const SCREENSAVER_URL = 'https://picsum.photos';
const SCREENSAVER_REFRESH_SECONDS = 60;
const SCREENSAVER_DEFAULT_WIDTH = 500;
const SCREENSAVER_DEFAULT_HEIGHT = Math.round(SCREENSAVER_DEFAULT_WIDTH / (16 / 9));
// Round requested dimensions to the nearest multiple to avoid fetching a unique
// image for every minor size difference.
const SCREENSAVER_DIMENSION_BUCKET = 100;
export const resolveImageMode = (options?: {
imageConfig?: ImageBaseConfig;
cameraConfig?: CameraConfig;
}): Exclude<ImageMode, 'auto'> => {
if (!options?.imageConfig?.mode) {
return 'default';
} else if (options?.imageConfig?.mode !== 'auto') {
return options.imageConfig.mode;
}
if (options?.imageConfig?.entity) {
return 'entity';
} else if (options?.imageConfig?.url) {
return 'url';
} else if (getCameraEntityFromConfig(options.cameraConfig)) {
return 'camera';
}
return 'default';
};
/**
* A media player to wrap an image that updates continuously.
*/
@customElement('advanced-camera-card-image-updating-player')
export class AdvancedCameraCardImageUpdatingPlayer
extends LitElement
implements MediaPlayer
{
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@property({ attribute: false })
public targetID?: string;
// The camera's title, shown in error messages to identify the camera.
@property({ attribute: false })
public cameraTitle?: string;
@property({ attribute: false, hasChanged: contentsChanged })
public proxyConfig?: EnabledProxyConfig;
// Using contentsChanged to ensure overridden configs (e.g. when the
// 'show_image_during_load' option is true for live views, an overridden
// config may be used here).
@property({ attribute: false, hasChanged: contentsChanged })
public imageConfig?: ImageBaseConfig;
@state()
private _imageLoadError = false;
// Tracks the signed/proxy error so it is reported once on the transition into
// failure, not on every update.
private _hasSignError = false;
private _refImage: Ref<HTMLImageElement> = createRef();
// Whether the <img> currently holds the stock image swapped in by
// `_forceSafeImage` rather than the intended media. The safe image load must
// not be announced as real media arriving, which may otherwise report a
// broken camera as healthy. Cleared on the next render, which restores the
// real source.
private _showingSafeImage = false;
private _cachedValueController = new CachedValueController(
this,
() => this._getEffectiveRefreshSeconds(),
() => this._getImageSource(),
() => dispatchMediaPlayEvent(this),
() => dispatchMediaPauseEvent(this),
// Clear image load errors on each timer tick so the next render retries
// the <img> -- but only for modes where the underlying URL genuinely
// changes between ticks (camera/entity snapshots). For mode: url, the
// same static URL will fail the same way every time, so clearing the
// error just causes a visible flicker (notification → blank <img> →
// notification) every refresh_seconds. URL-mode retries are driven by
// mediaEpoch (user-initiated or auto-retry) instead.
() => {
const mode = resolveImageMode({
imageConfig: this.imageConfig,
cameraConfig: this.cameraConfig,
});
if (mode !== 'url') {
this._imageLoadError = false;
}
},
);
private _signedURLController = new SignedURLController(
this,
() => ({
hass: this.hass,
endpoint: this.imageConfig?.url ? { endpoint: this.imageConfig.url } : undefined,
proxyConfig: this.proxyConfig,
}),
() => {
this._cachedValueController.clearValue();
this._imageLoadError = false;
},
);
private _boundVisibilityHandler = this._visibilityHandler.bind(this);
// A poll-refreshed snapshot: the cached-value timer is the pausable update
// loop, and its cached URL is the screenshot.
private _mediaPlayerController = new ImageMediaPlayerController(
this,
() => this._refImage.value ?? null,
{
updateControl: {
start: () => this._cachedValueController.startTimer(),
stop: () => this._cachedValueController.stopTimer(),
isRunning: () => this._cachedValueController.hasTimer(),
},
screenshotProvider: async () => this._cachedValueController.getValue(),
livenessOptions: {
// Frames are only due while the refresh timer runs. A snapshot with
// refreshing switched off shows one picture forever, which is the
// configured behavior and never a stall.
isFrameExpected: () => this._cachedValueController.hasTimer(),
// Allow a whole refresh interval to pass, plus the standard window, so
// one slow fetch is not mistaken for a stopped camera.
getStallAfterSeconds: () =>
(this._getEffectiveRefreshSeconds() ?? 0) + FRAME_STALL_SECONDS,
},
},
);
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
getTargetID: () => this.targetID ?? null,
});
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._mediaPlayerController;
}
/**
* 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.isConnected || !this.hass || document.visibilityState !== 'visible') {
return false;
}
const relevantEntity = this._getRelevantEntityForMode(
resolveImageMode({
imageConfig: this.imageConfig,
cameraConfig: this.cameraConfig,
}),
);
if (changedProps.has('hass') && changedProps.size == 1 && relevantEntity) {
if (isHassDifferent(this.hass, changedProps.get('hass'), [relevantEntity])) {
// 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 !this.hasUpdated;
}
return true;
}
/**
* Ensure there is a cached value before an update.
* @param _changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void {
// The render (below) restores the real source (via `live()`), so its load
// is the real media again.
this._showingSafeImage = false;
const relevantEntity = this._getRelevantEntityForMode(
resolveImageMode({
imageConfig: this.imageConfig,
cameraConfig: this.cameraConfig,
}),
);
// 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('imageConfig') ||
changedProps.has('cameraConfig') ||
changedProps.has('proxyConfig') ||
changedProps.has('view') ||
(relevantEntity && !this._getAcceptableState(relevantEntity))
) {
this._cachedValueController?.clearValue();
this._imageLoadError = false;
}
if (!this._cachedValueController?.getValue()) {
this._cachedValueController?.updateValue();
}
const hasSignError = !!this._signedURLController.getError();
if (hasSignError && !this._hasSignError) {
this._dispatchError('server_error');
}
this._hasSignError = hasSignError;
}
private _dispatchError(reason: MediaUnavailableIssueReason): void {
// An image request can fail after this player has left the DOM. Ignore
// such failures: they describe media that is no longer shown.
if (!this.isConnected) {
return;
}
fireAdvancedCameraCardEvent(this, 'image-updating-player:error', reason, {
bubbles: false,
composed: false,
});
}
/**
* 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/advanced-camera-card/issues/398 .
* @param entity The entity.
* @returns The state or null if not acceptable.
*/
private _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);
this._cachedValueController?.startTimer();
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
this._cachedValueController?.stopTimer();
this._imageLoadError = false;
document.removeEventListener('visibilitychange', this._boundVisibilityHandler);
super.disconnectedCallback();
}
private _getScreensaverURL(): string {
const bucket = (value: number, fallback: number): number =>
Math.ceil((value || fallback) / SCREENSAVER_DIMENSION_BUCKET) *
SCREENSAVER_DIMENSION_BUCKET;
const w = bucket(this.clientWidth, SCREENSAVER_DEFAULT_WIDTH);
const h = bucket(this.clientHeight, SCREENSAVER_DEFAULT_HEIGHT);
const urlObj = new URL(`${SCREENSAVER_URL}/${w}/${h}`);
return this._buildCacheBustURL(urlObj, 'query-string');
}
/**
* Handle document visibility changes.
*/
private _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/advanced-camera-card/issues/398
this._cachedValueController?.stopTimer();
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._cachedValueController?.startTimer();
this.requestUpdate();
}
}
/**
* Build an image URL that the browser will not cache. Supports two modes:
* - 'query-string': Appends a `_t` parameter. This is the most robust way to
* defeat caching (it bypasses HTTP caches) but it changes the path sent to
* the server and so can invalidate signed URLs.
* - 'fragment': Appends a `_t` fragment. This is less robust (the browser
* might still serve from its HTTP cache) but it does not change the URL
* sent to the server so it is safe for signed URLs.
* @param url The input URL.
* @param mode The cache-busting mode.
* @returns The cache-busted URL string.
*/
private _buildCacheBustURL(url: URL, mode: 'query-string' | 'fragment'): string {
if (mode === 'query-string') {
url.searchParams.append('_t', String(Date.now()));
} else {
url.hash = `_t=${Date.now()}`;
}
return url.toString();
}
private _addQueryParametersToURL(url: URL, parameters?: string): URL {
if (parameters) {
const searchParams = new URLSearchParams(parameters);
for (const [key, value] of searchParams.entries()) {
url.searchParams.append(key, value);
}
}
return url;
}
private _getEffectiveRefreshSeconds(): number | null {
const seconds = this.imageConfig?.refresh_seconds ?? null;
if (seconds !== 'auto') {
return seconds;
}
const mode = resolveImageMode({
imageConfig: this.imageConfig,
cameraConfig: this.cameraConfig,
});
return mode === 'screensaver' ? SCREENSAVER_REFRESH_SECONDS : 1;
}
private _getRelevantEntityForMode(mode: Exclude<ImageMode, 'auto'>): string | null {
return mode === 'camera'
? getCameraEntityFromConfig(this.cameraConfig)
: mode === 'entity'
? this.imageConfig?.entity ?? null
: null;
}
private _getImageSource(): string {
const mode = resolveImageMode({
imageConfig: this.imageConfig,
cameraConfig: this.cameraConfig,
});
if (this.hass && mode === 'camera') {
const state = this._getAcceptableState(
getCameraEntityFromConfig(this.cameraConfig),
);
if (state?.attributes.entity_picture) {
const urlObj = new URL(state.attributes.entity_picture, document.baseURI);
this._addQueryParametersToURL(urlObj, this.imageConfig?.entity_parameters);
return this._buildCacheBustURL(urlObj, 'query-string');
}
}
if (this.hass && mode === 'entity' && this.imageConfig?.entity) {
const state = this._getAcceptableState(this.imageConfig?.entity);
if (state?.attributes.entity_picture) {
const urlObj = new URL(state.attributes.entity_picture, document.baseURI);
this._addQueryParametersToURL(urlObj, this.imageConfig?.entity_parameters);
return this._buildCacheBustURL(urlObj, 'query-string');
}
}
if (mode === 'url' && this.imageConfig?.url) {
const url = this._signedURLController.getValue();
if (url) {
const urlObj = new URL(url, document.baseURI);
if (this.proxyConfig?.enabled) {
// Use a fragment for cache-busting proxied URLs, as this does not
// change the path and thus preserves the validity of the signed URL.
return this._buildCacheBustURL(urlObj, 'fragment');
}
return this._buildCacheBustURL(urlObj, 'query-string');
}
}
if (mode === 'screensaver') {
return this._getScreensaverURL();
}
return defaultImage;
}
/**
* Force the img element to a safe image.
*/
private _forceSafeImage(stockOnly?: boolean): void {
if (this._refImage.value) {
this._showingSafeImage = true;
// Avoid restoring the raw configured URL when proxying is enabled, since
// that would bypass the proxied/signed URL path on visibility changes.
const configuredURL =
!stockOnly && !this.proxyConfig?.enabled ? this.imageConfig?.url ?? null : null;
this._refImage.value.src = configuredURL ?? defaultImage;
}
}
private _getDisplayNotification(): Notification | null {
const error = this._signedURLController.getError();
if (error) {
return createMediaNotification({
title: getSignedURLErrorText(error),
targetTitle: this.cameraTitle,
});
}
if (this._imageLoadError) {
return createMediaNotification({
title: localize('error.image_load_error'),
targetTitle: this.cameraTitle,
});
}
return null;
}
protected render(): TemplateResult | void {
const notification = this._getDisplayNotification();
if (notification) {
return renderNotificationBlock(notification);
}
const src = this._cachedValueController?.getValue();
// Note the use of live() below to ensure the update will restore the image
// src if it's been changed via _forceSafeImage().
return src
? html`
<img
${ref(this._refImage)}
src=${live(src)}
@load=${(ev: Event) => {
if (this._showingSafeImage) {
return;
}
const mediaLoadedInfo = createMediaLoadedInfo(ev, {
mediaPlayerController: this._mediaPlayerController,
capabilities: {
supportsPause: !!this._getEffectiveRefreshSeconds(),
},
});
if (mediaLoadedInfo) {
this._mediaLoadedInfoSourceController.set(mediaLoadedInfo);
}
}}
@error=${() => {
const mode = resolveImageMode({
imageConfig: this.imageConfig,
cameraConfig: this.cameraConfig,
});
if (mode === 'camera' || mode === 'entity' || mode === 'screensaver') {
this._forceSafeImage(true);
} else if (mode === 'url') {
this._imageLoadError = true;
}
this._dispatchError('server_error');
}}
/>
`
: html``;
}
static get styles(): CSSResultGroup {
return unsafeCSS(imageUpdatingPlayerStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-image-updating-player': AdvancedCameraCardImageUpdatingPlayer;
}
}