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 { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../camera-manager/manager.js'; import { MediaGridSelected } from '../components-lib/media-grid-controller.js'; import { ZoomSettingsObserved } from '../components-lib/zoom/types.js'; import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context.js'; import { dispatchMessageEvent, renderMessage, renderProgressIndicator, } from '../components/message.js'; import { CardWideConfig, frigateCardConfigDefaults, TransitionEffect, ViewerConfig, } from '../config/types.js'; import { localize } from '../localize/localize.js'; import '../patches/ha-hls-player'; import basicBlockStyle from '../scss/basic-block.scss'; import viewerCarouselStyle from '../scss/viewer-carousel.scss'; import viewerProviderStyle from '../scss/viewer-provider.scss'; import viewerStyle from '../scss/viewer.scss'; import { ExtendedHomeAssistant, FrigateCardMediaPlayer, MediaLoadedInfo, } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { mayHaveAudio } from '../utils/audio.js'; import { aspectRatioToString, contentsChanged, errorToConsole, setOrRemoveAttribute, } 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 { AutoMediaActions } from '../utils/embla/plugins/auto-media-actions/auto-media-actions.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 { canonicalizeHAURL } from '../utils/ha/index.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.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 { EmblaCarouselPlugins } from './carousel.js'; import './next-prev-control.js'; import './ptz'; import './surround.js'; import './title-control.js'; import { FrigateCardTitleControl, 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; 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) { // Directly render an error message (instead of dispatching it upwards) // to preserve the mini-timeline if the user pans into an area with no // media. return renderMessage({ type: 'info', message: localize('common.no_media'), icon: 'mdi:multimedia', }); } if (mediaType === 'recordings') { changeViewToRecentRecordingForCameraAndDependents( this, this.cameraManager, this.cardWideConfig, this.view, { allCameras: this.view.isGrid(), targetView: 'recording', useCache: false, }, ); } else { changeViewToRecentEventsForCameraAndDependents( this, this.cameraManager, this.cardWideConfig, this.view, { allCameras: this.view.isGrid(), targetView: 'media', eventsMediaType: mediaType, useCache: false, }, ); } return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); } return html` `; } 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; @state() protected _selected = 0; protected _media: ViewMedia[] | null = null; protected _refTitleControl: Ref = createRef(); protected _player: FrigateCardMediaPlayer | 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 ); } /** * Get the Embla plugins to use. * @returns A list of EmblaOptionsTypes. */ protected _getPlugins(): EmblaCarouselPlugins { return [ AutoLazyLoad({ ...(this.viewerConfig?.lazy_load && { lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide), }), }), AutoMediaLoadedInfo(), AutoMediaActions({ playerSelector: FRIGATE_CARD_VIEWER_PROVIDER, ...(this.viewerConfig?.auto_play && { autoPlayConditions: this.viewerConfig.auto_play, }), ...(this.viewerConfig?.auto_pause && { autoPauseConditions: this.viewerConfig.auto_pause, }), ...(this.viewerConfig?.auto_mute && { autoMuteConditions: this.viewerConfig.auto_mute, }), ...(this.viewerConfig?.auto_unmute && { autoUnmuteConditions: this.viewerConfig.auto_unmute, }), }), AutoSize(), ]; } /** * 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) { 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 _setViewSelectedIndex(index: number): void { if (!this._media) { return; } if (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.load = true; } } /** * 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); 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('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( selectedMedia.getCameraID(), ); const titleConfig = getDefaultTitleConfigForView( this.view, this.viewerConfig?.controls.title, ); return html` ) => { this._setViewSelectedIndex(ev.detail.index); }} @frigate-card:media:loaded=${(ev: CustomEvent) => { if (this._refTitleControl.value) { this._refTitleControl.value.show(); } this._player = ev.detail.player ?? null; this._seekHandler(); }} @frigate-card:media:unloaded=${() => { this._player = null; }} > { scroll('previous'); stopEventFromActivatingCardWideActions(ev); }} > ${guard([this._media, this.view], () => this._getSlides())} { scroll('next'); stopEventFromActivatingCardWideActions(ev); }} > ${this.view ? html` ` : ''}
${cameraMetadata && titleConfig ? html` ` : ``} `; } /** * 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._player) { return; } const selectedMedia = this._media[this._selected]; if (!selectedMedia) { return; } const seekTimeInMedia = selectedMedia.includesTime(seek); setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable'); if (!seekTimeInMedia && !this._player.isPaused()) { this._player.pause(); } else if (seekTimeInMedia && this._player.isPaused()) { this._player.play(); } const seekTime = (await this.cameraManager?.getMediaSeekTime(selectedMedia, seek)) ?? null; if (seekTime !== null) { this._player.seek(seekTime); } } protected _renderMediaItem(media: ViewMedia): TemplateResult | null { if (!this.hass || !this.view || !this.viewerConfig) { return null; } return html`
`; } 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 load the viewer media. If `false`, no contents are // rendered until this attribute is set to `true` (this is useful for lazy // loading). @property({ attribute: false }) public load = 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(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('load') || changedProps.has('media') || changedProps.has('viewerConfig') || changedProps.has('resolvedMediaCache') || changedProps.has('hass')) && this.hass && mediaContentID && !this.resolvedMediaCache?.has(mediaContentID) && (!this.viewerConfig?.lazy_load || this.load) ) { resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => { this.requestUpdate(); }); } if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) { import('./zoomer.js'); } if (changedProps.has('media') || changedProps.has('cameraManager')) { const cameraID = this.media?.getCameraID(); const cameraConfig = cameraID ? this.cameraManager?.getStore().getCameraConfig(cameraID) : null; updateElementStyleFromMediaLayoutConfig(this, cameraConfig?.dimensions?.layout); this.style.aspectRatio = aspectRatioToString({ ratio: cameraConfig?.dimensions?.aspect_ratio, }); } } protected _useZoomIfRequired(template: TemplateResult): TemplateResult { if (!this.media) { return template; } const cameraID = this.media.getCameraID(); const mediaID = this.media.getID() ?? undefined; const cameraConfig = this.cameraManager?.getStore().getCameraConfig(cameraID); return this.viewerConfig?.zoomable ? html` cameraConfig?.dimensions?.layout ? { pan: cameraConfig.dimensions.layout.pan, zoom: cameraConfig.dimensions.layout.zoom, } : undefined, )} .settings=${mediaID ? this.view?.context?.zoom?.[mediaID]?.requested : undefined} @frigate-card:zoom:zoomed=${() => this.setControls(false)} @frigate-card:zoom:unzoomed=${() => this.setControls()} @frigate-card:zoom:change=${(ev: CustomEvent) => handleZoomSettingsObservedEvent(this, ev, mediaID)} > ${template} ` : template; } protected render(): TemplateResult | void { if (!this.load || !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