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 { CameraManagerCameraMetadata } from '../../camera-manager/types.js'; import { MicrophoneState } from '../../card-controller/types.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 } from '../../config/schema/cameras.js'; import { TransitionEffect } from '../../config/schema/common/transition-effect.js'; import { LiveConfig } from '../../config/schema/live.js'; import { CardWideConfig, configDefaults } from '../../config/schema/types.js'; import { HomeAssistant } from '../../ha/types.js'; import liveCarouselStyle from '../../scss/live-carousel.scss'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.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 { getTextDirection } from '../../utils/text-direction.js'; import { View } from '../../view/view.js'; import '../carousel'; import { EmblaCarouselPlugins } from '../carousel.js'; import '../next-prev-control.js'; import '../ptz.js'; import { AdvancedCameraCardPTZ } from '../ptz.js'; import './provider.js'; import { AdvancedCameraCardLiveProvider } from './provider.js'; const ADVANCED_CAMERA_CARD_LIVE_PROVIDER = 'advanced-camera-card-live-provider'; interface CameraNeighbor { id: string; metadata?: CameraManagerCameraMetadata | null; } interface CameraNeighbors { previous?: CameraNeighbor; next?: CameraNeighbor; } @customElement('advanced-camera-card-live-carousel') export class AdvancedCameraCardLiveCarousel extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public liveConfig?: LiveConfig; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @property({ attribute: false }) public cameraManager?: CameraManager; @property({ attribute: false }) public microphoneState?: MicrophoneState; @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.liveConfig?.transition_effect ?? configDefaults.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('microphoneState') || changedProps.has('liveConfig')) { this._mediaActionsController.setOptions({ playerSelector: ADVANCED_CAMERA_CARD_LIVE_PROVIDER, ...(this.liveConfig?.auto_play && { autoPlayConditions: this.liveConfig.auto_play, }), ...(this.liveConfig?.auto_pause && { autoPauseConditions: this.liveConfig.auto_pause, }), ...(this.liveConfig?.auto_mute && { autoMuteConditions: this.liveConfig.auto_mute, }), ...(this.liveConfig?.auto_unmute && { autoUnmuteConditions: this.liveConfig.auto_unmute, }), ...((this.liveConfig?.auto_unmute || this.liveConfig?.auto_mute) && { microphoneState: this.microphoneState, microphoneMuteSeconds: this.liveConfig.microphone.mute_after_microphone_mute_seconds, }), }); } } protected _getPlugins(): EmblaCarouselPlugins { return [ AutoLazyLoad({ ...(this.liveConfig?.lazy_load && { lazyLoadCallback: (index, slide) => this._lazyloadOrUnloadSlide('load', index, slide), }), lazyUnloadConditions: this.liveConfig?.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.liveConfig?.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( ADVANCED_CAMERA_CARD_LIVE_PROVIDER, ) as AdvancedCameraCardLiveProvider | null; if (liveProvider) { liveProvider.load = action === 'load'; } } protected _renderLive( cameraID: string, cameraConfig: CameraConfig, ): TemplateResult | void { if (!this.liveConfig || !this.hass || !this.cameraManager) { return; } const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID); const view = this.viewManagerEpoch?.manager.getView(); return html`
this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined, )} .label=${cameraMetadata?.title ?? ''} .liveConfig=${this.liveConfig} .hass=${this.hass} .cardWideConfig=${this.cardWideConfig} .zoomSettings=${view?.context?.zoom?.[cameraID]?.requested} @advanced-camera-card:zoom:change=${(ev: CustomEvent) => handleZoomSettingsObservedEvent( ev, this.viewManagerEpoch?.manager, cameraID, )} >
`; } protected _getSubstreamCameraID(cameraID: string, view?: View | null): string { return view?.context?.live?.overrides?.get(cameraID) ?? cameraID; } protected _getCameraNeighbors(): CameraNeighbors | 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 {}; } const cameraID = this.viewFilterCameraID ?? view.camera; const currentIndex = cameraIDs.indexOf(cameraID); if (currentIndex < 0) { return {}; } const prevID = cameraIDs[currentIndex > 0 ? currentIndex - 1 : cameraIDs.length - 1]; const nextID = cameraIDs[currentIndex + 1 < cameraIDs.length ? currentIndex + 1 : 0]; return { previous: { id: prevID, metadata: prevID ? this.cameraManager?.getCameraMetadata( this._getSubstreamCameraID(prevID, view), ) : null, }, next: { id: nextID, metadata: nextID ? this.cameraManager?.getCameraMetadata( this._getSubstreamCameraID(nextID, view), ) : null, }, }; } protected _renderNextPrevious( side: 'left' | 'right', neighbors: CameraNeighbors | null, ): TemplateResult { const textDirection = getTextDirection(this); const neighbor = (textDirection === 'ltr' && side === 'left') || (textDirection === 'rtl' && side === 'right') ? neighbors?.previous : neighbors?.next; return html` { this._setViewCameraID(neighbor?.id); stopEventFromActivatingCardWideActions(ev); }} > `; } protected render(): TemplateResult | void { const view = this.viewManagerEpoch?.manager.getView(); if (!this.liveConfig || !this.hass || !view || !this.cameraManager) { return; } const [slides, cameraToSlide] = this._getSlides(); this._cameraToSlide = cameraToSlide; if (!slides.length) { return; } const hasMultipleCameras = slides.length > 1; const neighbors = this._getCameraNeighbors(); 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; }} @advanced-camera-card:media:unloaded=${() => { this._mediaHasLoaded = false; }} > ${this._renderNextPrevious('left', neighbors)} ${slides} ${this._renderNextPrevious('right', neighbors)} `; } 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); const rootChanged = this._refCarousel.value ? this._mediaActionsController.setRoot(this._refCarousel.value) : false; // 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/advanced-camera-card/issues/1626 if (rootChanged || changedProperties.has('viewManagerEpoch')) { this._setMediaTarget(); } } static get styles(): CSSResultGroup { return unsafeCSS(liveCarouselStyle); } } declare global { interface HTMLElementTagNameMap { 'advanced-camera-card-live-carousel': AdvancedCameraCardLiveCarousel; } }