import { Task } from '@lit-labs/task'; import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { CSSResultGroup, html, LitElement, PropertyValues, TemplateResult, unsafeCSS, } from 'lit'; import { guard } from 'lit/directives/guard.js'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { renderProgressIndicator } from '../components/message.js'; import viewerStyle from '../scss/viewer.scss'; import viewerCarouselStyle from '../scss/viewer-carousel.scss'; import { BrowseMediaNeighbors, BrowseMediaQueryParameters, CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource, frigateCardConfigDefaults, FrigateCardMediaPlayer, MediaLoadedInfo, TransitionEffect, ViewerConfig, } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { contentsChanged } from '../utils/basic.js'; import { fetchLatestMediaAndDispatchViewChange, getEventStartTime, getFullDependentBrowseMediaQueryParametersOrDispatchError, isTrueMedia, multipleBrowseMediaQueryMerged, overrideMultiBrowseMediaQueryParameters, } from '../utils/ha/browse-media.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; import { View } from '../view.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload } from './embla-plugins/lazyload.js'; import { FrigateCardMediaCarousel, IMG_EMPTY, wrapRawMediaLoadedEventForCarousel, wrapMediaLoadedEventForCarousel, } from './media-carousel.js'; import './next-prev-control.js'; import './title-control.js'; import '../patches/ha-hls-player'; import './surround.js'; import { EmblaCarouselPlugins } from './carousel.js'; import { renderTask } from '../utils/task.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; import { TimelineDataManager } from '../utils/timeline-data-manager.js'; import { changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js'; export interface MediaSeek { // Specifies the point at which this recording should be played, the // seek_time is the date of the desired play point (for display purposes // usually), and seek_seconds is the number of seconds to seek into the video // stream to reach that point. seekTime: number; seekSeconds: number; } export interface MediaViewerViewContext { seek: Map; } declare module 'view' { interface ViewContext { mediaViewer?: MediaViewerViewContext; } } @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 cameras?: Map; @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; @property({ attribute: false }) public timelineDataManager?: TimelineDataManager; /** * Master render method. * @returns A rendered template. */ protected render(): TemplateResult | void { if ( !this.hass || !this.view || !this.cameras || !this.viewerConfig || !this.timelineDataManager ) { return; } const browseMediaQueryParameters = getFullDependentBrowseMediaQueryParametersOrDispatchError( this, this.hass, this.cameras, this.view.camera, ); if (!this.view.target) { // If the target is not specified, the view must tell us which mediaType // to search for. When the target *is* specified, the view is not required // to indicate the media type (e.g. the mixed 'events' view from the // timeline). const mediaType = this.view.getMediaType(); if (!browseMediaQueryParameters || !mediaType) { return; } if (mediaType === 'recordings') { changeViewToRecentRecordingForCameraAndDependents( this, this.hass, this.timelineDataManager, this.cameras, this.view, { targetView: 'recording', }, ); } else { fetchLatestMediaAndDispatchViewChange( this, this.hass, this.view, overrideMultiBrowseMediaQueryParameters(browseMediaQueryParameters, { mediaType: mediaType, }), ); } return renderProgressIndicator(); } return html` `; } /** * Get element styles. */ static get styles(): CSSResultGroup { return unsafeCSS(viewerStyle); } } const FRIGATE_CARD_HLS_SELECTOR = 'frigate-card-ha-hls-player'; @customElement('frigate-card-viewer-carousel') export class FrigateCardViewerCarousel extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @property({ attribute: false }) public view?: Readonly; // 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 browseMediaQueryParameters?: BrowseMediaQueryParameters[] | null; @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; protected _refMediaCarousel: Ref = createRef(); // Mapping of slide # to FrigateBrowseMediaSource child #. // (Folders are not media items that can be rendered). protected _slideToChild: Record = {}; // A task to resolve target media if lazy loading is disabled. protected _mediaResolutionTask = new Task< [FrigateBrowseMediaSource | null | undefined], void >( this, async ([target]: (FrigateBrowseMediaSource | null | undefined)[]): Promise => { for ( let i = 0; !this.viewerConfig?.lazy_load && this.hass && target && target.children && i < (target.children || []).length; ++i ) { if (isTrueMedia(target.children[i])) { await resolveMedia(this.hass, target.children[i], this.resolvedMediaCache); } } }, () => [this.view?.target], ); /** * The updated lifecycle callback for this element. * @param changedProperties The properties that were changed in this render. */ updated(changedProperties: PropertyValues): void { const frigateCardCarousel = this._refMediaCarousel.value?.frigateCardCarousel(); if (frigateCardCarousel && changedProperties.has('view')) { const oldView = changedProperties.get('view') as View | undefined; if (oldView) { if ( oldView.target === this.view?.target && oldView.childIndex !== this.view.childIndex ) { const slide = this._getSlideForChild(this.view.childIndex); if ( slide !== null && slide !== frigateCardCarousel.getCarouselSelected()?.index ) { // If the media target is the same as already loaded, but isn't of // the selected slide, scroll to that slide. frigateCardCarousel.carouselScrollTo(slide); } } } // 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._recordingSeekHandler(); } } super.updated(changedProperties); } /** * Get the slide number given a media child number. * @param childIndex The child index (relative to `view.target`) * @returns A number or null if the child is not found. */ protected _getSlideForChild(childIndex: number | null | undefined): number | null { if (childIndex === undefined || childIndex === null) { return null; } const slideIndex = Object.keys(this._slideToChild).find( (key) => this._slideToChild[key] === childIndex, ); return slideIndex !== undefined ? Number(slideIndex) : null; } /** * 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 options to use. * @returns An EmblaOptionsType object or undefined for no options. */ protected _getOptions(): EmblaOptionsType { return { // Start the carousel on the selected child number. startIndex: this._getSlideForChild(this.view?.childIndex) ?? 0, draggable: this.viewerConfig?.draggable ?? true, }; } /** * 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_HLS_SELECTOR) 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.view && this.view.target && this.view.target.children && this.view.target.children.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: this._lazyloadSlide.bind(this), }), }), AutoMediaPlugin({ playerSelector: FRIGATE_CARD_HLS_SELECTOR, ...(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(): BrowseMediaNeighbors | null { if ( !this.view || !this.view.target || !this.view.target.children || this.view.childIndex === null ) { return null; } // Work backwards from the index to get the previous real media. let prevIndex: number | null = null; for (let i = this.view.childIndex - 1; i >= 0; i--) { const media = this.view.target.children[i]; if (media && isTrueMedia(media)) { prevIndex = i; break; } } // Work forwards from the index to get the next real media. let nextIndex: number | null = null; for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) { const media = this.view.target.children[i]; if (media && isTrueMedia(media)) { nextIndex = i; break; } } return { previousIndex: prevIndex, previous: prevIndex != null ? this.view.target.children[prevIndex] : null, nextIndex: nextIndex, next: nextIndex != null ? this.view.target.children[nextIndex] : null, }; } /** * Get a clip view that matches a given snapshot. Includes clips within the * same range as the current view. * @param snapshot The snapshot to find a matching clip for. * @returns The view that would show the matching clip. */ protected async _findRelatedClipView( snapshot: FrigateBrowseMediaSource, ): Promise { if ( !this.hass || !this.view || !this.view.target || !this.view.target.children || !this.view.target.children.length || !this.browseMediaQueryParameters ) { return null; } const snapshotStartTime = getEventStartTime(snapshot); if (!snapshotStartTime) { return null; } // Heuristic: At this point, the user has a particular snapshot that they // are interested in and want to see a related clip, yet the viewer code // does not know the exact search criteria that led to that snapshot (e.g. // it could be a 10-deep folder in the gallery). To give the user to ability // to 'navigate' in the clips view once they change into that mode, this // heuristic finds the earliest and latest snapshot that the user is // currently viewing and mirrors that range into the clips view. Then, // within the results see if there's a clip that matches the same time as // the snapshot. let earliest: number | null = null; let latest: number | null = null; for (let i = 0; i < this.view.target.children.length; i++) { const child = this.view.target.children[i]; if (!isTrueMedia(child)) { continue; } const startTime = getEventStartTime(child); if (startTime && (earliest === null || startTime < earliest)) { earliest = startTime; } if (startTime && (latest === null || startTime > latest)) { latest = startTime; } } if (!earliest || !latest) { return null; } let clips: FrigateBrowseMediaSource | null; const params = overrideMultiBrowseMediaQueryParameters( this.browseMediaQueryParameters, { mediaType: 'clips', before: latest, after: earliest, }, ); try { clips = await multipleBrowseMediaQueryMerged(this.hass, params); } catch (e) { // This is best effort. return null; } if (!clips || !clips.children || !clips.children.length) { return null; } for (let i = 0; i < clips.children.length; i++) { const child = clips.children[i]; if (!isTrueMedia(child)) { continue; } const clipStartTime = getEventStartTime(child); if (clipStartTime && clipStartTime === snapshotStartTime) { return this.view.evolve({ view: 'clip', target: clips, childIndex: i, }); } } return null; } /** * Handle the user selecting a new slide in the carousel. */ protected _setViewHandler(): void { if (!this._refMediaCarousel.value || !this.view) { return; } // Update the childIndex in the view. const selected = this._refMediaCarousel.value .frigateCardCarousel() ?.getCarouselSelected()?.index; if (selected !== undefined) { const childIndex = this._slideToChild[selected]; if (childIndex !== undefined) { this.view .evolve({ childIndex: childIndex, }) .dispatchChangeEvent(this); } } } /** * Ensure media URLs use the correct HA URL (relevant for Chromecast where the * default location will be the Chromecast receiver, not HA). * @param url The media URL */ protected _canonicalizeHAURL(url?: string): string | undefined { if (this.hass && url && url.startsWith('/')) { return this.hass.hassUrl(url); } return url; } /** * Lazy load a slide. * @param index The index of the slide to lazy load. * @param slide The slide to lazy load. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars protected _lazyloadSlide(index: number, slide: HTMLElement): void { const childIndex: number | undefined = this._slideToChild[index]; if ( childIndex === undefined || !this.hass || !this.view || !this.view.target || !this.view.target.children || !isTrueMedia(this.view.target.children[childIndex]) ) { return; } resolveMedia( this.hass, this.view.target.children[childIndex], this.resolvedMediaCache, ).then((resolvedMedia) => { if (!resolvedMedia) { return; } // Snapshots. const img = slide.querySelector('img') as HTMLImageElement; // Frigate >= 0.9.0+ clips. const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & { url: string; }; if (img) { img.src = this._canonicalizeHAURL(resolvedMedia.url) || ''; } else if (hls_player) { hls_player.url = this._canonicalizeHAURL(resolvedMedia.url) || ''; } }); } /** * Get slides to include in the render. * @returns The slides to include in the render and an index keyed by slide * number that maps to child number. */ protected _getSlides(): [TemplateResult[], Record] { if ( !this.view || !this.view.target || !this.view.target.children || !this.view.target.children.length ) { return [[], {}]; } const slideToChild: Record = {}; const slides: TemplateResult[] = []; for (let i = 0; i < this.view.target.children?.length; ++i) { const slide = this._renderMediaItem(this.view.target.children[i], slides.length); if (slide) { slideToChild[slides.length] = i; slides.push(slide); } } return [slides, slideToChild]; } /** * Determine if all the media in the carousel are resolved. */ protected _isMediaFullyResolved(): boolean { for (const child of this.view?.target?.children || []) { if (!this.resolvedMediaCache?.has(child.media_content_id)) { return false; } } return true; } /** * 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); } } /** * Render the element, resolving the media first if necessary. */ protected render(): TemplateResult | void { this._slideToChild = {}; // If lazy loading is not enabled, wait for the media resolver task to // complete and show a progress indictator until this. if (!this.viewerConfig?.lazy_load && !this._isMediaFullyResolved()) { return renderTask(this, this._mediaResolutionTask, this._render.bind(this)); } return this._render(); } /** * Render the element. * @returns A template to display to the user. */ protected _render(): TemplateResult | void { const [slides, slideToChild] = this._getSlides(); this._slideToChild = slideToChild; if (!slides.length || !this.view?.media) { return; } const neighbors = this._getMediaNeighbors(); const [prev, next] = [neighbors?.previous, neighbors?.next]; // Notes on the below: // - guard() is used to avoid reseting the carousel unless the // options/plugins actually change. return html` { this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollPrevious(); stopEventFromActivatingCardWideActions(ev); }} > ${slides} { this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext(); stopEventFromActivatingCardWideActions(ev); }} > `; } /** * Fire a media show event when a slide is selected. */ protected _recordingSeekHandler(): void { const player = this._getPlayer(); const childIndex = this.view?.childIndex ?? null; const seek = childIndex !== null ? this.view?.context?.mediaViewer?.seek.get(childIndex) : null; if (player && seek) { player.seek(seek.seekSeconds); } } /** * Render a single media item in the viewer carousel. * @param mediaToRender The FrigateBrowseMediaSource to render. * @param slideIndex The index of the slide to render. * @returns A rendered template. */ protected _renderMediaItem( mediaToRender: FrigateBrowseMediaSource, slideIndex: number, ): TemplateResult | void { // Skip folders as they cannot be rendered by this viewer. if ( !this.hass || !this.view || !this.viewerConfig || !isTrueMedia(mediaToRender) || !['video', 'image'].includes(mediaToRender.media_content_type) ) { return; } const lazyLoad = this.viewerConfig.lazy_load; const resolvedMedia = this.resolvedMediaCache?.get(mediaToRender.media_content_id); if (!resolvedMedia && !lazyLoad) { return; } // The media is attached to the player as '.media' which is used in // `_selectSlideMediaShowHandler` (and not used by the player itself). return html`
${mediaToRender.media_content_type === 'video' ? html`) => { wrapMediaLoadedEventForCarousel(slideIndex, e); }} > ` : html` { if ( this._refMediaCarousel.value ?.frigateCardCarousel() ?.carouselClickAllowed() ) { this._findRelatedClipView(mediaToRender).then((view) => { if (view) { view.dispatchChangeEvent(this); } }); } }} @load="${(e: Event) => { const lazyloadPlugin = this._refMediaCarousel.value ?.frigateCardCarousel() ?.getCarouselPlugins()?.lazyload; if ( // This handler will be called on the empty image (including // an updated empty image that is the same dimensions large as // the previously fully loaded image -- see the note on dummy // images in media-carousel.ts). Here we need to only call the // media load handler on a 'real' load. !lazyLoad || lazyloadPlugin?.hasLazyloaded(slideIndex) ) { wrapRawMediaLoadedEventForCarousel(slideIndex, e); } }}" />`}
`; } /** * Get element styles. */ static get styles(): CSSResultGroup { return unsafeCSS(viewerCarouselStyle); } } declare global { interface HTMLElementTagNameMap { 'frigate-card-viewer-carousel': FrigateCardViewerCarousel; 'frigate-card-viewer': FrigateCardViewer; } }