From f473cae6b52c7b801210dd9c95b1a92ccf922c42 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 13 Oct 2024 20:00:38 -0700 Subject: [PATCH] refactor: Split `live.ts` into multiple files (#1644) --- .github/ISSUE_TEMPLATE/bug_report.md | 1 - .../utils/get-technology-for-video-rtc.ts | 2 +- src/components/image.ts | 2 +- src/components/live/carousel.ts | 478 ++++++++ src/components/live/grid.ts | 133 +++ src/components/live/index.ts | 109 ++ src/components/live/live.ts | 1021 ----------------- src/components/live/provider.ts | 372 ++++++ .../live/{ => providers}/go2rtc/README.md | 0 .../go2rtc/index.ts} | 22 +- .../{ => providers}/go2rtc/video-rtc.d.ts | 0 .../live/{ => providers}/go2rtc/video-rtc.js | 14 +- .../live/{live-ha.ts => providers/ha.ts} | 18 +- .../{live-image.ts => providers/image.ts} | 10 +- .../{live-jsmpeg.ts => providers/jsmpeg.ts} | 20 +- .../webrtc-card.ts} | 24 +- src/components/views.ts | 2 +- .../get-technology-for-video-rts.test.ts | 2 +- 18 files changed, 1151 insertions(+), 1079 deletions(-) create mode 100644 src/components/live/carousel.ts create mode 100644 src/components/live/grid.ts create mode 100644 src/components/live/index.ts delete mode 100644 src/components/live/live.ts create mode 100644 src/components/live/provider.ts rename src/components/live/{ => providers}/go2rtc/README.md (100%) rename src/components/live/{live-go2rtc.ts => providers/go2rtc/index.ts} (87%) rename src/components/live/{ => providers}/go2rtc/video-rtc.d.ts (100%) rename src/components/live/{ => providers}/go2rtc/video-rtc.js (97%) rename src/components/live/{live-ha.ts => providers/ha.ts} (83%) rename src/components/live/{live-image.ts => providers/image.ts} (88%) rename src/components/live/{live-jsmpeg.ts => providers/jsmpeg.ts} (92%) rename src/components/live/{live-webrtc-card.ts => providers/webrtc-card.ts} (91%) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 68a0f7d5..fc028421 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -35,7 +35,6 @@ Explain what the issue is, and how things should look/behave. If possible provid **[OPTIONAL] Last working release (if known):** - **[OPTIONAL] Javascript errors shown in the web inspector:** ```text diff --git a/src/components-lib/live/utils/get-technology-for-video-rtc.ts b/src/components-lib/live/utils/get-technology-for-video-rtc.ts index 5d2d8aa8..28da7d10 100644 --- a/src/components-lib/live/utils/get-technology-for-video-rtc.ts +++ b/src/components-lib/live/utils/get-technology-for-video-rtc.ts @@ -1,5 +1,5 @@ +import { VideoRTC } from '../../../components/live/providers/go2rtc/video-rtc'; import { MediaTechnology } from '../../../types'; -import { VideoRTC } from '../../../components/live/go2rtc/video-rtc'; export const getTechnologyForVideoRTC = ( element: VideoRTC, diff --git a/src/components/image.ts b/src/components/image.ts index fecb17f4..58b5798b 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -91,7 +91,7 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay } public isPaused(): boolean { - return !this._cachedValueController?.hasTimer() ?? true; + return !this._cachedValueController?.hasTimer(); } public async getScreenshotURL(): Promise { diff --git a/src/components/live/carousel.ts b/src/components/live/carousel.ts new file mode 100644 index 00000000..0f6b6d65 --- /dev/null +++ b/src/components/live/carousel.ts @@ -0,0 +1,478 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { guard } from 'lit/directives/guard.js'; +import { createRef, Ref, ref } from 'lit/directives/ref.js'; +import { CameraManager } from '../../camera-manager/manager.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 { MediaActionsController } from '../../components-lib/media-actions-controller.js'; +import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js'; +import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js'; +import { + CameraConfig, + CardWideConfig, + frigateCardConfigDefaults, + LiveConfig, + liveConfigAbsoluteRootSchema, + Overrides, + TransitionEffect, +} from '../../config/types.js'; +import liveCarouselStyle from '../../scss/live-carousel.scss'; +import { ExtendedHomeAssistant } from '../../types.js'; +import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; +import { 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 { getStreamCameraID } from '../../utils/substream.js'; +import { View } from '../../view/view.js'; +import { EmblaCarouselPlugins } from '../carousel.js'; +import { dispatchFrigateCardErrorEvent } from '../message.js'; +import '../next-prev-control.js'; +import '../ptz.js'; +import { FrigateCardPTZ } from '../ptz.js'; +import './provider.js'; +import { FrigateCardLiveProvider } from './provider.js'; + +const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider'; + +@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); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-live-carousel': FrigateCardLiveCarousel; + } +} diff --git a/src/components/live/grid.ts b/src/components/live/grid.ts new file mode 100644 index 00000000..845f39c4 --- /dev/null +++ b/src/components/live/grid.ts @@ -0,0 +1,133 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { CameraManager } from '../../camera-manager/manager.js'; +import { ConditionsManagerEpoch } from '../../card-controller/conditions-manager.js'; +import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js'; +import { ViewManagerEpoch } from '../../card-controller/view/types.js'; +import { MediaGridSelected } from '../../components-lib/media-grid-controller.js'; +import { CardWideConfig, LiveConfig, Overrides } from '../../config/types.js'; +import liveGridStyle from '../../scss/live-grid.scss'; +import { ExtendedHomeAssistant } from '../../types.js'; +import { contentsChanged } from '../../utils/basic.js'; +import './carousel.js'; + +@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); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-live-grid': FrigateCardLiveGrid; + } +} diff --git a/src/components/live/index.ts b/src/components/live/index.ts new file mode 100644 index 00000000..1210754d --- /dev/null +++ b/src/components/live/index.ts @@ -0,0 +1,109 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + 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'; +import { ViewManagerEpoch } from '../../card-controller/view/types.js'; +import { LiveController } from '../../components-lib/live/live-controller.js'; +import { CardWideConfig, LiveConfig, Overrides } from '../../config/types.js'; +import basicBlockStyle from '../../scss/basic-block.scss'; +import { ExtendedHomeAssistant } from '../../types.js'; +import { contentsChanged } from '../../utils/basic.js'; +import './grid.js'; + +@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); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-live': FrigateCardLive; + } +} diff --git a/src/components/live/live.ts b/src/components/live/live.ts deleted file mode 100644 index 2b06101b..00000000 --- a/src/components/live/live.ts +++ /dev/null @@ -1,1021 +0,0 @@ -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; - } -} diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts new file mode 100644 index 00000000..69a45e77 --- /dev/null +++ b/src/components/live/provider.ts @@ -0,0 +1,372 @@ +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 { createRef, Ref, ref } from 'lit/directives/ref.js'; +import { CameraEndpoints } from '../../camera-manager/types.js'; +import { PartialZoomSettings } from '../../components-lib/zoom/types.js'; +import { + CameraConfig, + CardWideConfig, + frigateCardConfigDefaults, + LiveConfig, + LiveProvider, +} from '../../config/types.js'; +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'; +import { renderMessage } from '../message.js'; +import '../next-prev-control.js'; +import '../ptz.js'; +import '../surround.js'; + +@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('./providers/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('./providers/jsmpeg.js')); + } else if (provider === 'ha') { + this._importPromises.push(import('./providers/ha.js')); + } else if (provider === 'webrtc-card') { + this._importPromises.push(import('./providers/webrtc-card.js')); + } else if (provider === 'image') { + this._importPromises.push(import('./providers/image.js')); + } else if (provider === 'go2rtc') { + this._importPromises.push(import('./providers/go2rtc/index.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; + } +} diff --git a/src/components/live/go2rtc/README.md b/src/components/live/providers/go2rtc/README.md similarity index 100% rename from src/components/live/go2rtc/README.md rename to src/components/live/providers/go2rtc/README.md diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/providers/go2rtc/index.ts similarity index 87% rename from src/components/live/live-go2rtc.ts rename to src/components/live/providers/go2rtc/index.ts index 102e6f3d..7124ddf2 100644 --- a/src/components/live/live-go2rtc.ts +++ b/src/components/live/providers/go2rtc/index.ts @@ -7,17 +7,17 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { CameraEndpoints } from '../../camera-manager/types.js'; -import { CameraConfig, MicrophoneConfig } from '../../config/types.js'; -import { localize } from '../../localize/localize'; -import liveGo2RTCStyle from '../../scss/live-go2rtc.scss'; -import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js'; -import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; -import { setControlsOnVideo } from '../../utils/media.js'; -import { screenshotMedia } from '../../utils/screenshot.js'; -import '../image.js'; -import { dispatchErrorMessageEvent } from '../message'; -import { VideoRTC } from './go2rtc/video-rtc'; +import { CameraEndpoints } from '../../../../camera-manager/types.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 { setControlsOnVideo } from '../../../../utils/media.js'; +import { screenshotMedia } from '../../../../utils/screenshot.js'; +import '../../../image.js'; +import { dispatchErrorMessageEvent } from '../../../message.js'; +import { VideoRTC } from './video-rtc.js'; customElements.define('frigate-card-live-go2rtc-player', VideoRTC); diff --git a/src/components/live/go2rtc/video-rtc.d.ts b/src/components/live/providers/go2rtc/video-rtc.d.ts similarity index 100% rename from src/components/live/go2rtc/video-rtc.d.ts rename to src/components/live/providers/go2rtc/video-rtc.d.ts diff --git a/src/components/live/go2rtc/video-rtc.js b/src/components/live/providers/go2rtc/video-rtc.js similarity index 97% rename from src/components/live/go2rtc/video-rtc.js rename to src/components/live/providers/go2rtc/video-rtc.js index c13e7260..7f1caa82 100644 --- a/src/components/live/go2rtc/video-rtc.js +++ b/src/components/live/providers/go2rtc/video-rtc.js @@ -1,16 +1,16 @@ -import { mayHaveAudio } from '../../../utils/audio'; +import { mayHaveAudio } from '../../../../utils/audio'; import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, setControlsOnVideo, -} from '../../../utils/media'; +} from '../../../../utils/media'; import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, dispatchMediaPlayEvent, dispatchMediaVolumeChangeEvent, -} from '../../../utils/media-info'; -import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get-technology-for-video-rtc.js'; +} from '../../../../utils/media-info'; +import { getTechnologyForVideoRTC } from '../../../../components-lib/live/utils/get-technology-for-video-rtc.js'; /** * VideoRTC v1.6.0 - Video player for go2rtc streaming application. @@ -292,7 +292,8 @@ export class VideoRTC extends HTMLElement { this.appendChild(this.video); - this.video.addEventListener('error', (ev) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + this.video.addEventListener('error', (_ev) => { // For Frigate Card, we avoid log spam here from errors, and also don't // attempt to close the websocket unless the connection is open (otherwise // on reconnect() an exception will be thrown here that we're attempting @@ -760,7 +761,8 @@ export class VideoRTC extends HTMLElement { video2.playsInline = true; video2.muted = true; - video2.addEventListener('loadeddata', (ev) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + video2.addEventListener('loadeddata', (_ev) => { if (!context) { canvas.width = video2.videoWidth; canvas.height = video2.videoHeight; diff --git a/src/components/live/live-ha.ts b/src/components/live/providers/ha.ts similarity index 83% rename from src/components/live/live-ha.ts rename to src/components/live/providers/ha.ts index 46920002..9cf819ad 100644 --- a/src/components/live/live-ha.ts +++ b/src/components/live/providers/ha.ts @@ -2,15 +2,15 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; 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'; +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 { diff --git a/src/components/live/live-image.ts b/src/components/live/providers/image.ts similarity index 88% rename from src/components/live/live-image.ts rename to src/components/live/providers/image.ts index 92c833e1..9167731d 100644 --- a/src/components/live/live-image.ts +++ b/src/components/live/providers/image.ts @@ -2,11 +2,11 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; 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 basicBlockStyle from '../../scss/basic-block.scss'; -import { FrigateCardMediaPlayer } from '../../types.js'; -import { getStateObjOrDispatchError } from '../../utils/get-state-obj'; -import '../image.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') export class FrigateCardLiveImage extends LitElement implements FrigateCardMediaPlayer { diff --git a/src/components/live/live-jsmpeg.ts b/src/components/live/providers/jsmpeg.ts similarity index 92% rename from src/components/live/live-jsmpeg.ts rename to src/components/live/providers/jsmpeg.ts index 1ee169c1..4087fd51 100644 --- a/src/components/live/live-jsmpeg.ts +++ b/src/components/live/providers/jsmpeg.ts @@ -2,20 +2,20 @@ import JSMpeg from '@cycjimmy/jsmpeg-player'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { until } from 'lit/directives/until.js'; -import { CameraEndpoints } from '../../camera-manager/types.js'; -import { renderProgressIndicator } from '../../components/message.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 { CameraEndpoints } from '../../../camera-manager/types.js'; +import { renderProgressIndicator } from '../../message.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 { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, dispatchMediaPlayEvent, -} from '../../utils/media-info.js'; -import { Timer } from '../../utils/timer.js'; -import { dispatchErrorMessageEvent } from '../message.js'; +} from '../../../utils/media-info.js'; +import { Timer } from '../../../utils/timer.js'; +import { dispatchErrorMessageEvent } from '../../message.js'; // Number of seconds a signed URL is valid for. const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; diff --git a/src/components/live/live-webrtc-card.ts b/src/components/live/providers/webrtc-card.ts similarity index 91% rename from src/components/live/live-webrtc-card.ts rename to src/components/live/providers/webrtc-card.ts index a7798e8f..a5977bca 100644 --- a/src/components/live/live-webrtc-card.ts +++ b/src/components/live/providers/webrtc-card.ts @@ -2,27 +2,27 @@ 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 { CameraEndpoints } from '../../camera-manager/types.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 { mayHaveAudio } from '../../utils/audio.js'; +import { CameraEndpoints } from '../../../camera-manager/types.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 { mayHaveAudio } from '../../../utils/audio.js'; import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, dispatchMediaPlayEvent, dispatchMediaVolumeChangeEvent, -} from '../../utils/media-info.js'; +} from '../../../utils/media-info.js'; import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, setControlsOnVideo, -} from '../../utils/media.js'; -import { screenshotMedia } from '../../utils/screenshot.js'; -import { renderTask } from '../../utils/task.js'; -import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js'; +} from '../../../utils/media.js'; +import { screenshotMedia } from '../../../utils/screenshot.js'; +import { renderTask } from '../../../utils/task.js'; +import { dispatchErrorMessageEvent, renderProgressIndicator } from '../../message.js'; import { VideoRTC } from './go2rtc/video-rtc.js'; // Create a wrapper for AlexxIT's WebRTC card diff --git a/src/components/views.ts b/src/components/views.ts index 1921ce31..a86e0332 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -71,7 +71,7 @@ export class FrigateCardViews extends LitElement { if (changedProps.has('viewManagerEpoch') || changedProps.has('config')) { const view = this.viewManagerEpoch?.manager.getView(); if (view?.is('live') || this._shouldLivePreload()) { - import('./live/live.js'); + import('./live/index.js'); } if (view?.isGalleryView()) { import('./gallery.js'); diff --git a/tests/components-lib/live/utils/get-technology-for-video-rts.test.ts b/tests/components-lib/live/utils/get-technology-for-video-rts.test.ts index d2219841..e409a474 100644 --- a/tests/components-lib/live/utils/get-technology-for-video-rts.test.ts +++ b/tests/components-lib/live/utils/get-technology-for-video-rts.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { getTechnologyForVideoRTC } from '../../../../src/components-lib/live/utils/get-technology-for-video-rtc'; -import { VideoRTC } from '../../../../src/components/live/go2rtc/video-rtc'; +import { VideoRTC } from '../../../../src/components/live/providers/go2rtc/video-rtc'; import { createLitElement } from '../../../test-utils'; // @vitest-environment jsdom