fix: Restore camera layout support in image view (#1831)
* Closes #1804
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import { CameraConfig } from '../../config/types';
|
import { CameraConfig } from '../../config/types';
|
||||||
|
|
||||||
export const getCameraEntityFromConfig = (cameraConfig: CameraConfig): string | null => {
|
export const getCameraEntityFromConfig = (
|
||||||
return cameraConfig.camera_entity ?? cameraConfig.webrtc_card?.entity ?? null;
|
cameraConfig?: CameraConfig,
|
||||||
|
): string | null => {
|
||||||
|
return cameraConfig?.camera_entity ?? cameraConfig?.webrtc_card?.entity ?? null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<ImageMode, 'auto'> => {
|
||||||
|
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<View>;
|
||||||
|
|
||||||
|
@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<HTMLImageElement> = createRef();
|
||||||
|
|
||||||
|
protected _cachedValueController?: CachedValueController<string>;
|
||||||
|
protected _boundVisibilityHandler = this._visibilityHandler.bind(this);
|
||||||
|
|
||||||
|
protected _mediaLoadedInfo: MediaLoadedInfo | null = null;
|
||||||
|
|
||||||
|
public async play(): Promise<void> {
|
||||||
|
this._cachedValueController?.startTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async pause(): Promise<void> {
|
||||||
|
this._cachedValueController?.stopTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async mute(): Promise<void> {
|
||||||
|
// Not implemented.
|
||||||
|
}
|
||||||
|
|
||||||
|
public async unmute(): Promise<void> {
|
||||||
|
// Not implemented.
|
||||||
|
}
|
||||||
|
|
||||||
|
public isMuted(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public async seek(_seconds: number): Promise<void> {
|
||||||
|
// Not implemented.
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public async setControls(_controls: boolean): Promise<void> {
|
||||||
|
// Not implemented.
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPaused(): boolean {
|
||||||
|
return !this._cachedValueController?.hasTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
|
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<ImageMode, 'auto'>): 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`
|
||||||
|
<img
|
||||||
|
${ref(this._refImage)}
|
||||||
|
src=${live(src)}
|
||||||
|
@load=${(ev: Event) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
-321
@@ -1,5 +1,4 @@
|
|||||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
import { HassEntity } from 'home-assistant-js-websocket';
|
|
||||||
import {
|
import {
|
||||||
CSSResultGroup,
|
CSSResultGroup,
|
||||||
html,
|
html,
|
||||||
@@ -8,30 +7,22 @@ import {
|
|||||||
TemplateResult,
|
TemplateResult,
|
||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { live } from 'lit/directives/live.js';
|
import { guard } from 'lit/directives/guard.js';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
import isEqual from 'lodash-es/isEqual';
|
import { CameraManager } from '../camera-manager/manager';
|
||||||
import { CameraManager } from '../camera-manager/manager.js';
|
import { ViewManagerEpoch } from '../card-controller/view/types';
|
||||||
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
import { ZoomSettingsObserved } from '../components-lib/zoom/types';
|
||||||
import { CameraConfig, ImageMode, ImageViewConfig } from '../config/types.js';
|
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context';
|
||||||
import defaultImage from '../images/frigate-bird-in-sky.jpg';
|
import { CameraConfig, ImageViewConfig } from '../config/types';
|
||||||
import { localize } from '../localize/localize.js';
|
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const';
|
||||||
import imageStyle from '../scss/image.scss';
|
import basicBlockStyle from '../scss/basic-block.scss';
|
||||||
import { FrigateCardMediaPlayer, MediaLoadedInfo, Message } from '../types.js';
|
import { FrigateCardMediaPlayer } from '../types.js';
|
||||||
import { contentsChanged } from '../utils/basic.js';
|
import { aspectRatioToString } from '../utils/basic';
|
||||||
import { isHassDifferent } from '../utils/ha';
|
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
||||||
import {
|
import './image-base';
|
||||||
createMediaLoadedInfo,
|
import { resolveImageMode } from './image-base';
|
||||||
dispatchExistingMediaLoadedInfoAsEvent,
|
import './zoomer.js';
|
||||||
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;
|
|
||||||
|
|
||||||
@customElement('frigate-card-image')
|
@customElement('frigate-card-image')
|
||||||
export class FrigateCardImage extends LitElement implements FrigateCardMediaPlayer {
|
export class FrigateCardImage extends LitElement implements FrigateCardMediaPlayer {
|
||||||
@@ -39,7 +30,7 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
|
|||||||
public hass?: HomeAssistant;
|
public hass?: HomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraConfig?: CameraConfig;
|
public cameraConfig?: CameraConfig;
|
||||||
@@ -47,348 +38,121 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
// Using contentsChanged to ensure overridden configs (e.g. when the
|
@property({ attribute: false })
|
||||||
// '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;
|
public imageConfig?: ImageViewConfig;
|
||||||
|
|
||||||
@state()
|
protected _refImage: Ref<Element & FrigateCardMediaPlayer> = createRef();
|
||||||
protected _message: Message | null = null;
|
|
||||||
|
|
||||||
protected _refImage: Ref<HTMLImageElement> = createRef();
|
|
||||||
|
|
||||||
protected _cachedValueController?: CachedValueController<string>;
|
|
||||||
protected _boundVisibilityHandler = this._visibilityHandler.bind(this);
|
|
||||||
|
|
||||||
protected _mediaLoadedInfo: MediaLoadedInfo | null = null;
|
|
||||||
|
|
||||||
public async play(): Promise<void> {
|
public async play(): Promise<void> {
|
||||||
this._cachedValueController?.startTimer();
|
await this._refImage.value?.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async pause(): Promise<void> {
|
public async pause(): Promise<void> {
|
||||||
this._cachedValueController?.stopTimer();
|
await this._refImage.value?.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async mute(): Promise<void> {
|
public async mute(): Promise<void> {
|
||||||
// Not implemented.
|
await this._refImage.value?.mute();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unmute(): Promise<void> {
|
public async unmute(): Promise<void> {
|
||||||
// Not implemented.
|
await this._refImage.value?.unmute();
|
||||||
}
|
}
|
||||||
|
|
||||||
public isMuted(): boolean {
|
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<void> {
|
||||||
public async seek(_seconds: number): Promise<void> {
|
await this._refImage.value?.seek(seconds);
|
||||||
// Not implemented.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
public async setControls(controls?: boolean): Promise<void> {
|
||||||
public async setControls(_controls: boolean): Promise<void> {
|
await this._refImage.value?.setControls(controls);
|
||||||
// Not implemented.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public isPaused(): boolean {
|
public isPaused(): boolean {
|
||||||
return !this._cachedValueController?.hasTimer();
|
return this._refImage.value?.isPaused() ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getScreenshotURL(): Promise<string | null> {
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
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 {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('imageConfig')) {
|
if (changedProps.has('cameraConfig') || 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(
|
|
||||||
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 (
|
if (
|
||||||
changedProps.has('cameraConfig') ||
|
resolveImageMode({
|
||||||
changedProps.has('view') ||
|
imageConfig: this.imageConfig,
|
||||||
(relevantEntity && !this._getAcceptableState(relevantEntity))
|
cameraConfig: this.cameraConfig,
|
||||||
|
}) === 'camera'
|
||||||
) {
|
) {
|
||||||
this._cachedValueController?.clearValue();
|
updateElementStyleFromMediaLayoutConfig(
|
||||||
}
|
this,
|
||||||
|
this.cameraConfig?.dimensions?.layout,
|
||||||
if (!this._cachedValueController?.value) {
|
);
|
||||||
this._cachedValueController?.updateValue();
|
this.style.aspectRatio = aspectRatioToString({
|
||||||
}
|
ratio: this.cameraConfig?.dimensions?.aspect_ratio,
|
||||||
|
});
|
||||||
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 {
|
} else {
|
||||||
// If the document is freshly re-visible, immediately re-render it to
|
updateElementStyleFromMediaLayoutConfig(this);
|
||||||
// restore the image src. If the HASS object is old (i.e. browser tab was
|
this.style.removeProperty('aspect-ratio');
|
||||||
// inactive for some time) this update request may be (correctly)
|
}
|
||||||
// rejected.
|
|
||||||
this._cachedValueController?.startTimer();
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
||||||
* Build a working absolute image URL that the browser will not cache.
|
const zoomTarget = IMAGE_VIEW_ZOOM_TARGET_SENTINEL;
|
||||||
* @param url An input URL (may be relative to document origin)
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
* @returns A new URL as a string (absolute, will not be browser cached).
|
const mode = resolveImageMode({
|
||||||
*/
|
imageConfig: this.imageConfig,
|
||||||
protected _buildImageURL(url: URL): string {
|
cameraConfig: this.cameraConfig,
|
||||||
url.searchParams.append('_t', String(Date.now()));
|
});
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _addQueryParametersToURL(url: URL, parameters?: string): URL {
|
return this.imageConfig?.zoomable
|
||||||
if (parameters) {
|
? html` <frigate-card-zoomer
|
||||||
const searchParams = new URLSearchParams(parameters);
|
.defaultSettings=${guard(
|
||||||
for (const [key, value] of searchParams.entries()) {
|
[this.imageConfig, this.cameraConfig?.dimensions?.layout],
|
||||||
url.searchParams.append(key, value);
|
() =>
|
||||||
}
|
mode === 'camera' && this.cameraConfig?.dimensions?.layout
|
||||||
}
|
? {
|
||||||
return url;
|
pan: this.cameraConfig.dimensions.layout.pan,
|
||||||
}
|
zoom: this.cameraConfig.dimensions.layout.zoom,
|
||||||
|
|
||||||
protected _getRelevantEntityForMode(mode: Exclude<ImageMode, 'auto'>): string | null {
|
|
||||||
return mode === 'camera'
|
|
||||||
? this._getCameraEntity()
|
|
||||||
: mode === 'entity'
|
|
||||||
? this.imageConfig?.entity ?? null
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _resolveMode(mode?: ImageMode): Exclude<ImageMode, 'auto'> {
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
: undefined,
|
||||||
|
)}
|
||||||
|
.settings=${view?.context?.zoom?.[zoomTarget]?.requested}
|
||||||
|
@frigate-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
|
||||||
|
handleZoomSettingsObservedEvent(
|
||||||
|
ev,
|
||||||
|
this.viewManagerEpoch?.manager,
|
||||||
|
zoomTarget,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
${template}
|
||||||
|
</frigate-card-zoomer>`
|
||||||
|
: template;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (this._message) {
|
if (!this.hass || !this.cameraConfig) {
|
||||||
return renderMessage(this._message);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const src = this._cachedValueController?.value;
|
return this._useZoomIfRequired(html`
|
||||||
// Note the use of live() below to ensure the update will restore the image
|
<frigate-card-image-base
|
||||||
// src if it's been changed via _forceSafeImage().
|
|
||||||
return src
|
|
||||||
? html`
|
|
||||||
<img
|
|
||||||
${ref(this._refImage)}
|
${ref(this._refImage)}
|
||||||
src=${live(src)}
|
.hass=${this.hass}
|
||||||
@load=${(ev: Event) => {
|
.view=${this.viewManagerEpoch?.manager.getView()}
|
||||||
const mediaLoadedInfo = createMediaLoadedInfo(ev, {
|
.imageConfig=${this.imageConfig}
|
||||||
player: this,
|
.cameraConfig=${this.cameraConfig}
|
||||||
capabilities: {
|
>
|
||||||
supportsPause: !!this.imageConfig?.refresh_seconds,
|
</frigate-card-image-base>
|
||||||
},
|
`);
|
||||||
});
|
|
||||||
// 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``;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(imageStyle);
|
return unsafeCSS(basicBlockStyle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
import { convertEndpointAddressToSignedWebsocket } from '../../../../utils/endpoint.js';
|
import { convertEndpointAddressToSignedWebsocket } from '../../../../utils/endpoint.js';
|
||||||
import { setControlsOnVideo } from '../../../../utils/media.js';
|
import { setControlsOnVideo } from '../../../../utils/media.js';
|
||||||
import { screenshotMedia } from '../../../../utils/screenshot.js';
|
import { screenshotMedia } from '../../../../utils/screenshot.js';
|
||||||
import '../../../image.js';
|
|
||||||
import { renderMessage } from '../../../message.js';
|
import { renderMessage } from '../../../message.js';
|
||||||
import { VideoRTC } from './video-rtc.js';
|
import { VideoRTC } from './video-rtc.js';
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
|||||||
import { CameraConfig } from '../../../config/types';
|
import { CameraConfig } from '../../../config/types';
|
||||||
import basicBlockStyle from '../../../scss/basic-block.scss';
|
import basicBlockStyle from '../../../scss/basic-block.scss';
|
||||||
import { FrigateCardMediaPlayer } from '../../../types.js';
|
import { FrigateCardMediaPlayer } from '../../../types.js';
|
||||||
import '../../image.js';
|
import '../../image-base.js';
|
||||||
|
|
||||||
@customElement('frigate-card-live-image')
|
@customElement('frigate-card-live-image')
|
||||||
export class FrigateCardLiveImage extends LitElement implements FrigateCardMediaPlayer {
|
export class FrigateCardLiveImage extends LitElement implements FrigateCardMediaPlayer {
|
||||||
@@ -59,13 +59,13 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<frigate-card-image
|
<frigate-card-image-base
|
||||||
${ref(this._refImage)}
|
${ref(this._refImage)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.imageConfig=${this.cameraConfig.image}
|
.imageConfig=${this.cameraConfig.image}
|
||||||
.cameraConfig=${this.cameraConfig}
|
.cameraConfig=${this.cameraConfig}
|
||||||
>
|
>
|
||||||
</frigate-card-image>
|
</frigate-card-image-base>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import './icon';
|
|||||||
export class FrigateCardLoading extends LitElement {
|
export class FrigateCardLoading extends LitElement {
|
||||||
protected render(): TemplateResult {
|
protected render(): TemplateResult {
|
||||||
return html`<frigate-card-icon .icon=${{ icon: 'iris' }}></frigate-card-icon
|
return html`<frigate-card-icon .icon=${{ icon: 'iris' }}></frigate-card-icon
|
||||||
><span>${getReleaseVersion(true)}</span>`;
|
><span>${getReleaseVersion()}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export class FrigateCardViews extends LitElement {
|
|||||||
${!this.hide && view?.is('image') && cameraConfig
|
${!this.hide && view?.is('image') && cameraConfig
|
||||||
? html` <frigate-card-image
|
? html` <frigate-card-image
|
||||||
.imageConfig=${this.overriddenConfig.image}
|
.imageConfig=${this.overriddenConfig.image}
|
||||||
.view=${view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraConfig=${cameraConfig}
|
.cameraConfig=${cameraConfig}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
|
|||||||
@@ -972,6 +972,7 @@ export type PTZControlsConfig = z.infer<typeof ptzControlsConfigSchema>;
|
|||||||
const imageConfigDefault = {
|
const imageConfigDefault = {
|
||||||
mode: 'auto' as const,
|
mode: 'auto' as const,
|
||||||
refresh_seconds: 1,
|
refresh_seconds: 1,
|
||||||
|
zoomable: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const IMAGE_MODES = ['auto', 'camera', 'entity', 'screensaver', 'url'] as const;
|
const IMAGE_MODES = ['auto', 'camera', 'entity', 'screensaver', 'url'] as const;
|
||||||
@@ -988,6 +989,9 @@ const imageBaseConfigSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const imageConfigSchema = imageBaseConfigSchema
|
const imageConfigSchema = imageBaseConfigSchema
|
||||||
|
.extend({
|
||||||
|
zoomable: z.boolean().default(imageConfigDefault.zoomable),
|
||||||
|
})
|
||||||
.merge(actionsSchema)
|
.merge(actionsSchema)
|
||||||
.default(imageConfigDefault);
|
.default(imageConfigDefault);
|
||||||
export type ImageViewConfig = z.infer<typeof imageConfigSchema>;
|
export type ImageViewConfig = z.infer<typeof imageConfigSchema>;
|
||||||
|
|||||||
@@ -387,3 +387,5 @@ export const MEDIA_CHUNK_SIZE_DEFAULT = 50;
|
|||||||
export const MEDIA_CHUNK_SIZE_MAX = 1000;
|
export const MEDIA_CHUNK_SIZE_MAX = 1000;
|
||||||
|
|
||||||
export const FRIGATE_BUTTON_MENU_ICON = 'frigate';
|
export const FRIGATE_BUTTON_MENU_ICON = 'frigate';
|
||||||
|
|
||||||
|
export const IMAGE_VIEW_ZOOM_TARGET_SENTINEL = '__IMAGE_VIEW_ZOOM__';
|
||||||
|
|||||||
+4
-2
@@ -31,6 +31,8 @@ import {
|
|||||||
STATUS_BAR_HEIGHT_MIN,
|
STATUS_BAR_HEIGHT_MIN,
|
||||||
THUMBNAIL_WIDTH_MAX,
|
THUMBNAIL_WIDTH_MAX,
|
||||||
THUMBNAIL_WIDTH_MIN,
|
THUMBNAIL_WIDTH_MIN,
|
||||||
|
ZOOM_MAX,
|
||||||
|
ZOOM_MIN,
|
||||||
} from './config/types.js';
|
} from './config/types.js';
|
||||||
import {
|
import {
|
||||||
CONF_CAMERAS,
|
CONF_CAMERAS,
|
||||||
@@ -1455,8 +1457,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
'mdi:page-layout-body',
|
'mdi:page-layout-body',
|
||||||
html`
|
html`
|
||||||
${this._renderNumberInput(configPathZoom, {
|
${this._renderNumberInput(configPathZoom, {
|
||||||
min: 0,
|
min: ZOOM_MIN,
|
||||||
max: 10,
|
max: ZOOM_MAX,
|
||||||
label: localize('config.cameras.dimensions.layout.zoom'),
|
label: localize('config.cameras.dimensions.layout.zoom'),
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -20,17 +20,17 @@ interface IntegrationDiagnostics {
|
|||||||
version?: string;
|
version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getReleaseVersion = (short?: boolean): string => {
|
export const getReleaseVersion = (): string => {
|
||||||
const releaseVersion = '__FRIGATE_CARD_RELEASE_VERSION__';
|
const releaseVersion: string = '__FRIGATE_CARD_RELEASE_VERSION__';
|
||||||
|
|
||||||
/* istanbul ignore if: depends on rollup substitution -- @preserve */
|
/* istanbul ignore if: depends on rollup substitution -- @preserve */
|
||||||
if ((releaseVersion as unknown) === 'pkg') {
|
if (releaseVersion === 'pkg') {
|
||||||
return pkg.version;
|
return pkg.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* istanbul ignore if: depends on rollup substitution -- @preserve */
|
/* istanbul ignore if: depends on rollup substitution -- @preserve */
|
||||||
if ((releaseVersion as unknown) === 'dev') {
|
if (releaseVersion === 'dev') {
|
||||||
return `${releaseVersion}+${pkg['gitAbbrevHash']}${short ? '' : ` (${pkg['buildDate']})`}`;
|
return `dev+${pkg['gitAbbrevHash']}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return releaseVersion;
|
return releaseVersion;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CameraManager } from '../camera-manager/manager';
|
import { CameraManager } from '../camera-manager/manager';
|
||||||
import { PTZAction } from '../config/ptz';
|
import { PTZAction } from '../config/ptz';
|
||||||
|
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const';
|
||||||
import { PTZCapabilities } from '../types';
|
import { PTZCapabilities } from '../types';
|
||||||
import { View } from '../view/view';
|
import { View } from '../view/view';
|
||||||
import { getStreamCameraID } from './substream';
|
import { getStreamCameraID } from './substream';
|
||||||
@@ -42,6 +43,11 @@ export const getPTZTarget = (
|
|||||||
targetID: substreamAwareCameraID,
|
targetID: substreamAwareCameraID,
|
||||||
type: type,
|
type: type,
|
||||||
};
|
};
|
||||||
|
} else if (view.is('image')) {
|
||||||
|
return {
|
||||||
|
targetID: IMAGE_VIEW_ZOOM_TARGET_SENTINEL,
|
||||||
|
type: 'digital',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ describe('config defaults', () => {
|
|||||||
height: 'auto',
|
height: 'auto',
|
||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
|
zoomable: true,
|
||||||
mode: 'auto',
|
mode: 'auto',
|
||||||
refresh_seconds: 1,
|
refresh_seconds: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
+10
-2
@@ -1,18 +1,19 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { Capabilities } from '../../src/camera-manager/capabilities';
|
import { Capabilities } from '../../src/camera-manager/capabilities';
|
||||||
|
import { FrigateCardView } from '../../src/config/types';
|
||||||
|
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../../src/const';
|
||||||
import {
|
import {
|
||||||
getPTZTarget,
|
getPTZTarget,
|
||||||
hasCameraTruePTZ,
|
hasCameraTruePTZ,
|
||||||
ptzActionToCapabilityKey,
|
ptzActionToCapabilityKey,
|
||||||
} from '../../src/utils/ptz';
|
} from '../../src/utils/ptz';
|
||||||
|
import { MediaQueriesResults } from '../../src/view/media-queries-results';
|
||||||
import {
|
import {
|
||||||
TestViewMedia,
|
TestViewMedia,
|
||||||
createCameraManager,
|
createCameraManager,
|
||||||
createStore,
|
createStore,
|
||||||
createView,
|
createView,
|
||||||
} from '../test-utils';
|
} from '../test-utils';
|
||||||
import { MediaQueriesResults } from '../../src/view/media-queries-results';
|
|
||||||
import { FrigateCardView } from '../../src/config/types';
|
|
||||||
|
|
||||||
describe('getPTZTarget', () => {
|
describe('getPTZTarget', () => {
|
||||||
describe('in a viewer view', () => {
|
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', () => {
|
describe('in live view', () => {
|
||||||
it('without restriction with true PTZ capability', () => {
|
it('without restriction with true PTZ capability', () => {
|
||||||
const view = createView({
|
const view = createView({
|
||||||
|
|||||||
Reference in New Issue
Block a user