import { CSSResultGroup, html, LitElement, PropertyValues, TemplateResult, unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { CameraManager } from '../camera-manager/manager.js'; import basicBlockStyle from '../scss/basic-block.scss'; import { CardWideConfig, ClipsOrSnapshotsOrAll, ExtendedHomeAssistant, MiniTimelineControlConfig, ThumbnailsControlConfig, } from '../types.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; import { getAllDependentCameras } from '../utils/camera.js'; import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view'; import { View } from '../view/view.js'; import './surround-basic.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; interface ThumbnailViewContext { // Whether or not to fetch thumbnails. fetch?: boolean; } declare module 'view' { interface ViewContext { thumbnails?: ThumbnailViewContext; } } @customElement('frigate-card-surround') export class FrigateCardSurround extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @property({ attribute: false }) public view?: Readonly; @property({ attribute: false, hasChanged: contentsChanged }) public thumbnailConfig?: ThumbnailsControlConfig; @property({ attribute: false, hasChanged: contentsChanged }) public timelineConfig?: MiniTimelineControlConfig; // If fetchMedia is not specified, no fetching is done. @property({ attribute: false, hasChanged: contentsChanged }) public fetchMedia?: ClipsOrSnapshotsOrAll; @property({ attribute: false }) public cameraManager?: CameraManager; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; protected _cameraIDsForTimeline?: Set; /** * Fetch thumbnail media when a target is not specified in the view (e.g. for * the live view). * @param param Task parameters. * @returns */ protected async _fetchMedia(): Promise { if ( !this.cameraManager || !this.cardWideConfig || !this.fetchMedia || !this.hass || !this.view || this.view.query || !this.thumbnailConfig || this.thumbnailConfig.mode === 'none' || !(this.view.context?.thumbnails?.fetch ?? true) ) { return; } await changeViewToRecentEventsForCameraAndDependents( this, this.hass, this.cameraManager, this.cardWideConfig, this.view, { allCameras: this.view.isGrid(), targetView: this.view.view, mediaType: this.fetchMedia, select: 'latest', }, ); } /** * Determine if a drawer is being used. * @returns `true` if a drawer is used, `false` otherwise. */ protected _hasDrawer(): boolean { return ( !!this.thumbnailConfig && ['left', 'right'].includes(this.thumbnailConfig.mode) ); } /** * Called before each update. */ protected willUpdate(changedProperties: PropertyValues): void { if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') { import('./timeline.js'); } // Only reset the timeline cameraIDs when the media or display mode // materially changes (and not on every view change, since the view will // change frequently when the user is scrubbing video). const oldView = changedProperties.get('view'); if ( changedProperties.has('view') && (View.isMajorMediaChange(oldView, this.view) || oldView.displayMode !== this.view?.displayMode) ) { this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined; } // Once the component will certainly update, dispatch a media request. Only // do so if properties relevant to the request have changed (as per their // hasChanged). if ( ['view', 'fetch', 'browseMediaParams'].some((prop) => changedProperties.has(prop)) ) { this._fetchMedia(); } } protected _getCameraIDsForTimeline(): Set | null { if (!this.view) { return null; } if (this.view?.is('live')) { return this.view.isGrid() ? this.cameraManager?.getStore().getVisibleCameraIDs() ?? null : getAllDependentCameras(this.cameraManager, this.view.camera); } if (this.view.isViewerView()) { return this.view.query?.getQueryCameraIDs() ?? null; } return null; } protected render(): TemplateResult | void { if (!this.hass || !this.view) { return; } const changeDrawer = (ev: CustomEvent, action: 'open' | 'close') => { // The event catch/re-dispatch below protect encapsulation: Catches the // request to view thumbnails and re-dispatches a request to open the drawer // (if the thumbnails are in a drawer). The new event needs to be dispatched // from the origin of the inbound event, so it can be handled by // . if (this.thumbnailConfig && this._hasDrawer()) { dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, { drawer: this.thumbnailConfig.mode, }); } }; return html` changeDrawer(ev, 'open')} @frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')} > ${this.thumbnailConfig && this.thumbnailConfig.mode !== 'none' ? html` changeDrawer(ev, 'close')} @frigate-card:thumbnail-carousel:tap=${( ev: CustomEvent, ) => { const media = ev.detail.queryResults.getSelectedResult(); if (media) { this.view ?.evolve({ view: 'media', queryResults: ev.detail.queryResults, ...(media.getCameraID() && { camera: media.getCameraID() }), }) .removeContext('timeline') // Send the view change from the source of the tap event, so // the view change will be caught by the handler above (to // close the drawer). .dispatchChangeEvent(ev.composedPath()[0]); } }} > ` : ''} ${this.timelineConfig && this.timelineConfig.mode !== 'none' ? html` ` : ''} `; } static get styles(): CSSResultGroup { return unsafeCSS(basicBlockStyle); } } declare global { interface HTMLElementTagNameMap { 'frigate-card-surround': FrigateCardSurround; } }