From fd8cdf21a0bff217505c80846665bd60b68e2a51 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 30 Dec 2021 17:11:43 -0800 Subject: [PATCH] Significant rework in how events are updated from components. --- package.json | 1 + src/browse-media-util.ts | 9 +- src/card.ts | 2 +- src/common.ts | 2 +- src/components/carousel.ts | 23 +- src/components/live.ts | 88 ++++-- src/components/media-carousel.ts | 30 +- src/components/thumbnail-carousel.ts | 7 +- src/components/viewer.ts | 412 ++++++++++++++++----------- src/localize/languages/en.json | 2 +- src/view.ts | 32 ++- 11 files changed, 375 insertions(+), 233 deletions(-) diff --git a/package.json b/package.json index 79b82e46..24b47921 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "license": "MIT", "dependencies": { "@cycjimmy/jsmpeg-player": "^5.0.1", + "@lit-labs/task": "^1.0.0", "@material/image-list": "^12.0.0", "@material/mwc-menu": "^0.25.3", "@material/rtl": "^13.0.0", diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index 62124a0a..84007d30 100644 --- a/src/browse-media-util.ts +++ b/src/browse-media-util.ts @@ -77,12 +77,9 @@ export class BrowseMediaUtil { * @returns A BrowseMediaSource object or null on malformed. */ static async browseMedia( - hass: (HomeAssistant & ExtendedHomeAssistant) | null, + hass: HomeAssistant & ExtendedHomeAssistant, media_content_id: string, - ): Promise { - if (!hass) { - return null; - } + ): Promise { const request = { type: 'media_source/browse_media', media_content_id: media_content_id, @@ -101,7 +98,7 @@ export class BrowseMediaUtil { static async browseMediaQuery( hass: HomeAssistant & ExtendedHomeAssistant, params: BrowseMediaQueryParameters, - ): Promise { + ): Promise { return this.browseMedia( hass, // Defined in: diff --git a/src/card.ts b/src/card.ts index ba943e93..5470f9eb 100644 --- a/src/card.ts +++ b/src/card.ts @@ -600,7 +600,7 @@ export class FrigateCard extends LitElement { } protected _changeView(args?: { view?: View; resetMessage?: boolean }): void { - console.info(`Request to change view: ${JSON.stringify(args?.view)}`) + console.info(`Request to change view: ${JSON.stringify(args?.view?.view)}`) if (args?.resetMessage ?? true) { this._message = null; diff --git a/src/common.ts b/src/common.ts index 05dc3c67..ed09ecb4 100644 --- a/src/common.ts +++ b/src/common.ts @@ -42,7 +42,7 @@ export async function homeAssistantWSRequest( hass: HomeAssistant & ExtendedHomeAssistant, schema: ZodSchema, request: MessageBase, -): Promise { +): Promise { const response = await hass.callWS(request); if (!response) { diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 41a43264..c6febeda 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -35,24 +35,13 @@ export class FrigateCardCarousel extends LitElement { updated(changedProperties: PropertyValues): void { super.updated(changedProperties); - if (this._shouldInitCarousel(changedProperties)) { + if (!this._carousel) { this.updateComplete.then(() => { this._initCarousel(); }); } } - /** - * Whether or not the carousel should be (re-)initialized when the given - * properties change. - * @param changedProperties The properties that triggered the (re-)render. - * @returns - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected _shouldInitCarousel(_: PropertyValues): boolean { - return true; - } - /** * Get the Embla options to use. * @returns An EmblaOptionsType object or undefined for no options. @@ -61,6 +50,13 @@ export class FrigateCardCarousel extends LitElement { return undefined; } + protected _destroyCarousel(): void { + if (this._carousel) { + this._carousel.destroy(); + } + this._carousel = undefined; + } + /** * Load the carousel with "slides". */ @@ -70,9 +66,6 @@ export class FrigateCardCarousel extends LitElement { ) as HTMLElement; if (carouselNode) { - if (this._carousel) { - this._carousel.destroy(); - } this._carousel = EmblaCarousel(carouselNode, this._getOptions()); this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init')); this._carousel.on('select', () => { diff --git a/src/components/live.ts b/src/components/live.ts index 407be964..d3f0180f 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -1,6 +1,7 @@ // TODO Live scrolling nav buttons disappearing -// TODO conditional elements based on camera name (requires event changed to propagate upwards) -// TODO _shouldInitCarousel on viewer carousel and thumbnail carousel. +// TODO Media loading events appear wrong for snapshot viewer +// TODO can I do away with clip/snapshot-specific? +// TODO Live carousel chevron/icons style // TODO call change-event in viewer // TODO editor for live lazy loading // TODO different live configs per camera @@ -72,7 +73,7 @@ export class FrigateCardLive extends LitElement { protected hass?: HomeAssistant & ExtendedHomeAssistant; @property({ attribute: false }) - protected view?: View; + protected view?: Readonly; @property({ attribute: false }) protected cameras?: Map; @@ -207,7 +208,7 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { protected hass?: HomeAssistant & ExtendedHomeAssistant; @property({ attribute: false }) - protected view?: View; + protected view?: Readonly; @property({ attribute: false }) protected cameras?: Map; @@ -215,19 +216,34 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { @property({ attribute: false }) protected liveConfig?: LiveConfig; + // Index between camera name and slide number. + protected _cameraToSlide: Record = {}; + /** - * Whether or not the carousel should be (re-)initialized when the given - * properties change. - * @param changedProperties The properties that triggered the (re-)render. - * @returns Whether to re-initialize the carousel. + * The updated lifecycle callback for this element. + * @param changedProperties The properties that were changed in this render. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected _shouldInitCarousel(changedProps: PropertyValues): boolean { - // These are the only properties that would cause new cameras or changed - // dimensions. Don't allow other properties to re-initialize the carousel as - // it's a jarring experience to the user (and 'view' is itself set as a - // result of a carousel move). - return changedProps.has('cameras') || changedProps.has('liveConfig'); + updated(changedProperties: PropertyValues): void { + if (changedProperties.has('cameras') || changedProperties.has('liveConfig')) { + this._destroyCarousel(); + } + + if (changedProperties.has('view')) { + const oldView = changedProperties.get('view') as View | undefined; + if ( + this._carousel && + oldView && + this.view?.camera && + this.view?.camera != oldView.camera + ) { + const slide: number | undefined = this._cameraToSlide[this.view.camera]; + if (slide !== undefined && slide !== this.carouselSelected()) { + this.carouselScrollTo(slide); + } + } + } + + super.updated(changedProperties); } /** @@ -260,15 +276,25 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { /** * Get slides to include in the render. - * @returns The slides to include in the render. + * @returns The slides to include in the render and an index keyed by camera + * name to slide number. */ - protected _getSlides(): TemplateResult[] { + protected _getSlides(): [TemplateResult[], Record] { if (!this.cameras) { - return []; + return [[], {}]; } - return Array.from(this.cameras.values()).map((cameraConfig, index) => { - return this._renderLive(cameraConfig, index); - }); + + const slides: TemplateResult[] = []; + const cameraToSlide: Record = {}; + + for (const [key, value] of this.cameras) { + const slide = this._renderLive(value, slides.length); + if (slide) { + cameraToSlide[key] = slides.length; + slides.push(slide); + } + } + return [slides, cameraToSlide]; } /** @@ -280,17 +306,20 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { } const selectedSnap = this._carousel.selectedScrollSnap(); - const newView = this.view.clone(); - newView.camera = Array.from(this.cameras.keys())[selectedSnap]; - newView.previous = this.view; - newView.dispatchChangeEvent(this); + this.view + .evolve({ + camera: Array.from(this.cameras.keys())[selectedSnap], + previous: this.view, + }) + .dispatchChangeEvent(this); } /** * Lazy load a slide. - * @param _slide The slide to lazy load. + * @param _index The slide number to lazy load. + * @param slide The slide to lazy load. */ - protected _lazyLoadSlide(slide: HTMLElement): void { + protected _lazyLoadSlide(_index: number, slide: HTMLElement): void { const liveProvider = slide.querySelector( 'frigate-card-live-provider', ) as FrigateCardLiveProvider; @@ -306,7 +335,7 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { .hass=${this.hass} .cameraConfig=${cameraConfig} .liveConfig=${this.liveConfig} - ?disabled=${this._getLazyLoadCount() != null} + ?disabled=${this._isLazyLoading()} @frigate-card:media-show=${(e: CustomEvent) => this._mediaShowEventHandler(slideIndex, e)} > @@ -365,7 +394,8 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { * @returns A template to display to the user. */ protected render(): TemplateResult | void { - const slides = this._getSlides(); + const [slides, cameraToSlide] = this._getSlides(); + this._cameraToSlide = cameraToSlide; if (!slides) { return; } diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index 2959f2b6..e5109790 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -43,6 +43,29 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { return 0; } + /** + * Determine if lazy loading is being used. + * @returns `true` is lazy loading is in use. + */ + protected _isLazyLoading(): boolean { + return this._getLazyLoadCount() !== null; + } + + protected _destroyCarousel(): void { + super._destroyCarousel(); + + // Notes on instance variables: + // * this._mediaShowInfo: This is set when the media in the DOM loads. If a + // new View included the same media, the DOM would not change and so the + // prior contents would still be valid and would not re-appear (as the + // media would not reload) -- as such, leave this alone on carousel + // destroy. New media in that slide will replace the prior contents on + // load. + // * this._slideHasBeenLazyLoaded: This is a performance optimization and + // can be safely reset. + this._slideHasBeenLazyLoaded = {}; + } + /** * Initializse the carousel with "slides" (clips or snapshots). */ @@ -158,16 +181,17 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { return; } this._slideHasBeenLazyLoaded[index] = true; - this._lazyLoadSlide(slides[index]); + this._lazyLoadSlide(index, slides[index]); }); } /** * 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(_slide: HTMLElement): void { + protected _lazyLoadSlide(_index: number, _slide: HTMLElement): void { // To be overridden in children. } @@ -213,7 +237,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { mediaShowInfo?: MediaShowInfo | null, ): void { // isValidMediaShowInfo is used to prevent saving media info that will be - // rejected upstream. + // rejected upstream (empty 1x1 images will be rejected here). if (mediaShowInfo && isValidMediaShowInfo(mediaShowInfo)) { this._mediaShowInfo[slideIndex] = mediaShowInfo; if (this._carousel && this._carousel?.slidesInView(true).includes(slideIndex)) { diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index fce95c99..62139d7a 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -5,7 +5,6 @@ import { customElement, property } from 'lit/decorators.js'; import type { BrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; import { FrigateCardCarousel } from './carousel.js'; -import { actionHandler } from '../action-handler-directive.js'; import { dispatchFrigateCardEvent } from '../common.js'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; @@ -112,11 +111,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { return html`
{ + @click=${() => { if (this._carousel && this._carousel.clickAllowed()) { dispatchFrigateCardEvent(this, 'carousel:tap', { slideIndex: slideIndex, diff --git a/src/components/viewer.ts b/src/components/viewer.ts index eeaea83b..30aed27d 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -1,11 +1,18 @@ -import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { + CSSResultGroup, + LitElement, + PropertyValues, + TemplateResult, + html, + unsafeCSS, +} from 'lit'; import { BrowseMediaUtil } from '../browse-media-util.js'; import { EmblaOptionsType } from 'embla-carousel'; import { HomeAssistant } from 'custom-card-helpers'; +import { Task } from '@lit-labs/task'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { until } from 'lit/directives/until.js'; import type { BrowseMediaNeighbors, @@ -17,16 +24,16 @@ import type { } from '../types.js'; import { FrigateCardMediaCarousel, IMG_EMPTY } from './media-carousel.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js'; -import { FrigateCardThumbnailCarousel, ThumbnailCarouselTap } from './thumbnail-carousel.js'; +import { + FrigateCardThumbnailCarousel, + ThumbnailCarouselTap, +} from './thumbnail-carousel.js'; import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js'; import { View } from '../view.js'; -import { actionHandler } from '../action-handler-directive.js'; import { createMediaShowInfo, dispatchErrorMessageEvent, dispatchMessageEvent, - dispatchPauseEvent, - dispatchPlayEvent, } from '../common.js'; import { localize } from '../localize/localize.js'; import { renderProgressIndicator } from '../components/message.js'; @@ -42,7 +49,7 @@ export class FrigateCardViewer extends LitElement { protected hass?: HomeAssistant & ExtendedHomeAssistant; @property({ attribute: false }) - protected view?: View; + protected view?: Readonly; @property({ attribute: false }) protected viewerConfig?: ViewerConfig; @@ -54,28 +61,41 @@ export class FrigateCardViewer extends LitElement { protected resolvedMediaCache?: ResolvedMediaCache; /** - * Resolve all the given media for a target. - * @param target The target to resolve media from. - * @returns True if the resolutions were all error free. + * Asyncronously render the element. + * @returns A rendered template. */ - protected async _resolveAllMediaForTarget( - target: BrowseMediaSource, - ): Promise { - if (!this.hass) { - return false; + protected async _fetchLatestMedia(): Promise { + if (!this.view || !this.hass || !this.browseMediaQueryParameters) { + return; + } + let parent: BrowseMediaSource | null; + try { + parent = await BrowseMediaUtil.browseMediaQuery( + this.hass, + this.browseMediaQueryParameters, + ); + } catch (e) { + return dispatchErrorMessageEvent(this, (e as Error).message); + } + const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent); + if (!parent || !parent.children || childIndex == null) { + return dispatchMessageEvent( + this, + this.browseMediaQueryParameters.mediaType == 'clips' + ? localize('common.no_clip') + : localize('common.no_snapshot'), + this.browseMediaQueryParameters.mediaType == 'clips' + ? 'mdi:filmstrip-off' + : 'mdi:camera-off', + ); } - let errorFree = true; - for (let i = 0; target.children && i < (target.children || []).length; ++i) { - if (BrowseMediaUtil.isTrueMedia(target.children[i])) { - errorFree &&= !!(await ResolvedMediaUtil.resolveMedia( - this.hass, - target.children[i], - this.resolvedMediaCache, - )); - } - } - return errorFree; + this.view + .evolve({ + target: parent, + childIndex: childIndex, + }) + .dispatchChangeEvent(this); } /** @@ -83,44 +103,13 @@ export class FrigateCardViewer extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - return html`${until(this._render(), renderProgressIndicator())}`; - } - - /** - * Asyncronously render the element. - * @returns A rendered template. - */ - protected async _render(): Promise { if (!this.hass || !this.view || !this.browseMediaQueryParameters) { - return html``; + return; } - if (this.view.is('clip') || this.view.is('snapshot')) { - let parent: BrowseMediaSource | null = null; - try { - parent = await BrowseMediaUtil.browseMediaQuery( - this.hass, - this.browseMediaQueryParameters, - ); - } catch (e) { - return dispatchErrorMessageEvent(this, (e as Error).message); - } - const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent); - if (!parent || !parent.children || childIndex == null) { - return dispatchMessageEvent( - this, - this.view.is('clip') - ? localize('common.no_clip') - : localize('common.no_snapshot'), - this.view.is('clip') ? 'mdi:filmstrip-off' : 'mdi:camera-off', - ); - } - this.view.target = parent; - this.view.childIndex = childIndex; - } - - if (this.view.target && !(await this._resolveAllMediaForTarget(this.view.target))) { - return dispatchErrorMessageEvent(this, localize('error.could_not_resolve')); + if (!this.view.target) { + this._fetchLatestMedia(); + return renderProgressIndicator(); } return html` ; @property({ attribute: false }) protected viewerConfig?: ViewerConfig; @@ -222,7 +211,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { protected hass?: HomeAssistant & ExtendedHomeAssistant; @property({ attribute: false }) - protected view?: View; + protected view?: Readonly; @property({ attribute: false }) protected viewerConfig?: ViewerConfig; @@ -237,20 +226,86 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { // (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<[BrowseMediaSource | undefined], void>( + this, + async ([target]: (BrowseMediaSource | undefined)[]): Promise => { + for ( + let i = 0; + !this._isLazyLoading() && + this.hass && + target && + target.children && + i < (target.children || []).length; + ++i + ) { + if (BrowseMediaUtil.isTrueMedia(target.children[i])) { + await ResolvedMediaUtil.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 { + if (changedProperties.has('viewerConfig')) { + this._destroyCarousel(); + } + + if (changedProperties.has('view')) { + const oldView = changedProperties.get('view') as View | undefined; + if (oldView) { + if (oldView.target != this.view?.target) { + // If the media target is different entirely, reset the carousel. + this._destroyCarousel(); + } else if (this._carousel && this.view?.childIndex != oldView.childIndex) { + const slide = this._getSlideForChild(this.view?.childIndex); + if (slide !== undefined && slide !== this.carouselSelected()) { + // If the media target is the same as already loaded, but isn't of + // the selected slide, scroll to that slide. + this.carouselScrollTo(slide); + } + } + } + } + + super.updated(changedProperties); + } + + protected _destroyCarousel(): void { + super._destroyCarousel(); + + // Notes on instance variables: + // * this._slideToChild: This is set as part of each render and does not + // need to be destroyed here. + } + + protected _getSlideForChild(childIndex: number | undefined): number | undefined { + if (childIndex === undefined) { + return undefined; + } + const slideIndex = Object.keys(this._slideToChild).find( + (key) => this._slideToChild[key] === childIndex, + ); + return slideIndex !== undefined ? Number(slideIndex) : undefined; + } + /** * Get the Embla options to use. * @returns An EmblaOptionsType object or undefined for no options. */ - protected _getOptions(): EmblaOptionsType { - // Start the carousel on the selected child number. - const startIndex = Number( - Object.keys(this._slideToChild).find( - (key) => this._slideToChild[key] === this.view?.childIndex, - ), - ); - + protected _getOptions(): EmblaOptionsType { return { - startIndex: isNaN(startIndex) ? undefined : startIndex, + // Start the carousel on the selected child number. + startIndex: this._getSlideForChild(this.view?.childIndex), draggable: this.viewerConfig?.draggable, }; } @@ -262,7 +317,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { * should load simultaneously. * @returns */ - protected _getLazyLoadCount(): number | null { + protected _getLazyLoadCount(): number | null { // Defaults to fully-lazy loading. return this.viewerConfig?.lazy_load === false ? null : 0; } @@ -414,54 +469,77 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { if (slidesInView.length) { const childIndex = this._slideToChild[slidesInView[0]]; if (childIndex !== undefined) { - // Update the currently live view in place. - this.view.childIndex = childIndex; + this.view + .evolve({ + childIndex: childIndex, + previous: this.view, + }) + .dispatchChangeEvent(this); } } } - /** + /** * Lazy load a slide. + * @param index The index of the slide to lazy load. * @param slide The slide to lazy load. */ - protected _lazyLoadSlide(slide: HTMLElement): void { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected _lazyLoadSlide(index: number, slide: HTMLElement): void { + const childIndex: number | undefined = this._slideToChild[index]; - // Snapshots. - const img = slide.querySelector('img') as HTMLImageElement; - - // Frigate >= 0.9.0+ clips. - const hls_player = slide.querySelector( - 'frigate-card-ha-hls-player', - ) as HTMLElement & { url: string }; - - // Frigate < 0.9.0 clips. frigate-card-ha-hls-player will also have a - // video source element, so search for that first. - const video_source = slide.querySelector('video source') as HTMLElement & { - src: string; - }; - - if (img) { - img.src = img.getAttribute('data-src') || img.src; - } else if (hls_player) { - hls_player.url = hls_player.getAttribute('data-url') || hls_player.url; - } else if (video_source) { - video_source.src = video_source.getAttribute('data-src') || video_source.src; + if ( + childIndex == undefined || + !this.hass || + !this.view || + !this.view.target || + !this.view.target.children || + !BrowseMediaUtil.isTrueMedia(this.view.target.children[childIndex]) + ) { + return; } + + ResolvedMediaUtil.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 = slide.querySelector( + 'frigate-card-ha-hls-player', + ) as HTMLElement & { url: string }; + + if (img) { + img.src = resolvedMedia.url; + } else if (hls_player) { + hls_player.url = resolvedMedia.url; + } + }); } /** * Handle updating of the next/previous controls when the carousel is moved. */ protected _selectSlideNextPreviousHandler(): void { - const updateNextPreviousControl = (control: FrigateCardNextPreviousControl, direction: 'previous' | 'next'): void => { + const updateNextPreviousControl = ( + control: FrigateCardNextPreviousControl, + direction: 'previous' | 'next', + ): void => { const neighbors = this._getMediaNeighbors(); - const [prev, next] = [neighbors?.previous, neighbors?.next] + const [prev, next] = [neighbors?.previous, neighbors?.next]; const target = direction == 'previous' ? prev : next; - - control.disabled = (target == null) - control.title = (target && target.title ? target.title : '') - control.thumbnail = (target && target.thumbnail ? target.thumbnail : undefined) - } + + control.disabled = target == null; + control.title = target && target.title ? target.title : ''; + control.thumbnail = target && target.thumbnail ? target.thumbnail : undefined; + }; if (this._previousControlRef.value) { updateNextPreviousControl(this._previousControlRef.value, 'previous'); @@ -473,43 +551,76 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { /** * Get slides to include in the render. - * @returns The 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[] { + protected _getSlides(): [TemplateResult[], Record] { if ( !this.view || !this.view.target || !this.view.target.children || !this.view.target.children.length ) { - return []; + return [[], {}]; } - this._slideToChild = {}; + 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) { - this._slideToChild[slides.length] = i; + slideToChild[slides.length] = i; slides.push(slide); } } - return slides; + 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; + } + + /** + * 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._isLazyLoading() && !this._isMediaFullyResolved()) { + return html`${this._mediaResolutionTask.render({ + initial: () => renderProgressIndicator(), + pending: () => renderProgressIndicator(), + error: (e: unknown) => dispatchErrorMessageEvent(this, (e as Error).message), + complete: () => this._render(), + })}`; + } + return this._render(); } /** * Render the element. * @returns A template to display to the user. */ - protected render(): TemplateResult | void { - const slides = this._getSlides(); + protected _render(): TemplateResult | void { + const [slides, slideToChild] = this._getSlides(); + this._slideToChild = slideToChild; if (!slides) { return; } const neighbors = this._getMediaNeighbors(); - const [prev, next] = [neighbors?.previous, neighbors?.next] + const [prev, next] = [neighbors?.previous, neighbors?.next]; return html`
`; } - protected _renderMediaItem( mediaToRender: BrowseMediaSource, slideIndex: number, ): TemplateResult | void { - // media that can be expanded (folders) cannot be resolved to a single media - // item, skip them. + // Skip folders as they cannot be rendered by this viewer. if ( !this.view || !this.viewerConfig || @@ -555,8 +664,9 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { return; } + const lazyLoad = this._isLazyLoading(); const resolvedMedia = this.resolvedMediaCache?.get(mediaToRender.media_content_id); - if (!resolvedMedia) { + if (!resolvedMedia && !lazyLoad) { return; } @@ -570,53 +680,26 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { autoplay = this.viewerConfig.autoplay_clip; } - const lazyLoad = (this._getLazyLoadCount() !== null); - return html`
${this.view.isClipRelatedView() - ? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl' - ? html`) => - this._mediaShowEventHandler(slideIndex, e)} - > - ` - : html`` - : html` { + muted + controls + playsinline + allow-exoplayer + ?autoplay="${autoplay}" + @frigate-card:media-show=${(e: CustomEvent) => + this._mediaShowEventHandler(slideIndex, e)} + > + ` + : html` { if (this._carousel?.clickAllowed()) { this._findRelatedClipView(mediaToRender).then((view) => { if (view) { @@ -626,14 +709,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { } }} @load="${(e: Event) => { - if ( - this.viewerConfig && - // This handler will be called on the empty image, only call - // the below when it's the 'real image'. - (!lazyLoad || this._slideHasBeenLazyLoaded[slideIndex]) - ) { - this._mediaLoadedHandler(slideIndex, createMediaShowInfo(e)); - } + this._mediaLoadedHandler(slideIndex, createMediaShowInfo(e)); }}" />`}
diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 7a8ed5b1..715cce1e 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -91,7 +91,7 @@ "frigate_ui": "Frigate user Interface", "fullscreen": "Fullscreen", "download": "Download event media", - "cameras": "Camera selection" + "cameras": "Select camera" }, "mode": "Menu mode", "modes": { diff --git a/src/view.ts b/src/view.ts index 4843d28f..a0650b37 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,14 +1,19 @@ import type { BrowseMediaSource, FrigateCardView } from './types.js'; import { dispatchFrigateCardEvent } from './common.js'; -export interface ViewParameters { - view: FrigateCardView; - camera: string; +export interface ViewEvolveParameters { + view?: FrigateCardView; + camera?: string; target?: BrowseMediaSource; childIndex?: number; previous?: View; } +export interface ViewParameters extends ViewEvolveParameters { + view: FrigateCardView; + camera: string; +} + export class View { view: FrigateCardView; camera: string; @@ -24,6 +29,9 @@ export class View { this.previous = params?.previous; } + /** + * Clone a view. + */ public clone(): View { return new View({ view: this.view, @@ -34,6 +42,24 @@ export class View { }); } + /** + * Evolve this view by changing parameters and returning a new view. + * @param params Parameters to change. + * @returns A new evolved view. + */ + public evolve(params: ViewEvolveParameters): View { + return new View({ + view: params.view ?? this.view, + camera: params.camera ?? this.camera, + target: params.target ?? this.target, + childIndex: params.childIndex ?? this.childIndex, + previous: params.previous ?? this.previous, + }) + } + + /** + * Determine if current view matches a named view. + */ public is(name: string): boolean { return this.view == name; }