import { EmblaPluginType } from 'embla-carousel'; import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { CSSResultGroup, html, LitElement, PropertyValues, TemplateResult, unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { guard } from 'lit/directives/guard.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import basicBlockStyle from '../scss/basic-block.scss'; import { CameraManager } from '../camera-manager/manager.js'; import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js'; import { localize } from '../localize/localize.js'; import '../patches/ha-hls-player'; import viewerCarouselStyle from '../scss/viewer-carousel.scss'; import viewerProviderStyle from '../scss/viewer-provider.scss'; import viewerStyle from '../scss/viewer.scss'; import { CardWideConfig, ExtendedHomeAssistant, frigateCardConfigDefaults, FrigateCardMediaPlayer, MediaLoadedInfo, TransitionEffect, ViewerConfig, } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { mayHaveAudio } from '../utils/audio.js'; import { contentsChanged, errorToConsole, setOrRemoveAttribute, } from '../utils/basic.js'; import { canonicalizeHAURL } from '../utils/ha/index.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; import { MediaGridSelected } from '../utils/media-grid-controller.js'; import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, dispatchMediaPlayEvent, dispatchMediaVolumeChangeEvent, } from '../utils/media-info.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents, } from '../utils/media-to-view.js'; import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, playMediaMutingIfNecessary, setControlsOnVideo, } from '../utils/media.js'; import { screenshotMedia } from '../utils/screenshot.js'; import { ViewMediaClassifier } from '../view/media-classifier'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { MediaQueriesResults } from '../view/media-queries-results.js'; import { VideoContentType, ViewMedia } from '../view/media.js'; import { View } from '../view/view.js'; import type { CarouselSelect } from './carousel.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload } from './embla-plugins/lazyload.js'; import { FrigateCardMediaCarousel, wrapMediaLoadedEventForCarousel, } from './media-carousel.js'; import './next-prev-control.js'; import './surround.js'; import './title-control.js'; import { getDefaultTitleConfigForView } from './title-control.js'; export interface MediaViewerViewContext { seek?: Date; } declare module 'view' { interface ViewContext { mediaViewer?: MediaViewerViewContext; } } interface MediaNeighbor { index: number; media: ViewMedia; } interface MediaNeighbors { previous?: MediaNeighbor; next?: MediaNeighbor; } @customElement('frigate-card-viewer') export class FrigateCardViewer extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @property({ attribute: false }) public view?: Readonly; @property({ attribute: false }) public viewerConfig?: ViewerConfig; @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; @property({ attribute: false }) public cameraManager?: CameraManager; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; /** * Master render method. * @returns A rendered template. */ protected render(): TemplateResult | void { if ( !this.hass || !this.view || !this.viewerConfig || !this.cameraManager || !this.cardWideConfig ) { return; } if (!this.view.queryResults?.hasResults()) { // If the query is not specified, the view must tell us which mediaType to // search for. When the query *is* specified, the view is not required to // indicate the media type (e.g. the mixed 'media' view from the // timeline). const mediaType = this.view.getDefaultMediaType(); if (!mediaType) { return; } if (mediaType === 'recordings') { changeViewToRecentRecordingForCameraAndDependents( this, this.hass, this.cameraManager, this.cardWideConfig, this.view, { allCameras: this.view.isGrid(), targetView: 'recording', }, ); } else { changeViewToRecentEventsForCameraAndDependents( this, this.hass, this.cameraManager, this.cardWideConfig, this.view, { allCameras: this.view.isGrid(), targetView: 'media', mediaType: mediaType, }, ); } return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); } return html` `; } /** * Get element styles. */ static get styles(): CSSResultGroup { return unsafeCSS(viewerStyle); } } const FRIGATE_CARD_VIEWER_PROVIDER = 'frigate-card-viewer-provider'; @customElement('frigate-card-viewer-carousel') export class FrigateCardViewerCarousel extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @property({ attribute: false }) public view?: Readonly; @property({ attribute: false }) public viewFilterCameraID?: string; // Resetting the viewer configuration causes a full reset so ensure the config // has actually changed with a full comparison (dynamic configuration // overrides may causes changes elsewhere in the full card configuration that // could lead to the address of the viewerConfig changing without it being // semantically different). @property({ attribute: false, hasChanged: contentsChanged }) public viewerConfig?: ViewerConfig; @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @property({ attribute: false }) public cameraManager?: CameraManager; @property({ attribute: false }) public selected = 0; protected _refMediaCarousel: Ref = createRef(); protected _media: ViewMedia[] | null = null; /** * The updated lifecycle callback for this element. * @param changedProperties The properties that were changed in this render. */ updated(changedProperties: PropertyValues): void { super.updated(changedProperties); if (changedProperties.has('view')) { const oldView = changedProperties.get('view') as View | undefined; // Seek into the video if the seek time has changed (this is also called // on media load, since the media may or may not have been loaded at // this point). if (this.view?.context?.mediaViewer !== oldView?.context?.mediaViewer) { this._seekHandler(); } } } /** * Get the transition effect to use. * @returns An TransitionEffect object. */ protected _getTransitionEffect(): TransitionEffect { return ( this.viewerConfig?.transition_effect ?? frigateCardConfigDefaults.media_viewer.transition_effect ); } /** * The the HLS player on a slide (or current slide if not provided.) * @param slide An optional slide. * @returns The FrigateCardMediaPlayer or null if not found. */ protected _getPlayer(slide?: HTMLElement | null): FrigateCardMediaPlayer | null { if (!slide) { slide = this._refMediaCarousel.value ?.frigateCardCarousel() ?.getCarouselSelected()?.element; } return ( (slide?.querySelector( FRIGATE_CARD_VIEWER_PROVIDER, ) as unknown as FrigateCardMediaPlayer) ?? null ); } /** * Get the Embla plugins to use. * @returns A list of EmblaOptionsTypes. */ protected _getPlugins(): EmblaPluginType[] { return [ // Only enable wheel plugin if there is more than one media item. ...(this._media && this._media.length > 1 ? [ WheelGesturesPlugin({ // Whether the carousel is vertical or horizontal, interpret y-axis wheel // gestures as scrolling for the carousel. forceWheelAxis: 'y', }), ] : []), Lazyload({ ...(this.viewerConfig?.lazy_load && { lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide), }), }), AutoMediaPlugin({ playerSelector: FRIGATE_CARD_VIEWER_PROVIDER, ...(this.viewerConfig?.auto_play && { autoPlayCondition: this.viewerConfig.auto_play, }), ...(this.viewerConfig?.auto_pause && { autoPauseCondition: this.viewerConfig.auto_pause, }), ...(this.viewerConfig?.auto_mute && { autoMuteCondition: this.viewerConfig.auto_mute, }), ...(this.viewerConfig?.auto_unmute && { autoUnmuteCondition: this.viewerConfig.auto_unmute, }), }), ]; } /** * Get the previous and next true media items from the current view. * @returns A BrowseMediaNeighbors with indices and objects of true media * neighbors. */ protected _getMediaNeighbors(): MediaNeighbors | null { const mediaCount = this._media?.length ?? 0; if (!this._media || this.selected === null) { return null; } const prevIndex = this.selected > 0 ? this.selected - 1 : null; const nextIndex = this.selected + 1 < mediaCount ? this.selected + 1 : null; return { ...(prevIndex !== null && { previous: { index: prevIndex, media: this._media[prevIndex], }, }), ...(nextIndex !== null && { next: { index: nextIndex, media: this._media[nextIndex], }, }), }; } protected _setViewHandler(ev: CustomEvent): void { this._setViewSelectedIndex(ev.detail.index); } protected _setViewSelectedIndex(index: number): void { if (!this._media) { return; } if (this.selected === null || this.selected === index) { // The slide may already be selected on load, so don't dispatch a new view // unless necessary (i.e. the new index is different from the current // index). return; } const newResults = this.view?.queryResults ?.clone() .selectIndex(index, this.viewFilterCameraID); if (!newResults) { return; } const cameraID = newResults .getSelectedResult(this.viewFilterCameraID) ?.getCameraID(); this.view ?.evolve({ queryResults: newResults, // Always change the camera to the owner of the selected media. ...(cameraID && { camera: cameraID }), }) .removeContextProperty('mediaViewer', 'seek') .dispatchChangeEvent(this); } /** * Lazy load a slide. * @param slide The slide to lazy load. */ protected _lazyloadSlide(slide: Element): void { if (slide instanceof HTMLSlotElement) { slide = slide.assignedElements({ flatten: true })[0]; } const viewerProvider = slide?.querySelector( 'frigate-card-viewer-provider', ) as FrigateCardViewerProvider | null; if (viewerProvider) { viewerProvider.disabled = false; } } /** * Get slides to include in the render. * @returns The slides to include in the render. */ protected _getSlides(): TemplateResult[] { if (!this._media) { return []; } const slides: TemplateResult[] = []; for (let i = 0; i < this._media.length; ++i) { const media = this._media[i]; if (media) { const slide = this._renderMediaItem(media, i); if (slide) { slides[i] = slide; } } } return slides; } /** * Called when an update will occur. * @param changedProps The changed properties */ protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('viewerConfig')) { updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout); } if (changedProps.has('view')) { const newMedia = this.view?.queryResults?.getResults(this.viewFilterCameraID) ?? null; const newSelected = this.view?.queryResults?.getSelectedIndex(this.viewFilterCameraID) ?? 0; const newSeek = this.view?.context?.mediaViewer?.seek; if (newMedia !== this._media || newSelected !== this.selected || !newSeek) { setOrRemoveAttribute(this, false, 'unseekable'); this._media = newMedia; this.selected = newSelected; } } } protected render(): TemplateResult | void { const mediaCount = this._media?.length ?? 0; if (!this._media || !mediaCount) { return dispatchMessageEvent(this, localize('common.no_media'), 'info', { icon: 'mdi:multimedia', }); } // If there's no selected media, just choose the last (most recent one) to // avoid rendering a blank. This situation should not occur in practice, as // this view should not be called without a selected media. const selectedMedia = this._media[this.selected] ?? this._media[mediaCount - 1]; if (!this.hass || !this.cameraManager || !selectedMedia) { return; } const neighbors = this._getMediaNeighbors(); const scroll = (direction: 'previous' | 'next'): void => { if (!neighbors || !this._media) { return; } const newIndex = (direction === 'previous' ? neighbors.previous?.index : neighbors.next?.index) ?? null; if (newIndex !== null) { this._setViewSelectedIndex(newIndex); } }; const cameraMetadata = this.cameraManager.getCameraMetadata( this.hass, selectedMedia.getCameraID(), ); const titleConfig = getDefaultTitleConfigForView( this.view, this.viewerConfig?.controls.title, ); return html` ({ draggable: this.viewerConfig?.draggable ?? true, }))} .carouselPlugins=${guard( [this.viewerConfig, this._media], this._getPlugins.bind(this), )} .label=${selectedMedia.getTitle() ?? undefined} .logo=${cameraMetadata?.engineLogo} .titlePopupConfig=${titleConfig ?? undefined} .selected=${this.selected ?? 0} transitionEffect=${this._getTransitionEffect()} @frigate-card:media-carousel:select=${this._setViewHandler.bind(this)} @frigate-card:media:loaded=${this._seekHandler.bind(this)} > { scroll('previous'); stopEventFromActivatingCardWideActions(ev); }} > ${guard(this._media, () => this._getSlides())} { scroll('next'); stopEventFromActivatingCardWideActions(ev); }} >
`; } /** * Fire a media show event when a slide is selected. */ protected async _seekHandler(): Promise { const seek = this.view?.context?.mediaViewer?.seek; if (!this.hass || !seek || !this._media || this.selected === null) { return; } const selectedMedia = this._media[this.selected]; if (!selectedMedia) { return; } const seekTimeInMedia = selectedMedia.includesTime(seek); setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable'); if (!seekTimeInMedia) { this._getPlayer()?.pause(); } else { this._getPlayer()?.play(); } const seekTime = (await this.cameraManager?.getMediaSeekTime(this.hass, selectedMedia, seek)) ?? null; const player = this._getPlayer(); if (player && seekTime !== null) { player.seek(seekTime); } } /** * Render a single media item in the viewer carousel. * @param media The ViewMedia to render. * @param index The (slide|queryResult) index of the item to render. * @returns A rendered template. */ protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null { if (!this.hass || !this.view || !this.viewerConfig) { return null; } return html`
) => { wrapMediaLoadedEventForCarousel(index, e); }} >
`; } static get styles(): CSSResultGroup { return unsafeCSS(viewerCarouselStyle); } } @customElement('frigate-card-viewer-grid') export class FrigateCardViewerGrid extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @property({ attribute: false }) public view?: Readonly; @property({ attribute: false }) public viewerConfig?: ViewerConfig; @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @property({ attribute: false }) public cameraManager?: CameraManager; protected _renderCarousel(filterCamera?: string): TemplateResult { return html` `; } protected _gridSelectCamera(cameraID: string, view?: View): void { const newView = view ?? this.view; const promotedQueryResults = newView?.queryResults ?.clone() .promoteCameraSelectionToMainSelection(cameraID); newView ?.evolve({ camera: cameraID, queryResults: promotedQueryResults, }) .dispatchChangeEvent(this); } protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('view') && this._needsGrid()) { import('./media-grid.js'); } } protected _needsGrid(): boolean { const cameraIDs = this.view?.queryResults?.getCameraIDs(); return ( !!this.view?.isGrid() && !!this.view?.supportsMultipleDisplayModes() && (cameraIDs?.size ?? 0) > 1 ); } protected render(): TemplateResult { const cameraIDs = this.view?.queryResults?.getCameraIDs(); if (!cameraIDs || !this._needsGrid()) { return this._renderCarousel(); } return html` ) => this._gridSelectCamera(ev.detail.selected)} @frigate-card:view:change=${(ev: CustomEvent) => { ev.stopPropagation(); const childView = ev.detail; this._gridSelectCamera(childView.camera, childView); }} > ${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))} `; } static get styles(): CSSResultGroup { return unsafeCSS(basicBlockStyle); } } @customElement(FRIGATE_CARD_VIEWER_PROVIDER) export class FrigateCardViewerProvider extends LitElement implements FrigateCardMediaPlayer { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @property({ attribute: false }) public view?: Readonly; @property({ attribute: false }) public media?: ViewMedia; @property({ attribute: false }) public viewerConfig?: ViewerConfig; @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; // Whether or not to disable this entity. If `true`, no contents are rendered // until this attribute is set to `false` (this is useful for lazy loading). @property({ attribute: false }) public disabled = false; @property({ attribute: false }) public cameraManager?: CameraManager; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; protected _refFrigateCardMediaPlayer: Ref = createRef(); protected _refVideoProvider: Ref = createRef(); protected _refImageProvider: Ref = createRef(); public async play(): Promise { await playMediaMutingIfNecessary( this, this._refFrigateCardMediaPlayer.value ?? this._refVideoProvider.value, ); } public async pause(): Promise { (this._refFrigateCardMediaPlayer.value || this._refVideoProvider.value)?.pause(); } public async mute(): Promise { if (this._refFrigateCardMediaPlayer.value) { this._refFrigateCardMediaPlayer.value?.mute(); } else if (this._refVideoProvider.value) { this._refVideoProvider.value.muted = true; } } public async unmute(): Promise { if (this._refFrigateCardMediaPlayer.value) { this._refFrigateCardMediaPlayer.value?.mute(); } else if (this._refVideoProvider.value) { this._refVideoProvider.value.muted = false; } } public isMuted(): boolean { if (this._refFrigateCardMediaPlayer.value) { return this._refFrigateCardMediaPlayer.value?.isMuted() ?? true; } else if (this._refVideoProvider.value) { return this._refVideoProvider.value.muted; } return true; } public async seek(seconds: number): Promise { if (this._refFrigateCardMediaPlayer.value) { return this._refFrigateCardMediaPlayer.value.seek(seconds); } else if (this._refVideoProvider.value) { hideMediaControlsTemporarily(this._refVideoProvider.value); this._refVideoProvider.value.currentTime = seconds; } } public async setControls(controls?: boolean): Promise { if (this._refFrigateCardMediaPlayer.value) { return this._refFrigateCardMediaPlayer.value.setControls(controls); } else if (this._refVideoProvider.value) { setControlsOnVideo( this._refVideoProvider.value, controls ?? this.viewerConfig?.controls.builtin ?? true, ); } } public isPaused(): boolean { if (this._refFrigateCardMediaPlayer.value) { return this._refFrigateCardMediaPlayer.value.isPaused(); } else if (this._refVideoProvider.value) { return this._refVideoProvider.value.paused; } return true; } public async getScreenshotURL(): Promise { if (this._refFrigateCardMediaPlayer.value) { return await this._refFrigateCardMediaPlayer.value.getScreenshotURL(); } else if (this._refVideoProvider.value) { return screenshotMedia(this._refVideoProvider.value); } else if (this._refImageProvider.value) { return this._refImageProvider.value.src; } return null; } /** * Dispatch a clip view that matches the current (snapshot) query. */ protected async _dispatchRelatedClipView(): Promise { if ( !this.hass || !this.view || !this.cameraManager || !this.media || // If this specific media item has no clip, then do nothing (even if all // the other media items do). !ViewMediaClassifier.isEvent(this.media) || !MediaQueriesClassifier.areEventQueries(this.view.query) ) { return; } // Convert the query to a clips equivalent. const clipQuery = this.view.query.clone(); clipQuery.convertToClipsQueries(); const queries = clipQuery.getQueries(); if (!queries) { return; } let mediaArray: ViewMedia[] | null; try { mediaArray = await this.cameraManager.executeMediaQueries(this.hass, queries); } catch (e) { errorToConsole(e as Error); return; } if (!mediaArray) { return; } const results = new MediaQueriesResults({ results: mediaArray }); results.selectResultIfFound( (clipMedia) => clipMedia.getID() === this.media?.getID(), ); if (!results.hasSelectedResult()) { return; } this.view .evolve({ view: 'media', query: clipQuery, queryResults: results, }) .dispatchChangeEvent(this); } protected willUpdate(changedProps: PropertyValues): void { const mediaContentID = this.media ? this.media.getContentID() : null; if ( (changedProps.has('disabled') || changedProps.has('media') || changedProps.has('viewerConfig') || changedProps.has('resolvedMediaCache') || changedProps.has('hass')) && this.hass && mediaContentID && !this.resolvedMediaCache?.has(mediaContentID) && (!this.viewerConfig?.lazy_load || !this.disabled) ) { resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => { this.requestUpdate(); }); } if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) { import('./zoomer.js'); } } protected _useZoomIfRequired(template: TemplateResult): TemplateResult { return this.viewerConfig?.zoomable ? html` this.setControls(false)} @frigate-card:zoom:unzoomed=${() => this.setControls()} > ${template} ` : template; } protected render(): TemplateResult | void { if (this.disabled || !this.media || !this.hass || !this.view || !this.viewerConfig) { return; } const mediaContentID = this.media.getContentID(); const resolvedMedia = mediaContentID ? this.resolvedMediaCache?.get(mediaContentID) : null; if (!resolvedMedia) { // Media will be resolved with the call in willUpdate() then this will be // re-rendered. return renderProgressIndicator({ cardWideConfig: this.cardWideConfig, }); } // Note: crossorigin="anonymous" is required on