import { CSSResultGroup, html, LitElement, PropertyValues, TemplateResult, unsafeCSS, } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { guard } from 'lit/directives/guard.js'; import { ifDefined } from 'lit/directives/if-defined.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 { CameraEndpoints } from '../../camera-manager/types.js'; import { ConditionsManagerEpoch, getOverriddenConfig, } from '../../card-controller/conditions-manager.js'; import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { LiveController } from '../../components-lib/live/live-controller.js'; import { MediaActionsController } from '../../components-lib/media-actions-controller.js'; import { MediaGridSelected } from '../../components-lib/media-grid-controller.js'; import { PartialZoomSettings, ZoomSettingsObserved, } from '../../components-lib/zoom/types.js'; import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js'; import { CameraConfig, CardWideConfig, frigateCardConfigDefaults, LiveConfig, liveConfigAbsoluteRootSchema, LiveProvider, Overrides, TransitionEffect, } from '../../config/types.js'; import { localize } from '../../localize/localize.js'; import basicBlockStyle from '../../scss/basic-block.scss'; import liveCarouselStyle from '../../scss/live-carousel.scss'; import liveGridStyle from '../../scss/live-grid.scss'; import liveProviderStyle from '../../scss/live-provider.scss'; import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import { aspectRatioToString, contentsChanged } from '../../utils/basic.js'; import { CarouselSelected } from '../../utils/embla/carousel-controller.js'; import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js'; import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js'; import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.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'; import { getStreamCameraID } from '../../utils/substream.js'; import { View } from '../../view/view.js'; import { EmblaCarouselPlugins } from '../carousel.js'; import { dispatchFrigateCardErrorEvent, renderMessage } from '../message.js'; import '../next-prev-control.js'; import '../ptz.js'; import { FrigateCardPTZ } from '../ptz.js'; import '../surround.js'; const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider'; @customElement('frigate-card-live') export class FrigateCardLive extends LitElement { @property({ attribute: false }) public conditionsManagerEpoch?: ConditionsManagerEpoch; @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @property({ attribute: false }) public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public nonOverriddenLiveConfig?: LiveConfig; @property({ attribute: false }) public overriddenLiveConfig?: LiveConfig; @property({ attribute: false, hasChanged: contentsChanged }) public overrides?: Overrides; @property({ attribute: false }) public cameraManager?: CameraManager; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @property({ attribute: false }) public microphoneManager?: ReadonlyMicrophoneManager; @property({ attribute: false }) public triggeredCameraIDs?: Set; 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; } // Implementation notes: // - See use of liveConfig and not config below -- the underlying carousel // will independently override the liveConfig to reflect the camera in the // 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` `, )}`; } static get styles(): CSSResultGroup { return unsafeCSS(basicBlockStyle); } } @customElement('frigate-card-live-grid') export class FrigateCardLiveGrid extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @property({ attribute: false }) public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public nonOverriddenLiveConfig?: LiveConfig; @property({ attribute: false }) public overriddenLiveConfig?: LiveConfig; @property({ attribute: false, hasChanged: contentsChanged }) public overrides?: Overrides; @property({ attribute: false }) public conditionsManagerEpoch?: ConditionsManagerEpoch; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @property({ attribute: false }) public cameraManager?: CameraManager; @property({ attribute: false }) public microphoneManager?: ReadonlyMicrophoneManager; @property({ attribute: false }) public triggeredCameraIDs?: Set; protected _renderCarousel(cameraID?: string): TemplateResult { const view = this.viewManagerEpoch?.manager.getView(); const triggeredCameraID = cameraID ?? view?.camera; return html` `; } protected _gridSelectCamera(cameraID: string): void { this.viewManagerEpoch?.manager.setViewByParameters({ params: { camera: cameraID, }, }); } protected _needsGrid(): boolean { const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live'); const view = this.viewManagerEpoch?.manager.getView(); return ( !!view?.isGrid() && !!view?.supportsMultipleDisplayModes() && !!cameraIDs && cameraIDs.size > 1 ); } protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('viewManagerEpoch') && this._needsGrid()) { import('../media-grid.js'); } } protected render(): TemplateResult | void { if (!this.conditionsManagerEpoch || !this.nonOverriddenLiveConfig) { return; } const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live'); if (!cameraIDs?.size || !this._needsGrid()) { return this._renderCarousel(); } return html` ) => this._gridSelectCamera(ev.detail.selected)} > ${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))} `; } static get styles(): CSSResultGroup { return unsafeCSS(liveGridStyle); } } @customElement('frigate-card-live-carousel') export class FrigateCardLiveCarousel extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @property({ attribute: false }) public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public nonOverriddenLiveConfig?: LiveConfig; @property({ attribute: false }) public overriddenLiveConfig?: LiveConfig; @property({ attribute: false, hasChanged: contentsChanged }) public overrides?: Overrides; @property({ attribute: false }) public conditionsManagerEpoch?: ConditionsManagerEpoch; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @property({ attribute: false }) public cameraManager?: CameraManager; @property({ attribute: false }) public microphoneManager?: ReadonlyMicrophoneManager; @property({ attribute: false }) public viewFilterCameraID?: string; // Index between camera name and slide number. protected _cameraToSlide: Record = {}; protected _refPTZControl: Ref = createRef(); protected _refCarousel: Ref = createRef(); protected _mediaActionsController = new MediaActionsController(); @state() protected _mediaHasLoaded = false; public connectedCallback(): void { super.connectedCallback(); // Request update in order to reinitialize the media action controller. this.requestUpdate(); } public disconnectedCallback(): void { this._mediaActionsController.destroy(); super.disconnectedCallback(); } protected _getTransitionEffect(): TransitionEffect { return ( this.overriddenLiveConfig?.transition_effect ?? frigateCardConfigDefaults.live.transition_effect ); } protected _getSelectedCameraIndex(): number { if (this.viewFilterCameraID) { // If the carousel is limited to a single cameraID, the first (only) // element is always the selected one. return 0; } const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live'); const view = this.viewManagerEpoch?.manager.getView(); if (!cameraIDs?.size || !view) { return 0; } return Math.max(0, Array.from(cameraIDs).indexOf(view.camera)); } protected willUpdate(changedProps: PropertyValues): void { if ( changedProps.has('microphoneManager') || changedProps.has('overriddenLiveConfig') ) { this._mediaActionsController.setOptions({ playerSelector: FRIGATE_CARD_LIVE_PROVIDER, ...(this.overriddenLiveConfig?.auto_play && { autoPlayConditions: this.overriddenLiveConfig.auto_play, }), ...(this.overriddenLiveConfig?.auto_pause && { autoPauseConditions: this.overriddenLiveConfig.auto_pause, }), ...(this.overriddenLiveConfig?.auto_mute && { autoMuteConditions: this.overriddenLiveConfig.auto_mute, }), ...(this.overriddenLiveConfig?.auto_unmute && { autoUnmuteConditions: this.overriddenLiveConfig.auto_unmute, }), ...((this.overriddenLiveConfig?.auto_unmute || this.overriddenLiveConfig?.auto_mute) && { microphoneManager: this.microphoneManager, microphoneMuteSeconds: this.overriddenLiveConfig.microphone.mute_after_microphone_mute_seconds, }), }); } } protected _getPlugins(): EmblaCarouselPlugins { return [ AutoLazyLoad({ ...(this.overriddenLiveConfig?.lazy_load && { lazyLoadCallback: (index, slide) => this._lazyloadOrUnloadSlide('load', index, slide), }), lazyUnloadConditions: this.overriddenLiveConfig?.lazy_unload, lazyUnloadCallback: (index, slide) => this._lazyloadOrUnloadSlide('unload', index, slide), }), AutoMediaLoadedInfo(), AutoSize(), ]; } /** * Returns the number of slides to lazily load. 0 means all slides are lazy * loaded, 1 means that 1 slide on each side of the currently selected slide * should lazy load, etc. `null` means lazy loading is disabled and everything * should load simultaneously. * @returns */ protected _getLazyLoadCount(): number | null { // Defaults to fully-lazy loading. return this.overriddenLiveConfig?.lazy_load === false ? null : 0; } protected _getSlides(): [TemplateResult[], Record] { if (!this.cameraManager) { return [[], {}]; } const view = this.viewManagerEpoch?.manager.getView(); const cameraIDs = this.viewFilterCameraID ? new Set([this.viewFilterCameraID]) : this.cameraManager?.getStore().getCameraIDsWithCapability('live'); const slides: TemplateResult[] = []; const cameraToSlide: Record = {}; for (const [cameraID, cameraConfig] of this.cameraManager .getStore() .getCameraConfigEntries(cameraIDs)) { const liveCameraID = this._getSubstreamCameraID(cameraID, view); const liveCameraConfig = cameraID === liveCameraID ? cameraConfig : this.cameraManager?.getStore().getCameraConfig(liveCameraID); const slide = liveCameraConfig ? this._renderLive(liveCameraID, liveCameraConfig) : null; if (slide) { cameraToSlide[cameraID] = slides.length; slides.push(slide); } } return [slides, cameraToSlide]; } protected _setViewHandler(ev: CustomEvent): void { const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live'); if (cameraIDs?.size && ev.detail.index !== this._getSelectedCameraIndex()) { this._setViewCameraID([...cameraIDs][ev.detail.index]); } } protected _setViewCameraID(cameraID?: string | null): void { if (cameraID) { this.viewManagerEpoch?.manager.setViewByParametersWithNewQuery({ params: { camera: cameraID, }, }); } } protected _lazyloadOrUnloadSlide( action: 'load' | 'unload', _index: number, slide: Element, ): void { if (slide instanceof HTMLSlotElement) { slide = slide.assignedElements({ flatten: true })[0]; } const liveProvider = slide?.querySelector( FRIGATE_CARD_LIVE_PROVIDER, ) as FrigateCardLiveProvider | null; if (liveProvider) { liveProvider.load = action === 'load'; } } protected _renderLive( cameraID: string, cameraConfig: CameraConfig, ): TemplateResult | void { if ( !this.overriddenLiveConfig || !this.nonOverriddenLiveConfig || !this.hass || !this.cameraManager || !this.conditionsManagerEpoch ) { return; } let liveConfig: LiveConfig | null = null; try { // The condition controller object contains the currently live camera, which // (in the carousel for example) is not necessarily the live camera *this* // is rendering right now, so we provide a // stateOverride to evaluate the condition in that context. liveConfig = getOverriddenConfig( this.conditionsManagerEpoch.manager, { live: this.nonOverriddenLiveConfig }, { configOverrides: this.overrides, stateOverrides: { camera: cameraID }, schema: liveConfigAbsoluteRootSchema, }, ).live; } catch (ev) { return dispatchFrigateCardErrorEvent(this, ev); } const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID); const view = this.viewManagerEpoch?.manager.getView(); return html`
this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined, )} .label=${cameraMetadata?.title ?? ''} .liveConfig=${liveConfig} .hass=${this.hass} .cardWideConfig=${this.cardWideConfig} .zoomSettings=${view?.context?.zoom?.[cameraID]?.requested} @frigate-card:zoom:change=${(ev: CustomEvent) => handleZoomSettingsObservedEvent( ev, this.viewManagerEpoch?.manager, cameraID, )} >
`; } protected _getCameraIDsOfNeighbors(): [string | null, string | null] { const cameraIDs = this.cameraManager ? [...this.cameraManager?.getStore().getCameraIDsWithCapability('live')] : []; const view = this.viewManagerEpoch?.manager.getView(); if (this.viewFilterCameraID || cameraIDs.length <= 1 || !view || !this.hass) { return [null, null]; } const cameraID = this.viewFilterCameraID ?? view.camera; const currentIndex = cameraIDs.indexOf(cameraID); if (currentIndex < 0) { return [null, null]; } return [ cameraIDs[currentIndex > 0 ? currentIndex - 1 : cameraIDs.length - 1], cameraIDs[currentIndex + 1 < cameraIDs.length ? currentIndex + 1 : 0], ]; } protected _getSubstreamCameraID(cameraID: string, view?: View | null): string { return view?.context?.live?.overrides?.get(cameraID) ?? cameraID; } protected render(): TemplateResult | void { const view = this.viewManagerEpoch?.manager.getView(); if (!this.overriddenLiveConfig || !this.hass || !view || !this.cameraManager) { return; } const [slides, cameraToSlide] = this._getSlides(); this._cameraToSlide = cameraToSlide; if (!slides.length) { return; } const hasMultipleCameras = slides.length > 1; const [prevID, nextID] = this._getCameraIDsOfNeighbors(); const cameraMetadataPrevious = prevID ? this.cameraManager.getCameraMetadata(this._getSubstreamCameraID(prevID, view)) : null; const cameraMetadataNext = nextID ? this.cameraManager.getCameraMetadata(this._getSubstreamCameraID(nextID, view)) : null; const forcePTZVisibility = !this._mediaHasLoaded || (!!this.viewFilterCameraID && this.viewFilterCameraID !== view.camera) || view.context?.ptzControls?.enabled === false ? false : view.context?.ptzControls?.enabled; // Notes on the below: // - guard() is used to avoid reseting the carousel unless the // options/plugins actually change. return html` { this._mediaHasLoaded = true; }} @frigate-card:media:unloaded=${() => { this._mediaHasLoaded = false; }} > { this._setViewCameraID(prevID); stopEventFromActivatingCardWideActions(ev); }} > ${slides} { this._setViewCameraID(nextID); stopEventFromActivatingCardWideActions(ev); }} > `; } protected _setMediaTarget(): void { const view = this.viewManagerEpoch?.manager.getView(); const selectedCameraIndex = this._getSelectedCameraIndex(); if (this.viewFilterCameraID) { this._mediaActionsController.setTarget( selectedCameraIndex, // Camera in this carousel is only selected if the camera from the // view matches the filtered camera. view?.camera === this.viewFilterCameraID, ); } else { // Carousel is not filtered, so the targeted camera is always selected. this._mediaActionsController.setTarget(selectedCameraIndex, true); } } public updated(changedProperties: PropertyValues): void { super.updated(changedProperties); let initialized = false; if (!this._mediaActionsController.hasRoot() && this._refCarousel.value) { this._mediaActionsController.initialize(this._refCarousel.value); initialized = true; } // If the view has changed, or if the media actions controller has just been // initialized, then call the necessary media action. // See: https://github.com/dermotduffy/frigate-hass-card/issues/1626 if (initialized || changedProperties.has('viewManagerEpoch')) { this._setMediaTarget(); } } static get styles(): CSSResultGroup { return unsafeCSS(liveCarouselStyle); } } @customElement(FRIGATE_CARD_LIVE_PROVIDER) export class FrigateCardLiveProvider extends LitElement implements FrigateCardMediaPlayer { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @property({ attribute: false }) public cameraConfig?: CameraConfig; @property({ attribute: false }) public cameraEndpoints?: CameraEndpoints; @property({ attribute: false }) public liveConfig?: LiveConfig; // Whether or not to load the video for this camera. If `false`, no contents // are rendered until this attribute is set to `true` (this is useful for lazy // loading). @property({ attribute: true, type: Boolean }) public load = false; // Label that is used for ARIA support and as tooltip. @property({ attribute: false }) public label = ''; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @property({ attribute: false }) public microphoneStream?: MediaStream; @property({ attribute: false }) public zoomSettings?: PartialZoomSettings | null; @state() protected _isVideoMediaLoaded = false; protected _refProvider: Ref = createRef(); // A note on dynamic imports: // // We gather the dynamic live provider import promises and do not consider the // update of the element complete until these imports have returned. Without // this behavior calls to the media methods (e.g. `mute()`) may throw if the // underlying code is not yet loaded. // // Test case: A card with a non-live view, but live pre-loaded, attempts to // call mute() when the element first renders in the // background. These calls fail without waiting for loading here. protected _importPromises: Promise[] = []; public async play(): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; await playMediaMutingIfNecessary(this, this._refProvider.value); } public async pause(): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; await this._refProvider.value?.pause(); } public async mute(): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; await this._refProvider.value?.mute(); } public async unmute(): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; await this._refProvider.value?.unmute(); } public isMuted(): boolean { return this._refProvider.value?.isMuted() ?? true; } public async seek(seconds: number): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; await this._refProvider.value?.seek(seconds); } public async setControls(controls?: boolean): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; await this._refProvider.value?.setControls(controls); } public isPaused(): boolean { return this._refProvider.value?.isPaused() ?? true; } public async getScreenshotURL(): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; return (await this._refProvider.value?.getScreenshotURL()) ?? null; } /** * Get the fully resolved live provider. * @returns A live provider (that is not 'auto'). */ protected _getResolvedProvider(): Omit { if (this.cameraConfig?.live_provider === 'auto') { if ( this.cameraConfig?.webrtc_card?.entity || this.cameraConfig?.webrtc_card?.url ) { return 'webrtc-card'; } else if (this.cameraConfig?.camera_entity) { return 'ha'; } else if (this.cameraConfig?.frigate.camera_name) { return 'jsmpeg'; } return frigateCardConfigDefaults.cameras.live_provider; } return this.cameraConfig?.live_provider || 'image'; } /** * Determine if a camera image should be shown in lieu of the real stream * whilst loading. * @returns`true` if an image should be shown. */ protected _shouldShowImageDuringLoading(): boolean { return ( !!this.cameraConfig?.camera_entity && !!this.hass && !!this.liveConfig?.show_image_during_load ); } public disconnectedCallback(): void { this._isVideoMediaLoaded = false; } protected _videoMediaShowHandler(): void { this._isVideoMediaLoaded = true; } protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('load')) { if (!this.load) { this._isVideoMediaLoaded = false; dispatchMediaUnloadedEvent(this); } } if (changedProps.has('liveConfig')) { if (this.liveConfig?.show_image_during_load) { this._importPromises.push(import('./live-image.js')); } if (this.liveConfig?.zoomable) { this._importPromises.push(import('./../zoomer.js')); } } if (changedProps.has('cameraConfig')) { const provider = this._getResolvedProvider(); if (provider === 'jsmpeg') { this._importPromises.push(import('./live-jsmpeg.js')); } else if (provider === 'ha') { this._importPromises.push(import('./live-ha.js')); } else if (provider === 'webrtc-card') { this._importPromises.push(import('./live-webrtc-card.js')); } else if (provider === 'image') { this._importPromises.push(import('./live-image.js')); } else if (provider === 'go2rtc') { this._importPromises.push(import('./live-go2rtc.js')); } updateElementStyleFromMediaLayoutConfig( this, this.cameraConfig?.dimensions?.layout, ); this.style.aspectRatio = aspectRatioToString({ ratio: this.cameraConfig?.dimensions?.aspect_ratio, }); } } override async getUpdateComplete(): Promise { // See 'A note on dynamic imports' above for explanation of why this is // necessary. const result = await super.getUpdateComplete(); await Promise.all(this._importPromises); this._importPromises = []; return result; } protected _useZoomIfRequired(template: TemplateResult): TemplateResult { return this.liveConfig?.zoomable ? html` this.cameraConfig?.dimensions?.layout ? { pan: this.cameraConfig.dimensions.layout.pan, zoom: this.cameraConfig.dimensions.layout.zoom, } : undefined, )} .settings=${this.zoomSettings} @frigate-card:zoom:zoomed=${() => this.setControls(false)} @frigate-card:zoom:unzoomed=${() => this.setControls()} > ${template} ` : template; } protected render(): TemplateResult | void { if (!this.load || !this.hass || !this.liveConfig || !this.cameraConfig) { return; } // Set title and ariaLabel from the provided label property. this.title = this.label; this.ariaLabel = this.label; const provider = this._getResolvedProvider(); const showImageDuringLoading = !this._isVideoMediaLoaded && this._shouldShowImageDuringLoading(); const providerClasses = { hidden: showImageDuringLoading, }; if (provider === 'ha' || provider === 'image') { const stateObj = getStateObjOrDispatchError(this, this.hass, this.cameraConfig); if (!stateObj) { return; } 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. return renderMessage({ message: `${localize('error.live_camera_unavailable')}${ this.label ? `: ${this.label}` : '' }`, type: 'info', icon: 'mdi:cctv-off', dotdotdot: true, }); } } return html`${this._useZoomIfRequired(html` ${showImageDuringLoading || provider === 'image' ? html` { if (provider === 'image') { // Only count the media has loaded if the required provider is // the image (not just the temporary image shown during // loading). this._videoMediaShowHandler(); } else { ev.stopPropagation(); } }} > ` : html``} ${provider === 'ha' ? html` ` : provider === 'go2rtc' ? html` ` : provider === 'webrtc-card' ? html` ` : provider === 'jsmpeg' ? html` ` : html``} `)} ${showImageDuringLoading && !this._isVideoMediaLoaded ? html`` : ''} `; } static get styles(): CSSResultGroup { return unsafeCSS(liveProviderStyle); } } declare global { interface HTMLElementTagNameMap { 'frigate-card-live-provider': FrigateCardLiveProvider; 'frigate-card-live-carousel': FrigateCardLiveCarousel; 'frigate-card-live-grid': FrigateCardLiveGrid; 'frigate-card-live': FrigateCardLive; } }