feat: Add hardened error handling and retries (#2451)

- Closes #1830
 - Closes #2099
This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 4bc787e2b7
commit 47bcce93d3
182 changed files with 7877 additions and 4043 deletions
+7 -10
View File
@@ -1,15 +1,14 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { ProblemPresence } from '../card-controller/problems/types';
import { IssuePresence } from '../card-controller/issues/types';
import { RawAdvancedCameraCardConfig } from '../config/types';
import { DeviceRegistryManager } from '../ha/registry/device';
import { HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize';
import basicBlockStyle from '../scss/basic-block.scss';
import { getDiagnostics } from '../utils/diagnostics';
import { renderMessage } from './message';
import { renderNotificationBlockFromText } from './notification/block';
@customElement('advanced-camera-card-diagnostics')
export class AdvancedCameraCardDiagnostics extends LitElement {
// Not a reactive property to avoid multiple diagnostics fetches.
@@ -22,18 +21,17 @@ export class AdvancedCameraCardDiagnostics extends LitElement {
public rawConfig?: RawAdvancedCameraCardConfig;
@property({ attribute: false })
public problems?: ProblemPresence;
public issues?: IssuePresence;
private async _renderDiagnostics(): Promise<TemplateResult> {
const diagnostics = await getDiagnostics(
this.hass,
this.deviceRegistryManager,
this.rawConfig,
this.problems,
this.issues,
);
return renderMessage({
message: localize('error.diagnostics'),
return renderNotificationBlockFromText(localize('error.diagnostics'), {
icon: 'mdi:cogs',
context: diagnostics,
});
@@ -42,10 +40,9 @@ export class AdvancedCameraCardDiagnostics extends LitElement {
protected render(): TemplateResult | void {
return html`${until(
this._renderDiagnostics(),
renderMessage({
message: localize('error.fetching_diagnostics'),
dotdotdot: true,
renderNotificationBlockFromText(localize('error.fetching_diagnostics'), {
icon: 'mdi:cogs',
in_progress: true,
}),
)}`;
}
+13 -3
View File
@@ -8,8 +8,8 @@ import {
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { isEqual } from 'lodash-es';
import { IssueTriggerEventData } from '../card-controller/issues/types.js';
import { TemplateRenderer } from '../card-controller/templates/index.js';
import { dispatchAdvancedCameraCardErrorEvent } from '../components-lib/message/dispatch.js';
import { ConditionsManager } from '../conditions/conditions-manager.js';
import { getConditionStateManagerViaEvent } from '../conditions/state-manager-via-event.js';
import { ConditionStateManager } from '../conditions/state-manager.js';
@@ -35,6 +35,12 @@ import { AdvancedCameraCardError } from '../types.js';
import { errorToConsole } from '../utils/basic.js';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
class ElementsCreationError extends AdvancedCameraCardError {
constructor(context?: unknown) {
super(localize('error.could_not_create_elements'), context);
}
}
/* A note on picture element rendering:
*
* To avoid needing to deal with the rendering of all the picture elements
@@ -106,7 +112,7 @@ export class AdvancedCameraCardElementsCore extends LitElement {
private _createRoot(): HuiConditionalElement {
const elementConstructor = customElements.get('hui-conditional-element');
if (!elementConstructor || !this.hass) {
throw new Error(localize('error.could_not_render_elements'));
throw new ElementsCreationError(this._renderedElements);
}
const element = new elementConstructor() as HuiConditionalElement;
@@ -145,7 +151,11 @@ export class AdvancedCameraCardElementsCore extends LitElement {
this._renderedElements = elements;
this._root = this._createRoot();
} catch (e) {
return dispatchAdvancedCameraCardErrorEvent(this, e as AdvancedCameraCardError);
errorToConsole(e as Error);
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this, 'issue:trigger', {
key: 'config_error',
error: new ElementsCreationError(elements),
});
}
};
-1
View File
@@ -16,7 +16,6 @@ import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thu
import { CardWideConfig } from '../../config/schema/types.js';
import { HomeAssistant } from '../../ha/types.js';
import galleryCoreStyle from '../../scss/gallery-core.scss';
import '../message.js';
import '../progress-indicator.js';
import { renderProgressIndicator } from '../progress-indicator.js';
+5 -10
View File
@@ -27,7 +27,6 @@ import { MediaGalleryConfig } from '../../config/schema/media-gallery.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import galleryStyle from '../../scss/gallery.scss';
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
@@ -36,8 +35,7 @@ import { UnifiedQueryBuilder } from '../../view/unified-query-builder.js';
import { UnifiedQueryRunner } from '../../view/unified-query-runner.js';
import { getReviewedQueryFilterFromQuery } from '../../view/utils/query-filter.js';
import '../media-filter.js';
import '../message.js';
import { renderMessage } from '../message.js';
import { renderNoMedia } from '../notification/no-media.js';
import '../surround-basic.js';
import '../thumbnail/thumbnail.js';
import './gallery-core.js';
@@ -216,13 +214,10 @@ export class AdvancedCameraCardGallery extends LitElement {
</advanced-camera-card-media-filter>`
: ''}
${!hasItems
? renderMessage({
type: 'info',
message: isLoading
? localize('error.awaiting_media')
: localize('common.no_media'),
icon: 'mdi:multimedia',
dotdotdot: isLoading,
? renderNoMedia({
cameraID: this.viewManagerEpoch?.manager.getView()?.camera ?? null,
cameraManager: this.cameraManager ?? null,
loading: isLoading,
})
: html`<advanced-camera-card-gallery-core
.hass=${this.hass}
+42 -25
View File
@@ -12,32 +12,33 @@ import { live } from 'lit/directives/live.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { isEqual } from 'lodash-es';
import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js';
import { IssueTriggerEventData } from '../card-controller/issues/types.js';
import { CachedValueController } from '../components-lib/cached-value-controller.js';
import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
import { dataToContext } from '../components-lib/notification/data-to-context.js';
import { SignedURLController } from '../components-lib/signed-url-controller.js';
import { Notification } from '../config/schema/actions/types.js';
import { CameraConfig } from '../config/schema/cameras.js';
import { type ImageBaseConfig, ImageMode } from '../config/schema/common/image.js';
import { EnabledProxyConfig } from '../config/schema/common/proxy.js';
import { TROUBLESHOOTING_URL } from '../const.js';
import { isHassDifferent } from '../ha/is-hass-different.js';
import { HomeAssistant } from '../ha/types.js';
import defaultImage from '../images/iris-screensaver.jpg';
import { localize } from '../localize/localize.js';
import imageUpdatingPlayerStyle from '../scss/image-updating-player.scss';
import {
MediaLoadedInfo,
MediaPlayer,
MediaPlayerController,
Message,
} from '../types.js';
import { MediaLoadedInfo, MediaPlayer, MediaPlayerController } from '../types.js';
import { contentsChanged } from '../utils/basic.js';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
import {
createMediaLoadedInfo,
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
} from '../utils/media-info.js';
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../view/target-id.js';
import { View } from '../view/view.js';
import { renderMessage } from './message.js';
import { renderNotificationBlock } from './notification/block.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;
@@ -109,10 +110,21 @@ export class AdvancedCameraCardImageUpdatingPlayer
() => this._getImageSource(),
() => dispatchMediaPlayEvent(this),
() => dispatchMediaPauseEvent(this),
// Clear image load errors on each timer tick so the next render retries the
// <img>. Retries are bounded by refresh_seconds, not a tight loop.
// Clear image load errors on each timer tick so the next render retries
// the <img> — but only for modes where the underlying URL genuinely
// changes between ticks (camera/entity snapshots). For mode: url, the
// same static URL will fail the same way every time, so clearing the
// error just causes a visible flicker (notification → blank <img> →
// notification) every refresh_seconds. URL-mode retries are driven by
// mediaEpoch (user-initiated or auto-retry) instead.
() => {
this._imageLoadError = false;
const mode = resolveImageMode({
imageConfig: this.imageConfig,
cameraConfig: this.cameraConfig,
});
if (mode !== 'url') {
this._imageLoadError = false;
}
},
);
@@ -393,31 +405,35 @@ export class AdvancedCameraCardImageUpdatingPlayer
}
}
private _getDisplayMessage(): Message | null {
private _getDisplayNotification(): Notification | null {
const error = this._signedURLController.getError();
if (error) {
return {
type: 'error',
message: localize(
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
),
context: this.proxyConfig,
heading: {
text: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
icon: 'mdi:alert-circle',
},
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
context: this.proxyConfig ? dataToContext(this.proxyConfig) : undefined,
};
}
if (this._imageLoadError) {
return {
type: 'error',
message: localize('error.image_load_error'),
context: this.imageConfig,
heading: {
text: localize('error.image_load_error'),
icon: 'mdi:alert-circle',
},
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
context: this.imageConfig ? dataToContext(this.imageConfig) : undefined,
};
}
return null;
}
protected render(): TemplateResult | void {
const message = this._getDisplayMessage();
if (message) {
return renderMessage(message);
const notification = this._getDisplayNotification();
if (notification) {
return renderNotificationBlock(notification);
}
const src = this._cachedValueController?.getValue();
@@ -449,13 +465,14 @@ export class AdvancedCameraCardImageUpdatingPlayer
cameraConfig: this.cameraConfig,
});
if (mode === 'camera' || mode === 'entity' || mode === 'screensaver') {
// In camera, entity, or screensaver mode the user has likely
// not made an error, but the source may be unavailable, so show
// the stock image.
this._forceSafeImage(true);
} else if (mode === 'url') {
this._imageLoadError = true;
}
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this, 'issue:trigger', {
key: 'media_load',
targetID: IMAGE_VIEW_TARGET_ID_SENTINEL,
});
}}
/>
`
+23 -15
View File
@@ -1,6 +1,7 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.js';
import { keyed } from 'lit/directives/keyed.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { CameraManager } from '../camera-manager/manager';
import { ViewManagerEpoch } from '../card-controller/view/types';
@@ -12,15 +13,15 @@ import {
resolveProxyConfig,
} from '../config/schema/common/proxy';
import { ImageViewConfig, type ImageViewProxyConfig } from '../config/schema/image';
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const';
import { HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize.js';
import imageStyle from '../scss/image.scss';
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types.js';
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../view/target-id.js';
import './image-updating-player';
import { resolveImageMode } from './image-updating-player';
import './media-dimensions-container';
import { renderMessage } from './message.js';
import { renderNotificationBlockFromText } from './notification/block.js';
import './zoomer.js';
@customElement('advanced-camera-card-image')
@@ -48,7 +49,7 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
}
private _renderContainer(template: TemplateResult): TemplateResult {
const zoomTarget = IMAGE_VIEW_ZOOM_TARGET_SENTINEL;
const zoomTarget = IMAGE_VIEW_TARGET_ID_SENTINEL;
const view = this.viewManagerEpoch?.manager.getView();
const mode = resolveImageMode({
imageConfig: this.imageConfig,
@@ -108,23 +109,30 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
});
if (mode === 'camera' && !this.cameraConfig) {
return renderMessage({
type: 'info',
message: localize('error.no_camera_for_image'),
return renderNotificationBlockFromText(localize('error.no_camera_for_image'), {
icon: 'mdi:camera-off',
});
}
const view = this.viewManagerEpoch?.manager.getView();
const mediaEpoch = view?.context?.mediaEpoch?.[IMAGE_VIEW_TARGET_ID_SENTINEL] ?? 0;
return this._renderContainer(html`
<advanced-camera-card-image-updating-player
${ref(this._refImage)}
.hass=${this.hass}
.view=${this.viewManagerEpoch?.manager.getView()}
.imageConfig=${this.imageConfig}
.cameraConfig=${this.cameraConfig}
.proxyConfig=${this._resolveProxyConfig(this.imageConfig?.proxy) ?? undefined}
>
</advanced-camera-card-image-updating-player>
${keyed(
mediaEpoch,
html`
<advanced-camera-card-image-updating-player
${ref(this._refImage)}
.hass=${this.hass}
.view=${view}
.imageConfig=${this.imageConfig}
.cameraConfig=${this.cameraConfig}
.proxyConfig=${this._resolveProxyConfig(this.imageConfig?.proxy) ??
undefined}
>
</advanced-camera-card-image-updating-player>
`,
)}
`);
}
+29 -22
View File
@@ -8,6 +8,7 @@ import {
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.js';
import { keyed } from 'lit/directives/keyed.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { CameraManagerCameraMetadata } from '../../camera-manager/types.js';
@@ -220,32 +221,38 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
const view = this.viewManagerEpoch?.manager.getView();
const mediaEpoch = view?.context?.mediaEpoch?.[cameraID] ?? 0;
return html`
<div class="embla__slide">
<advanced-camera-card-live-provider
.microphoneState=${view?.camera === cameraID
? this.microphoneState
: undefined}
.camera=${camera}
.cameraEndpoints=${guard(
[this.cameraManager, cameraID],
() => this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined,
)}
.label=${cameraMetadata?.title ?? ''}
.liveConfig=${this.liveConfig}
.hass=${this.hass}
.cardWideConfig=${this.cardWideConfig}
.zoomSettings=${view?.context?.zoom?.[cameraID]?.requested}
.zoom=${!this._isGesturesPTZActive(view, cameraID)}
@advanced-camera-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
handleZoomSettingsObservedEvent(
ev,
this.viewManagerEpoch?.manager,
cameraID,
${keyed(
mediaEpoch,
html`<advanced-camera-card-live-provider
.microphoneState=${view?.camera === cameraID
? this.microphoneState
: undefined}
.camera=${camera}
.cameraEndpoints=${guard(
[this.cameraManager, cameraID],
() => this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined,
)}
>
</advanced-camera-card-live-provider>
.label=${cameraMetadata?.title ?? ''}
.liveConfig=${this.liveConfig}
.hass=${this.hass}
.cardWideConfig=${this.cardWideConfig}
.zoomSettings=${view?.context?.zoom?.[cameraID]?.requested}
.zoom=${!this._isGesturesPTZActive(view, cameraID)}
@advanced-camera-card:zoom:change=${(
ev: CustomEvent<ZoomSettingsObserved>,
) =>
handleZoomSettingsObservedEvent(
ev,
this.viewManagerEpoch?.manager,
cameraID,
)}
>
</advanced-camera-card-live-provider>`,
)}
</div>
`;
}
+36 -24
View File
@@ -31,7 +31,7 @@ import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-ca
import { getResolvedLiveProvider } from '../../utils/live-provider.js';
import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
import '../icon.js';
import { renderMessage } from '../message.js';
import { renderNotificationBlockFromText } from '../notification/block.js';
import './../media-dimensions-container';
@customElement('advanced-camera-card-live-provider')
@@ -73,6 +73,12 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
@state()
private _hasProviderError = false;
// Whether the camera entity has ever been in a non-unavailable state. Used
// to suppress transient unavailability errors for entities that were
// previously working (e.g. during PTZ operations).
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2124
private _entityHasBeenAvailable = false;
private _refProvider: Ref<MediaPlayerElement> = createRef();
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
@@ -121,6 +127,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
public disconnectedCallback(): void {
this._isVideoMediaLoaded = false;
this._entityHasBeenAvailable = false;
super.disconnectedCallback();
}
@@ -132,11 +139,12 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
ev.stopPropagation();
this._hasProviderError = true;
const cameraID = this.camera?.getID();
if (cameraID) {
fireAdvancedCameraCardEvent(this, 'problem:trigger', {
key: 'stream_not_loading' as const,
cameraID,
// this.camera is already substream-aware (resolved by the carousel layer).
const targetID = this.camera?.getID();
if (targetID) {
fireAdvancedCameraCardEvent(this, 'issue:trigger', {
key: 'media_load' as const,
targetID,
});
}
}
@@ -162,6 +170,10 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
}
if (changedProps.has('camera')) {
this._isVideoMediaLoaded = false;
this._hasProviderError = false;
this._entityHasBeenAvailable = false;
const provider = getResolvedLiveProvider(this.camera?.getConfig());
if (provider === 'jsmpeg') {
this._importPromises.push(import('./providers/jsmpeg.js'));
@@ -256,9 +268,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
) {
if (!cameraConfig?.camera_entity) {
dispatchLiveErrorEvent(this);
return renderMessage({
message: localize('error.no_live_camera'),
type: 'error',
return renderNotificationBlockFromText(localize('error.no_live_camera'), {
icon: 'mdi:camera',
context: cameraConfig,
});
@@ -267,25 +277,28 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
const stateObj = this.hass.states[cameraConfig.camera_entity];
if (!stateObj) {
dispatchLiveErrorEvent(this);
return renderMessage({
message: localize('error.live_camera_not_found'),
type: 'error',
return renderNotificationBlockFromText(localize('error.live_camera_not_found'), {
icon: 'mdi:camera',
context: cameraConfig,
});
}
if (stateObj.state === 'unavailable') {
dispatchLiveErrorEvent(this);
dispatchMediaUnloadedEvent(this);
return renderMessage({
message: `${localize('error.live_camera_unavailable')}${
this.label ? `: ${this.label}` : ''
}`,
type: 'info',
icon: 'mdi:cctv-off',
dotdotdot: true,
});
if (
!this._entityHasBeenAvailable ||
cameraConfig.always_error_if_entity_unavailable
) {
dispatchLiveErrorEvent(this);
dispatchMediaUnloadedEvent(this);
return renderNotificationBlockFromText(
`${localize('error.live_camera_unavailable')}${
this.label ? `: ${this.label}` : ''
}`,
{ icon: 'mdi:cctv-off', in_progress: true },
);
}
} else {
this._entityHasBeenAvailable = true;
}
}
@@ -373,8 +386,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
? html`<advanced-camera-card-icon
title=${localize('error.awaiting_live')}
.icon=${{ icon: 'mdi:progress-helper' }}
@click=${() =>
fireAdvancedCameraCardEvent(this, 'problem:notify', 'stream_not_loading')}
@click=${() => fireAdvancedCameraCardEvent(this, 'issue:notify', 'media_load')}
></advanced-camera-card-icon>`
: ''}`;
}
+6 -11
View File
@@ -18,7 +18,7 @@ import { HomeAssistant } from '../../../../ha/types.js';
import { localize } from '../../../../localize/localize.js';
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
import { MediaPlayer, MediaPlayerController } from '../../../../types.js';
import { renderMessage } from '../../../message.js';
import { renderNotificationBlockFromText } from '../../../notification/block.js';
import { VideoRTC } from './video-rtc.js';
customElements.define('advanced-camera-card-live-go2rtc-player', VideoRTC);
@@ -144,18 +144,13 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
protected render(): TemplateResult | void {
const error = this._signedURLController.getError();
if (error) {
return renderMessage({
type: 'error',
message: localize(
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
),
context: this.camera?.getConfig(),
});
return renderNotificationBlockFromText(
localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
{ context: this.camera?.getConfig() },
);
}
if (!this.cameraEndpoints?.go2rtc) {
return renderMessage({
type: 'error',
message: localize('error.live_camera_no_endpoint'),
return renderNotificationBlockFromText(localize('error.live_camera_no_endpoint'), {
context: this.camera?.getConfig(),
});
}
+21 -23
View File
@@ -12,13 +12,15 @@ import { until } from 'lit/directives/until.js';
import { CameraEndpoints } from '../../../camera-manager/types.js';
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js';
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
import { Notification } from '../../../config/schema/actions/types.js';
import { CameraConfig } from '../../../config/schema/cameras.js';
import { CardWideConfig } from '../../../config/schema/types.js';
import { homeAssistantGetSignedURLIfNecessary } from '../../../ha/sign-path.js';
import { HomeAssistant } from '../../../ha/types.js';
import { localize } from '../../../localize/localize.js';
import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss';
import { MediaPlayer, MediaPlayerController, Message } from '../../../types.js';
import { MediaPlayer, MediaPlayerController } from '../../../types.js';
import { convertHTTPAdressToWebsocket, errorToConsole } from '../../../utils/basic.js';
import {
dispatchMediaLoadedEvent,
@@ -26,8 +28,8 @@ import {
dispatchMediaPlayEvent,
} from '../../../utils/media-info.js';
import { Timer } from '../../../utils/timer.js';
import '../../message.js';
import { renderMessage } from '../../message.js';
import '../../notification/block.js';
import { renderNotificationBlock } from '../../notification/block.js';
import '../../progress-indicator.js';
import { renderProgressIndicator } from '../../progress-indicator.js';
@@ -51,7 +53,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
public cardWideConfig?: CardWideConfig;
@state()
private _message: Message | null = null;
private _notification: Notification | null = null;
private _jsmpegCanvasElement?: HTMLCanvasElement;
private _jsmpegVideoPlayer?: JSMpeg.VideoElement;
@@ -71,7 +73,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
if (
['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop))
) {
this._message = null;
this._notification = null;
}
}
@@ -133,7 +135,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
}
private _resetPlayer(): void {
this._message = null;
this._notification = null;
this._refreshPlayerTimer.stop();
if (this._jsmpegVideoPlayer) {
try {
@@ -175,11 +177,10 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
const endpoint = this.cameraEndpoints?.jsmpeg;
if (!endpoint) {
this._message = {
message: localize('error.live_camera_no_endpoint'),
type: 'error',
context: this.cameraConfig,
};
this._notification = createNotificationFromText(
localize('error.live_camera_no_endpoint'),
{ context: this.cameraConfig },
);
dispatchLiveErrorEvent(this);
return;
}
@@ -197,11 +198,9 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
const address = response ? convertHTTPAdressToWebsocket(response) : null;
if (!address) {
this._message = {
type: 'error',
message: localize('error.failed_sign'),
this._notification = createNotificationFromText(localize('error.failed_sign'), {
context: this.cameraConfig,
};
});
dispatchLiveErrorEvent(this);
return;
}
@@ -214,20 +213,19 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
}
protected render(): TemplateResult | void {
if (this._message) {
return renderMessage(this._message);
if (this._notification) {
return renderNotificationBlock(this._notification);
}
const _render = async (): Promise<TemplateResult | void> => {
await this._refreshPlayer();
if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) {
if (!this._message) {
this._message = {
message: localize('error.jsmpeg_no_player'),
type: 'error',
context: this.cameraConfig,
};
if (!this._notification) {
this._notification = createNotificationFromText(
localize('error.jsmpeg_no_player'),
{ context: this.cameraConfig },
);
dispatchLiveErrorEvent(this);
}
return;
+19 -18
View File
@@ -12,6 +12,8 @@ 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 { VideoMediaPlayerController } from '../../../components-lib/media-player/video.js';
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
import { Notification } from '../../../config/schema/actions/types.js';
import { CameraConfig } from '../../../config/schema/cameras.js';
import { CardWideConfig } from '../../../config/schema/types.js';
import { HomeAssistant } from '../../../ha/types.js';
@@ -21,7 +23,6 @@ import {
AdvancedCameraCardError,
MediaPlayer,
MediaPlayerController,
Message,
} from '../../../types.js';
import { mayHaveAudio } from '../../../utils/audio.js';
import {
@@ -29,6 +30,7 @@ import {
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
setControlsOnVideo,
} from '../../../utils/controls.js';
import { getContextFromError } from '../../../utils/error-context.js';
import {
dispatchMediaLoadedEvent,
dispatchMediaPauseEvent,
@@ -36,8 +38,8 @@ import {
dispatchMediaVolumeChangeEvent,
} from '../../../utils/media-info.js';
import { renderTask } from '../../../utils/task.js';
import '../../message.js';
import { renderMessage } from '../../message.js';
import '../../notification/block.js';
import { renderNotificationBlock } from '../../notification/block.js';
import '../../progress-indicator.js';
import { renderProgressIndicator } from '../../progress-indicator.js';
import { VideoRTC } from './go2rtc/video-rtc.js';
@@ -59,7 +61,7 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
public controls = false;
@state()
private _message: Message | null = null;
private _notification: Notification | null = null;
private hass?: HomeAssistant;
@@ -88,7 +90,7 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
disconnectedCallback(): void {
this._videoRTC = null;
this._message = null;
this._notification = null;
super.disconnectedCallback();
}
@@ -96,7 +98,7 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
if (
['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop))
) {
this._message = null;
this._notification = null;
}
}
@@ -148,8 +150,8 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
}
protected render(): TemplateResult | void {
if (this._message) {
return renderMessage(this._message);
if (this._notification) {
return renderNotificationBlock(this._notification);
}
const render = (): TemplateResult | void => {
@@ -157,16 +159,15 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
try {
webrtcElement = this._createWebRTC();
} catch (e) {
this._message = {
type: 'error',
message:
e instanceof AdvancedCameraCardError
? e.message
: localize('error.webrtc_card_reported_error') +
': ' +
(e as Error).message,
context: (e as AdvancedCameraCardError).context,
};
const context = getContextFromError(e);
this._notification = createNotificationFromText(
e instanceof AdvancedCameraCardError
? e.message
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
{
...(context && { context }),
},
);
dispatchLiveErrorEvent(this);
return;
}
-73
View File
@@ -1,73 +0,0 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { MessageController } from '../components-lib/message/controller.js';
import messageStyle from '../scss/message.scss';
import { Message } from '../types.js';
import './icon.js';
export function renderMessage(
message: Message | null,
renderOptions?: {
overlay?: boolean;
},
): TemplateResult {
return html` <advanced-camera-card-message
.message=${message}
?overlay=${!!renderOptions?.overlay}
></advanced-camera-card-message>`;
}
@customElement('advanced-camera-card-message')
export class AdvancedCameraCardMessage extends LitElement {
@property({ attribute: false })
public message?: Message;
@property({ attribute: true, type: Boolean })
public overlay = false;
private _controller = new MessageController();
protected render(): TemplateResult | void {
if (!this.message) {
return;
}
const link = this._controller.getLink(this.message);
const messageTemplate = html`
${this._controller.getMessageString(this.message)}
${link ? html`. <a href="${link.url}">${link.title}</a>` : ''}
`;
const icon = this._controller.getIcon(this.message);
const classes = {
dotdotdot: !!this.message?.dotdotdot,
};
return html` <div class="wrapper">
<div class="message padded">
<div class="icon">
<advanced-camera-card-icon
part="icon"
.icon="${{ icon: icon }}"
></advanced-camera-card-icon>
</div>
<div class="contents">
<span class="${classMap(classes)}">${messageTemplate}</span>
${this._controller
.getContextStrings(this.message)
.map((contextItem) => html`<pre>${contextItem}</pre>`)}
</div>
</div>
</div>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(messageStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-message': AdvancedCameraCardMessage;
}
}
-180
View File
@@ -1,180 +0,0 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { actionHandler } from '../action-handler-directive.js';
import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request';
import {
Notification,
NotificationControl,
NotificationDetail,
} from '../config/schema/actions/types.js';
import notificationStyle from '../scss/notification.scss';
import {
getActionConfigGivenAction,
hasAction,
stopEventFromActivatingCardWideActions,
} from '../utils/action.js';
import { arrayify } from '../utils/basic.js';
import { dispatchDismissNotificationEvent } from '../utils/notification.js';
import './icon.js';
@customElement('advanced-camera-card-notification')
export class AdvancedCameraCardNotification extends LitElement {
@property({ attribute: false })
public notification: Notification | null = null;
private _refNotification: Ref<HTMLElement> = createRef();
public connectedCallback(): void {
super.connectedCallback();
window.addEventListener('click', this._handleOutsideInteraction);
window.addEventListener('focusin', this._handleOutsideInteraction);
window.addEventListener('keydown', this._handleKeyDown);
}
public disconnectedCallback(): void {
window.removeEventListener('click', this._handleOutsideInteraction);
window.removeEventListener('focusin', this._handleOutsideInteraction);
window.removeEventListener('keydown', this._handleKeyDown);
super.disconnectedCallback();
}
protected render(): TemplateResult | void {
if (!this.notification) {
return;
}
const heading = this.notification.heading;
const details = this.notification.details ?? [];
const text = this.notification.text;
const controls = this.notification.controls ?? [];
return html`
<div class="backdrop" @click=${this._dismiss}></div>
<div
class="notification"
${ref(this._refNotification)}
@animationend=${this._handleAnimationEnd}
>
<div class="details">
${heading ? this._renderDetail(heading, true) : ''}
${details.map((detail) => this._renderDetail(detail))}
${text ? html`<div class="description">${text}</div>` : ''}
${this.notification.link
? html`<div class="url">
<a
href=${this.notification.link.url}
target="_blank"
rel="noopener noreferrer"
@click=${stopEventFromActivatingCardWideActions}
>${this.notification.link.title}</a
>
</div>`
: ''}
</div>
${controls.length
? html`<div class="controls">
${controls.map((control) => this._renderControl(control))}
</div>`
: ''}
<div class="close" @click=${this._dismiss}>
<advanced-camera-card-icon
.icon=${{ icon: 'mdi:close' }}
></advanced-camera-card-icon>
</div>
</div>
`;
}
private _renderControl(control: NotificationControl): TemplateResult {
const severityClass = control.severity ? `severity-${control.severity}` : '';
return html`
<div
class="control ${severityClass}"
title=${control.tooltip ?? ''}
.actionHandler=${actionHandler({
hasHold: hasAction(control.actions?.hold_action),
hasDoubleClick: hasAction(control.actions?.double_tap_action),
})}
@action=${(ev: CustomEvent) => this._handleControlAction(ev, control)}
>
${control.icon
? html`<advanced-camera-card-icon
.icon=${{ icon: control.icon }}
></advanced-camera-card-icon>`
: ''}
</div>
`;
}
private _handleControlAction(
ev: CustomEvent<{ action: string }>,
control: NotificationControl,
): void {
stopEventFromActivatingCardWideActions(ev);
const action = getActionConfigGivenAction(ev.detail.action, control.actions);
if (action) {
dispatchActionExecutionRequest(this, {
actions: arrayify(action),
});
}
if (control.dismiss !== false) {
this._dismiss();
}
}
private _renderDetail(detail: NotificationDetail, isHeading = false): TemplateResult {
const classes = {
detail: true,
heading: isHeading,
[`severity-${detail.severity}`]: !!detail.severity,
};
return html`
<div class="${classMap(classes)}">
${detail.icon
? html`<advanced-camera-card-icon
title=${detail.tooltip ?? ''}
.icon=${{ icon: detail.icon }}
></advanced-camera-card-icon>`
: ''}
<span title=${detail.text}>${detail.text}</span>
</div>
`;
}
private _dismiss = (): void => {
this._refNotification.value?.classList.add('exiting');
};
private _handleAnimationEnd = (ev: AnimationEvent): void => {
if (ev.animationName === 'slideDown') {
dispatchDismissNotificationEvent(this);
}
};
private _handleOutsideInteraction = (ev: Event): void => {
if (!ev.composedPath().includes(this)) {
this._dismiss();
}
};
private _handleKeyDown = (ev: KeyboardEvent): void => {
if (ev.key === 'Escape') {
this._dismiss();
ev.stopPropagation();
ev.preventDefault();
}
};
static get styles(): CSSResultGroup {
return unsafeCSS(notificationStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-notification': AdvancedCameraCardNotification;
}
}
+88
View File
@@ -0,0 +1,88 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { handleControlAction } from '../../components-lib/notification/action.js';
import { localize } from '../../localize/localize.js';
import {
createNotificationFromText,
NotificationOptions,
} from '../../components-lib/notification/factory.js';
import { Notification } from '../../config/schema/actions/types.js';
import notificationBlockStyle from '../../scss/notification-block.scss';
import {
renderControl,
renderDetail,
renderNotificationBody,
} from './common-rendering.js';
export function renderNotificationBlock(
notification: Notification | null,
): TemplateResult {
return html`<advanced-camera-card-notification-block
.notification=${notification}
></advanced-camera-card-notification-block>`;
}
export function renderNotificationBlockFromText(
text: string,
options?: NotificationOptions,
): TemplateResult {
return renderNotificationBlock(createNotificationFromText(text, options));
}
@customElement('advanced-camera-card-notification-block')
export class AdvancedCameraCardNotificationBlock extends LitElement {
@property({ attribute: false })
public notification: Notification | null = null;
protected render(): TemplateResult | void {
if (!this.notification) {
return;
}
const { heading, in_progress } = this.notification;
const controls = this.notification.controls ?? [];
// Anchor the spinner to whichever status element is most prominent: the
// heading if present, the body icon otherwise. Never render it orphaned
// on its own row (which would float it to the left with no visual tie to
// the text).
const spinner = in_progress
? html`<div class="spinner" title=${localize('common.in_progress')}>
<ha-spinner indeterminate size="tiny"></ha-spinner>
</div>`
: null;
const spinnerInHeadingRow = spinner && (heading || controls.length);
const spinnerInBody = spinner && !spinnerInHeadingRow;
return html`
<div class="content">
${heading || spinnerInHeadingRow || controls.length
? html`<div class="heading-row">
${heading ? renderDetail(heading, 'heading') : ''}
${spinnerInHeadingRow || controls.length
? html`<div class="controls">
${spinnerInHeadingRow ? spinner : ''}
${controls.map((control) =>
renderControl(control, (ev, c) =>
handleControlAction(ev, c, this),
),
)}
</div>`
: ''}
</div>`
: ''}
${renderNotificationBody(this.notification, spinnerInBody ? spinner : undefined)}
</div>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(notificationBlockStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-notification-block': AdvancedCameraCardNotificationBlock;
}
}
@@ -0,0 +1,94 @@
import { html, TemplateResult } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import { actionHandler } from '../../action-handler-directive.js';
import {
Notification,
NotificationControl,
NotificationDetail,
} from '../../config/schema/actions/types.js';
import {
hasAction,
stopEventFromActivatingCardWideActions,
} from '../../utils/action.js';
import '../icon.js';
export function renderDetail(
detail: NotificationDetail,
role: 'heading' | 'body' | 'metadata' = 'metadata',
iconOverride?: TemplateResult,
): TemplateResult {
const classes = {
detail: true,
heading: role === 'heading',
body: role === 'body',
[`severity-${detail.severity}`]: !!detail.severity,
};
return html`
<div class="${classMap(classes)}">
${iconOverride ??
(detail.icon
? html`<advanced-camera-card-icon
title=${detail.tooltip ?? ''}
.icon=${{ icon: detail.icon }}
></advanced-camera-card-icon>`
: '')}
<span title=${detail.text}>${detail.text}</span>
</div>
`;
}
export function renderControl(
control: NotificationControl,
onAction: (ev: CustomEvent<{ action: string }>, control: NotificationControl) => void,
): TemplateResult {
const classes = {
control: true,
[`severity-${control.severity}`]: !!control.severity,
};
return html`
<div
class="${classMap(classes)}"
title=${control.tooltip ?? ''}
.actionHandler=${actionHandler({
hasHold: hasAction(control.actions?.hold_action),
hasDoubleClick: hasAction(control.actions?.double_tap_action),
})}
@action=${(ev: CustomEvent) => onAction(ev, control)}
>
${control.icon
? html`<advanced-camera-card-icon
.icon=${{ icon: control.icon }}
></advanced-camera-card-icon>`
: ''}
</div>
`;
}
export function renderNotificationBody(
notification: Notification,
bodyIconOverride?: TemplateResult,
): TemplateResult {
const { body, link } = notification;
const context = notification.context ?? [];
const metadata = notification.metadata ?? [];
return html`
${metadata.map((detail) => renderDetail(detail, 'metadata'))}
${body ? renderDetail(body, 'body', bodyIconOverride) : ''}
${link
? html`<div class="url">
<a
href=${link.url}
target="_blank"
rel="noopener noreferrer"
@click=${stopEventFromActivatingCardWideActions}
>${link.title}</a
>
</div>`
: ''}
${context.length
? html`<div class="context">
${context.map((item) => html`<pre>${item}</pre>`)}
</div>`
: ''}
`;
}
+31
View File
@@ -0,0 +1,31 @@
import { TemplateResult } from 'lit';
import { CameraManager } from '../../camera-manager/manager.js';
import { localize } from '../../localize/localize.js';
import { renderNotificationBlock } from './block.js';
interface NoMediaOptions {
cameraID: string | null;
cameraManager: CameraManager | null;
loading?: boolean;
}
export function renderNoMedia(options: NoMediaOptions): TemplateResult {
const cameraID =
options.cameraID ?? options.cameraManager?.getStore().getDefaultCameraID() ?? null;
const cameraTitle = cameraID
? options.cameraManager?.getCameraMetadata(cameraID)?.title ?? cameraID
: null;
return renderNotificationBlock({
heading: {
text: options.loading
? localize('error.awaiting_media')
: localize('common.no_media'),
icon: 'mdi:multimedia',
},
in_progress: options.loading,
...(cameraTitle && {
metadata: [{ text: cameraTitle, icon: 'mdi:cctv' }],
}),
});
}
+111
View File
@@ -0,0 +1,111 @@
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 { handleControlAction } from '../../components-lib/notification/action.js';
import { Notification } from '../../config/schema/actions/types.js';
import { localize } from '../../localize/localize.js';
import notificationPopupStyle from '../../scss/notification-popup.scss';
import { dispatchDismissNotificationEvent } from '../../utils/notification.js';
import {
renderControl,
renderDetail,
renderNotificationBody,
} from './common-rendering.js';
@customElement('advanced-camera-card-notification')
export class AdvancedCameraCardNotification extends LitElement {
@property({ attribute: false })
public notification: Notification | null = null;
private _refNotification: Ref<HTMLElement> = createRef();
public connectedCallback(): void {
super.connectedCallback();
window.addEventListener('click', this._handleOutsideInteraction);
window.addEventListener('focusin', this._handleOutsideInteraction);
window.addEventListener('keydown', this._handleKeyDown);
}
public disconnectedCallback(): void {
window.removeEventListener('click', this._handleOutsideInteraction);
window.removeEventListener('focusin', this._handleOutsideInteraction);
window.removeEventListener('keydown', this._handleKeyDown);
super.disconnectedCallback();
}
protected render(): TemplateResult | void {
if (!this.notification) {
return;
}
const { heading, in_progress } = this.notification;
const controls = this.notification.controls ?? [];
return html`
<div class="backdrop" @click=${this._dismiss}></div>
<div
class="notification"
${ref(this._refNotification)}
@animationend=${this._handleAnimationEnd}
>
${controls.length || in_progress
? html`<div class="controls">
${in_progress
? html`<div class="spinner" title=${localize('common.in_progress')}>
<ha-spinner indeterminate size="tiny"></ha-spinner>
</div>`
: ''}
${controls.map((control) =>
renderControl(control, (ev, c) =>
handleControlAction(ev, c, this, this._dismiss),
),
)}
</div>`
: ''}
<div class="close" @click=${this._dismiss}>
<advanced-camera-card-icon
.icon=${{ icon: 'mdi:close' }}
></advanced-camera-card-icon>
</div>
<div class="details">
${heading ? renderDetail(heading, 'heading') : ''}
${renderNotificationBody(this.notification)}
</div>
</div>
`;
}
private _dismiss = (): void => {
this._refNotification.value?.classList.add('exiting');
};
private _handleAnimationEnd = (ev: AnimationEvent): void => {
if (ev.animationName === 'slideDown') {
dispatchDismissNotificationEvent(this);
}
};
private _handleOutsideInteraction = (ev: Event): void => {
if (!ev.composedPath().includes(this)) {
this._dismiss();
}
};
private _handleKeyDown = (ev: KeyboardEvent): void => {
if (ev.key === 'Escape') {
this._dismiss();
ev.stopPropagation();
ev.preventDefault();
}
};
static get styles(): CSSResultGroup {
return unsafeCSS(notificationPopupStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-notification': AdvancedCameraCardNotification;
}
}
+3 -3
View File
@@ -3,7 +3,7 @@ import { customElement, property } from 'lit/decorators.js';
import { ClassInfo, classMap } from 'lit/directives/class-map.js';
import { ref, Ref } from 'lit/directives/ref.js';
import { CardWideConfig } from '../config/schema/types';
import messageStyle from '../scss/message.scss';
import progressIndicatorStyle from '../scss/progress-indicator.scss';
import './icon.js';
type AdvancedCameraCardProgressIndicatorSize = 'tiny' | 'small' | 'medium' | 'large';
@@ -40,7 +40,7 @@ export class AdvancedCameraCardProgressIndicator extends LitElement {
public size: AdvancedCameraCardProgressIndicatorSize = 'large';
protected render(): TemplateResult {
return html` <div class="message vertical">
return html` <div class="indicator">
${this.animated
? html`<ha-spinner indeterminate size="${this.size}"> </ha-spinner>`
: html`<advanced-camera-card-icon
@@ -51,7 +51,7 @@ export class AdvancedCameraCardProgressIndicator extends LitElement {
}
static get styles(): CSSResultGroup {
return unsafeCSS(messageStyle);
return unsafeCSS(progressIndicatorStyle);
}
}
+5 -5
View File
@@ -10,7 +10,7 @@ import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { CameraManager } from '../../camera-manager/manager';
import { MediaDetailsController } from '../../components-lib/media/details-controller';
import { MediaNotificationController } from '../../components-lib/media/notification-controller';
import { NotificationDetail } from '../../config/schema/actions/types';
import { HomeAssistant } from '../../ha/types';
import thumbnailDetailsStyle from '../../scss/thumbnail-details.scss';
@@ -31,17 +31,17 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
@property({ attribute: false })
public seek?: Date;
private _controller = new MediaDetailsController();
private _notificationController = new MediaNotificationController();
protected willUpdate(changedProperties: PropertyValues): void {
if (['item', 'seek', 'cameraManager'].some((prop) => changedProperties.has(prop))) {
this._controller.calculate(this.cameraManager, this.item, this.seek);
this._notificationController.calculate(this.cameraManager, this.item, this.seek);
}
}
protected render(): TemplateResult | void {
const heading = this._controller.getHeading();
const details = this._controller.getDetails();
const heading = this._notificationController.getHeading();
const details = this._notificationController.getMetadata();
const renderDetail = (
detail: NotificationDetail,
+5 -5
View File
@@ -13,9 +13,9 @@ import { dispatchActionExecutionRequest } from '../../../card-controller/actions
import { ViewItemManager } from '../../../card-controller/view/item-manager';
import { ViewManagerEpoch } from '../../../card-controller/view/types';
import {
MediaDetailsController,
MediaNotificationController,
NotificationControlsContext,
} from '../../../components-lib/media/details-controller';
} from '../../../components-lib/media/notification-controller';
import { ThumbnailFeatureController } from '../../../components-lib/thumbnail/feature/controller';
import { HomeAssistant } from '../../../ha/types';
import { localize } from '../../../localize/localize';
@@ -208,12 +208,12 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
title=${this.item?.getDescription() ?? ''}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
const detailsController = new MediaDetailsController();
detailsController.calculate(this.cameraManager, this.item);
const notificationController = new MediaNotificationController();
notificationController.calculate(this.cameraManager, this.item);
dispatchActionExecutionRequest(this, {
actions: [
createNotificationAction(
detailsController.getNotification(this._getControlContext()),
notificationController.getNotification(this._getControlContext()),
),
],
});
+7 -10
View File
@@ -30,7 +30,7 @@ import { contentsChanged } from '../utils/basic';
import './date-picker.js';
import { AdvancedCameraCardDatePicker, DatePickerEvent } from './date-picker.js';
import './icon';
import { renderMessage } from './message';
import { renderNotificationBlockFromText } from './notification/block';
import './thumbnail/thumbnail.js';
/**
@@ -151,11 +151,9 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
if (isLoading) {
if (!this.mini) {
return renderMessage({
message: localize('error.awaiting_media'),
return renderNotificationBlockFromText(localize('error.awaiting_media'), {
icon: 'mdi:chart-gantt',
type: 'info',
dotdotdot: true,
in_progress: true,
});
}
return;
@@ -163,11 +161,10 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
if (!view?.query || !view.query.hasNodes()) {
if (!this.mini) {
return renderMessage({
message: localize('error.no_camera_or_media_for_timeline'),
icon: 'mdi:chart-gantt',
type: 'info',
});
return renderNotificationBlockFromText(
localize('error.no_camera_or_media_for_timeline'),
{ icon: 'mdi:chart-gantt' },
);
}
return;
}
+23 -19
View File
@@ -8,6 +8,7 @@ import {
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.js';
import { keyed } from 'lit/directives/keyed.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { RemoveContextPropertyViewModifier } from '../../card-controller/view/modifiers/remove-context-property.js';
@@ -32,8 +33,8 @@ import { ViewItemClassifier } from '../../view/item-classifier.js';
import { ViewMedia } from '../../view/item.js';
import '../carousel';
import type { EmblaCarouselPlugins } from '../carousel.js';
import { renderMessage } from '../message.js';
import '../next-prev-control.js';
import { renderNoMedia } from '../notification/no-media.js';
import '../ptz.js';
import './provider.js';
@@ -314,15 +315,12 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
protected render(): TemplateResult | void {
const mediaCount = this._media?.length ?? 0;
if (!this._media || !mediaCount) {
return renderMessage({
message: localize('common.no_media'),
type: 'info',
icon: 'mdi:multimedia',
...(this.viewFilterCameraID && {
context: {
camera_id: this.viewFilterCameraID,
},
}),
return renderNoMedia({
cameraID:
this.viewFilterCameraID ??
this.viewManagerEpoch?.manager.getView()?.camera ??
null,
cameraManager: this.cameraManager ?? null,
});
}
@@ -464,16 +462,22 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
return null;
}
const mediaID = media.getID();
const mediaEpoch = mediaID ? view.context?.mediaEpoch?.[mediaID] ?? 0 : 0;
return html` <div class="embla__slide">
<advanced-camera-card-viewer-provider
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.media=${media}
.viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
></advanced-camera-card-viewer-provider>
${keyed(
mediaEpoch,
html`<advanced-camera-card-viewer-provider
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.media=${media}
.viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
></advanced-camera-card-viewer-provider>`,
)}
</div>`;
}
+5 -11
View File
@@ -14,11 +14,10 @@ import { CardWideConfig } from '../../config/schema/types.js';
import { ViewerConfig } from '../../config/schema/viewer.js';
import { ResolvedMediaCache } from '../../ha/resolved-media.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import '../../patches/ha-hls-player.js';
import viewerStyle from '../../scss/viewer.scss';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { renderMessage } from '../message.js';
import { renderNoMedia } from '../notification/no-media.js';
import './grid';
export interface MediaViewerViewContext {
@@ -81,15 +80,10 @@ export class AdvancedCameraCardViewer extends LitElement {
// Directly render an error message (instead of dispatching it upwards)
// to preserve the mini-timeline if the user pans into an area with no
// media.
const loadingMedia =
!!this.viewManagerEpoch.manager.getView()?.context?.loading?.query;
return renderMessage({
type: 'info',
message: loadingMedia
? localize('error.awaiting_media')
: localize('common.no_media'),
icon: 'mdi:multimedia',
dotdotdot: loadingMedia,
return renderNoMedia({
cameraID: this.viewManagerEpoch.manager.getView()?.camera ?? null,
cameraManager: this.cameraManager ?? null,
loading: !!this.viewManagerEpoch.manager.getView()?.context?.loading?.query,
});
}
+8 -8
View File
@@ -31,7 +31,7 @@ import { ViewItemClassifier } from '../../view/item-classifier.js';
import { VideoContentType, ViewMedia } from '../../view/item.js';
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js';
import '../image-player.js';
import { renderMessage } from '../message.js';
import { renderNotificationBlockFromText } from '../notification/block.js';
import { renderProgressIndicator } from '../progress-indicator.js';
import '../video-player.js';
import './../media-dimensions-container';
@@ -233,13 +233,13 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
const error = this._signedURLController.getError();
if (error) {
return renderMessage({
type: 'error',
message: localize(
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
),
context: this.media?.getContentID(),
});
const contentID = this.media?.getContentID();
return renderNotificationBlockFromText(
localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
{
...(contentID && { metadata: [{ text: contentID, icon: 'mdi:identifier' }] }),
},
);
}
const url = this._signedURLController.getValue();
+3 -3
View File
@@ -10,7 +10,7 @@ import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
import { FoldersManager } from '../card-controller/folders/manager.js';
import { ProblemPresence } from '../card-controller/problems/types.js';
import { IssuePresence } from '../card-controller/issues/types.js';
import { MicrophoneState } from '../card-controller/types.js';
import { ViewItemManager } from '../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
@@ -70,7 +70,7 @@ export class AdvancedCameraCardViews extends LitElement {
public deviceRegistryManager?: DeviceRegistryManager;
@property({ attribute: false })
public problems?: ProblemPresence;
public issues?: IssuePresence;
@property({ attribute: false })
public conditionStateManager?: ConditionStateManagerReadonlyInterface;
@@ -215,7 +215,7 @@ export class AdvancedCameraCardViews extends LitElement {
.hass=${this.hass}
.rawConfig=${this.rawConfig}
.deviceRegistryManager=${this.deviceRegistryManager}
.problems=${this.problems}
.issues=${this.issues}
>
</advanced-camera-card-diagnostics>`
: ``}