import { html, LitElement, unsafeCSS, type CSSResultGroup, type PropertyValues, type TemplateResult, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { keyed } from 'lit/directives/keyed.js'; import { createRef, ref, type Ref } from 'lit/directives/ref.js'; import type { CameraManager } from '../../camera-manager/manager.js'; import type { CameraManagerCameraMetadata } from '../../camera-manager/types.js'; import type { CallSession } from '../../card-controller/call/types.js'; import type { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher.js'; import type { MicrophoneState } from '../../card-controller/types.js'; import type { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { resolveAutoHideState } from '../../components-lib/auto-hide.js'; import { MediaActionsController } from '../../components-lib/media-actions-controller.js'; import { MediaHeightController } from '../../components-lib/media-height-controller.js'; import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js'; import { PTZDragController } from '../../components-lib/ptz/drag-controller.js'; import type { ZoomSettingsObserved } from '../../components-lib/zoom/types.js'; import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js'; import { ptzControlsDefaults, type PTZControlType, } from '../../config/schema/common/controls/ptz.js'; import type { TransitionEffect } from '../../config/schema/common/transition-effect.js'; import type { LiveConfig } from '../../config/schema/live.js'; import { configDefaults, type CardWideConfig } from '../../config/schema/types.js'; import type { HomeAssistant } from '../../ha/types.js'; import liveCarouselStyle from '../../scss/live-carousel.scss?inline'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import type { CarouselSelected } from '../../utils/embla/carousel-controller.js'; import { getTextDirection } from '../../utils/text-direction.js'; import { getStreamCameraID } from '../../view/substream.js'; import type { View } from '../../view/view.js'; import '../call-controls.js'; import '../carousel'; import '../next-prev-control.js'; import '../ptz.js'; import './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 stateWatcher?: StateWatcherSubscriptionInterface; @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 call?: CallSession; @property({ attribute: false }) public locked?: boolean; @property({ attribute: false }) public viewFilterCameraID?: string; // Whether this carousel automatically sets its own height (via // MediaHeightController) to fit the media it shows. Counter-example: A grid // cell is sized by its grid instead. @property({ attribute: false }) public autoHeight = true; private _refCarousel: Ref = createRef(); private _mediaActionsController = new MediaActionsController(); private _mediaHeightController = new MediaHeightController(this, '.embla__slide'); private _ptzDragController = new PTZDragController(this); private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, { getTargetID: () => this._getCarouselCameraID(), callback: () => this._mediaHeightController.recalculate(), }); public connectedCallback(): void { super.connectedCallback(); if (this.autoHeight) { this._mediaHeightController.setRoot(this.renderRoot); } // Request update in order to reinitialize the media action controller. this.requestUpdate(); } public disconnectedCallback(): void { this._mediaActionsController.destroy(); this._mediaHeightController.destroy(); super.disconnectedCallback(); } private _getDisplayPTZType(cameraID: string | null): PTZControlType { if ( !cameraID || // For cameras without physical PTZ, always display buttons so the user // can control digital zoom. Gesture type controls have no effect on those // cameras. !this.cameraManager?.getCameraCapabilities(cameraID)?.hasPTZCapability() ) { return 'buttons'; } return ( this.viewManagerEpoch?.manager.getView()?.context?.ptzControls?.type ?? this.liveConfig?.controls.ptz.type ?? ptzControlsDefaults.type ); } private _isGesturesPTZActive( view: View | null | undefined, cameraID: string | null, ): boolean { // _getDisplayPTZType returns 'buttons' for digital-only cameras, so this // implicitly guards against cameras without physical PTZ capability. return ( this._getDisplayPTZType(cameraID) === 'gestures' && view?.context?.ptzControls?.enabled !== false ); } private _getTransitionEffect = (): TransitionEffect => this.liveConfig?.transition_effect ?? configDefaults.live.transition_effect; // The cameraID this carousel currently represents: the filtered camera when // the carousel is scoped to one, otherwise the camera of the active view. private _getCarouselCameraID(): string | null { return ( this.viewFilterCameraID ?? this.viewManagerEpoch?.manager.getView()?.camera ?? null ); } private _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?.camera) { return 0; } return Math.max(0, Array.from(cameraIDs).indexOf(view.camera)); } protected willUpdate(changedProps: PropertyValues): void { if (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 && { microphoneMuteSeconds: this.liveConfig.microphone.mute_after_microphone_mute_seconds, }), }); } if (changedProps.has('microphoneState') && this.microphoneState) { this._mediaActionsController.setMicrophoneState(this.microphoneState); } if ( changedProps.has('call') || changedProps.has('viewManagerEpoch') || changedProps.has('viewFilterCameraID') ) { // Scope the call-answered signal to the carousel that owns the call: in // grid mode every carousel receives `.call`, but only the call camera's // audio should be acted on. Gating on `answered` (not mere presence) // keeps `live.auto_unmute: ['call']` from unmuting the camera's audio // during the pre-answer ringing state. this._mediaActionsController.setCallAnswered( this.call?.cameraID === this._getCarouselCameraID() && !!this.call?.answered, ); } } private _getSlides(): TemplateResult[] { if (!this.cameraManager) { return []; } const cameraIDs = this.viewFilterCameraID ? new Set([this.viewFilterCameraID]) : this.cameraManager?.getStore().getCameraIDsWithCapability('live'); const slides: TemplateResult[] = []; for (const cameraID of cameraIDs ?? []) { const slide = this._renderLive(cameraID); if (slide) { slides.push(slide); } } return slides; } private _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]); } } private _setViewCameraID(cameraID?: string | null): void { if (cameraID) { void this.viewManagerEpoch?.manager.setViewByParametersWithNewQuery({ params: { camera: cameraID, }, }); } } private _renderLive(cameraID: string): TemplateResult | void { const view = this.viewManagerEpoch?.manager.getView(); // Resolve substream INTERNALLY: the substream is a playback-layer concern // (which `Camera` to actually stream), invisible above the provider. The // base `cameraID` flows up as the targetID; the substream `Camera` flows // down for playback. const resolvedCamera = this.cameraManager ?.getStore() .getCamera(this._getSubstreamCameraID(cameraID, view)); if (!this.liveConfig || !this.hass || !this.cameraManager || !resolvedCamera) { return; } const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID); const mediaEpoch = view?.context?.mediaEpoch?.[cameraID] ?? 0; const isSelectedSlide = !!view?.camera && cameraID === view.camera; return html`
${keyed( mediaEpoch, html` 0} @advanced-camera-card:zoom:change=${( ev: CustomEvent, ) => handleZoomSettingsObservedEvent( ev, this.viewManagerEpoch?.manager, cameraID, )} > `, )}
`; } private _getSubstreamCameraID(cameraID: string, view?: View | null): string { return view?.context?.live?.overrides?.get(cameraID) ?? cameraID; } private _toggleMute(): void { const controller = this._mediaLoadedInfoSinkController.get()?.mediaPlayerController; // Fire-and-forget; the `volumechange` event drives the re-render. if (controller?.isMuted()) { void controller.unmute(); } else { void controller?.mute(); } } private _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; if (!cameraID) { return {}; } 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, }, }; } private _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 = this._getSlides(); if (!slides.length) { return; } const hasMultipleCameras = slides.length > 1; const neighbors = this._getCameraNeighbors(); const carouselCameraID = this._getCarouselCameraID(); const streamAwareCameraID = getStreamCameraID(view, this.viewFilterCameraID); const gesturesPTZActive = this._isGesturesPTZActive(view, streamAwareCameraID); const forcePTZVisibility = !this._mediaLoadedInfoSinkController.has() || carouselCameraID !== view.camera || view.context?.ptzControls?.enabled === false ? false : view.context?.ptzControls?.enabled; const dragEnabled = hasMultipleCameras && this.liveConfig?.draggable && !gesturesPTZActive && !this.locked; const isCallControlsActive = this.liveConfig.controls.call.enabled && this.call?.cameraID === carouselCameraID; const callMediaPlayerController = this._mediaLoadedInfoSinkController.get()?.mediaPlayerController ?? null; return html` // Re-render so the call-controls are updated. this.requestUpdate()} > ${this._renderNextPrevious('left', neighbors)} ${slides} ${this._renderNextPrevious('right', neighbors)}
this._toggleMute()} > `; } private _setMediaTarget(): void { const view = this.viewManagerEpoch?.manager.getView(); const selectedCameraIndex = this._getSelectedCameraIndex(); if (this.viewFilterCameraID) { void 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. void this._mediaActionsController.setTarget(selectedCameraIndex, true); } this._mediaHeightController.setSelected(selectedCameraIndex); } 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(); } const carouselEl = this._refCarousel.value; const view = this.viewManagerEpoch?.manager.getView(); const streamAwareCameraID = view ? getStreamCameraID(view, this.viewFilterCameraID) : null; if (this._isGesturesPTZActive(view, streamAwareCameraID) && carouselEl) { this._ptzDragController.activateIfNecessary(carouselEl); } else { this._ptzDragController.deactivateIfNecessary(); } } static get styles(): CSSResultGroup { return unsafeCSS(liveCarouselStyle); } } declare global { interface HTMLElementTagNameMap { 'advanced-camera-card-live-carousel': AdvancedCameraCardLiveCarousel; } }