fix: Substantially reduce cases where errors block whole card (#1764)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { LitElement, ReactiveController } from 'lit';
|
||||
import { FrigateCardMessageEventTarget } from '../../components/message.js';
|
||||
import { MediaLoadedInfo, Message } from '../../types.js';
|
||||
import { MediaLoadedInfo } from '../../types.js';
|
||||
import {
|
||||
FrigateCardMediaLoadedEventTarget,
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
@@ -38,16 +38,11 @@ export class LiveController implements ReactiveController {
|
||||
// foreground and background (in preload mode).
|
||||
protected _intersectionObserver: IntersectionObserver;
|
||||
|
||||
// Whether or not to allow updates.
|
||||
protected _messageReceived = false;
|
||||
|
||||
// MediaLoadedInfo object and target from the underlying live media. In the
|
||||
// case of pre-loading these may be propagated later (from the original
|
||||
// source).
|
||||
protected _lastMediaLoadedInfo: LastMediaLoadedInfo | null = null;
|
||||
|
||||
protected _renderEpoch = 0;
|
||||
|
||||
constructor(host: LiveControllerHost) {
|
||||
this._host = host;
|
||||
|
||||
@@ -58,50 +53,22 @@ export class LiveController implements ReactiveController {
|
||||
);
|
||||
}
|
||||
|
||||
public shouldUpdate(): boolean {
|
||||
// Don't process updates if it's in the background and a message was
|
||||
// received (otherwise an error message thrown by the background live
|
||||
// component may continually be re-spammed hitting performance).
|
||||
return !(this._inBackground && this._messageReceived);
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._intersectionObserver.observe(this._host);
|
||||
|
||||
this._host.addEventListener('frigate-card:media:loaded', this._handleMediaLoaded);
|
||||
this._host.addEventListener('frigate-card:message', this._handleMessage);
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._intersectionObserver.disconnect();
|
||||
|
||||
this._host.removeEventListener('frigate-card:media:loaded', this._handleMediaLoaded);
|
||||
this._host.removeEventListener('frigate-card:message', this._handleMessage);
|
||||
}
|
||||
|
||||
public clearMessageReceived(): void {
|
||||
this._messageReceived = false;
|
||||
}
|
||||
|
||||
public isInBackground(): boolean {
|
||||
return this._inBackground;
|
||||
}
|
||||
|
||||
public getRenderEpoch(): number {
|
||||
return this._renderEpoch;
|
||||
}
|
||||
|
||||
protected _handleMessage = (ev: CustomEvent<Message>): void => {
|
||||
this._messageReceived = true;
|
||||
|
||||
if (this._inBackground) {
|
||||
ev.stopPropagation();
|
||||
|
||||
// Force the whole DOM to re-render next time.
|
||||
this._renderEpoch++;
|
||||
}
|
||||
};
|
||||
|
||||
protected _handleMediaLoaded = (ev: CustomEvent<MediaLoadedInfo>): void => {
|
||||
this._lastMediaLoadedInfo = {
|
||||
source: ev.composedPath()[0],
|
||||
@@ -117,7 +84,7 @@ export class LiveController implements ReactiveController {
|
||||
const wasInBackground = this._inBackground;
|
||||
this._inBackground = !entries.some((entry) => entry.isIntersecting);
|
||||
|
||||
if (!this._inBackground && !this._messageReceived && this._lastMediaLoadedInfo) {
|
||||
if (!this._inBackground && this._lastMediaLoadedInfo) {
|
||||
// If this isn't being rendered in the background, the last render did not
|
||||
// generate a message and there's a saved MediaInfo, dispatch it upwards.
|
||||
dispatchExistingMediaLoadedInfoAsEvent(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { dispatchFrigateCardEvent } from '../../../utils/basic';
|
||||
|
||||
export function dispatchLiveErrorEvent(element: EventTarget): void {
|
||||
dispatchFrigateCardEvent(element, 'live:error');
|
||||
}
|
||||
+19
-5
@@ -8,7 +8,7 @@ import {
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
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';
|
||||
@@ -18,7 +18,7 @@ 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 } from '../types.js';
|
||||
import { FrigateCardMediaPlayer, MediaLoadedInfo, Message } from '../types.js';
|
||||
import { contentsChanged } from '../utils/basic.js';
|
||||
import { isHassDifferent } from '../utils/ha';
|
||||
import {
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
dispatchMediaPlayEvent,
|
||||
} from '../utils/media-info.js';
|
||||
import { View } from '../view/view.js';
|
||||
import { dispatchErrorMessageEvent } from './message.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;
|
||||
@@ -53,6 +53,9 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public imageConfig?: ImageViewConfig;
|
||||
|
||||
@state()
|
||||
protected _message: Message | null = null;
|
||||
|
||||
protected _refImage: Ref<HTMLImageElement> = createRef();
|
||||
|
||||
protected _cachedValueController?: CachedValueController<string>;
|
||||
@@ -175,6 +178,10 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
|
||||
if (!this._cachedValueController?.value) {
|
||||
this._cachedValueController?.updateValue();
|
||||
}
|
||||
|
||||
if (['imageConfig', 'view'].some((prop) => changedProps.has(prop))) {
|
||||
this._message = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,6 +218,7 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
this._cachedValueController?.stopTimer();
|
||||
this._message = null;
|
||||
document.removeEventListener('visibilitychange', this._boundVisibilityHandler);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
@@ -329,6 +337,10 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
|
||||
}
|
||||
|
||||
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().
|
||||
@@ -363,9 +375,11 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
|
||||
} else if (mode === 'url') {
|
||||
// In url mode, the user likely specified a URL that cannot be
|
||||
// resolved. Show an error message.
|
||||
dispatchErrorMessageEvent(this, localize('error.image_load_error'), {
|
||||
this._message = {
|
||||
type: 'error',
|
||||
message: localize('error.image_load_error'),
|
||||
context: this.imageConfig,
|
||||
});
|
||||
};
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { ConditionsManagerEpoch } from '../../card-controller/conditions-manager.js';
|
||||
import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js';
|
||||
@@ -53,15 +45,6 @@ export class FrigateCardLive extends LitElement {
|
||||
|
||||
protected _controller = new LiveController(this);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected shouldUpdate(_changedProps: PropertyValues): boolean {
|
||||
return this._controller.shouldUpdate();
|
||||
}
|
||||
|
||||
protected willUpdate(): void {
|
||||
this._controller.clearMessageReceived();
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.nonOverriddenLiveConfig || !this.cameraManager) {
|
||||
return;
|
||||
@@ -73,28 +56,22 @@ export class FrigateCardLive extends LitElement {
|
||||
// carousel (not necessarily the selected camera).
|
||||
// - Various events are captured to prevent them propagating upwards if the
|
||||
// card is in the background.
|
||||
// - The entire returned template is keyed to allow for the whole template
|
||||
// to be re-rendered in certain circumstances (specifically: if a message
|
||||
// is received when the card is in the background).
|
||||
return html`${keyed(
|
||||
this._controller.getRenderEpoch(),
|
||||
html`
|
||||
<frigate-card-live-grid
|
||||
.hass=${this.hass}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
|
||||
.overriddenLiveConfig=${this.overriddenLiveConfig}
|
||||
.inBackground=${this._controller.isInBackground()}
|
||||
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
|
||||
.overrides=${this.overrides}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.microphoneManager=${this.microphoneManager}
|
||||
.triggeredCameraIDs=${this.triggeredCameraIDs}
|
||||
>
|
||||
</frigate-card-live-grid>
|
||||
`,
|
||||
)}`;
|
||||
return html`
|
||||
<frigate-card-live-grid
|
||||
.hass=${this.hass}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
|
||||
.overriddenLiveConfig=${this.overriddenLiveConfig}
|
||||
.inBackground=${this._controller.isInBackground()}
|
||||
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
|
||||
.overrides=${this.overrides}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.microphoneManager=${this.microphoneManager}
|
||||
.triggeredCameraIDs=${this.triggeredCameraIDs}
|
||||
>
|
||||
</frigate-card-live-grid>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -23,7 +23,6 @@ import { localize } from '../../localize/localize.js';
|
||||
import liveProviderStyle from '../../scss/live-provider.scss';
|
||||
import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js';
|
||||
import { aspectRatioToString } from '../../utils/basic.js';
|
||||
import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js';
|
||||
import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
|
||||
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
|
||||
import { playMediaMutingIfNecessary } from '../../utils/media.js';
|
||||
@@ -31,6 +30,7 @@ import { renderMessage } from '../message.js';
|
||||
import '../next-prev-control.js';
|
||||
import '../ptz.js';
|
||||
import '../surround.js';
|
||||
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
|
||||
|
||||
@customElement('frigate-card-live-provider')
|
||||
export class FrigateCardLiveProvider
|
||||
@@ -71,6 +71,9 @@ export class FrigateCardLiveProvider
|
||||
@state()
|
||||
protected _isVideoMediaLoaded = false;
|
||||
|
||||
@state()
|
||||
protected _hasProviderError = false;
|
||||
|
||||
protected _refProvider: Ref<LitElement & FrigateCardMediaPlayer> = createRef();
|
||||
|
||||
// A note on dynamic imports:
|
||||
@@ -165,7 +168,9 @@ export class FrigateCardLiveProvider
|
||||
return (
|
||||
!!this.cameraConfig?.camera_entity &&
|
||||
!!this.hass &&
|
||||
!!this.liveConfig?.show_image_during_load
|
||||
!!this.liveConfig?.show_image_during_load &&
|
||||
// Do not continue to show image during loading if an error has occurred.
|
||||
!this._hasProviderError
|
||||
);
|
||||
}
|
||||
|
||||
@@ -177,6 +182,10 @@ export class FrigateCardLiveProvider
|
||||
this._isVideoMediaLoaded = true;
|
||||
}
|
||||
|
||||
protected _providerErrorHandler(): void {
|
||||
this._hasProviderError = true;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('load')) {
|
||||
if (!this.load) {
|
||||
@@ -263,17 +272,30 @@ export class FrigateCardLiveProvider
|
||||
};
|
||||
|
||||
if (provider === 'ha' || provider === 'image') {
|
||||
const stateObj = getStateObjOrDispatchError(this, this.hass, this.cameraConfig);
|
||||
if (!stateObj) {
|
||||
return;
|
||||
if (!this.cameraConfig?.camera_entity) {
|
||||
dispatchLiveErrorEvent(this);
|
||||
return renderMessage({
|
||||
message: localize('error.no_live_camera'),
|
||||
type: 'error',
|
||||
icon: 'mdi:camera',
|
||||
context: this.cameraConfig,
|
||||
});
|
||||
}
|
||||
if (stateObj.state === 'unavailable') {
|
||||
dispatchMediaUnloadedEvent(this);
|
||||
|
||||
// An unavailable camera gets a message rendered in place vs dispatched,
|
||||
// as this may be a common occurrence (e.g. Frigate cameras that stop
|
||||
// receiving frames). Otherwise a single temporarily unavailable camera
|
||||
// would render a whole carousel inoperable.
|
||||
const stateObj = this.hass.states[this.cameraConfig.camera_entity];
|
||||
if (!stateObj) {
|
||||
dispatchLiveErrorEvent(this);
|
||||
return renderMessage({
|
||||
message: localize('error.live_camera_not_found'),
|
||||
type: 'error',
|
||||
icon: 'mdi:camera',
|
||||
context: this.cameraConfig,
|
||||
});
|
||||
}
|
||||
|
||||
if (stateObj.state === 'unavailable') {
|
||||
dispatchLiveErrorEvent(this);
|
||||
dispatchMediaUnloadedEvent(this);
|
||||
return renderMessage({
|
||||
message: `${localize('error.live_camera_unavailable')}${
|
||||
this.label ? `: ${this.label}` : ''
|
||||
@@ -291,6 +313,7 @@ export class FrigateCardLiveProvider
|
||||
${ref(this._refProvider)}
|
||||
.hass=${this.hass}
|
||||
.cameraConfig=${this.cameraConfig}
|
||||
@frigate-card:live:error=${() => this._providerErrorHandler()}
|
||||
@frigate-card:media:loaded=${(ev: Event) => {
|
||||
if (provider === 'image') {
|
||||
// Only count the media has loaded if the required provider is
|
||||
@@ -311,6 +334,7 @@ export class FrigateCardLiveProvider
|
||||
.hass=${this.hass}
|
||||
.cameraConfig=${this.cameraConfig}
|
||||
?controls=${this.liveConfig.controls.builtin}
|
||||
@frigate-card:live:error=${() => this._providerErrorHandler()}
|
||||
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-live-ha>`
|
||||
@@ -324,6 +348,7 @@ export class FrigateCardLiveProvider
|
||||
.microphoneStream=${this.microphoneStream}
|
||||
.microphoneConfig=${this.liveConfig.microphone}
|
||||
?controls=${this.liveConfig.controls.builtin}
|
||||
@frigate-card:live:error=${() => this._providerErrorHandler()}
|
||||
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-live-go2rtc>`
|
||||
@@ -336,6 +361,7 @@ export class FrigateCardLiveProvider
|
||||
.cameraEndpoints=${this.cameraEndpoints}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
?controls=${this.liveConfig.controls.builtin}
|
||||
@frigate-card:live:error=${() => this._providerErrorHandler()}
|
||||
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-live-webrtc-card>`
|
||||
@@ -347,6 +373,7 @@ export class FrigateCardLiveProvider
|
||||
.cameraConfig=${this.cameraConfig}
|
||||
.cameraEndpoints=${this.cameraEndpoints}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
@frigate-card:live:error=${() => this._providerErrorHandler()}
|
||||
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-live-jsmpeg>`
|
||||
|
||||
@@ -6,17 +6,22 @@ import {
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { CameraEndpoints } from '../../../../camera-manager/types.js';
|
||||
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
|
||||
import { CameraConfig, MicrophoneConfig } from '../../../../config/types.js';
|
||||
import { localize } from '../../../../localize/localize.js';
|
||||
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
|
||||
import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../../../types.js';
|
||||
import { getEndpointAddressOrDispatchError } from '../../../../utils/endpoint.js';
|
||||
import {
|
||||
ExtendedHomeAssistant,
|
||||
FrigateCardMediaPlayer,
|
||||
Message,
|
||||
} from '../../../../types.js';
|
||||
import { convertEndpointAddressToSignedWebsocket } from '../../../../utils/endpoint.js';
|
||||
import { setControlsOnVideo } from '../../../../utils/media.js';
|
||||
import { screenshotMedia } from '../../../../utils/screenshot.js';
|
||||
import '../../../image.js';
|
||||
import { dispatchErrorMessageEvent } from '../../../message.js';
|
||||
import { renderMessage } from '../../../message.js';
|
||||
import { VideoRTC } from './video-rtc.js';
|
||||
|
||||
customElements.define('frigate-card-live-go2rtc-player', VideoRTC);
|
||||
@@ -48,6 +53,9 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public controls = false;
|
||||
|
||||
@state()
|
||||
protected _message: Message | null = null;
|
||||
|
||||
protected _player?: VideoRTC;
|
||||
|
||||
public async play(): Promise<void> {
|
||||
@@ -96,6 +104,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this._player = undefined;
|
||||
this._message = null;
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
@@ -113,18 +122,27 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
||||
|
||||
const endpoint = this.cameraEndpoints?.go2rtc;
|
||||
if (!endpoint) {
|
||||
return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), {
|
||||
this._message = {
|
||||
type: 'error',
|
||||
message: localize('error.live_camera_no_endpoint'),
|
||||
context: this.cameraConfig,
|
||||
});
|
||||
};
|
||||
dispatchLiveErrorEvent(this);
|
||||
return;
|
||||
}
|
||||
|
||||
const address = await getEndpointAddressOrDispatchError(
|
||||
this,
|
||||
const address = await convertEndpointAddressToSignedWebsocket(
|
||||
this.hass,
|
||||
endpoint,
|
||||
GO2RTC_URL_SIGN_EXPIRY_SECONDS,
|
||||
);
|
||||
if (!address) {
|
||||
this._message = {
|
||||
type: 'error',
|
||||
message: localize('error.failed_sign'),
|
||||
context: this.cameraConfig,
|
||||
};
|
||||
dispatchLiveErrorEvent(this);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -143,7 +161,11 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (!this._player || changedProps.has('cameraEndpoints')) {
|
||||
if (changedProps.has('cameraEndpoints')) {
|
||||
this._message = null;
|
||||
}
|
||||
|
||||
if (!this._message && (!this._player || changedProps.has('cameraEndpoints'))) {
|
||||
this._createPlayer();
|
||||
}
|
||||
|
||||
@@ -163,6 +185,9 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (this._message) {
|
||||
return renderMessage(this._message);
|
||||
}
|
||||
return html`${this._player}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,11 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { CameraConfig } from '../../../config/types';
|
||||
import { localize } from '../../../localize/localize';
|
||||
import '../../../patches/ha-camera-stream';
|
||||
import '../../../patches/ha-hls-player.js';
|
||||
import '../../../patches/ha-web-rtc-player.js';
|
||||
import liveHAStyle from '../../../scss/live-ha.scss';
|
||||
import { FrigateCardMediaPlayer } from '../../../types.js';
|
||||
import { renderMessage } from '../../message';
|
||||
import { getStateObjOrDispatchError } from '../../../utils/get-state-obj';
|
||||
|
||||
@customElement('frigate-card-live-ha')
|
||||
export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPlayer {
|
||||
@@ -66,22 +63,12 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
|
||||
return;
|
||||
}
|
||||
|
||||
const stateObj = getStateObjOrDispatchError(this, this.hass, this.cameraConfig);
|
||||
if (!stateObj) {
|
||||
return;
|
||||
}
|
||||
if (stateObj.state === 'unavailable') {
|
||||
return renderMessage({
|
||||
message: localize('error.live_camera_unavailable'),
|
||||
type: 'error',
|
||||
icon: 'mdi:connection',
|
||||
context: this.cameraConfig,
|
||||
});
|
||||
}
|
||||
return html` <frigate-card-ha-camera-stream
|
||||
${ref(this._playerRef)}
|
||||
.hass=${this.hass}
|
||||
.stateObj=${stateObj}
|
||||
.stateObj=${this.cameraConfig?.camera_entity
|
||||
? this.hass.states[this.cameraConfig.camera_entity]
|
||||
: undefined}
|
||||
.controls=${this.controls}
|
||||
.muted=${true}
|
||||
>
|
||||
|
||||
@@ -5,7 +5,6 @@ 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 { getStateObjOrDispatchError } from '../../../utils/get-state-obj';
|
||||
import '../../image.js';
|
||||
|
||||
@customElement('frigate-card-live-image')
|
||||
@@ -59,8 +58,6 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
||||
return;
|
||||
}
|
||||
|
||||
getStateObjOrDispatchError(this, this.hass, this.cameraConfig);
|
||||
|
||||
return html`
|
||||
<frigate-card-image
|
||||
${ref(this._refImage)}
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { until } from 'lit/directives/until.js';
|
||||
import { CameraEndpoints } from '../../../camera-manager/types.js';
|
||||
import { renderProgressIndicator } from '../../message.js';
|
||||
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
|
||||
import { CameraConfig, CardWideConfig } from '../../../config/types.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss';
|
||||
import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../../types.js';
|
||||
import { getEndpointAddressOrDispatchError } from '../../../utils/endpoint.js';
|
||||
import {
|
||||
ExtendedHomeAssistant,
|
||||
FrigateCardMediaPlayer,
|
||||
Message,
|
||||
} from '../../../types.js';
|
||||
import { convertEndpointAddressToSignedWebsocket } from '../../../utils/endpoint.js';
|
||||
import {
|
||||
dispatchMediaLoadedEvent,
|
||||
dispatchMediaPauseEvent,
|
||||
dispatchMediaPlayEvent,
|
||||
} from '../../../utils/media-info.js';
|
||||
import { Timer } from '../../../utils/timer.js';
|
||||
import { dispatchErrorMessageEvent } from '../../message.js';
|
||||
import { renderMessage, renderProgressIndicator } from '../../message.js';
|
||||
|
||||
// Number of seconds a signed URL is valid for.
|
||||
const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
|
||||
@@ -40,6 +51,9 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
|
||||
protected _jsmpegVideoPlayer?: JSMpeg.VideoElement;
|
||||
protected _refreshPlayerTimer = new Timer();
|
||||
|
||||
@state()
|
||||
protected _message: Message | null = null;
|
||||
|
||||
public async play(): Promise<void> {
|
||||
return this._jsmpegVideoPlayer?.play();
|
||||
}
|
||||
@@ -84,6 +98,14 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
|
||||
return this._jsmpegCanvasElement?.toDataURL('image/jpeg') ?? null;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
if (
|
||||
['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop))
|
||||
) {
|
||||
this._message = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JSMPEG player.
|
||||
* @param url The URL for the player to connect to.
|
||||
@@ -150,6 +172,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
|
||||
* Reset / destroy the player.
|
||||
*/
|
||||
protected _resetPlayer(): void {
|
||||
this._message = null;
|
||||
this._refreshPlayerTimer.stop();
|
||||
if (this._jsmpegVideoPlayer) {
|
||||
try {
|
||||
@@ -199,18 +222,27 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
|
||||
|
||||
const endpoint = this.cameraEndpoints?.jsmpeg;
|
||||
if (!endpoint) {
|
||||
return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), {
|
||||
this._message = {
|
||||
message: localize('error.live_camera_no_endpoint'),
|
||||
type: 'error',
|
||||
context: this.cameraConfig,
|
||||
});
|
||||
};
|
||||
dispatchLiveErrorEvent(this);
|
||||
return;
|
||||
}
|
||||
|
||||
const address = await getEndpointAddressOrDispatchError(
|
||||
this,
|
||||
const address = await convertEndpointAddressToSignedWebsocket(
|
||||
this.hass,
|
||||
endpoint,
|
||||
JSMPEG_URL_SIGN_EXPIRY_SECONDS,
|
||||
);
|
||||
if (!address) {
|
||||
this._message = {
|
||||
type: 'error',
|
||||
message: localize('error.failed_sign'),
|
||||
context: this.cameraConfig,
|
||||
};
|
||||
dispatchLiveErrorEvent(this);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,11 +257,23 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
|
||||
* Master render method.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (this._message) {
|
||||
return renderMessage(this._message);
|
||||
}
|
||||
|
||||
const _render = async (): Promise<TemplateResult | void> => {
|
||||
await this._refreshPlayer();
|
||||
|
||||
if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) {
|
||||
return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player'));
|
||||
if (!this._message) {
|
||||
this._message = {
|
||||
message: localize('error.jsmpeg_no_player'),
|
||||
type: 'error',
|
||||
context: this.cameraConfig,
|
||||
};
|
||||
dispatchLiveErrorEvent(this);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return html`${this._jsmpegCanvasElement}`;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { Task } from '@lit-labs/task';
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { CameraEndpoints } from '../../../camera-manager/types.js';
|
||||
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
|
||||
import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get-technology-for-video-rtc.js';
|
||||
import { CameraConfig, CardWideConfig } from '../../../config/types.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
import liveWebRTCCardStyle from '../../../scss/live-webrtc-card.scss';
|
||||
import { FrigateCardError, FrigateCardMediaPlayer } from '../../../types.js';
|
||||
import { FrigateCardError, FrigateCardMediaPlayer, Message } from '../../../types.js';
|
||||
import { mayHaveAudio } from '../../../utils/audio.js';
|
||||
import {
|
||||
dispatchMediaLoadedEvent,
|
||||
@@ -22,7 +30,7 @@ import {
|
||||
} from '../../../utils/media.js';
|
||||
import { screenshotMedia } from '../../../utils/screenshot.js';
|
||||
import { renderTask } from '../../../utils/task.js';
|
||||
import { dispatchErrorMessageEvent, renderProgressIndicator } from '../../message.js';
|
||||
import { renderMessage, renderProgressIndicator } from '../../message.js';
|
||||
import { VideoRTC } from './go2rtc/video-rtc.js';
|
||||
|
||||
// Create a wrapper for AlexxIT's WebRTC card
|
||||
@@ -44,6 +52,9 @@ export class FrigateCardLiveWebRTCCard
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public controls = false;
|
||||
|
||||
@state()
|
||||
protected _message: Message | null = null;
|
||||
|
||||
protected hass?: HomeAssistant;
|
||||
|
||||
// A task to await the load of the WebRTC component.
|
||||
@@ -106,6 +117,19 @@ export class FrigateCardLiveWebRTCCard
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this._message = null;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
if (
|
||||
['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop))
|
||||
) {
|
||||
this._message = null;
|
||||
}
|
||||
}
|
||||
|
||||
protected _getVideoRTC(): VideoRTC | null {
|
||||
return (this.renderRoot?.querySelector('#webrtc') ?? null) as VideoRTC | null;
|
||||
}
|
||||
@@ -162,23 +186,28 @@ export class FrigateCardLiveWebRTCCard
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (this._message) {
|
||||
return renderMessage(this._message);
|
||||
}
|
||||
|
||||
const render = (): TemplateResult | void => {
|
||||
let webrtcElement: HTMLElement | null;
|
||||
try {
|
||||
webrtcElement = this._createWebRTC();
|
||||
} catch (e) {
|
||||
return dispatchErrorMessageEvent(
|
||||
this,
|
||||
e instanceof FrigateCardError
|
||||
? e.message
|
||||
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
|
||||
{ context: (e as FrigateCardError).context },
|
||||
);
|
||||
this._message = {
|
||||
type: 'error',
|
||||
message:
|
||||
e instanceof FrigateCardError
|
||||
? e.message
|
||||
: localize('error.webrtc_card_reported_error') +
|
||||
': ' +
|
||||
(e as Error).message,
|
||||
context: (e as FrigateCardError).context,
|
||||
};
|
||||
dispatchLiveErrorEvent(this);
|
||||
return;
|
||||
}
|
||||
if (webrtcElement) {
|
||||
// Set the id to ensure that the relevant CSS styles will have
|
||||
@@ -192,7 +221,7 @@ export class FrigateCardLiveWebRTCCard
|
||||
// Use a task to allow us to asynchronously wait for the WebRTC card to
|
||||
// load, but yet still have the card load be followed by the updated()
|
||||
// lifecycle callback (unlike just using `until`).
|
||||
return renderTask(this, this._webrtcTask, render, {
|
||||
return renderTask(this._webrtcTask, render, {
|
||||
inProgressFunc: () =>
|
||||
renderProgressIndicator({
|
||||
message: localize('error.webrtc_card_waiting'),
|
||||
@@ -201,9 +230,6 @@ export class FrigateCardLiveWebRTCCard
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updated lifecycle callback.
|
||||
*/
|
||||
public updated(): void {
|
||||
// Extract the video component after it has been rendered and generate the
|
||||
// media load event.
|
||||
@@ -232,9 +258,6 @@ export class FrigateCardLiveWebRTCCard
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(liveWebRTCCardStyle);
|
||||
}
|
||||
|
||||
@@ -152,12 +152,15 @@ export function renderProgressIndicator(options?: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event with a message to show to the user.
|
||||
* Dispatch an event with a message to show to the user. Calling this method
|
||||
* will grind the card to a halt, so should only be used for "global" / critical
|
||||
* errors (i.e. not for individual errors with a given camera, since there may
|
||||
* be multiple correctly functioning cameras in a grid).
|
||||
* @param element The element to send the event.
|
||||
* @param message The message to show.
|
||||
* @param options Optional icon and context to include.
|
||||
*/
|
||||
export function dispatchMessageEvent(
|
||||
function dispatchMessageEvent(
|
||||
element: EventTarget,
|
||||
message: string,
|
||||
type: MessageType,
|
||||
@@ -175,12 +178,15 @@ export function dispatchMessageEvent(
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event with an error message to show to the user.
|
||||
* Dispatch an event with an error message to show to the user. Calling this
|
||||
* method will grind the card to a halt, so should only be used for "global" /
|
||||
* critical errors (i.e. not for individual errors with a given camera, since
|
||||
* there may be multiple correctly functioning cameras in a grid).
|
||||
* @param element The element to send the event.
|
||||
* @param message The message to show.
|
||||
* @param options Optional context to include.
|
||||
*/
|
||||
export function dispatchErrorMessageEvent(
|
||||
function dispatchErrorMessageEvent(
|
||||
element: EventTarget,
|
||||
message: string,
|
||||
options?: {
|
||||
@@ -193,7 +199,10 @@ export function dispatchErrorMessageEvent(
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event with an error message to show to the user.
|
||||
* Dispatch an event with an error message to show to the user. Calling this
|
||||
* method will grind the card to a halt, so should only be used for "global" /
|
||||
* critical errors (i.e. not for individual errors with a given camera, since
|
||||
* there may be multiple correctly functioning cameras in a grid).
|
||||
* @param element The element to send the event.
|
||||
* @param message The message to show.
|
||||
*/
|
||||
|
||||
@@ -37,6 +37,9 @@ export class FrigateCardNextPreviousControl extends LitElement {
|
||||
// Label that is used for ARIA support and as tooltip.
|
||||
@property() label = '';
|
||||
|
||||
@state()
|
||||
protected _thumbnailError = false;
|
||||
|
||||
protected _embedThumbnailTask = createFetchThumbnailTask(
|
||||
this,
|
||||
() => this.hass,
|
||||
@@ -49,7 +52,9 @@ export class FrigateCardNextPreviousControl extends LitElement {
|
||||
}
|
||||
|
||||
const renderIcon =
|
||||
!this.thumbnail || ['chevrons', 'icons'].includes(this._controlConfig.style);
|
||||
!this.thumbnail ||
|
||||
['chevrons', 'icons'].includes(this._controlConfig.style) ||
|
||||
this._thumbnailError;
|
||||
|
||||
const classes = {
|
||||
controls: true,
|
||||
@@ -62,7 +67,10 @@ export class FrigateCardNextPreviousControl extends LitElement {
|
||||
|
||||
if (renderIcon) {
|
||||
const icon =
|
||||
!this.thumbnail || !this.icon || this._controlConfig.style === 'chevrons'
|
||||
!this.thumbnail ||
|
||||
!this.icon ||
|
||||
this._controlConfig.style === 'chevrons' ||
|
||||
this._thumbnailError
|
||||
? this.side === 'left'
|
||||
? 'mdi:chevron-left'
|
||||
: 'mdi:chevron-right'
|
||||
@@ -74,7 +82,6 @@ export class FrigateCardNextPreviousControl extends LitElement {
|
||||
}
|
||||
|
||||
return renderTask(
|
||||
this,
|
||||
this._embedThumbnailTask,
|
||||
(embeddedThumbnail: string | null) =>
|
||||
embeddedThumbnail
|
||||
@@ -85,7 +92,13 @@ export class FrigateCardNextPreviousControl extends LitElement {
|
||||
aria-label="${this.label}"
|
||||
/>`
|
||||
: html``,
|
||||
{ inProgressFunc: () => html`<div class=${classMap(classes)}></div>` },
|
||||
{
|
||||
inProgressFunc: () => html`<div class=${classMap(classes)}></div>`,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
errorFunc: (_e: Error) => {
|
||||
this._thumbnailError = true;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { CameraManagerCameraMetadata } from '../camera-manager/types.js';
|
||||
@@ -45,6 +45,9 @@ export class FrigateCardThumbnailFeatureThumbnail extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
@state()
|
||||
protected _thumbnailError = false;
|
||||
|
||||
protected _embedThumbnailTask?: Task<FetchThumbnailTaskArgs, string | null>;
|
||||
|
||||
// Only load thumbnails on view in case there is a very large number of them.
|
||||
@@ -107,17 +110,21 @@ export class FrigateCardThumbnailFeatureThumbnail extends LitElement {
|
||||
title=${localize('thumbnail.no_thumbnail')}
|
||||
></ha-icon> `;
|
||||
|
||||
if (!this._embedThumbnailTask) {
|
||||
if (!this._embedThumbnailTask || this._thumbnailError) {
|
||||
return imageOff;
|
||||
}
|
||||
|
||||
return html`${this.thumbnail
|
||||
? renderTask(
|
||||
this,
|
||||
this._embedThumbnailTask,
|
||||
(embeddedThumbnail: string | null) =>
|
||||
embeddedThumbnail ? html`<img src="${embeddedThumbnail}" />` : html``,
|
||||
{ inProgressFunc: () => imageOff },
|
||||
{
|
||||
inProgressFunc: () => imageOff,
|
||||
errorFunc: () => {
|
||||
this._thumbnailError = true;
|
||||
},
|
||||
},
|
||||
)
|
||||
: imageOff} `;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import { getTextDirection } from '../../utils/text-direction.js';
|
||||
import { ViewMedia } from '../../view/media.js';
|
||||
import '../carousel';
|
||||
import type { EmblaCarouselPlugins } from '../carousel.js';
|
||||
import { dispatchMessageEvent } from '../message.js';
|
||||
import { renderMessage } from '../message.js';
|
||||
import '../next-prev-control.js';
|
||||
import '../ptz.js';
|
||||
import './provider.js';
|
||||
@@ -348,8 +348,15 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
protected render(): TemplateResult | void {
|
||||
const mediaCount = this._media?.length ?? 0;
|
||||
if (!this._media || !mediaCount) {
|
||||
return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
|
||||
return renderMessage({
|
||||
message: localize('common.no_media'),
|
||||
type: 'info',
|
||||
icon: 'mdi:multimedia',
|
||||
...(this.viewFilterCameraID && {
|
||||
context: {
|
||||
camera_id: this.viewFilterCameraID,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
export const REPO_URL = 'https://github.com/dermotduffy/frigate-hass-card' as const;
|
||||
export const TROUBLESHOOTING_URL = `${REPO_URL}#troubleshooting` as const;
|
||||
export const TROUBLESHOOTING_URL = `https://card.camera/#/troubleshooting` as const;
|
||||
|
||||
export const CONF_AUTOMATIONS = 'automations' as const;
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { query } from 'lit/decorators/query.js';
|
||||
import { dispatchErrorMessageEvent } from '../components/message.js';
|
||||
import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js';
|
||||
import { renderMessage } from '../components/message.js';
|
||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
||||
import { FrigateCardMediaPlayer } from '../types.js';
|
||||
import { mayHaveAudio } from '../utils/audio.js';
|
||||
@@ -97,8 +98,14 @@ customElements.whenDefined('ha-hls-player').then(() => {
|
||||
protected render(): TemplateResult {
|
||||
if (this._error) {
|
||||
if (this._errorIsFatal) {
|
||||
// Use native Frigate card error handling for fatal errors.
|
||||
return dispatchErrorMessageEvent(this, this._error);
|
||||
dispatchLiveErrorEvent(this);
|
||||
return renderMessage({
|
||||
type: 'error',
|
||||
message: this._error,
|
||||
context: {
|
||||
entity_id: this.entityid,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
errorToConsole(this._error, console.error);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { query } from 'lit/decorators/query.js';
|
||||
import { screenshotMedia } from '../utils/screenshot.js';
|
||||
import { dispatchErrorMessageEvent } from '../components/message.js';
|
||||
import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js';
|
||||
import { renderMessage } from '../components/message.js';
|
||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
||||
import { FrigateCardMediaPlayer } from '../types.js';
|
||||
import { mayHaveAudio } from '../utils/audio.js';
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||
setControlsOnVideo,
|
||||
} from '../utils/media.js';
|
||||
import { screenshotMedia } from '../utils/screenshot.js';
|
||||
|
||||
customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||
@customElement('frigate-card-ha-web-rtc-player')
|
||||
@@ -94,9 +95,14 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||
// =====================================================================================
|
||||
protected render(): TemplateResult | void {
|
||||
if (this._error) {
|
||||
// Use native Frigate card error handling, and attach the entityid to
|
||||
// clarify which camera the error refers to.
|
||||
return dispatchErrorMessageEvent(this, `${this._error} (${this.entityid})`);
|
||||
dispatchLiveErrorEvent(this);
|
||||
return renderMessage({
|
||||
type: 'error',
|
||||
message: this._error,
|
||||
context: {
|
||||
entity_id: this.entityid,
|
||||
},
|
||||
});
|
||||
}
|
||||
return html`
|
||||
<video
|
||||
|
||||
+2
-11
@@ -1,12 +1,9 @@
|
||||
import { CameraEndpoint } from '../camera-manager/types';
|
||||
import { dispatchErrorMessageEvent } from '../components/message';
|
||||
import { localize } from '../localize/localize';
|
||||
import { ExtendedHomeAssistant } from '../types';
|
||||
import { errorToConsole } from './basic';
|
||||
import { homeAssistantSignPath } from './ha';
|
||||
|
||||
export const getEndpointAddressOrDispatchError = async (
|
||||
element: HTMLElement,
|
||||
export const convertEndpointAddressToSignedWebsocket = async (
|
||||
hass: ExtendedHomeAssistant,
|
||||
endpoint: CameraEndpoint,
|
||||
expires?: number,
|
||||
@@ -20,13 +17,7 @@ export const getEndpointAddressOrDispatchError = async (
|
||||
response = await homeAssistantSignPath(hass, endpoint.endpoint, expires);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
dispatchErrorMessageEvent(element, localize('error.failed_sign'));
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.replace(/^http/i, 'ws');
|
||||
return response ? response.replace(/^http/i, 'ws') : null;
|
||||
};
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
import { CameraConfig } from '../config/types.js';
|
||||
import { dispatchErrorMessageEvent } from '../components/message.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
|
||||
/**
|
||||
* Get the state object or dispatch an error. Used in `ha` and `image` live
|
||||
* providers.
|
||||
* @param element HTMLElement to dispatch errors from.
|
||||
* @param hass Home Assistant object.
|
||||
* @param cameraConfig Camera configuration.
|
||||
* @returns
|
||||
*/
|
||||
export const getStateObjOrDispatchError = (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
cameraConfig?: CameraConfig,
|
||||
): HassEntity | null => {
|
||||
if (!cameraConfig?.camera_entity) {
|
||||
dispatchErrorMessageEvent(element, localize('error.no_live_camera'), {
|
||||
context: cameraConfig,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const stateObj = hass.states[cameraConfig.camera_entity];
|
||||
if (!stateObj) {
|
||||
dispatchErrorMessageEvent(element, localize('error.live_camera_not_found'), {
|
||||
context: cameraConfig,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return stateObj;
|
||||
};
|
||||
+5
-6
@@ -1,9 +1,6 @@
|
||||
import { Task } from '@lit-labs/task';
|
||||
import { html, TemplateResult } from 'lit';
|
||||
import {
|
||||
dispatchFrigateCardErrorEvent,
|
||||
renderProgressIndicator,
|
||||
} from '../components/message';
|
||||
import { renderProgressIndicator } from '../components/message';
|
||||
import { CardWideConfig } from '../config/types';
|
||||
import { errorToConsole } from './basic';
|
||||
|
||||
@@ -16,12 +13,12 @@ import { errorToConsole } from './basic';
|
||||
* @returns A template.
|
||||
*/
|
||||
export const renderTask = <R>(
|
||||
host: EventTarget,
|
||||
task: Task<unknown[], R>,
|
||||
completeFunc: (result: R) => TemplateResult | void,
|
||||
options?: {
|
||||
cardWideConfig?: CardWideConfig;
|
||||
inProgressFunc?: () => TemplateResult | void;
|
||||
errorFunc?: (e: Error) => void;
|
||||
},
|
||||
): TemplateResult => {
|
||||
const progressConfig = {
|
||||
@@ -34,7 +31,9 @@ export const renderTask = <R>(
|
||||
options?.inProgressFunc?.() ?? renderProgressIndicator(progressConfig),
|
||||
error: (e: unknown) => {
|
||||
errorToConsole(e as Error);
|
||||
dispatchFrigateCardErrorEvent(host, e as Error);
|
||||
if (options?.errorFunc) {
|
||||
options.errorFunc(e as Error);
|
||||
}
|
||||
},
|
||||
complete: completeFunc,
|
||||
})}`;
|
||||
|
||||
@@ -43,7 +43,8 @@ const fetchThumbnail = async (
|
||||
};
|
||||
reader.onerror = (e) => reject(e);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
})
|
||||
.catch((e) => reject(e));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { LiveController } from '../../../src/components-lib/live/live-controller';
|
||||
import { dispatchMessageEvent } from '../../../src/components/message';
|
||||
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../../src/utils/media-info';
|
||||
import {
|
||||
IntersectionObserverMock,
|
||||
callIntersectionHandler,
|
||||
@@ -26,7 +26,7 @@ describe('LiveController', () => {
|
||||
const host = createLitElement();
|
||||
const parent = createParent({ children: [host] });
|
||||
const eventListener = vi.fn();
|
||||
parent.addEventListener('frigate-card:message', eventListener);
|
||||
parent.addEventListener('frigate-card:media:loaded', eventListener);
|
||||
|
||||
const controller = new LiveController(host);
|
||||
expect(host.addController).toBeCalled();
|
||||
@@ -34,12 +34,13 @@ describe('LiveController', () => {
|
||||
controller.hostConnected();
|
||||
|
||||
callIntersectionHandler(false);
|
||||
dispatchMessageEvent(host, 'message', 'info');
|
||||
|
||||
dispatchExistingMediaLoadedInfoAsEvent(host, createMediaLoadedInfo());
|
||||
|
||||
expect(eventListener).toBeCalledTimes(0);
|
||||
|
||||
controller.hostDisconnected();
|
||||
dispatchMessageEvent(host, 'message', 'info');
|
||||
dispatchExistingMediaLoadedInfoAsEvent(host, createMediaLoadedInfo());
|
||||
|
||||
expect(eventListener).toBeCalledTimes(1);
|
||||
});
|
||||
@@ -92,70 +93,6 @@ describe('LiveController', () => {
|
||||
|
||||
host.dispatchEvent(createMediaLoadedInfoEvent(mediaLoadedInfo));
|
||||
expect(eventListener).toBeCalledTimes(2);
|
||||
|
||||
callIntersectionHandler(false);
|
||||
dispatchMessageEvent(host, 'message', 'info');
|
||||
callIntersectionHandler(true);
|
||||
expect(eventListener).toBeCalledTimes(2);
|
||||
|
||||
controller.clearMessageReceived();
|
||||
callIntersectionHandler(true);
|
||||
expect(eventListener).toBeCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should correctly allow updates', () => {
|
||||
it('when not in background', () => {
|
||||
const controller = new LiveController(createLitElement());
|
||||
expect(controller.shouldUpdate()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('when in background without message', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new LiveController(host);
|
||||
controller.hostConnected();
|
||||
|
||||
callIntersectionHandler(false);
|
||||
|
||||
expect(controller.shouldUpdate()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('when in background with message', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new LiveController(host);
|
||||
controller.hostConnected();
|
||||
|
||||
callIntersectionHandler(false);
|
||||
dispatchMessageEvent(host, 'message', 'info');
|
||||
|
||||
expect(controller.shouldUpdate()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle message', () => {
|
||||
const host = createLitElement();
|
||||
const parent = createParent({ children: [host] });
|
||||
const eventListener = vi.fn();
|
||||
parent.addEventListener('frigate-card:message', eventListener);
|
||||
|
||||
const controller = new LiveController(host);
|
||||
controller.hostConnected();
|
||||
|
||||
callIntersectionHandler(false);
|
||||
expect(controller.isInBackground()).toBeTruthy();
|
||||
|
||||
const firstRenderEpoch = controller.getRenderEpoch();
|
||||
|
||||
dispatchMessageEvent(host, 'message', 'info');
|
||||
expect(eventListener).toBeCalledTimes(0);
|
||||
|
||||
const secondRenderEpoch = controller.getRenderEpoch();
|
||||
expect(secondRenderEpoch).not.toBe(firstRenderEpoch);
|
||||
|
||||
callIntersectionHandler(true);
|
||||
|
||||
dispatchMessageEvent(host, 'message', 'info');
|
||||
expect(eventListener).toBeCalledTimes(1);
|
||||
expect(controller.getRenderEpoch()).toBe(secondRenderEpoch);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { dispatchLiveErrorEvent } from '../../../../src/components-lib/live/utils/dispatch-live-error';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
it('should dispatch live error event', () => {
|
||||
const element = document.createElement('div');
|
||||
const handler = vi.fn();
|
||||
element.addEventListener('frigate-card:live:error', handler);
|
||||
|
||||
dispatchLiveErrorEvent(element);
|
||||
expect(handler).toBeCalled();
|
||||
});
|
||||
@@ -1,22 +1,17 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getEndpointAddressOrDispatchError } from '../../src/utils/endpoint';
|
||||
import { convertEndpointAddressToSignedWebsocket } from '../../src/utils/endpoint';
|
||||
import { homeAssistantSignPath } from '../../src/utils/ha';
|
||||
import { createHASS } from '../test-utils';
|
||||
|
||||
vi.mock('../../src/utils/ha');
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('getEndpointAddressOrDispatchError', () => {
|
||||
describe('convertEndpointAddressToSignedWebsocket', () => {
|
||||
it('without signing', async () => {
|
||||
expect(
|
||||
await getEndpointAddressOrDispatchError(
|
||||
document.createElement('div'),
|
||||
createHASS(),
|
||||
{
|
||||
endpoint: 'http://example.com',
|
||||
sign: false,
|
||||
},
|
||||
),
|
||||
await convertEndpointAddressToSignedWebsocket(createHASS(), {
|
||||
endpoint: 'http://example.com',
|
||||
sign: false,
|
||||
}),
|
||||
).toBe('http://example.com');
|
||||
});
|
||||
|
||||
@@ -29,51 +24,31 @@ describe('getEndpointAddressOrDispatchError', () => {
|
||||
vi.mocked(homeAssistantSignPath).mockResolvedValue('http://signed.com');
|
||||
|
||||
expect(
|
||||
await getEndpointAddressOrDispatchError(
|
||||
document.createElement('div'),
|
||||
createHASS(),
|
||||
{
|
||||
endpoint: 'http://example.com',
|
||||
sign: true,
|
||||
},
|
||||
),
|
||||
await convertEndpointAddressToSignedWebsocket(createHASS(), {
|
||||
endpoint: 'http://example.com',
|
||||
sign: true,
|
||||
}),
|
||||
).toBe('ws://signed.com');
|
||||
});
|
||||
|
||||
it('with null response', async () => {
|
||||
const element = document.createElement('div');
|
||||
const listener = vi.fn();
|
||||
element.addEventListener('frigate-card:message', listener);
|
||||
|
||||
vi.mocked(homeAssistantSignPath).mockResolvedValue(null);
|
||||
|
||||
expect(
|
||||
await getEndpointAddressOrDispatchError(element, createHASS(), {
|
||||
await convertEndpointAddressToSignedWebsocket(createHASS(), {
|
||||
endpoint: 'http://example.com',
|
||||
sign: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
expect(listener).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: expect.objectContaining({
|
||||
message: 'Could not sign Home Assistant URL',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('with exception on signing', async () => {
|
||||
const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||
|
||||
const element = document.createElement('div');
|
||||
const listener = vi.fn();
|
||||
element.addEventListener('frigate-card:message', listener);
|
||||
|
||||
vi.mocked(homeAssistantSignPath).mockRejectedValue(new Error());
|
||||
|
||||
expect(
|
||||
await getEndpointAddressOrDispatchError(element, createHASS(), {
|
||||
await convertEndpointAddressToSignedWebsocket(createHASS(), {
|
||||
endpoint: 'http://example.com',
|
||||
sign: true,
|
||||
}),
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createCameraConfig, createHASS, createStateEntity } from '../test-utils';
|
||||
import { getStateObjOrDispatchError } from '../../src/utils/get-state-obj';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('getStateObjOrDispatchError', () => {
|
||||
it('should retrieve valid state object', () => {
|
||||
const messageHandler = vi.fn();
|
||||
const element = document.createElement('div');
|
||||
element.addEventListener('frigate-card:message', messageHandler);
|
||||
const state = createStateEntity();
|
||||
|
||||
expect(
|
||||
getStateObjOrDispatchError(
|
||||
element,
|
||||
createHASS({
|
||||
'camera.test': state,
|
||||
}),
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.test',
|
||||
}),
|
||||
),
|
||||
).toBe(state);
|
||||
|
||||
expect(messageHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should dispatch unspecified entity', () => {
|
||||
const messageHandler = vi.fn();
|
||||
const element = document.createElement('div');
|
||||
element.addEventListener('frigate-card:message', messageHandler);
|
||||
|
||||
expect(
|
||||
getStateObjOrDispatchError(element, createHASS(), createCameraConfig()),
|
||||
).toBeNull();
|
||||
|
||||
expect(messageHandler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: expect.objectContaining({
|
||||
message:
|
||||
'The camera_entity parameter must be set and valid for this live provider',
|
||||
type: 'error',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch not found state', () => {
|
||||
const messageHandler = vi.fn();
|
||||
const element = document.createElement('div');
|
||||
element.addEventListener('frigate-card:message', messageHandler);
|
||||
|
||||
expect(
|
||||
getStateObjOrDispatchError(
|
||||
element,
|
||||
createHASS(),
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.will-not-be-found',
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
expect(messageHandler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: expect.objectContaining({
|
||||
message: 'The configured camera_entity was not found',
|
||||
type: 'error',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user