diff --git a/src/camera-manager/utils/camera-entity-from-config.ts b/src/camera-manager/utils/camera-entity-from-config.ts index 4319b467..b18109cc 100644 --- a/src/camera-manager/utils/camera-entity-from-config.ts +++ b/src/camera-manager/utils/camera-entity-from-config.ts @@ -1,5 +1,7 @@ import { CameraConfig } from '../../config/types'; -export const getCameraEntityFromConfig = (cameraConfig: CameraConfig): string | null => { - return cameraConfig.camera_entity ?? cameraConfig.webrtc_card?.entity ?? null; +export const getCameraEntityFromConfig = ( + cameraConfig?: CameraConfig, +): string | null => { + return cameraConfig?.camera_entity ?? cameraConfig?.webrtc_card?.entity ?? null; }; diff --git a/src/components/image-base.ts b/src/components/image-base.ts new file mode 100644 index 00000000..b373943c --- /dev/null +++ b/src/components/image-base.ts @@ -0,0 +1,405 @@ +import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { HassEntity } from 'home-assistant-js-websocket'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { live } from 'lit/directives/live.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import isEqual from 'lodash-es/isEqual'; +import { CameraManager } from '../camera-manager/manager.js'; +import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js'; +import { CachedValueController } from '../components-lib/cached-value-controller.js'; +import { CameraConfig, ImageMode, ImageViewConfig } from '../config/types.js'; +import defaultImage from '../images/frigate-bird-in-sky.jpg'; +import { localize } from '../localize/localize.js'; +import imageStyle from '../scss/image.scss'; +import { FrigateCardMediaPlayer, MediaLoadedInfo, Message } from '../types.js'; +import { contentsChanged } from '../utils/basic.js'; +import { isHassDifferent } from '../utils/ha/index.js'; +import { + createMediaLoadedInfo, + dispatchExistingMediaLoadedInfoAsEvent, + dispatchMediaPauseEvent, + dispatchMediaPlayEvent, +} from '../utils/media-info.js'; +import { View } from '../view/view.js'; +import { renderMessage } from './message.js'; + +// 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; + +export const resolveImageMode = (options?: { + imageConfig?: ImageViewConfig; + cameraConfig?: CameraConfig; +}): Exclude => { + if (!options?.imageConfig?.mode) { + return 'screensaver'; + } 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 'screensaver'; +}; + +@customElement('frigate-card-image-base') +export class FrigateCardImageBase extends LitElement implements FrigateCardMediaPlayer { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false }) + public cameraConfig?: CameraConfig; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + // 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?: ImageViewConfig; + + @state() + protected _message: Message | null = null; + + protected _refImage: Ref = createRef(); + + protected _cachedValueController?: CachedValueController; + protected _boundVisibilityHandler = this._visibilityHandler.bind(this); + + protected _mediaLoadedInfo: MediaLoadedInfo | null = null; + + public async play(): Promise { + this._cachedValueController?.startTimer(); + } + + public async pause(): Promise { + this._cachedValueController?.stopTimer(); + } + + public async mute(): Promise { + // Not implemented. + } + + public async unmute(): Promise { + // Not implemented. + } + + public isMuted(): boolean { + return true; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async seek(_seconds: number): Promise { + // Not implemented. + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async setControls(_controls: boolean): Promise { + // Not implemented. + } + + public isPaused(): boolean { + return !this._cachedValueController?.hasTimer(); + } + + public async getScreenshotURL(): Promise { + return this._cachedValueController?.value ?? 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 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 { + 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), + () => dispatchMediaPlayEvent(this), + () => dispatchMediaPauseEvent(this), + ); + } + } + + 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('cameraConfig') || + changedProps.has('view') || + (relevantEntity && !this._getAcceptableState(relevantEntity)) + ) { + this._cachedValueController?.clearValue(); + } + + if (!this._cachedValueController?.value) { + this._cachedValueController?.updateValue(); + } + + if (['imageConfig', 'view'].some((prop) => changedProps.has(prop))) { + this._message = null; + } + } + + /** + * 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); + this._cachedValueController?.startTimer(); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + this._cachedValueController?.stopTimer(); + this._message = null; + 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?.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 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 as a string (absolute, will not be browser cached). + */ + protected _buildImageURL(url: URL): string { + url.searchParams.append('_t', String(Date.now())); + return url.toString(); + } + + protected _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; + } + + protected _getRelevantEntityForMode(mode: Exclude): string | null { + return mode === 'camera' + ? getCameraEntityFromConfig(this.cameraConfig) + : mode === 'entity' + ? this.imageConfig?.entity ?? null + : null; + } + + protected _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._buildImageURL(urlObj); + } + } + + 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._buildImageURL(urlObj); + } + } + + if (mode === 'url' && this.imageConfig?.url) { + return this._buildImageURL(new URL(this.imageConfig.url, document.baseURI)); + } + + 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 { + if (this._message) { + return renderMessage(this._message); + } + + const src = this._cachedValueController?.value; + // 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` + { + const mediaLoadedInfo = createMediaLoadedInfo(ev, { + player: this, + capabilities: { + supportsPause: !!this.imageConfig?.refresh_seconds, + }, + }); + // Avoid the media being reported as repeatedly loading unless the + // media info changes. + if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) { + this._mediaLoadedInfo = mediaLoadedInfo; + dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo); + } + }} + @error=${() => { + const mode = resolveImageMode({ + imageConfig: this.imageConfig, + cameraConfig: this.cameraConfig, + }); + if (mode === 'camera' || mode === 'entity') { + // In camera or entity 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 (mode === 'url') { + // In url mode, the user likely specified a URL that cannot be + // resolved. Show an error message. + this._message = { + type: 'error', + message: localize('error.image_load_error'), + context: this.imageConfig, + }; + } + }} + /> + ` + : html``; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(imageStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-image-base': FrigateCardImageBase; + } +} diff --git a/src/components/image.ts b/src/components/image.ts index 38f0c1f0..47f57ab0 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -1,5 +1,4 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; -import { HassEntity } from 'home-assistant-js-websocket'; import { CSSResultGroup, html, @@ -8,30 +7,22 @@ import { TemplateResult, unsafeCSS, } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; -import { live } from 'lit/directives/live.js'; +import { customElement, property } from 'lit/decorators.js'; +import { guard } from 'lit/directives/guard.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import isEqual from 'lodash-es/isEqual'; -import { CameraManager } from '../camera-manager/manager.js'; -import { CachedValueController } from '../components-lib/cached-value-controller.js'; -import { CameraConfig, ImageMode, ImageViewConfig } from '../config/types.js'; -import defaultImage from '../images/frigate-bird-in-sky.jpg'; -import { localize } from '../localize/localize.js'; -import imageStyle from '../scss/image.scss'; -import { FrigateCardMediaPlayer, MediaLoadedInfo, Message } from '../types.js'; -import { contentsChanged } from '../utils/basic.js'; -import { isHassDifferent } from '../utils/ha'; -import { - createMediaLoadedInfo, - dispatchExistingMediaLoadedInfoAsEvent, - dispatchMediaPauseEvent, - dispatchMediaPlayEvent, -} from '../utils/media-info.js'; -import { View } from '../view/view.js'; -import { renderMessage } from './message.js'; - -// 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; +import { CameraManager } from '../camera-manager/manager'; +import { ViewManagerEpoch } from '../card-controller/view/types'; +import { ZoomSettingsObserved } from '../components-lib/zoom/types'; +import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context'; +import { CameraConfig, ImageViewConfig } from '../config/types'; +import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const'; +import basicBlockStyle from '../scss/basic-block.scss'; +import { FrigateCardMediaPlayer } from '../types.js'; +import { aspectRatioToString } from '../utils/basic'; +import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; +import './image-base'; +import { resolveImageMode } from './image-base'; +import './zoomer.js'; @customElement('frigate-card-image') export class FrigateCardImage extends LitElement implements FrigateCardMediaPlayer { @@ -39,7 +30,7 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay public hass?: HomeAssistant; @property({ attribute: false }) - public view?: Readonly; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public cameraConfig?: CameraConfig; @@ -47,348 +38,121 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay @property({ attribute: false }) public cameraManager?: CameraManager; - // 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 }) + @property({ attribute: false }) public imageConfig?: ImageViewConfig; - @state() - protected _message: Message | null = null; - - protected _refImage: Ref = createRef(); - - protected _cachedValueController?: CachedValueController; - protected _boundVisibilityHandler = this._visibilityHandler.bind(this); - - protected _mediaLoadedInfo: MediaLoadedInfo | null = null; + protected _refImage: Ref = createRef(); public async play(): Promise { - this._cachedValueController?.startTimer(); + await this._refImage.value?.play(); } public async pause(): Promise { - this._cachedValueController?.stopTimer(); + await this._refImage.value?.pause(); } public async mute(): Promise { - // Not implemented. + await this._refImage.value?.mute(); } public async unmute(): Promise { - // Not implemented. + await this._refImage.value?.unmute(); } public isMuted(): boolean { - return true; + return !!this._refImage.value?.isMuted(); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public async seek(_seconds: number): Promise { - // Not implemented. + public async seek(seconds: number): Promise { + await this._refImage.value?.seek(seconds); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public async setControls(_controls: boolean): Promise { - // Not implemented. + public async setControls(controls?: boolean): Promise { + await this._refImage.value?.setControls(controls); } public isPaused(): boolean { - return !this._cachedValueController?.hasTimer(); + return this._refImage.value?.isPaused() ?? true; } public async getScreenshotURL(): Promise { - return this._cachedValueController?.value ?? null; + return (await this._refImage.value?.getScreenshotURL()) ?? null; } - /** - * 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 relevantEntity = this._getRelevantEntityForMode( - this._resolveMode(this.imageConfig?.mode), - ); - - 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 { - if (changedProps.has('imageConfig')) { - if (this._cachedValueController) { - this._cachedValueController.removeController(); - } - if (this.imageConfig) { - this._cachedValueController = new CachedValueController( + if (changedProps.has('cameraConfig') || changedProps.has('imageConfig')) { + if ( + resolveImageMode({ + imageConfig: this.imageConfig, + cameraConfig: this.cameraConfig, + }) === 'camera' + ) { + updateElementStyleFromMediaLayoutConfig( this, - this.imageConfig.refresh_seconds, - this._getImageSource.bind(this), - () => dispatchMediaPlayEvent(this), - () => dispatchMediaPauseEvent(this), + this.cameraConfig?.dimensions?.layout, ); + this.style.aspectRatio = aspectRatioToString({ + ratio: this.cameraConfig?.dimensions?.aspect_ratio, + }); + } else { + updateElementStyleFromMediaLayoutConfig(this); + this.style.removeProperty('aspect-ratio'); } } - - const relevantEntity = this._getRelevantEntityForMode( - this._resolveMode(this.imageConfig?.mode), - ); - - // 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') || - (relevantEntity && !this._getAcceptableState(relevantEntity)) - ) { - this._cachedValueController?.clearValue(); - } - - if (!this._cachedValueController?.value) { - this._cachedValueController?.updateValue(); - } - - if (['imageConfig', 'view'].some((prop) => changedProps.has(prop))) { - this._message = null; - } } - /** - * 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; + protected _useZoomIfRequired(template: TemplateResult): TemplateResult { + const zoomTarget = IMAGE_VIEW_ZOOM_TARGET_SENTINEL; + const view = this.viewManagerEpoch?.manager.getView(); + const mode = resolveImageMode({ + imageConfig: this.imageConfig, + cameraConfig: this.cameraConfig, + }); - 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._message = null; - 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?.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 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 as a string (absolute, will not be browser cached). - */ - protected _buildImageURL(url: URL): string { - url.searchParams.append('_t', String(Date.now())); - return url.toString(); - } - - protected _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; - } - - protected _getRelevantEntityForMode(mode: Exclude): string | null { - return mode === 'camera' - ? this._getCameraEntity() - : mode === 'entity' - ? this.imageConfig?.entity ?? null - : null; - } - - protected _resolveMode(mode?: ImageMode): Exclude { - if (!mode) { - return 'screensaver'; - } else if (mode !== 'auto') { - return mode; - } - - const cameraEntity = this._getCameraEntity(); - if (this.imageConfig?.entity) { - return 'entity'; - } else if (this.imageConfig?.url) { - return 'url'; - } else if (cameraEntity) { - return 'camera'; - } - - return 'screensaver'; - } - - protected _getImageSource(): string { - const mode = this._resolveMode(this.imageConfig?.mode); - - if (this.hass && mode === 'camera') { - const state = this._getAcceptableState(this._getCameraEntity()); - if (state?.attributes.entity_picture) { - const urlObj = new URL(state.attributes.entity_picture, document.baseURI); - this._addQueryParametersToURL(urlObj, this.imageConfig?.entity_parameters); - return this._buildImageURL(urlObj); - } - } - - 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._buildImageURL(urlObj); - } - } - - if (mode === 'url' && this.imageConfig?.url) { - return this._buildImageURL(new URL(this.imageConfig.url, document.baseURI)); - } - - 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; - } + return this.imageConfig?.zoomable + ? html` + mode === 'camera' && this.cameraConfig?.dimensions?.layout + ? { + pan: this.cameraConfig.dimensions.layout.pan, + zoom: this.cameraConfig.dimensions.layout.zoom, + } + : undefined, + )} + .settings=${view?.context?.zoom?.[zoomTarget]?.requested} + @frigate-card:zoom:change=${(ev: CustomEvent) => + handleZoomSettingsObservedEvent( + ev, + this.viewManagerEpoch?.manager, + zoomTarget, + )} + > + ${template} + ` + : template; } protected render(): TemplateResult | void { - if (this._message) { - return renderMessage(this._message); + if (!this.hass || !this.cameraConfig) { + return; } - const src = this._cachedValueController?.value; - // 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` - { - const mediaLoadedInfo = createMediaLoadedInfo(ev, { - player: this, - capabilities: { - supportsPause: !!this.imageConfig?.refresh_seconds, - }, - }); - // Avoid the media being reported as repeatedly loading unless the - // media info changes. - if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) { - this._mediaLoadedInfo = mediaLoadedInfo; - dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo); - } - }} - @error=${() => { - const mode = this._resolveMode(this.imageConfig?.mode); - if (mode === 'camera' || mode === 'entity') { - // In camera or entity 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 (mode === 'url') { - // In url mode, the user likely specified a URL that cannot be - // resolved. Show an error message. - this._message = { - type: 'error', - message: localize('error.image_load_error'), - context: this.imageConfig, - }; - } - }} - /> - ` - : html``; + return this._useZoomIfRequired(html` + + + `); } static get styles(): CSSResultGroup { - return unsafeCSS(imageStyle); + return unsafeCSS(basicBlockStyle); } } diff --git a/src/components/live/providers/go2rtc/index.ts b/src/components/live/providers/go2rtc/index.ts index 08e7caf6..79e4b899 100644 --- a/src/components/live/providers/go2rtc/index.ts +++ b/src/components/live/providers/go2rtc/index.ts @@ -20,7 +20,6 @@ import { import { convertEndpointAddressToSignedWebsocket } from '../../../../utils/endpoint.js'; import { setControlsOnVideo } from '../../../../utils/media.js'; import { screenshotMedia } from '../../../../utils/screenshot.js'; -import '../../../image.js'; import { renderMessage } from '../../../message.js'; import { VideoRTC } from './video-rtc.js'; diff --git a/src/components/live/providers/image.ts b/src/components/live/providers/image.ts index b7090233..8cdb8518 100644 --- a/src/components/live/providers/image.ts +++ b/src/components/live/providers/image.ts @@ -5,7 +5,7 @@ import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { CameraConfig } from '../../../config/types'; import basicBlockStyle from '../../../scss/basic-block.scss'; import { FrigateCardMediaPlayer } from '../../../types.js'; -import '../../image.js'; +import '../../image-base.js'; @customElement('frigate-card-live-image') export class FrigateCardLiveImage extends LitElement implements FrigateCardMediaPlayer { @@ -59,13 +59,13 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia } return html` - - + `; } diff --git a/src/components/loading.ts b/src/components/loading.ts index d3e5142a..f0ca552e 100644 --- a/src/components/loading.ts +++ b/src/components/loading.ts @@ -8,7 +8,7 @@ import './icon'; export class FrigateCardLoading extends LitElement { protected render(): TemplateResult { return html`${getReleaseVersion(true)}`; + >${getReleaseVersion()}`; } static get styles(): CSSResultGroup { diff --git a/src/components/views.ts b/src/components/views.ts index 09f18ace..e637c1dd 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -177,7 +177,7 @@ export class FrigateCardViews extends LitElement { ${!this.hide && view?.is('image') && cameraConfig ? html` ; const imageConfigDefault = { mode: 'auto' as const, refresh_seconds: 1, + zoomable: true, }; const IMAGE_MODES = ['auto', 'camera', 'entity', 'screensaver', 'url'] as const; @@ -988,6 +989,9 @@ const imageBaseConfigSchema = z.object({ }); const imageConfigSchema = imageBaseConfigSchema + .extend({ + zoomable: z.boolean().default(imageConfigDefault.zoomable), + }) .merge(actionsSchema) .default(imageConfigDefault); export type ImageViewConfig = z.infer; diff --git a/src/const.ts b/src/const.ts index adfb8fb9..5f372f09 100644 --- a/src/const.ts +++ b/src/const.ts @@ -387,3 +387,5 @@ export const MEDIA_CHUNK_SIZE_DEFAULT = 50; export const MEDIA_CHUNK_SIZE_MAX = 1000; export const FRIGATE_BUTTON_MENU_ICON = 'frigate'; + +export const IMAGE_VIEW_ZOOM_TARGET_SENTINEL = '__IMAGE_VIEW_ZOOM__'; diff --git a/src/editor.ts b/src/editor.ts index 5e7095be..a34bb78a 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -31,6 +31,8 @@ import { STATUS_BAR_HEIGHT_MIN, THUMBNAIL_WIDTH_MAX, THUMBNAIL_WIDTH_MIN, + ZOOM_MAX, + ZOOM_MIN, } from './config/types.js'; import { CONF_CAMERAS, @@ -1455,8 +1457,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor 'mdi:page-layout-body', html` ${this._renderNumberInput(configPathZoom, { - min: 0, - max: 10, + min: ZOOM_MIN, + max: ZOOM_MAX, label: localize('config.cameras.dimensions.layout.zoom'), step: 0.1, })} diff --git a/src/utils/diagnostics.ts b/src/utils/diagnostics.ts index 1a51a6c2..5eda9dbd 100644 --- a/src/utils/diagnostics.ts +++ b/src/utils/diagnostics.ts @@ -20,17 +20,17 @@ interface IntegrationDiagnostics { version?: string; } -export const getReleaseVersion = (short?: boolean): string => { - const releaseVersion = '__FRIGATE_CARD_RELEASE_VERSION__'; +export const getReleaseVersion = (): string => { + const releaseVersion: string = '__FRIGATE_CARD_RELEASE_VERSION__'; /* istanbul ignore if: depends on rollup substitution -- @preserve */ - if ((releaseVersion as unknown) === 'pkg') { + if (releaseVersion === 'pkg') { return pkg.version; } /* istanbul ignore if: depends on rollup substitution -- @preserve */ - if ((releaseVersion as unknown) === 'dev') { - return `${releaseVersion}+${pkg['gitAbbrevHash']}${short ? '' : ` (${pkg['buildDate']})`}`; + if (releaseVersion === 'dev') { + return `dev+${pkg['gitAbbrevHash']}`; } return releaseVersion; diff --git a/src/utils/ptz.ts b/src/utils/ptz.ts index c4391a4f..8e8e03b1 100644 --- a/src/utils/ptz.ts +++ b/src/utils/ptz.ts @@ -1,5 +1,6 @@ import { CameraManager } from '../camera-manager/manager'; import { PTZAction } from '../config/ptz'; +import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const'; import { PTZCapabilities } from '../types'; import { View } from '../view/view'; import { getStreamCameraID } from './substream'; @@ -42,6 +43,11 @@ export const getPTZTarget = ( targetID: substreamAwareCameraID, type: type, }; + } else if (view.is('image')) { + return { + targetID: IMAGE_VIEW_ZOOM_TARGET_SENTINEL, + type: 'digital', + }; } return null; }; diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index d9c8a154..f9cbc502 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -69,6 +69,7 @@ describe('config defaults', () => { height: 'auto', }, image: { + zoomable: true, mode: 'auto', refresh_seconds: 1, }, diff --git a/tests/utils/ptz.test.ts b/tests/utils/ptz.test.ts index 9d3b3ddb..de9ac066 100644 --- a/tests/utils/ptz.test.ts +++ b/tests/utils/ptz.test.ts @@ -1,18 +1,19 @@ import { describe, expect, it } from 'vitest'; import { Capabilities } from '../../src/camera-manager/capabilities'; +import { FrigateCardView } from '../../src/config/types'; +import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../../src/const'; import { getPTZTarget, hasCameraTruePTZ, ptzActionToCapabilityKey, } from '../../src/utils/ptz'; +import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { TestViewMedia, createCameraManager, createStore, createView, } from '../test-utils'; -import { MediaQueriesResults } from '../../src/view/media-queries-results'; -import { FrigateCardView } from '../../src/config/types'; describe('getPTZTarget', () => { describe('in a viewer view', () => { @@ -47,6 +48,13 @@ describe('getPTZTarget', () => { }); }); + it('in image view', () => { + expect(getPTZTarget(createView({ view: 'image' }))).toEqual({ + targetID: IMAGE_VIEW_ZOOM_TARGET_SENTINEL, + type: 'digital', + }); + }); + describe('in live view', () => { it('without restriction with true PTZ capability', () => { const view = createView({