perf: Improve performance of the carousel auto-height functionality (#2094)
This commit is contained in:
@@ -233,6 +233,7 @@ export class MediaActionsController {
|
||||
child.addEventListener('advanced-camera-card:media:loaded', eventListener);
|
||||
}
|
||||
}
|
||||
|
||||
protected async _intersectionHandler(
|
||||
entries: IntersectionObserverEntry[],
|
||||
): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { debounce, isEqual } from 'lodash-es';
|
||||
|
||||
export class MediaHeightController {
|
||||
private _host: HTMLElement;
|
||||
private _selector: string;
|
||||
|
||||
private _root: HTMLElement | DocumentFragment | null = null;
|
||||
private _children: HTMLElement[] = [];
|
||||
private _selectedChild: HTMLElement | null = null;
|
||||
|
||||
private _mutationObserver = new MutationObserver(() => this._initializeRoot());
|
||||
private _resizeObserver = new ResizeObserver(() => this._debouncedSetHeight());
|
||||
|
||||
private _debouncedSetHeight = debounce(
|
||||
() => this._setHeight(),
|
||||
// Balancing act: Debounce to avoid excessive calls to setHeight, when new
|
||||
// media is loading the player may be a much smaller height momentarily.
|
||||
300,
|
||||
{
|
||||
trailing: true,
|
||||
leading: false,
|
||||
},
|
||||
);
|
||||
|
||||
constructor(host: HTMLElement, selector: string) {
|
||||
this._host = host;
|
||||
this._selector = selector;
|
||||
}
|
||||
|
||||
public setRoot(root: HTMLElement | DocumentFragment): void {
|
||||
if (root === this._root) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._root = root;
|
||||
this._mutationObserver.disconnect();
|
||||
this._mutationObserver.observe(this._root, {
|
||||
childList: true,
|
||||
});
|
||||
this._initializeRoot();
|
||||
}
|
||||
|
||||
public setSelected(selectedIndex: number): void {
|
||||
const selectedChild: HTMLElement | undefined = this._children[selectedIndex];
|
||||
if (!selectedChild || selectedChild === this._selectedChild) {
|
||||
return;
|
||||
}
|
||||
this._selectedChild = selectedChild;
|
||||
|
||||
this._resizeObserver.disconnect();
|
||||
this._resizeObserver.observe(selectedChild);
|
||||
|
||||
this._debouncedSetHeight();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._mutationObserver.disconnect();
|
||||
this._resizeObserver.disconnect();
|
||||
|
||||
this._root = null;
|
||||
this._children = [];
|
||||
this._selectedChild = null;
|
||||
}
|
||||
|
||||
private _setHeight(): void {
|
||||
if (!this._selectedChild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalHeight = this._host.style.maxHeight;
|
||||
|
||||
// Remove the height restriction to ensure the full max height. Example of
|
||||
// behavior without this: Chrome on Android will not correctly size if the
|
||||
// card is in fullscreen mode.
|
||||
this._host.style.maxHeight = '';
|
||||
|
||||
// Calculate the true height.
|
||||
const selectedHeight = this._selectedChild.getBoundingClientRect().height;
|
||||
|
||||
// Reset the original height so that browser transition animation can be
|
||||
// applied from the current to the target.
|
||||
this._host.style.maxHeight = originalHeight;
|
||||
|
||||
// Force the browser to reflow.
|
||||
this._selectedChild.getBoundingClientRect();
|
||||
|
||||
if (selectedHeight && !isNaN(selectedHeight) && selectedHeight > 0) {
|
||||
this._host.style.maxHeight = `${selectedHeight}px`;
|
||||
}
|
||||
}
|
||||
|
||||
private _initializeRoot(): void {
|
||||
const children = [
|
||||
...(this._root?.querySelectorAll<HTMLElement>(this._selector) ??
|
||||
/* istanbul ignore next: this path cannot be reached as root will always
|
||||
exist by the time the mutation observer is observing -- @preserve */
|
||||
[]),
|
||||
];
|
||||
if (isEqual(children, this._children)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._children = children;
|
||||
this._selectedChild = null;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { CameraManagerCameraMetadata } from '../../camera-manager/types.js';
|
||||
import { MicrophoneState } from '../../card-controller/types.js';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
|
||||
import { MediaHeightController } from '../../components-lib/media-height-controller.js';
|
||||
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
|
||||
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
|
||||
import { CameraConfig } from '../../config/schema/cameras.js';
|
||||
@@ -25,7 +26,6 @@ import liveCarouselStyle from '../../scss/live-carousel.scss';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||
import { CarouselSelected } from '../../utils/embla/carousel-controller.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 { getStreamCameraID } from '../../utils/substream.js';
|
||||
import { getTextDirection } from '../../utils/text-direction.js';
|
||||
import { View } from '../../view/view.js';
|
||||
@@ -77,6 +77,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
protected _refCarousel: Ref<HTMLElement> = createRef();
|
||||
|
||||
protected _mediaActionsController = new MediaActionsController();
|
||||
protected _mediaHeightController = new MediaHeightController(this, '.embla__slide');
|
||||
|
||||
@state()
|
||||
protected _mediaHasLoaded = false;
|
||||
@@ -84,12 +85,15 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this._mediaHeightController.setRoot(this.renderRoot);
|
||||
|
||||
// Request update in order to reinitialize the media action controller.
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this._mediaActionsController.destroy();
|
||||
this._mediaHeightController.destroy();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -138,7 +142,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
}
|
||||
|
||||
protected _getPlugins(): EmblaCarouselPlugins {
|
||||
return [AutoMediaLoadedInfo(), AutoSize()];
|
||||
return [AutoMediaLoadedInfo()];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,6 +393,8 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
// Carousel is not filtered, so the targeted camera is always selected.
|
||||
this._mediaActionsController.setTarget(selectedCameraIndex, true);
|
||||
}
|
||||
|
||||
this._mediaHeightController.setSelected(selectedCameraIndex);
|
||||
}
|
||||
|
||||
public updated(changedProperties: PropertyValues): void {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { RemoveContextPropertyViewModifier } from '../../card-controller/view/modifiers/remove-context-property.js';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
|
||||
import { MediaHeightController } from '../../components-lib/media-height-controller.js';
|
||||
import { TransitionEffect } from '../../config/schema/common/transition-effect.js';
|
||||
import { CardWideConfig, configDefaults } from '../../config/schema/types.js';
|
||||
import { ViewerConfig } from '../../config/schema/viewer.js';
|
||||
@@ -26,7 +27,6 @@ import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||
import { contentsChanged, setOrRemoveAttribute } from '../../utils/basic.js';
|
||||
import { CarouselSelected } from '../../utils/embla/carousel-controller.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 { getTextDirection } from '../../utils/text-direction.js';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
||||
import { ViewMedia } from '../../view/item.js';
|
||||
@@ -85,18 +85,22 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
|
||||
protected _media: ViewMedia[] | null = null;
|
||||
protected _mediaActionsController = new MediaActionsController();
|
||||
protected _mediaHeightController = new MediaHeightController(this, '.embla__slide');
|
||||
protected _loadedMediaPlayerController: MediaPlayerController | null = null;
|
||||
protected _refCarousel: Ref<HTMLElement> = createRef();
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this._mediaHeightController.setRoot(this.renderRoot);
|
||||
|
||||
// Request update in order to reinitialize the media action controller.
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this._mediaActionsController.destroy();
|
||||
this._mediaHeightController.destroy();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -116,7 +120,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
* @returns A list of EmblaOptionsTypes.
|
||||
*/
|
||||
protected _getPlugins(): EmblaCarouselPlugins {
|
||||
return [AutoMediaLoadedInfo(), AutoSize()];
|
||||
return [AutoMediaLoadedInfo()];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,6 +412,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
? this.viewManagerEpoch?.manager.getView()?.camera === this.viewFilterCameraID
|
||||
: true,
|
||||
);
|
||||
this._mediaHeightController.setSelected(this._selected);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
// Keep carousel controls relative to the media carousel itself.
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.embla {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
--video-max-height: none;
|
||||
|
||||
transition: max-height 0.1s ease-in-out;
|
||||
|
||||
// Keep carousel controls relative to the media carousel itself.
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// When the carousel is not part of a grid ensure its height matches its
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
:host {
|
||||
// Center unseekable icon.
|
||||
display: block;
|
||||
|
||||
// Keep carousel controls + unseekable icon relative to the media carousel
|
||||
// itself.
|
||||
position: relative;
|
||||
|
||||
--video-max-height: none;
|
||||
|
||||
transition: max-height 0.2s ease-in;
|
||||
}
|
||||
|
||||
// If the carousel has an unselected attribute set on it, do not let the
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { EmblaCarouselType } from 'embla-carousel';
|
||||
import { LooseOptionsType } from 'embla-carousel/components/Options';
|
||||
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { EmblaReInitController } from '../../reinit-controller';
|
||||
|
||||
declare module 'embla-carousel/components/Plugins' {
|
||||
interface EmblaPluginsType {
|
||||
AutoSize?: AutoSizeType;
|
||||
}
|
||||
}
|
||||
|
||||
type AutoSizeType = CreatePluginType<LoosePluginType, LooseOptionsType>;
|
||||
interface SlideDimensions {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* This plugin offers the following functionality:
|
||||
* - Auto-height: Automatically resize the container to fit the largest slide on
|
||||
* view. Unlike the stock `auto-height` plugin, this version will use active
|
||||
* DOM sizing vs the internal engine sizes to account for pre-reinit resize
|
||||
* detection.
|
||||
* - Resize and intersection re-initializing: Re-initialize the carousel on
|
||||
* slide or container resizes, or container intersection changes.
|
||||
*/
|
||||
|
||||
function AutoSize(): AutoSizeType {
|
||||
let emblaApi: EmblaCarouselType;
|
||||
let reInitController: EmblaReInitController | null = null;
|
||||
|
||||
let previousContainerIntersecting: boolean | null = null;
|
||||
const previousDimensions: Map<Element, SlideDimensions> = new Map();
|
||||
|
||||
const resizeObserver: ResizeObserver = new ResizeObserver(resizeHandler);
|
||||
const intersectionObserver: IntersectionObserver = new IntersectionObserver(
|
||||
intersectionHandler,
|
||||
);
|
||||
|
||||
const debouncedSetContainerHeight = debounce(
|
||||
() => setContainerHeightAndReInit(),
|
||||
200,
|
||||
{
|
||||
trailing: true,
|
||||
},
|
||||
);
|
||||
|
||||
function init(emblaApiInstance: EmblaCarouselType): void {
|
||||
emblaApi = emblaApiInstance;
|
||||
reInitController = new EmblaReInitController(emblaApi);
|
||||
|
||||
intersectionObserver.observe(emblaApi.containerNode());
|
||||
resizeObserver.observe(emblaApi.containerNode());
|
||||
for (const slide of emblaApi.slideNodes()) {
|
||||
resizeObserver.observe(slide);
|
||||
}
|
||||
|
||||
// Need to examine container size on both settle and media load, as settle
|
||||
// may happen before the media is loaded (which they subsequently changes
|
||||
// the size to large than the maxHeight is set).
|
||||
emblaApi
|
||||
.containerNode()
|
||||
.addEventListener(
|
||||
'advanced-camera-card:media:loaded',
|
||||
debouncedSetContainerHeight,
|
||||
);
|
||||
emblaApi.on('settle', debouncedSetContainerHeight);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
intersectionObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
reInitController?.destroy();
|
||||
|
||||
emblaApi
|
||||
.containerNode()
|
||||
.removeEventListener(
|
||||
'advanced-camera-card:media:loaded',
|
||||
debouncedSetContainerHeight,
|
||||
);
|
||||
emblaApi.off('settle', debouncedSetContainerHeight);
|
||||
}
|
||||
|
||||
function intersectionHandler(entries: IntersectionObserverEntry[]): void {
|
||||
/**
|
||||
* - If the DOM that contains this carousel changes such that it causes
|
||||
* slides to entirely appear/disappear (e.g. `display: none` or hidden),
|
||||
* then the displayed slide sizes will significantly change and the
|
||||
* carousel will need to be reinitialized. Without this, odd bugs may
|
||||
* occur for some users in some circumstances causing the carousel to
|
||||
* appear 'stuck'.
|
||||
* - Example bug when this reinitialization is not performed:
|
||||
* https://github.com/dermotduffy/advanced-camera-card/issues/651
|
||||
*/
|
||||
const isContainerIntersectingNow = entries.some((entry) => entry.isIntersecting);
|
||||
|
||||
if (isContainerIntersectingNow !== previousContainerIntersecting) {
|
||||
// Don't reinitialize on first call (intersectionHandler is always called
|
||||
// on initial observation), nor when the viewport is not intersecting.
|
||||
const callReInit =
|
||||
isContainerIntersectingNow && previousContainerIntersecting !== null;
|
||||
previousContainerIntersecting = isContainerIntersectingNow;
|
||||
if (callReInit) {
|
||||
reInitController?.reinit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resizeHandler(entries: ResizeObserverEntry[]): void {
|
||||
let resize = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
const newDimensions: SlideDimensions = {
|
||||
height: entry.contentRect.height,
|
||||
width: entry.contentRect.width,
|
||||
};
|
||||
|
||||
const oldDimensions = previousDimensions.get(entry.target);
|
||||
if (
|
||||
newDimensions.width &&
|
||||
newDimensions.height &&
|
||||
(oldDimensions?.height !== newDimensions.height ||
|
||||
oldDimensions?.width !== newDimensions.width)
|
||||
) {
|
||||
previousDimensions.set(entry.target, newDimensions);
|
||||
resize = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (resize) {
|
||||
debouncedSetContainerHeight();
|
||||
}
|
||||
}
|
||||
|
||||
function setContainerHeightAndReInit(): void {
|
||||
const {
|
||||
slideRegistry,
|
||||
options: { axis },
|
||||
} = emblaApi.internalEngine();
|
||||
|
||||
if (axis === 'y') {
|
||||
return;
|
||||
}
|
||||
|
||||
emblaApi.containerNode().style.removeProperty('max-height');
|
||||
|
||||
const selectedIndexes = slideRegistry[emblaApi.selectedScrollSnap()];
|
||||
const slides = emblaApi.slideNodes();
|
||||
const highest = Math.max(
|
||||
...selectedIndexes.map((i) => slides[i].getBoundingClientRect().height),
|
||||
);
|
||||
|
||||
if (!isNaN(highest) && highest > 0) {
|
||||
emblaApi.containerNode().style.maxHeight = `${highest}px`;
|
||||
}
|
||||
|
||||
reInitController?.reinit();
|
||||
}
|
||||
|
||||
const self: AutoSizeType = {
|
||||
name: 'autoSize',
|
||||
options: {},
|
||||
init,
|
||||
destroy,
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
export default AutoSize;
|
||||
@@ -1,60 +0,0 @@
|
||||
import { EmblaCarouselType } from 'embla-carousel';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
||||
/**
|
||||
* This class takes care of "safe re-initializing": Only re-initializing the
|
||||
* carousel when it is not scrolling (unlike the builtin Embla reinitializations,
|
||||
* e.g. slide additions or resizes). Without this class the carousel is visually
|
||||
* jarring as in-progress transitions are skipped (vs completing prior to
|
||||
* reinit).
|
||||
*/
|
||||
|
||||
export class EmblaReInitController {
|
||||
protected _emblaApi: EmblaCarouselType;
|
||||
protected _scrolling = false;
|
||||
protected _shouldReInitOnScrollStop = false;
|
||||
|
||||
constructor(emblaApi: EmblaCarouselType) {
|
||||
this._emblaApi = emblaApi;
|
||||
this._emblaApi.on('scroll', this._scrollingStart);
|
||||
this._emblaApi.on('settle', this._scrollingStop);
|
||||
this._emblaApi.on('destroy', this.destroy);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._emblaApi.off('scroll', this._scrollingStart);
|
||||
this._emblaApi.off('settle', this._scrollingStop);
|
||||
this._emblaApi.off('destroy', this.destroy);
|
||||
}
|
||||
|
||||
public reinit(): void {
|
||||
if (this._scrolling) {
|
||||
this._shouldReInitOnScrollStop = true;
|
||||
} else {
|
||||
this._debouncedReInit();
|
||||
}
|
||||
}
|
||||
|
||||
protected _scrollingStart = (): void => {
|
||||
this._scrolling = true;
|
||||
};
|
||||
|
||||
protected _scrollingStop = (): void => {
|
||||
this._scrolling = false;
|
||||
|
||||
if (this._shouldReInitOnScrollStop) {
|
||||
this._shouldReInitOnScrollStop = false;
|
||||
this._debouncedReInit();
|
||||
}
|
||||
};
|
||||
|
||||
protected _debouncedReInit = debounce(
|
||||
() => {
|
||||
this._scrolling = false;
|
||||
this._shouldReInitOnScrollStop = false;
|
||||
this._emblaApi?.reInit();
|
||||
},
|
||||
500,
|
||||
{ trailing: true },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user