From 6010d7b1e16f00971ea62255f0515bcd8d4993b5 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Jul 2022 10:54:07 -0700 Subject: [PATCH] Improve reinit/resize handling. --- src/components/carousel.ts | 88 +++++++++++++++++++++------- src/components/live.ts | 7 +-- src/components/media-carousel.ts | 59 ++++++++++--------- src/components/thumbnail-carousel.ts | 8 +-- src/components/viewer.ts | 9 ++- 5 files changed, 109 insertions(+), 62 deletions(-) diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 0e3a697a..0cb8fe37 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -15,12 +15,14 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { throttle } from 'lodash-es'; import carouselStyle from '../scss/carousel.scss'; import { TransitionEffect } from '../types'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; export interface CarouselSelect { index: number; + element: HTMLElement; } export type EmblaCarouselPlugins = CreatePluginType< @@ -46,6 +48,18 @@ export class FrigateCardCarousel extends LitElement { protected _carousel?: EmblaCarouselType; + // Whether the carousel is actively scrolling. + protected _scrolling = false; + + // Whether to reinit the carousel when it settles. + protected _reInitOnSettle = false; + + protected _carouselReInitInPlace = throttle( + this._carouselReInitInPlaceInternal.bind(this), + 500, + { trailing: true }, + ); + connectedCallback(): void { super.connectedCallback(); @@ -104,20 +118,16 @@ export class FrigateCardCarousel extends LitElement { /** * Get the selected slide. - * @returns The slide index or undefined if the carousel is not loaded. + * @returns A CarouselSelect object (index & element). */ - public carouselSelected(): number | undefined { - return this._carousel?.selectedScrollSnap(); - } - - /** - * Get the selected node. - * @returns The slide index or undefined if the carousel is not loaded. - */ - public carouselSelectedElement(): HTMLElement | null { - const selected = this._carousel?.selectedScrollSnap(); - if (selected !== undefined) { - return this._carousel?.slideNodes()[selected] ?? null; + public getCarouselSelected(): CarouselSelect | null { + const index = this._carousel?.selectedScrollSnap(); + const element = index !== undefined ? (this._carousel?.slideNodes()[index] ?? null) : null; + if (index !== undefined && element) { + return { + index: index, + element: element, + } } return null; } @@ -139,10 +149,38 @@ export class FrigateCardCarousel extends LitElement { /** * ReInit the carousel. */ - public carouselReInit(): void { + protected _carouselReInit(options?: EmblaOptionsType): void { + window.requestAnimationFrame(() => { + // Safari appears to not loop the carousel unless the options are passed + // back in during re-initialization. + this._carousel?.reInit({ ...this.carouselOptions, ...options }); + }); + } + /** + * ReInit the carousel but stay on the current slide. + */ + protected _carouselReInitInPlaceInternal(): void { + const selected = this.getCarouselSelected(); + // Safari appears to not loop the carousel unless the options are passed // back in during re-initialization. - return this._carousel?.reInit(this.carouselOptions); + const options = { + ...this.carouselOptions, + ...(selected && { startIndex: selected.index }), + }; + this._carouselReInit(options); + } + + /** + * ReInit the carousel when it is safe to do so without disturbing the + * appearance (i.e. cutting off a scroll in progress). + */ + public carouselReInitWhenSafe(): void { + if (this._scrolling) { + this._reInitOnSettle = true; + } else { + this._carouselReInitInPlace(); + } } /** @@ -196,23 +234,33 @@ export class FrigateCardCarousel extends LitElement { nodes, { axis: this.direction == 'horizontal' ? 'x' : 'y', + speed: 20, ...this.carouselOptions, }, this.carouselPlugins, ); this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init')); this._carousel.on('select', () => { - const selected = this.carouselSelected(); - if (selected !== undefined) { - dispatchFrigateCardEvent(this, 'carousel:select', { - index: selected, - }); + const selected = this.getCarouselSelected(); + if (selected) { + dispatchFrigateCardEvent(this, 'carousel:select', selected); } // Make sure every select causes a refresh to allow for re-paint of the // next/previous controls. this.requestUpdate(); }); + + this._carousel.on('scroll', () => { + this._scrolling = true; + }); + this._carousel.on('settle', () => { + this._scrolling = false; + if (this._reInitOnSettle) { + this._reInitOnSettle = false; + this._carouselReInitInPlace(); + } + }); } } diff --git a/src/components/live.ts b/src/components/live.ts index 2445190a..d9a6c5ae 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -235,7 +235,7 @@ export class FrigateCardLiveCarousel extends LitElement { this.view?.camera != oldView.camera ) { const slide: number | undefined = this._cameraToSlide[this.view.camera]; - if (slide !== undefined && slide !== frigateCardCarousel.carouselSelected()) { + if (slide !== undefined && slide !== frigateCardCarousel.getCarouselSelected()?.index) { frigateCardCarousel.carouselScrollTo(slide); } } @@ -310,8 +310,6 @@ export class FrigateCardLiveCarousel extends LitElement { lazyUnloadCallback: (index, slide) => this._lazyloadOrUnloadSlide('unload', index, slide), }), - - // TODO: AutoMediaPlugin could be moved to MediaCarousel. AutoMediaPlugin({ playerSelector: 'frigate-card-live-provider', ...(this.liveConfig?.auto_play && { @@ -371,7 +369,8 @@ export class FrigateCardLiveCarousel extends LitElement { protected _setViewHandler(): void { const selectedCameraIndex = this._refMediaCarousel.value ?.frigateCardCarousel() - ?.carouselSelected(); + ?.getCarouselSelected() + ?.index; if (selectedCameraIndex === undefined || !this.view || !this.cameras) { return; } diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index b5bab45c..42f9e0bb 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -18,7 +18,7 @@ import { dispatchExistingMediaShowInfoAsEvent, isValidMediaShowInfo, } from '../utils/media-info.js'; -import { EmblaCarouselPlugins, FrigateCardCarousel } from './carousel'; +import { CarouselSelect, EmblaCarouselPlugins, FrigateCardCarousel } from './carousel'; import { AutoMediaType } from './embla-plugins/automedia.js'; import './next-prev-control.js'; import './carousel.js'; @@ -110,20 +110,25 @@ export class FrigateCardMediaCarousel extends LitElement { protected _boundAutoPlayHandler = this.autoPlay.bind(this); protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this); - protected _boundAdaptiveHeightHandler = this._adaptiveHeightHandler.bind(this); + protected _boundAdaptiveHeightHandler = this._adaptHeightToMedia.bind(this); protected _boundTitleHandler = this._titleHandler.bind(this); // This carousel may be resized by Lovelace resizes, window resizes, // fullscreen, etc. Always call the adaptive height handler when the size // changes. protected _resizeObserver: ResizeObserver; + protected _slideResizeObserver: ResizeObserver; protected _intersectionObserver: IntersectionObserver; protected _refCarousel: Ref = createRef(); constructor() { super(); - this._resizeObserver = new ResizeObserver(this._adaptiveHeightHandler.bind(this)); + // Need to watch both changes in this element (e.g. caused by a window + // resize or fullscreen change) and changes in the selected slide itself + // (e.g. changing from a progress indicator to a loaded media). + this._resizeObserver = new ResizeObserver(this._reInitAndAdjustHeight.bind(this)); + this._slideResizeObserver = new ResizeObserver(this._reInitAndAdjustHeight.bind(this)); this._intersectionObserver = new IntersectionObserver( this._intersectionHandler.bind(this), ); @@ -252,6 +257,14 @@ export class FrigateCardMediaCarousel extends LitElement { super.disconnectedCallback(); } + /** + * ReInit the carousel and adapt the container height. + */ + protected _reInitAndAdjustHeight(): void { + this.frigateCardCarousel()?.carouselReInitWhenSafe(); + this._adaptHeightToMedia(); + } + /** * Called when the carousel intersects with the viewport. * @param entries The IntersectionObserverEntry entries (should be only 1). @@ -267,24 +280,8 @@ export class FrigateCardMediaCarousel extends LitElement { * - Example bug when this reinitialization is not performed: * https://github.com/dermotduffy/frigate-hass-card/issues/651 */ - - const reInit = (): void => { - // In some cases the carousel may need its height adjusted after the DOM is - // newly visible (e.g. a smaller camera live in preload mode, that becomes - // visible after switching from a larger camera snapshot). - this._adaptiveHeightHandler(); - this.frigateCardCarousel()?.carouselReInit(); - }; - if (entries.some((entry) => entry.isIntersecting)) { - // For performance, run the reinit in idle cycles if the browser supports - // it, but only give it 400ms before running as it may otherwise be - // noticeable to the user. - if (window.requestIdleCallback !== undefined) { - window.requestIdleCallback(reInit, { timeout: 400 }); - } else { - reInit(); - } + this._reInitAndAdjustHeight(); } } @@ -293,14 +290,16 @@ export class FrigateCardMediaCarousel extends LitElement { * have changed. This handler is not triggered from carousel events, as it's * actually the media load/show that will change the dimensions, and that is * async from carousel actions (e.g. lazy-loaded media). + * + * This component does not use the stock Embla auto-height plugin as it + * resizes the container on selection rather than media load. */ - protected _adaptiveHeightHandler(): void { + protected _adaptHeightToMedia(): void { const adaptCarouselHeight = (): void => { - const slide = this.frigateCardCarousel()?.carouselSelected(); - if (slide !== undefined) { + const selected = this.frigateCardCarousel()?.getCarouselSelected(); + if (selected) { this.style.removeProperty('max-height'); - const currentSlide = this.frigateCardCarousel()?.carouselSelectedElement(); - const height = currentSlide?.getBoundingClientRect().height; + const height = selected.element.getBoundingClientRect().height; if (height !== undefined && height > 0) { this.style.maxHeight = `${height}px`; } @@ -321,7 +320,7 @@ export class FrigateCardMediaCarousel extends LitElement { * Fire a media show event when a slide is selected. */ protected _dispatchMediaShowInfo(): void { - const slideIndex = this.frigateCardCarousel()?.carouselSelected(); + const slideIndex = this.frigateCardCarousel()?.getCarouselSelected()?.index; if (slideIndex !== undefined && slideIndex in this._mediaShowInfo) { dispatchExistingMediaShowInfoAsEvent(this, this._mediaShowInfo[slideIndex]); } @@ -344,7 +343,7 @@ export class FrigateCardMediaCarousel extends LitElement { // rejected upstream (empty 1x1 images will be rejected here). if (mediaShowInfo && isValidMediaShowInfo(mediaShowInfo)) { this._mediaShowInfo[slideIndex] = mediaShowInfo; - if (this.frigateCardCarousel()?.carouselSelected() === slideIndex) { + if (this.frigateCardCarousel()?.getCarouselSelected()?.index === slideIndex) { dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo); } } @@ -357,7 +356,11 @@ export class FrigateCardMediaCarousel extends LitElement { .carouselPlugins=${this.carouselPlugins} transitionEffect=${ifDefined(this.transitionEffect)} @frigate-card:carousel:init=${this._dispatchMediaShowInfo.bind(this)} - @frigate-card:carousel:select=${this._dispatchMediaShowInfo.bind(this)} + @frigate-card:carousel:select=${(ev: CustomEvent) => { + this._slideResizeObserver.disconnect(); + this._slideResizeObserver.observe(ev.detail.element); + this._dispatchMediaShowInfo(); + }} @frigate-card:carousel:media-show=${this._storeMediaShowInfo.bind(this)} > diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index b7795481..c8cb2f0c 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -80,13 +80,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { * Handle gallery resize. */ protected _resizeHandler(): void { - this._refCarousel.value?.carouselReInit(); - - // Reinit will cause the scroll position to reset, so re-scroll to the - // correct location. - if (this._selected !== null) { - this._refCarousel.value?.carouselScrollTo(this._selected); - } + this._refCarousel.value?.carouselReInitWhenSafe(); } /** diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 8a296d84..806bbd29 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -206,7 +206,10 @@ export class FrigateCardViewerCarousel extends LitElement { this.view.childIndex != oldView.childIndex ) { const slide = this._getSlideForChild(this.view.childIndex); - if (slide !== null && slide !== frigateCardCarousel.carouselSelected()) { + 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); @@ -265,7 +268,7 @@ export class FrigateCardViewerCarousel extends LitElement { if (!slide) { slide = this._refMediaCarousel.value ?.frigateCardCarousel() - ?.carouselSelectedElement(); + ?.getCarouselSelected()?.element; } return ( @@ -462,7 +465,7 @@ export class FrigateCardViewerCarousel extends LitElement { // Update the childIndex in the view. const selected = this._refMediaCarousel.value .frigateCardCarousel() - ?.carouselSelected(); + ?.getCarouselSelected()?.index; if (selected !== undefined) { const childIndex = this._slideToChild[selected]; if (childIndex !== undefined) {