fix: Lazy unloading should not leave dangling connections (#2004)
- Closes #1992
This commit is contained in:
@@ -0,0 +1,102 @@
|
|||||||
|
import { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||||
|
import { LazyUnloadCondition } from '../config/schema/common/media-actions';
|
||||||
|
|
||||||
|
type LazyLoadListener = (loaded: boolean) => void;
|
||||||
|
|
||||||
|
export class LazyLoadController implements ReactiveController {
|
||||||
|
private _host: ReactiveControllerHost & HTMLElement;
|
||||||
|
private _documentVisible = true;
|
||||||
|
private _intersects = false;
|
||||||
|
private _loaded: boolean;
|
||||||
|
private _unloadConditions: LazyUnloadCondition[] | null = null;
|
||||||
|
private _intersectionObserver = new IntersectionObserver(
|
||||||
|
this._intersectionHandler.bind(this),
|
||||||
|
);
|
||||||
|
private _listeners: LazyLoadListener[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
host: ReactiveControllerHost & HTMLElement,
|
||||||
|
lazyLoad?: boolean,
|
||||||
|
lazyUnloadConditions?: LazyUnloadCondition[],
|
||||||
|
) {
|
||||||
|
this._host = host;
|
||||||
|
this._host.addController(this);
|
||||||
|
|
||||||
|
this._loaded = !lazyLoad;
|
||||||
|
this._unloadConditions = lazyUnloadConditions ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this._removeEventHandlers();
|
||||||
|
this._listeners = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public isLoaded(): boolean {
|
||||||
|
return this._loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addListener(listener: LazyLoadListener): void {
|
||||||
|
this._listeners.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeListener(listener: LazyLoadListener): void {
|
||||||
|
this._listeners = this._listeners.filter((l) => l !== listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeController(): void {
|
||||||
|
this._host.removeController(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public hostConnected(): void {
|
||||||
|
this._addEventHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
public hostDisconnected(): void {
|
||||||
|
this._removeEventHandlers();
|
||||||
|
this._setLoaded(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _addEventHandlers(): void {
|
||||||
|
document.addEventListener('visibilitychange', this._visibilityHandler);
|
||||||
|
this._intersectionObserver.observe(this._host);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _removeEventHandlers(): void {
|
||||||
|
document.removeEventListener('visibilitychange', this._visibilityHandler);
|
||||||
|
this._intersectionObserver.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _lazyLoadOrUnloadIfNecessary(): void {
|
||||||
|
const shouldBeLoaded = !this._loaded && this._documentVisible && this._intersects;
|
||||||
|
const shouldBeUnloaded =
|
||||||
|
this._loaded &&
|
||||||
|
((this._unloadConditions?.includes('hidden') && !this._documentVisible) ||
|
||||||
|
(this._unloadConditions?.includes('unselected') && !this._intersects));
|
||||||
|
|
||||||
|
if (shouldBeLoaded) {
|
||||||
|
this._setLoaded(true);
|
||||||
|
} else if (shouldBeUnloaded) {
|
||||||
|
this._setLoaded(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setLoaded(loaded: boolean): void {
|
||||||
|
this._loaded = loaded;
|
||||||
|
this._notifyListeners();
|
||||||
|
this._host.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _notifyListeners(): void {
|
||||||
|
this._listeners.forEach((listener) => listener(this._loaded));
|
||||||
|
}
|
||||||
|
|
||||||
|
private _intersectionHandler(entries: IntersectionObserverEntry[]): void {
|
||||||
|
this._intersects = entries.some((entry) => entry.isIntersecting);
|
||||||
|
this._lazyLoadOrUnloadIfNecessary();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _visibilityHandler = (): void => {
|
||||||
|
this._documentVisible = document.visibilityState === 'visible';
|
||||||
|
this._lazyLoadOrUnloadIfNecessary();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -97,7 +97,18 @@ export class AdvancedCameraCardCarousel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected updated(changedProps: PropertyValues): void {
|
protected updated(changedProps: PropertyValues): void {
|
||||||
if (!this._carousel && this._refRoot.value && this._refParent.value) {
|
if (
|
||||||
|
!this._carousel &&
|
||||||
|
this._refRoot.value &&
|
||||||
|
this._refParent.value &&
|
||||||
|
// Never construct a carousel if the node is not connected. There can be a
|
||||||
|
// race condition between the Lit update lifecycle, and the
|
||||||
|
// disconnect/connect callbacks, causing a carousel to potentially be
|
||||||
|
// created after the node is disconnected. This could cause a dangling
|
||||||
|
// carousel and hold open connections that should have been closed.
|
||||||
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1992
|
||||||
|
this.isConnected
|
||||||
|
) {
|
||||||
this._carousel = new CarouselController(
|
this._carousel = new CarouselController(
|
||||||
this._refRoot.value,
|
this._refRoot.value,
|
||||||
this._refParent.value,
|
this._refParent.value,
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { HomeAssistant } from '../../ha/types.js';
|
|||||||
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||||
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
|
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
|
||||||
import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
|
|
||||||
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.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 AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
|
||||||
import { getStreamCameraID } from '../../utils/substream.js';
|
import { getStreamCameraID } from '../../utils/substream.js';
|
||||||
@@ -36,7 +35,6 @@ import '../next-prev-control.js';
|
|||||||
import '../ptz.js';
|
import '../ptz.js';
|
||||||
import { AdvancedCameraCardPTZ } from '../ptz.js';
|
import { AdvancedCameraCardPTZ } from '../ptz.js';
|
||||||
import './provider.js';
|
import './provider.js';
|
||||||
import { AdvancedCameraCardLiveProvider } from './provider.js';
|
|
||||||
|
|
||||||
const ADVANCED_CAMERA_CARD_LIVE_PROVIDER = 'advanced-camera-card-live-provider';
|
const ADVANCED_CAMERA_CARD_LIVE_PROVIDER = 'advanced-camera-card-live-provider';
|
||||||
|
|
||||||
@@ -140,19 +138,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected _getPlugins(): EmblaCarouselPlugins {
|
protected _getPlugins(): EmblaCarouselPlugins {
|
||||||
return [
|
return [AutoMediaLoadedInfo(), AutoSize()];
|
||||||
AutoLazyLoad({
|
|
||||||
...(this.liveConfig?.lazy_load && {
|
|
||||||
lazyLoadCallback: (index, slide) =>
|
|
||||||
this._lazyloadOrUnloadSlide('load', index, slide),
|
|
||||||
}),
|
|
||||||
lazyUnloadConditions: this.liveConfig?.lazy_unload,
|
|
||||||
lazyUnloadCallback: (index, slide) =>
|
|
||||||
this._lazyloadOrUnloadSlide('unload', index, slide),
|
|
||||||
}),
|
|
||||||
AutoMediaLoadedInfo(),
|
|
||||||
AutoSize(),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -217,23 +203,6 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _lazyloadOrUnloadSlide(
|
|
||||||
action: 'load' | 'unload',
|
|
||||||
_index: number,
|
|
||||||
slide: Element,
|
|
||||||
): void {
|
|
||||||
if (slide instanceof HTMLSlotElement) {
|
|
||||||
slide = slide.assignedElements({ flatten: true })[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const liveProvider = slide?.querySelector(
|
|
||||||
ADVANCED_CAMERA_CARD_LIVE_PROVIDER,
|
|
||||||
) as AdvancedCameraCardLiveProvider | null;
|
|
||||||
if (liveProvider) {
|
|
||||||
liveProvider.load = action === 'load';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _renderLive(
|
protected _renderLive(
|
||||||
cameraID: string,
|
cameraID: string,
|
||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
@@ -248,7 +217,6 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<div class="embla__slide">
|
<div class="embla__slide">
|
||||||
<advanced-camera-card-live-provider
|
<advanced-camera-card-live-provider
|
||||||
?load=${!this.liveConfig.lazy_load}
|
|
||||||
.microphoneState=${view?.camera === cameraID
|
.microphoneState=${view?.camera === cameraID
|
||||||
? this.microphoneState
|
? this.microphoneState
|
||||||
: undefined}
|
: undefined}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { guard } from 'lit/directives/guard.js';
|
|||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
import { CameraEndpoints } from '../../camera-manager/types.js';
|
import { CameraEndpoints } from '../../camera-manager/types.js';
|
||||||
import { MicrophoneState } from '../../card-controller/types.js';
|
import { MicrophoneState } from '../../card-controller/types.js';
|
||||||
|
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
||||||
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
|
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
|
||||||
import { PartialZoomSettings } from '../../components-lib/zoom/types.js';
|
import { PartialZoomSettings } from '../../components-lib/zoom/types.js';
|
||||||
import { CameraConfig, LiveProvider } from '../../config/schema/cameras.js';
|
import { CameraConfig, LiveProvider } from '../../config/schema/cameras.js';
|
||||||
@@ -45,12 +46,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public liveConfig?: LiveConfig;
|
public liveConfig?: LiveConfig;
|
||||||
|
|
||||||
// Whether or not to load the video for this camera. If `false`, no contents
|
|
||||||
// are rendered until this attribute is set to `true` (this is useful for lazy
|
|
||||||
// loading).
|
|
||||||
@property({ attribute: true, type: Boolean })
|
|
||||||
public load = false;
|
|
||||||
|
|
||||||
// Label that is used for ARIA support and as tooltip.
|
// Label that is used for ARIA support and as tooltip.
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public label = '';
|
public label = '';
|
||||||
@@ -74,6 +69,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
protected _showStreamTroubleshooting = false;
|
protected _showStreamTroubleshooting = false;
|
||||||
|
|
||||||
protected _refProvider: Ref<MediaPlayerElement> = createRef();
|
protected _refProvider: Ref<MediaPlayerElement> = createRef();
|
||||||
|
protected _lazyLoadController: LazyLoadController | null = null;
|
||||||
|
|
||||||
// A note on dynamic imports:
|
// A note on dynamic imports:
|
||||||
//
|
//
|
||||||
@@ -144,11 +140,23 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('load')) {
|
if (
|
||||||
if (!this.load) {
|
changedProps.has('liveConfig') ||
|
||||||
|
(!this._lazyLoadController && this.liveConfig)
|
||||||
|
) {
|
||||||
|
this._lazyLoadController?.destroy();
|
||||||
|
this._lazyLoadController?.removeController();
|
||||||
|
this._lazyLoadController = new LazyLoadController(
|
||||||
|
this,
|
||||||
|
this.liveConfig?.lazy_load,
|
||||||
|
this.liveConfig?.lazy_unload,
|
||||||
|
);
|
||||||
|
this._lazyLoadController.addListener((loaded: boolean) => {
|
||||||
|
if (!loaded) {
|
||||||
this._isVideoMediaLoaded = false;
|
this._isVideoMediaLoaded = false;
|
||||||
dispatchMediaUnloadedEvent(this);
|
dispatchMediaUnloadedEvent(this);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedProps.has('liveConfig')) {
|
if (changedProps.has('liveConfig')) {
|
||||||
@@ -216,7 +224,12 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.load || !this.hass || !this.liveConfig || !this.cameraConfig) {
|
if (
|
||||||
|
!this._lazyLoadController?.isLoaded() ||
|
||||||
|
!this.hass ||
|
||||||
|
!this.liveConfig ||
|
||||||
|
!this.cameraConfig
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { MediaLoadedInfo, MediaPlayerController } from '../../types.js';
|
|||||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||||
import { contentsChanged, setOrRemoveAttribute } from '../../utils/basic.js';
|
import { contentsChanged, setOrRemoveAttribute } from '../../utils/basic.js';
|
||||||
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
|
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
|
||||||
import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
|
|
||||||
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.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 AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
|
||||||
import { ResolvedMediaCache } from '../../utils/ha/resolved-media.js';
|
import { ResolvedMediaCache } from '../../utils/ha/resolved-media.js';
|
||||||
@@ -36,7 +35,6 @@ import { renderMessage } from '../message.js';
|
|||||||
import '../next-prev-control.js';
|
import '../next-prev-control.js';
|
||||||
import '../ptz.js';
|
import '../ptz.js';
|
||||||
import './provider.js';
|
import './provider.js';
|
||||||
import { AdvancedCameraCardViewerProvider } from './provider.js';
|
|
||||||
|
|
||||||
interface MediaNeighbor {
|
interface MediaNeighbor {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -137,15 +135,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
|||||||
* @returns A list of EmblaOptionsTypes.
|
* @returns A list of EmblaOptionsTypes.
|
||||||
*/
|
*/
|
||||||
protected _getPlugins(): EmblaCarouselPlugins {
|
protected _getPlugins(): EmblaCarouselPlugins {
|
||||||
return [
|
return [AutoMediaLoadedInfo(), AutoSize()];
|
||||||
AutoLazyLoad({
|
|
||||||
...(this.viewerConfig?.lazy_load && {
|
|
||||||
lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
AutoMediaLoadedInfo(),
|
|
||||||
AutoSize(),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,23 +201,6 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazy load a slide.
|
|
||||||
* @param slide The slide to lazy load.
|
|
||||||
*/
|
|
||||||
protected _lazyloadSlide(slide: Element): void {
|
|
||||||
if (slide instanceof HTMLSlotElement) {
|
|
||||||
slide = slide.assignedElements({ flatten: true })[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewerProvider = slide?.querySelector(
|
|
||||||
'advanced-camera-card-viewer-provider',
|
|
||||||
) as AdvancedCameraCardViewerProvider | null;
|
|
||||||
if (viewerProvider) {
|
|
||||||
viewerProvider.load = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get slides to include in the render.
|
* Get slides to include in the render.
|
||||||
* @returns The slides to include in the render.
|
* @returns The slides to include in the render.
|
||||||
@@ -450,7 +423,6 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
|||||||
.viewerConfig=${this.viewerConfig}
|
.viewerConfig=${this.viewerConfig}
|
||||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.load=${!this.viewerConfig.lazy_load}
|
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
></advanced-camera-card-viewer-provider>
|
></advanced-camera-card-viewer-provider>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { guard } from 'lit/directives/guard.js';
|
|||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
import { CameraManager } from '../../camera-manager/manager.js';
|
import { CameraManager } from '../../camera-manager/manager.js';
|
||||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||||
|
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
||||||
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
|
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
|
||||||
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
|
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
|
||||||
import { CardWideConfig } from '../../config/schema/types.js';
|
import { CardWideConfig } from '../../config/schema/types.js';
|
||||||
@@ -61,12 +62,6 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public resolvedMediaCache?: ResolvedMediaCache;
|
public resolvedMediaCache?: ResolvedMediaCache;
|
||||||
|
|
||||||
// Whether or not to load the viewer media. If `false`, no contents are
|
|
||||||
// rendered until this attribute is set to `true` (this is useful for lazy
|
|
||||||
// loading).
|
|
||||||
@property({ attribute: false })
|
|
||||||
public load = false;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
@@ -74,6 +69,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
protected _refProvider: Ref<MediaPlayerElement> = createRef();
|
protected _refProvider: Ref<MediaPlayerElement> = createRef();
|
||||||
|
protected _lazyLoadController: LazyLoadController | null = null;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
protected _url: string | null = null;
|
protected _url: string | null = null;
|
||||||
@@ -127,7 +123,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
!this.media ||
|
!this.media ||
|
||||||
!mediaContentID ||
|
!mediaContentID ||
|
||||||
!this.hass ||
|
!this.hass ||
|
||||||
(this.viewerConfig?.lazy_load && !this.load)
|
!this._lazyLoadController?.isLoaded()
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -185,15 +181,25 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (
|
if (
|
||||||
changedProps.has('load') ||
|
changedProps.has('viewerConfig') ||
|
||||||
|
(!this._lazyLoadController && this.viewerConfig)
|
||||||
|
) {
|
||||||
|
this._lazyLoadController?.destroy();
|
||||||
|
this._lazyLoadController?.removeController();
|
||||||
|
this._lazyLoadController = new LazyLoadController(
|
||||||
|
this,
|
||||||
|
this.viewerConfig?.lazy_load,
|
||||||
|
);
|
||||||
|
this._lazyLoadController.addListener((loaded) => loaded && this._setURL());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
changedProps.has('media') ||
|
changedProps.has('media') ||
|
||||||
changedProps.has('viewerConfig') ||
|
changedProps.has('viewerConfig') ||
|
||||||
changedProps.has('resolvedMediaCache') ||
|
changedProps.has('resolvedMediaCache') ||
|
||||||
changedProps.has('hass')
|
changedProps.has('hass')
|
||||||
) {
|
) {
|
||||||
this._setURL().then(() => {
|
this._setURL();
|
||||||
this.requestUpdate();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) {
|
if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) {
|
||||||
@@ -246,7 +252,12 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.load || !this.media || !this.hass || !this.viewerConfig) {
|
if (
|
||||||
|
!this._lazyLoadController?.isLoaded() ||
|
||||||
|
!this.media ||
|
||||||
|
!this.hass ||
|
||||||
|
!this.viewerConfig
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
import { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
|
|
||||||
import { CreateOptionsType } from 'embla-carousel/components/Options';
|
|
||||||
import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler';
|
|
||||||
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
|
|
||||||
import { LazyUnloadCondition } from '../../../../config/schema/common/media-actions';
|
|
||||||
|
|
||||||
declare module 'embla-carousel/components/Plugins' {
|
|
||||||
interface EmblaPluginsType {
|
|
||||||
lazyload?: AutoLazyLoadType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type OptionsType = CreateOptionsType<{
|
|
||||||
// Number of slides to lazyload left/right of selected (0 == only selected
|
|
||||||
// slide).
|
|
||||||
lazyLoadCount: number;
|
|
||||||
lazyUnloadConditions?: readonly LazyUnloadCondition[];
|
|
||||||
|
|
||||||
lazyLoadCallback?: (index: number, slide: HTMLElement) => void;
|
|
||||||
lazyUnloadCallback?: (index: number, slide: HTMLElement) => void;
|
|
||||||
}>;
|
|
||||||
type AutoLazyLoadOptionsType = Partial<OptionsType>;
|
|
||||||
type AutoLazyLoadType = CreatePluginType<LoosePluginType, AutoLazyLoadOptionsType>;
|
|
||||||
|
|
||||||
const defaultOptions: OptionsType = {
|
|
||||||
active: true,
|
|
||||||
breakpoints: {},
|
|
||||||
lazyLoadCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AutoLazyLoad(
|
|
||||||
userOptions: AutoLazyLoadOptionsType = {},
|
|
||||||
): AutoLazyLoadType {
|
|
||||||
let options: OptionsType;
|
|
||||||
let emblaApi: EmblaCarouselType;
|
|
||||||
let slides: HTMLElement[];
|
|
||||||
const lazyLoadedSlides: Set<number> = new Set();
|
|
||||||
|
|
||||||
const loadEvents: EmblaEventType[] = ['init', 'select'];
|
|
||||||
const unloadEvents: EmblaEventType[] = ['select'];
|
|
||||||
|
|
||||||
function init(
|
|
||||||
emblaApiInstance: EmblaCarouselType,
|
|
||||||
optionsHandler: OptionsHandlerType,
|
|
||||||
): void {
|
|
||||||
const { mergeOptions, optionsAtMedia } = optionsHandler;
|
|
||||||
const allOptions = mergeOptions(defaultOptions, userOptions);
|
|
||||||
options = optionsAtMedia(allOptions);
|
|
||||||
|
|
||||||
emblaApi = emblaApiInstance;
|
|
||||||
slides = emblaApi.slideNodes();
|
|
||||||
|
|
||||||
if (options.lazyLoadCallback) {
|
|
||||||
loadEvents.forEach((evt) => emblaApi.on(evt, lazyLoadHandler));
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
options.lazyUnloadCallback &&
|
|
||||||
options.lazyUnloadConditions?.includes('unselected')
|
|
||||||
) {
|
|
||||||
unloadEvents.forEach((evt) => emblaApi.on(evt, lazyUnloadPreviousHandler));
|
|
||||||
}
|
|
||||||
document.addEventListener('visibilitychange', visibilityHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroy(): void {
|
|
||||||
if (options.lazyLoadCallback) {
|
|
||||||
loadEvents.forEach((evt) => emblaApi.off(evt, lazyLoadHandler));
|
|
||||||
}
|
|
||||||
if (options.lazyUnloadCallback) {
|
|
||||||
unloadEvents.forEach((evt) => emblaApi.off(evt, lazyUnloadPreviousHandler));
|
|
||||||
}
|
|
||||||
document.removeEventListener('visibilitychange', visibilityHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
function visibilityHandler(): void {
|
|
||||||
if (
|
|
||||||
document.visibilityState === 'hidden' &&
|
|
||||||
options.lazyUnloadConditions?.includes('hidden')
|
|
||||||
) {
|
|
||||||
lazyUnloadAllHandler();
|
|
||||||
} else if (document.visibilityState === 'visible' && options.lazyLoadCallback) {
|
|
||||||
lazyLoadHandler();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasLazyloaded(index: number): boolean {
|
|
||||||
return lazyLoadedSlides.has(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
function lazyLoadHandler(): void {
|
|
||||||
const lazyLoadCount = options.lazyLoadCount;
|
|
||||||
const currentIndex = emblaApi.selectedScrollSnap();
|
|
||||||
const slidesToLoad = new Set<number>();
|
|
||||||
|
|
||||||
// Lazily load 'count' slides on either side of the slides in view.
|
|
||||||
for (let i = 1; i <= lazyLoadCount && currentIndex - i >= 0; i++) {
|
|
||||||
slidesToLoad.add(currentIndex - i);
|
|
||||||
}
|
|
||||||
slidesToLoad.add(currentIndex);
|
|
||||||
for (let i = 1; i <= lazyLoadCount && currentIndex + i < slides.length; i++) {
|
|
||||||
slidesToLoad.add(currentIndex + i);
|
|
||||||
}
|
|
||||||
|
|
||||||
slidesToLoad.forEach((index) => {
|
|
||||||
if (!hasLazyloaded(index) && options.lazyLoadCallback) {
|
|
||||||
lazyLoadedSlides.add(index);
|
|
||||||
options.lazyLoadCallback(index, slides[index]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function lazyUnloadAllHandler(): void {
|
|
||||||
lazyLoadedSlides.forEach((index) => {
|
|
||||||
if (options.lazyUnloadCallback) {
|
|
||||||
options.lazyUnloadCallback(index, slides[index]);
|
|
||||||
lazyLoadedSlides.delete(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function lazyUnloadPreviousHandler(): void {
|
|
||||||
const index = emblaApi.previousScrollSnap();
|
|
||||||
|
|
||||||
if (hasLazyloaded(index) && options.lazyUnloadCallback) {
|
|
||||||
options.lazyUnloadCallback(index, slides[index]);
|
|
||||||
lazyLoadedSlides.delete(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const self: AutoLazyLoadType = {
|
|
||||||
name: 'autoLazyLoad',
|
|
||||||
options: userOptions,
|
|
||||||
init,
|
|
||||||
destroy,
|
|
||||||
};
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import {
|
||||||
|
afterAll,
|
||||||
|
afterEach,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import { LazyLoadController } from '../../src/components-lib/lazy-load-controller';
|
||||||
|
import { LazyUnloadCondition } from '../../src/config/schema/common/media-actions';
|
||||||
|
import {
|
||||||
|
callIntersectionHandler,
|
||||||
|
callVisibilityHandler,
|
||||||
|
createLitElement,
|
||||||
|
getMockIntersectionObserver,
|
||||||
|
IntersectionObserverMock,
|
||||||
|
} from '../test-utils';
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('LazyLoadController', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(global.document, 'addEventListener');
|
||||||
|
vi.spyOn(global.document, 'removeEventListener');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be loaded by default', () => {
|
||||||
|
const controller = new LazyLoadController(createLitElement());
|
||||||
|
expect(controller.isLoaded()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not be loaded by default when lazy load is set to true', () => {
|
||||||
|
const controller = new LazyLoadController(createLitElement(), true);
|
||||||
|
expect(controller.isLoaded()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add controller to host', () => {
|
||||||
|
const host = createLitElement();
|
||||||
|
const controller = new LazyLoadController(host);
|
||||||
|
expect(host.addController).toBeCalledWith(controller);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove controller from host', () => {
|
||||||
|
const host = createLitElement();
|
||||||
|
const controller = new LazyLoadController(host);
|
||||||
|
controller.removeController();
|
||||||
|
expect(host.removeController).toBeCalledWith(controller);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove handlers and listeners on destroy', () => {
|
||||||
|
const controller = new LazyLoadController(createLitElement(), true, [
|
||||||
|
'unselected',
|
||||||
|
'hidden',
|
||||||
|
]);
|
||||||
|
controller.hostConnected();
|
||||||
|
|
||||||
|
const listener = vi.fn();
|
||||||
|
controller.addListener(listener);
|
||||||
|
|
||||||
|
controller.destroy();
|
||||||
|
|
||||||
|
expect(getMockIntersectionObserver()?.disconnect).toBeCalled();
|
||||||
|
expect(global.document.removeEventListener).toBeCalledWith(
|
||||||
|
'visibilitychange',
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(controller.isLoaded()).toBe(false);
|
||||||
|
|
||||||
|
callVisibilityHandler(true);
|
||||||
|
callIntersectionHandler(true);
|
||||||
|
expect(listener).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should lazy load', () => {
|
||||||
|
it('should load when both visible and intersecting', () => {
|
||||||
|
const controller = new LazyLoadController(createLitElement(), true);
|
||||||
|
controller.hostConnected();
|
||||||
|
|
||||||
|
expect(controller.isLoaded()).toBe(false);
|
||||||
|
|
||||||
|
callVisibilityHandler(true);
|
||||||
|
expect(controller.isLoaded()).toBe(false);
|
||||||
|
|
||||||
|
callIntersectionHandler(true);
|
||||||
|
expect(controller.isLoaded()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should lazy unload', () => {
|
||||||
|
it('should unload on DOM disconnection', () => {
|
||||||
|
const controller = new LazyLoadController(createLitElement());
|
||||||
|
controller.hostConnected();
|
||||||
|
|
||||||
|
expect(controller.isLoaded()).toBe(true);
|
||||||
|
|
||||||
|
controller.hostDisconnected();
|
||||||
|
|
||||||
|
expect(controller.isLoaded()).toBe(false);
|
||||||
|
|
||||||
|
// Should also stop observing.
|
||||||
|
expect(getMockIntersectionObserver()?.disconnect).toBeCalled();
|
||||||
|
expect(global.document.removeEventListener).toBeCalledWith(
|
||||||
|
'visibilitychange',
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should lazy unload when not visible', () => {
|
||||||
|
it.each([
|
||||||
|
[[], true],
|
||||||
|
[['unselected' as const], true],
|
||||||
|
[['hidden' as const], false],
|
||||||
|
[['unselected' as const, 'hidden' as const], false],
|
||||||
|
])(
|
||||||
|
'when unload conditions are: %s',
|
||||||
|
(unloadConditions: LazyUnloadCondition[], shouldBeLoaded: boolean) => {
|
||||||
|
const controller = new LazyLoadController(
|
||||||
|
createLitElement(),
|
||||||
|
true,
|
||||||
|
unloadConditions,
|
||||||
|
);
|
||||||
|
controller.hostConnected();
|
||||||
|
|
||||||
|
callIntersectionHandler(true);
|
||||||
|
callVisibilityHandler(true);
|
||||||
|
expect(controller.isLoaded()).toBe(true);
|
||||||
|
|
||||||
|
callVisibilityHandler(false);
|
||||||
|
expect(controller.isLoaded()).toBe(shouldBeLoaded);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should lazy unload when not intersecting', () => {
|
||||||
|
it.each([
|
||||||
|
[[], true],
|
||||||
|
[['unselected' as const], false],
|
||||||
|
[['hidden' as const], true],
|
||||||
|
[['unselected' as const, 'hidden' as const], false],
|
||||||
|
])(
|
||||||
|
'when unload conditions are: %s',
|
||||||
|
(unloadConditions: LazyUnloadCondition[], shouldBeLoaded: boolean) => {
|
||||||
|
const controller = new LazyLoadController(
|
||||||
|
createLitElement(),
|
||||||
|
true,
|
||||||
|
unloadConditions,
|
||||||
|
);
|
||||||
|
controller.hostConnected();
|
||||||
|
|
||||||
|
callIntersectionHandler(true);
|
||||||
|
callVisibilityHandler(true);
|
||||||
|
expect(controller.isLoaded()).toBe(true);
|
||||||
|
|
||||||
|
callIntersectionHandler(false);
|
||||||
|
expect(controller.isLoaded()).toBe(shouldBeLoaded);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call listeners', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
const controller = new LazyLoadController(createLitElement(), true, [
|
||||||
|
'unselected',
|
||||||
|
'hidden',
|
||||||
|
]);
|
||||||
|
controller.hostConnected();
|
||||||
|
controller.addListener(listener);
|
||||||
|
|
||||||
|
expect(controller.isLoaded()).toBe(false);
|
||||||
|
|
||||||
|
callIntersectionHandler(true);
|
||||||
|
callVisibilityHandler(true);
|
||||||
|
expect(listener).toHaveBeenLastCalledWith(true);
|
||||||
|
expect(listener).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
callIntersectionHandler(false);
|
||||||
|
expect(listener).toHaveBeenLastCalledWith(false);
|
||||||
|
expect(listener).toBeCalledTimes(2);
|
||||||
|
|
||||||
|
callIntersectionHandler(true);
|
||||||
|
expect(listener).toHaveBeenLastCalledWith(true);
|
||||||
|
expect(listener).toBeCalledTimes(3);
|
||||||
|
|
||||||
|
controller.removeListener(listener);
|
||||||
|
|
||||||
|
callIntersectionHandler(false);
|
||||||
|
expect(listener).toBeCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
MutationObserverMock,
|
MutationObserverMock,
|
||||||
callIntersectionHandler,
|
callIntersectionHandler,
|
||||||
callMutationHandler,
|
callMutationHandler,
|
||||||
|
callVisibilityHandler,
|
||||||
createParent,
|
createParent,
|
||||||
flushPromises,
|
flushPromises,
|
||||||
} from '../test-utils';
|
} from '../test-utils';
|
||||||
import { callVisibilityHandler, createTestSlideNodes } from '../utils/embla/test-utils';
|
import { createTestSlideNodes } from '../utils/embla/test-utils';
|
||||||
|
|
||||||
const getPlayer = (
|
const getPlayer = (
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
@@ -47,7 +48,7 @@ describe('MediaActionsController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
vi.restoreAllMocks();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -455,11 +456,7 @@ describe('MediaActionsController', () => {
|
|||||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||||
).not.toBeCalled();
|
).not.toBeCalled();
|
||||||
|
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
await callVisibilityHandler(true);
|
||||||
value: 'visible',
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
await callVisibilityHandler();
|
|
||||||
|
|
||||||
// Not configured to take action on selection.
|
// Not configured to take action on selection.
|
||||||
expect(
|
expect(
|
||||||
@@ -502,11 +499,7 @@ describe('MediaActionsController', () => {
|
|||||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||||
).not.toBeCalled();
|
).not.toBeCalled();
|
||||||
|
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
await callVisibilityHandler(false);
|
||||||
value: 'hidden',
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
await callVisibilityHandler();
|
|
||||||
|
|
||||||
// Not configured to take action on selection.
|
// Not configured to take action on selection.
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
+25
-3
@@ -383,15 +383,22 @@ export const requestAnimationFrameMock = (callback: FrameRequestCallback) => {
|
|||||||
return 1;
|
return 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getMockIntersectionObserver = (n = 0): IntersectionObserver | null => {
|
||||||
|
const mockResult = vi.mocked(IntersectionObserver).mock.results[n];
|
||||||
|
if (mockResult.type !== 'return') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return mockResult.value;
|
||||||
|
};
|
||||||
|
|
||||||
export const callIntersectionHandler = async (
|
export const callIntersectionHandler = async (
|
||||||
intersecting = true,
|
intersecting = true,
|
||||||
n = 0,
|
n = 0,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const mockResult = vi.mocked(IntersectionObserver).mock.results[n];
|
const observer = getMockIntersectionObserver(n);
|
||||||
if (mockResult.type !== 'return') {
|
if (!observer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const observer = mockResult.value;
|
|
||||||
await (
|
await (
|
||||||
vi.mocked(IntersectionObserver).mock.calls[n][0] as
|
vi.mocked(IntersectionObserver).mock.calls[n][0] as
|
||||||
| IntersectionObserverCallback
|
| IntersectionObserverCallback
|
||||||
@@ -422,6 +429,20 @@ export const callMutationHandler = async (n = 0): Promise<void> => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const callVisibilityHandler = async (visible: boolean): Promise<void> => {
|
||||||
|
Object.defineProperty(document, 'visibilityState', {
|
||||||
|
value: visible ? 'visible' : 'hidden',
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mock = vi.mocked(global.document.addEventListener).mock;
|
||||||
|
for (const [evt, cb] of mock.calls) {
|
||||||
|
if (evt === 'visibilitychange' && typeof cb === 'function') {
|
||||||
|
await (cb as EventListener | ((_: unknown) => Promise<void>))(new Event('foo'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const createSlotHost = (options?: {
|
export const createSlotHost = (options?: {
|
||||||
slot?: HTMLSlotElement;
|
slot?: HTMLSlotElement;
|
||||||
children?: HTMLElement[];
|
children?: HTMLElement[];
|
||||||
@@ -453,6 +474,7 @@ export const createParent = (options?: { children?: HTMLElement[] }): HTMLElemen
|
|||||||
export const createLitElement = (): LitElement => {
|
export const createLitElement = (): LitElement => {
|
||||||
const element = document.createElement('div') as unknown as LitElement;
|
const element = document.createElement('div') as unknown as LitElement;
|
||||||
element.addController = vi.fn();
|
element.addController = vi.fn();
|
||||||
|
element.removeController = vi.fn();
|
||||||
element.requestUpdate = vi.fn();
|
element.requestUpdate = vi.fn();
|
||||||
|
|
||||||
const promise: Promise<boolean> = new Promise((resolve) => {
|
const promise: Promise<boolean> = new Promise((resolve) => {
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { MEDIA_ACTION_NEGATIVE_CONDITIONS } from '../../../../../src/config/schema/common/media-actions';
|
|
||||||
import { AutoLazyLoad } from '../../../../../src/utils/embla/plugins/auto-lazy-load/auto-lazy-load';
|
|
||||||
import {
|
|
||||||
callEmblaHandler,
|
|
||||||
callVisibilityHandler,
|
|
||||||
createEmblaApiInstance,
|
|
||||||
createTestEmblaOptionHandler,
|
|
||||||
createTestSlideNodes,
|
|
||||||
} from '../../test-utils';
|
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
|
||||||
describe('AutoLazyLoad', () => {
|
|
||||||
it('should construct', () => {
|
|
||||||
const plugin = AutoLazyLoad();
|
|
||||||
expect(plugin.name).toBe('autoLazyLoad');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should destroy', () => {
|
|
||||||
const plugin = AutoLazyLoad({
|
|
||||||
lazyLoadCallback: vi.fn(),
|
|
||||||
lazyUnloadCallback: vi.fn(),
|
|
||||||
});
|
|
||||||
const emblaApi = createEmblaApiInstance();
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
plugin.destroy();
|
|
||||||
|
|
||||||
expect(emblaApi.off).toBeCalledWith('init', expect.anything());
|
|
||||||
expect(emblaApi.off).toBeCalledWith('select', expect.anything());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should do nothing without callbacks', () => {
|
|
||||||
const plugin = AutoLazyLoad({
|
|
||||||
// No callbacks provided.
|
|
||||||
});
|
|
||||||
|
|
||||||
const children = createTestSlideNodes();
|
|
||||||
const emblaApi = createEmblaApiInstance({ slideNodes: children });
|
|
||||||
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
expect(emblaApi.on).not.toBeCalled();
|
|
||||||
|
|
||||||
plugin.destroy();
|
|
||||||
expect(emblaApi.off).not.toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should lazy load single slide on select', () => {
|
|
||||||
const lazyLoadCallback = vi.fn();
|
|
||||||
const plugin = AutoLazyLoad({
|
|
||||||
lazyLoadCallback: lazyLoadCallback,
|
|
||||||
});
|
|
||||||
|
|
||||||
const children = createTestSlideNodes();
|
|
||||||
const emblaApi = createEmblaApiInstance({ slideNodes: children });
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
|
|
||||||
expect(emblaApi.on).toBeCalledWith('init', expect.anything());
|
|
||||||
expect(emblaApi.on).toBeCalledWith('select', expect.anything());
|
|
||||||
|
|
||||||
callEmblaHandler(emblaApi, 'init');
|
|
||||||
expect(lazyLoadCallback).toBeCalledWith(0, children[0]);
|
|
||||||
|
|
||||||
callEmblaHandler(emblaApi, 'select');
|
|
||||||
|
|
||||||
// The select call will not re-lazyload the same slide.
|
|
||||||
expect(lazyLoadCallback).toBeCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should lazy load multiple slides on select', () => {
|
|
||||||
const lazyLoadCallback = vi.fn();
|
|
||||||
const plugin = AutoLazyLoad({
|
|
||||||
lazyLoadCallback: lazyLoadCallback,
|
|
||||||
lazyLoadCount: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
const children = createTestSlideNodes();
|
|
||||||
const emblaApi = createEmblaApiInstance({
|
|
||||||
slideNodes: children,
|
|
||||||
selectedScrollSnap: 5,
|
|
||||||
});
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
|
|
||||||
callEmblaHandler(emblaApi, 'select');
|
|
||||||
for (let i = 3; i <= 8; ++i) {
|
|
||||||
expect(lazyLoadCallback).toBeCalledWith(i, children[i]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should lazy unload on select', () => {
|
|
||||||
const lazyUnloadCallback = vi.fn();
|
|
||||||
const plugin = AutoLazyLoad({
|
|
||||||
lazyLoadCallback: vi.fn(),
|
|
||||||
lazyLoadCount: 3,
|
|
||||||
lazyUnloadCallback: lazyUnloadCallback,
|
|
||||||
lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const children = createTestSlideNodes();
|
|
||||||
const emblaApi = createEmblaApiInstance({
|
|
||||||
selectedScrollSnap: 5,
|
|
||||||
slideNodes: children,
|
|
||||||
});
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
|
|
||||||
callEmblaHandler(emblaApi, 'select');
|
|
||||||
|
|
||||||
// First call will not unload anything, since it was not lazy loaded.
|
|
||||||
expect(lazyUnloadCallback).not.toBeCalled();
|
|
||||||
|
|
||||||
vi.mocked(emblaApi.previousScrollSnap).mockReturnValue(5);
|
|
||||||
callEmblaHandler(emblaApi, 'select');
|
|
||||||
|
|
||||||
// Second call should lazy unload the previous slide.
|
|
||||||
expect(lazyUnloadCallback).toBeCalledWith(5, children[5]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should lazy load on visibility', () => {
|
|
||||||
vi.spyOn(global.document, 'addEventListener');
|
|
||||||
|
|
||||||
const lazyLoadCallback = vi.fn();
|
|
||||||
const plugin = AutoLazyLoad({
|
|
||||||
lazyLoadCallback: lazyLoadCallback,
|
|
||||||
});
|
|
||||||
|
|
||||||
const children = createTestSlideNodes();
|
|
||||||
const emblaApi = createEmblaApiInstance({
|
|
||||||
slideNodes: children,
|
|
||||||
});
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
|
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
|
||||||
value: 'visible',
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
callVisibilityHandler();
|
|
||||||
expect(lazyLoadCallback).toBeCalledWith(0, children[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should lazy unload on visibility', () => {
|
|
||||||
vi.spyOn(global.document, 'addEventListener');
|
|
||||||
|
|
||||||
const lazyUnloadCallback = vi.fn();
|
|
||||||
|
|
||||||
const plugin = AutoLazyLoad({
|
|
||||||
lazyLoadCallback: vi.fn(),
|
|
||||||
lazyUnloadCallback: lazyUnloadCallback,
|
|
||||||
lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const children = createTestSlideNodes();
|
|
||||||
const emblaApi = createEmblaApiInstance({
|
|
||||||
slideNodes: children,
|
|
||||||
});
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
|
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
|
||||||
value: 'visible',
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
callVisibilityHandler();
|
|
||||||
|
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
|
||||||
value: 'hidden',
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
callVisibilityHandler();
|
|
||||||
expect(lazyUnloadCallback).toBeCalledWith(0, children[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not lazy unload on visibility without a callback', () => {
|
|
||||||
vi.spyOn(global.document, 'addEventListener');
|
|
||||||
|
|
||||||
const lazyLoadCallback = vi.fn();
|
|
||||||
const plugin = AutoLazyLoad({
|
|
||||||
lazyLoadCallback: lazyLoadCallback,
|
|
||||||
lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
|
||||||
// No lazy unload callback.
|
|
||||||
});
|
|
||||||
const children = createTestSlideNodes();
|
|
||||||
const emblaApi = createEmblaApiInstance({
|
|
||||||
slideNodes: children,
|
|
||||||
});
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
|
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
|
||||||
value: 'visible',
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
callVisibilityHandler();
|
|
||||||
expect(lazyLoadCallback).toBeCalledTimes(1);
|
|
||||||
|
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
|
||||||
value: 'hidden',
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
callVisibilityHandler();
|
|
||||||
expect(lazyLoadCallback).toBeCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not lazy load or unload on visibility when no callback provided', () => {
|
|
||||||
vi.spyOn(global.document, 'addEventListener');
|
|
||||||
|
|
||||||
const plugin = AutoLazyLoad({
|
|
||||||
lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
|
||||||
// No callbacks provided.
|
|
||||||
});
|
|
||||||
|
|
||||||
const children = createTestSlideNodes();
|
|
||||||
const emblaApi = createEmblaApiInstance({
|
|
||||||
slideNodes: children,
|
|
||||||
});
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
|
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
|
||||||
value: 'visible',
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
callVisibilityHandler();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -35,15 +35,6 @@ export const callEmblaHandler = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const callVisibilityHandler = async (): Promise<void> => {
|
|
||||||
const mock = vi.mocked(global.document.addEventListener).mock;
|
|
||||||
for (const [evt, cb] of mock.calls) {
|
|
||||||
if (evt === 'visibilitychange' && typeof cb === 'function') {
|
|
||||||
await (cb as EventListener | ((_: unknown) => Promise<void>))(new Event('foo'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const callResizeHandler = (
|
export const callResizeHandler = (
|
||||||
entries: {
|
entries: {
|
||||||
target: HTMLElement;
|
target: HTMLElement;
|
||||||
|
|||||||
Reference in New Issue
Block a user