From e830db1a7775475f946f79a7541948f8bb72facc Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 4 Sep 2023 16:25:32 -0700 Subject: [PATCH] Complete carousel refactor. Reduces one layer of DOM nesting for simplication, uses the latest Embla version, unittests for everything. --- package.json | 10 +- src/action-handler-directive.ts | 2 +- src/cached-value-controller.ts | 12 +- src/camera-manager/engine-factory.ts | 2 +- src/camera-manager/range.ts | 19 +- src/components/carousel.ts | 292 ++---- src/components/drawer.ts | 20 +- src/components/embla-plugins/automedia.ts | 234 ----- src/components/live/live.ts | 209 ++-- src/components/media-carousel.ts | 440 --------- src/components/thumbnail-carousel.ts | 214 ++--- src/components/title-control.ts | 41 +- src/components/viewer.ts | 169 ++-- src/scss/carousel.scss | 3 + src/scss/live-carousel.scss | 6 +- src/scss/live-ha.scss | 2 - src/scss/media-carousel.scss | 9 - src/scss/viewer-carousel.scss | 11 +- src/utils/basic.ts | 8 + src/utils/camera.ts | 4 +- src/utils/embla/carousel-controller.ts | 178 ++++ .../plugins/auto-lazy-load/auto-lazy-load.ts} | 106 +-- .../auto-media-actions/auto-media-actions.ts | 232 +++++ .../auto-media-loaded-info.ts | 94 ++ .../embla/plugins/auto-size/auto-size.ts | 143 +++ src/utils/embla/reinit-controller.ts | 63 ++ src/utils/media-grid-controller.ts | 21 +- src/utils/media-info.ts | 12 +- src/utils/menu-controller.ts | 3 +- src/utils/zod.ts | 17 +- src/view/media-queries-results.ts | 2 +- src/view/view.ts | 10 +- tests/camera-manager/cache.test.ts | 28 +- tests/camera-manager/engine-factory.test.ts | 5 + tests/camera-manager/range.test.ts | 5 + tests/conditions.test.ts | 7 + tests/test-utils.ts | 58 +- tests/utils/basic.test.ts | 33 +- tests/utils/download.test.ts | 97 +- tests/utils/embla/carousel-controller.test.ts | 291 ++++++ .../auto-lazy-load/auto-lazy-load.test.ts | 219 +++++ .../auto-media-actions.test.ts | 304 ++++++ .../auto-media-loaded-info.test.ts | 92 ++ .../embla/plugins/auto-size/auto-size.test.ts | 161 ++++ tests/utils/embla/reinit-controller.test.ts | 59 ++ tests/utils/embla/test-utils.ts | 132 +++ tests/utils/media-grid-controller.test.ts | 250 +++-- tests/utils/media.test.ts | 10 + tests/utils/menu-controller.test.ts | 33 +- tests/utils/substream.test.ts | 22 +- tests/utils/zod.test.ts | 11 +- tests/utils/zoom.test.ts | 67 ++ tests/view/media-queries-results.test.ts | 36 + tests/view/view.test.ts | 97 +- vite.config.ts | 11 + yarn.lock | 895 ++++++++++++------ 56 files changed, 3561 insertions(+), 1950 deletions(-) delete mode 100644 src/components/embla-plugins/automedia.ts delete mode 100644 src/components/media-carousel.ts delete mode 100644 src/scss/media-carousel.scss create mode 100644 src/utils/embla/carousel-controller.ts rename src/{components/embla-plugins/lazyload.ts => utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts} (57%) create mode 100644 src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts create mode 100644 src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.ts create mode 100644 src/utils/embla/plugins/auto-size/auto-size.ts create mode 100644 src/utils/embla/reinit-controller.ts create mode 100644 tests/utils/embla/carousel-controller.test.ts create mode 100644 tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts create mode 100644 tests/utils/embla/plugins/auto-media-actions/auto-media-actions.test.ts create mode 100644 tests/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.test.ts create mode 100644 tests/utils/embla/plugins/auto-size/auto-size.test.ts create mode 100644 tests/utils/embla/reinit-controller.test.ts create mode 100644 tests/utils/embla/test-utils.ts create mode 100644 vite.config.ts diff --git a/package.json b/package.json index ef7c35fd..e7f479de 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "custom-card-helpers": "^1.9.0", "date-fns": "^2.29.2", "date-fns-tz": "^1.3.7", - "embla-carousel": "^7.0.9", - "embla-carousel-wheel-gestures": "^3.0.0", + "embla-carousel": "8.0.0-rc12", + "embla-carousel-wheel-gestures": "8.0.0-rc04", "home-assistant-js-websocket": "^8.0.0", "keycharm": "^0.4.0", "lit": "^2.3.1", @@ -62,7 +62,7 @@ "@types/masonry-layout": "^4.2.5", "@typescript-eslint/eslint-plugin": "^5.36.2", "@typescript-eslint/parser": "^5.36.2", - "@vitest/coverage-c8": "^0.29.8", + "@vitest/coverage-istanbul": "^0.34.3", "eslint": "^8.23.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-prettier": "^8.5.0", @@ -80,8 +80,8 @@ "sass": "^1.54.9", "ts-prune": "^0.10.3", "typescript": "^4.9.5", - "vitest": "^0.29.8", - "vitest-mock-extended": "^1.1.3" + "vitest": "^0.34.3", + "vitest-mock-extended": "^1.2.1" }, "scripts": { "start": "rollup -c --watch", diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index 17cad147..170c3e41 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -6,9 +6,9 @@ import type { import { noChange } from 'lit'; import { AttributePart, - directive, Directive, DirectiveParameters, + directive, } from 'lit/directive.js'; import { stopEventFromActivatingCardWideActions } from './utils/action.js'; import { Timer } from './utils/timer.js'; diff --git a/src/cached-value-controller.ts b/src/cached-value-controller.ts index f0ce59dd..894d15c7 100644 --- a/src/cached-value-controller.ts +++ b/src/cached-value-controller.ts @@ -69,13 +69,11 @@ export class CachedValueController implements ReactiveController { public startTimer(): void { this.stopTimer(); - if (this._timerSeconds > 0) { - this._timerStartCallback?.(); - this._timer.startRepeated(this._timerSeconds, () => { - this.updateValue(); - this._host.requestUpdate(); - }); - } + this._timerStartCallback?.(); + this._timer.startRepeated(this._timerSeconds, () => { + this.updateValue(); + this._host.requestUpdate(); + }); } public hasTimer(): boolean { diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 7aab5ed1..61dd5901 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -66,7 +66,7 @@ export class CameraManagerEngineFactory { engine = Engine.MotionEye; } else if (cameraConfig.engine === 'generic') { engine = Engine.Generic; - } else if (cameraConfig.engine === 'auto') { + } else { const cameraEntity = getCameraEntityFromConfig(cameraConfig); if (cameraEntity) { diff --git a/src/camera-manager/range.ts b/src/camera-manager/range.ts index c358395b..e680a5e1 100644 --- a/src/camera-manager/range.ts +++ b/src/camera-manager/range.ts @@ -58,8 +58,8 @@ export class ExpiringMemoryRangeSet } public add(range: ExpiringRange): void { - this._expireOldRanges(); this._ranges.push(range); + this._expireOldRanges(); } protected _expireOldRanges(): void { @@ -95,26 +95,25 @@ export const compressRanges = ( ranges = orderBy(ranges, (range) => range.start, 'asc'); let current: Range | null = null; - for (let i = 0; i < ranges.length; ++i) { - const item = ranges[i]; - const itemStartSeconds = - item.start instanceof Date ? item.start.getTime() : item.start; + for (const range of ranges) { + const rangeStartSeconds: number = + range.start instanceof Date ? range.start.getTime() : range.start; if (!current) { - current = { ...item }; + current = { ...range }; continue; } const currentEndSeconds = current.end instanceof Date ? current.end.getTime() : (current.end as number); - if (currentEndSeconds + toleranceSeconds * 1000 >= itemStartSeconds) { - if (item.end > current.end) { - current.end = item.end; + if (currentEndSeconds + toleranceSeconds * 1000 >= rangeStartSeconds) { + if (range.end > current.end) { + current.end = range.end; } } else { compressedRanges.push(current); - current = { ...item }; + current = { ...range }; } } if (current) { diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 59a43c87..1b83b78c 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -1,5 +1,3 @@ -import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel'; -import { EmblaNodesType } from 'embla-carousel/components'; import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; import { CSSResultGroup, @@ -11,16 +9,12 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js'; -import isEqual from 'lodash-es/isEqual'; -import throttle from 'lodash-es/throttle'; import carouselStyle from '../scss/carousel.scss'; import { TransitionEffect } from '../types'; -import { dispatchFrigateCardEvent, isHTMLElement } from '../utils/basic.js'; - -export interface CarouselSelect { - index: number; - element: HTMLElement; -} +import { + CarouselController, + CarouselDirection, +} from '../utils/embla/carousel-controller'; export type EmblaCarouselPlugins = CreatePluginType< LoosePluginType, @@ -30,269 +24,91 @@ export type EmblaCarouselPlugins = CreatePluginType< @customElement('frigate-card-carousel') export class FrigateCardCarousel extends LitElement { @property({ attribute: true, reflect: true }) - public direction: 'vertical' | 'horizontal' = 'horizontal'; - - @property({ attribute: false }) - public carouselOptions?: EmblaOptionsType; - - @property({ attribute: false }) - public carouselPlugins?: EmblaCarouselPlugins; - - @property({ attribute: false }) - public selected = 0; + public direction: CarouselDirection = 'horizontal'; @property({ attribute: true }) public transitionEffect?: TransitionEffect; - protected _refSlot: Ref = createRef(); + @property({ attribute: false }) + public loop?: boolean; - protected _carousel?: EmblaCarouselType; + @property({ attribute: false }) + public dragFree?: boolean; - // Whether the carousel is actively scrolling. - protected _scrolling = false; + @property({ attribute: false }) + public dragEnabled = true; - // Whether to reinit the carousel when it settles. - protected _reInitOnSettle = false; + @property({ attribute: false }) + public plugins?: EmblaCarouselPlugins; - protected _carouselReInitInPlace = throttle( - this._carouselReInitInPlaceInternal.bind(this), - 500, - { trailing: true }, - ); + @property({ attribute: false }) + public selected = 0; + + protected _refParent: Ref = createRef(); + protected _refRoot: Ref = createRef(); + protected _carousel: CarouselController | null = null; connectedCallback(): void { super.connectedCallback(); - // Guarantee a re-render if the component is reconnected. See note in - // disconnectedCallback(). + // Guarantee recreation of carousel if the component is reconnected. this.requestUpdate(); } - /** - * Component disconnected callback. - */ disconnectedCallback(): void { // Destroy the carousel when the component is disconnected, which forces the // plugins (which may have registered event handlers) to also be destroyed. // The carousel will automatically reconstruct if the component is re-rendered. - this._destroyCarousel(); + this._carousel?.destroy(); super.disconnectedCallback(); } - /** - * Destroy the carousel if certain properties change. - * @param changedProps The changed properties - */ protected willUpdate(changedProps: PropertyValues): void { - const destroyProperties = [ - 'direction', - 'carouselOptions', - 'carouselPlugins', - ] as const; + if (changedProps.has('direction')) { + this.setAttribute('direction', this.direction); + } + + const destroyProperties = ['direction', 'dragFree', 'transitionEffect'] as const; if (destroyProperties.some((prop) => changedProps.has(prop))) { - this._destroyCarousel(); - } - } - - /** - * Get the selected slide. - * @returns A CarouselSelect object (index & element). - */ - public getCarouselSelected(slide?: number): CarouselSelect | null { - const index = slide ?? this._carousel?.selectedScrollSnap(); - const element = - index !== undefined ? this._carousel?.slideNodes()[index] ?? null : null; - if (index !== undefined && element) { - return { - index: index, - element: element, - }; - } - return null; - } - - /** - * Get the carousel. - */ - public carousel(): EmblaCarouselType | null { - return this._carousel ?? null; - } - - /** - * ReInit the carousel but stay on the current slide. - */ - protected _carouselReInitInPlaceInternal(): void { - const carouselReInit = (options?: EmblaOptionsType): void => { - // Allow the browser a moment to paint components that are inflight, to - // ensure accurate measurements are taken during the carousel - // reinitialization. - window.requestAnimationFrame(() => { - this._carousel?.reInit({ ...options }); - }); - }; - - carouselReInit({ - startIndex: this.selected, - }); - } - - /** - * 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(); - } - } - - /** - * The updated lifecycle callback for this element. - * @param changedProperties The properties that were changed in this render. - */ - updated(changedProperties: PropertyValues): void { - super.updated(changedProperties); - - if (!this._carousel) { - this._initCarousel(); - } - - if (changedProperties.has('selected')) { - this._carousel?.scrollTo(this.selected, this.transitionEffect === 'none'); - } - } - - /** - * Destroy the carousel. - * @param options If `savePosition` is set the existing carousel position - * will be saved so it can be restored if the carousel is recreated. - */ - protected _destroyCarousel(): void { - if (this._carousel) { - this._carousel.destroy(); - } - this._carousel = undefined; - } - - protected _getSlideElements(): HTMLElement[] { - return ( - this._refSlot.value?.assignedElements({ flatten: true }).filter(isHTMLElement) ?? - [] - ); - } - - /** - * Initialize the carousel. - */ - protected _initCarousel(): void { - const carouselNode = this.renderRoot.querySelector( - '.embla__viewport', - ) as HTMLElement; - - const nodes: EmblaNodesType = { - root: carouselNode, - // As the slides are slotted, need to explicitly pull them out and pass - // them to Embla. - slides: this._getSlideElements(), - }; - - if (carouselNode && nodes.slides) { - this._carousel = EmblaCarousel( - nodes, - { - axis: this.direction == 'horizontal' ? 'x' : 'y', - speed: 30, - startIndex: this.selected, - ...this.carouselOptions, - }, - this.carouselPlugins, - ); - const selectSlide = (slide?: number): void => { - const selected = this.getCarouselSelected(slide); - 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( - 'init', - // On initialization selectedScrollSnap() will return 0, even if the - // startIndex during initialization is different, as such we override - // the selected slide as returned by the carousel. This need should be - // verified in future versions of Embla (tested as necessary on v7.0.9). - // Test case: - // - // - Start in `live` view in grid mode. - // - Select any camera that is not the first one. - // - Go to non-grid mode. - // - Go back to grid mode. - // - If successful, thumbnails will load correctly (and the query and - // queryResults in the view will be set vs having been reset in - // `_setViewCameraID` in `live.ts`). - () => selectSlide(this.selected), - ); - this._carousel.on('select', () => selectSlide()); - this._carousel.on('scroll', () => { - this._scrolling = true; - }); - this._carousel.on('settle', () => { - // Reinitialize the carousel if a request to reinitialize was made - // during scrolling (instead the request is handled after the scrolling - // has settled). - this._scrolling = false; - if (this._reInitOnSettle) { - this._reInitOnSettle = false; - this._carouselReInitInPlace(); - } - }); - this._carousel.on('settle', () => { - const selected = this.getCarouselSelected(); - if (selected) { - dispatchFrigateCardEvent(this, 'carousel:settle', selected); - } - }); - } - } - - /** - * Called when the slotted children in the carousel change. - */ - protected _slotChanged(): void { - // Check whether the slotted elements have changed (without this check the - // carousel initializations are duplicated). - if (!isEqual(this._getSlideElements(), this._carousel?.slideNodes())) { - // Cannot just re-init, because the slide elements themselves may have - // changed, and only a carousel init can pass in new (slotted) children. - this._destroyCarousel(); - this.requestUpdate(); + this._carousel?.destroy(); + this._carousel = null; } } protected render(): TemplateResult | void { - const slides = this._refSlot.value?.assignedElements({ flatten: true }) || []; - const showPrevious = this.carouselOptions?.loop || this.selected > 0; - const showNext = this.carouselOptions?.loop || this.selected + 1 < slides.length; - return html`
- ${showPrevious ? html`` : ``} -
+ +
- +
- ${showNext ? html`` : ``} +
`; } - /** - * Get element styles. - */ + protected updated(changedProps: PropertyValues): void { + if (!this._carousel && this._refRoot.value && this._refParent.value) { + this._carousel = new CarouselController( + this._refRoot.value, + this._refParent.value, + { + direction: this.direction, + dragEnabled: this.dragEnabled, + dragFree: this.dragFree, + startIndex: this.selected, + transitionEffect: this.transitionEffect, + loop: this.loop, + plugins: this.plugins, + }, + ); + } + + if (changedProps.has('selected')) { + this._carousel?.selectSlide(this.selected); + } + } + static get styles(): CSSResultGroup { return unsafeCSS(carouselStyle); } diff --git a/src/components/drawer.ts b/src/components/drawer.ts index 0639c707..6ea33139 100644 --- a/src/components/drawer.ts +++ b/src/components/drawer.ts @@ -13,7 +13,7 @@ import { SideDrawer } from 'side-drawer'; import drawerInjectStyle from '../scss/drawer-inject.scss'; import drawerStyle from '../scss/drawer.scss'; import { stopEventFromActivatingCardWideActions } from '../utils/action'; -import { isHoverableDevice } from '../utils/basic'; +import { getChildrenFromElement, isHoverableDevice } from '../utils/basic'; export interface DrawerIcons { open?: string; @@ -67,12 +67,14 @@ export class FrigateCardDrawer extends LitElement { * Called when the slotted children in the drawer change. */ protected _slotChanged(): void { - const elements = this._refSlot.value?.assignedElements({ flatten: true }); + const children = this._refSlot.value + ? getChildrenFromElement(this._refSlot.value) + : []; // Watch all slot children for size changes. this._resizeObserver.disconnect(); - for (const element of elements ?? []) { - this._resizeObserver.observe(element); + for (const child of children) { + this._resizeObserver.observe(child); } this._hideDrawerIfNecessary(); } @@ -86,11 +88,13 @@ export class FrigateCardDrawer extends LitElement { return; } - const elements = this._refSlot.value?.assignedElements({ flatten: true }); + const children = this._refSlot.value + ? getChildrenFromElement(this._refSlot.value) + : null; this.empty = - !elements || - !elements.length || - elements.every((element) => { + !children || + !children.length || + children.every((element) => { const box = element.getBoundingClientRect(); return !box.width || !box.height; }); diff --git a/src/components/embla-plugins/automedia.ts b/src/components/embla-plugins/automedia.ts deleted file mode 100644 index a4f3dbd6..00000000 --- a/src/components/embla-plugins/automedia.ts +++ /dev/null @@ -1,234 +0,0 @@ -import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel'; -import { CreateOptionsType } from 'embla-carousel/components/Options.js'; -import { CreatePluginType } from 'embla-carousel/components/Plugins.js'; -import { - AutoMuteCondition, - AutoPauseCondition, - AutoPlayCondition, - AutoUnmuteCondition, - FrigateCardMediaPlayer, -} from '../../types.js'; - -type OptionsType = CreateOptionsType<{ - playerSelector?: string; - - // Note: Neither play nor unmute will activate on selection. The caller is - // expected to call the `play()` or `unmute()` methods manually when the media - // is actually loaded (and not just when the slide is visible -- the browser - // cannot play media that is not actually loaded yet, e.g. lazy loading). - autoPlayCondition?: AutoPlayCondition; - autoUnmuteCondition?: AutoUnmuteCondition; - autoPauseCondition?: AutoPauseCondition; - autoMuteCondition?: AutoMuteCondition; -}>; - -const defaultOptions: OptionsType = { - active: true, - breakpoints: {}, -}; - -type AutoMediaOptionsType = Partial - -export type AutoMediaType = CreatePluginType< - { - play: () => void; - pause: () => void; - mute: () => void; - unmute: () => void; - }, - AutoMediaOptionsType ->; - -declare module 'embla-carousel/components/Plugins' { - interface EmblaPluginsType { - autoMedia?: AutoMediaType - } -} - -/** - * An Embla plugin to take automated actions on media (e.g. pause, unmute, etc). - * @param userOptions - * @returns - */ -export function AutoMediaPlugin( - userOptions?: AutoMediaOptionsType, -): AutoMediaType { - const optionsHandler = EmblaCarousel.optionsHandler(); - const optionsBase = optionsHandler.merge( - defaultOptions, - AutoMediaPlugin.globalOptions, - ); - - let options: AutoMediaType['options']; - let carousel: EmblaCarouselType; - let slides: HTMLElement[]; - - /** - * Initialize the plugin. - */ - function init(embla: EmblaCarouselType): void { - carousel = embla; - options = optionsHandler.atMedia(self.options); - slides = carousel.slideNodes(); - - // Frigate card media autoplays when the media loads not necessarily when the - // slide is selected, so only pause (and not play/unmute) based on carousel - // events. - carousel.on('destroy', pause); - if ( - options.autoPauseCondition && - ['all', 'unselected'].includes(options.autoPauseCondition) - ) { - carousel.on('select', pausePrevious); - } - carousel.on('destroy', mute); - if ( - options.autoMuteCondition && - ['all', 'unselected'].includes(options.autoMuteCondition) - ) { - carousel.on('select', mutePrevious); - } - - document.addEventListener('visibilitychange', visibilityHandler); - } - - /** - * Destroy the plugin. - */ - function destroy(): void { - carousel.off('destroy', pause); - if ( - options.autoPauseCondition && - ['all', 'unselected'].includes(options.autoPauseCondition) - ) { - carousel.off('select', pausePrevious); - } - carousel.off('destroy', mute); - if ( - options.autoMuteCondition && - ['all', 'unselected'].includes(options.autoMuteCondition) - ) { - carousel.off('select', mutePrevious); - } - - document.removeEventListener('visibilitychange', visibilityHandler); - } - - /** - * Handle document visibility changes. - */ - function visibilityHandler(): void { - if (document.visibilityState === 'hidden') { - if ( - options.autoPauseCondition && - ['all', 'hidden'].includes(options.autoPauseCondition) - ) { - pauseAll(); - } - if ( - options.autoMuteCondition && - ['all', 'hidden'].includes(options.autoMuteCondition) - ) { - muteAll(); - } - } else if (document.visibilityState === 'visible') { - if ( - options.autoPlayCondition && - ['all', 'visible'].includes(options.autoPlayCondition) - ) { - play(); - } - if ( - options.autoUnmuteCondition && - ['all', 'visible'].includes(options.autoUnmuteCondition) - ) { - unmute(); - } - } - } - - /** - * Get the media player from a slide. - * @param slide - * @returns A FrigateCardMediaPlayer object or `null`. - */ - function getPlayer(slide: HTMLElement | undefined): FrigateCardMediaPlayer | null { - return options.playerSelector - ? (slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null) - : null; - } - - /** - * Play the current slide. - */ - function play(): void { - getPlayer(slides[carousel.selectedScrollSnap()])?.play(); - } - - /** - * Pause the current slide. - */ - function pause(): void { - getPlayer(slides[carousel.selectedScrollSnap()])?.pause(); - } - - /** - * Pause the previous slide. - */ - function pausePrevious(): void { - getPlayer(slides[carousel.previousScrollSnap()])?.pause(); - } - - /** - * Pause all slides. - */ - function pauseAll(): void { - for (const slide of slides) { - getPlayer(slide)?.pause(); - } - } - - /** - * Unmute the current slide. - */ - function unmute(): void { - getPlayer(slides[carousel.selectedScrollSnap()])?.unmute(); - } - - /** - * Mute the current slide. - */ - function mute(): void { - getPlayer(slides[carousel.selectedScrollSnap()])?.mute(); - } - - /** - * Mute the previous slide. - */ - function mutePrevious(): void { - getPlayer(slides[carousel.previousScrollSnap()])?.mute(); - } - - /** - * Mute all slides. - */ - function muteAll(): void { - for (const slide of slides) { - getPlayer(slide)?.mute(); - } - } - - const self: AutoMediaType = { - name: 'autoMedia', - options: optionsHandler.merge(optionsBase, userOptions), - init, - destroy, - play, - pause, - mute, - unmute, - }; - return self; -} - -AutoMediaPlugin.globalOptions = undefined; diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 957ce164..25bfd7a0 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -1,6 +1,4 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { EmblaOptionsType } from 'embla-carousel'; -import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { HassEntity } from 'home-assistant-js-websocket'; import { CSSResultGroup, @@ -13,15 +11,16 @@ import { import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { guard } from 'lit/directives/guard.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; import { keyed } from 'lit/directives/keyed.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../../camera-manager/manager.js'; import { CameraConfigs, CameraEndpoints } from '../../camera-manager/types.js'; import { ConditionControllerEpoch, getOverriddenConfig } from '../../conditions.js'; import { localize } from '../../localize/localize.js'; +import basicBlockStyle from '../../scss/basic-block.scss'; import liveCarouselStyle from '../../scss/live-carousel.scss'; import liveProviderStyle from '../../scss/live-provider.scss'; -import basicBlockStyle from '../../scss/basic-block.scss'; import { CameraConfig, CardWideConfig, @@ -37,28 +36,30 @@ import { } from '../../types.js'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import { contentsChanged } from '../../utils/basic.js'; +import { CarouselSelected } from '../../utils/embla/carousel-controller.js'; +import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js'; +import { AutoMediaActions } from '../../utils/embla/plugins/auto-media-actions/auto-media-actions.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 { MediaGridSelected } from '../../utils/media-grid-controller.js'; import { dispatchExistingMediaLoadedInfoAsEvent, dispatchMediaUnloadedEvent, } from '../../utils/media-info.js'; import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; import { playMediaMutingIfNecessary } from '../../utils/media.js'; +import { Timer } from '../../utils/timer.js'; import { dispatchViewContextChangeEvent, View } from '../../view/view.js'; -import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js'; -import { - FrigateCardMediaCarousel, - wrapMediaLoadedEventForCarousel, - wrapMediaUnloadedEventForCarousel, -} from '../media-carousel.js'; +import { EmblaCarouselPlugins } from '../carousel.js'; import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../message.js'; import '../next-prev-control.js'; import '../surround.js'; import '../title-control.js'; -import { AutoMediaPlugin } from './../embla-plugins/automedia.js'; -import { Lazyload } from './../embla-plugins/lazyload.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; -import { MediaGridSelected } from '../../utils/media-grid-controller.js'; -import { getDefaultTitleConfigForView } from '../title-control.js'; +import { + FrigateCardTitleControl, + getDefaultTitleConfigForView, + showTitleControlAfterDelay, +} from '../title-control.js'; interface LiveViewContext { // A cameraID override (used for dependencies/substreams to force a different @@ -200,11 +201,6 @@ export class FrigateCardLive extends LitElement { } } - /** - * Determine whether the element should be updated. - * @param _changedProps The changed properties if any. - * @returns `true` if the element should be updated. - */ // eslint-disable-next-line @typescript-eslint/no-unused-vars protected shouldUpdate(_changedProps: PropertyValues): boolean { // Don't process updates if it's in the background and a message was @@ -213,26 +209,16 @@ export class FrigateCardLive extends LitElement { return !this._inBackground || !this._messageReceivedPostRender; } - /** - * Component connected callback. - */ connectedCallback(): void { this._intersectionObserver.observe(this); super.connectedCallback(); } - /** - * Component disconnected callback. - */ disconnectedCallback(): void { super.disconnectedCallback(); this._intersectionObserver.disconnect(); } - /** - * Master render method. - * @returns A rendered template. - */ protected render(): TemplateResult | void { if ( !this.hass || @@ -318,9 +304,6 @@ export class FrigateCardLiveGrid extends LitElement { @property({ attribute: false, hasChanged: contentsChanged }) public liveOverrides?: LiveOverrides; - @property({ attribute: false }) - public inBackground?: boolean; - @property({ attribute: false }) public conditionControllerEpoch?: ConditionControllerEpoch; @@ -342,7 +325,6 @@ export class FrigateCardLiveGrid extends LitElement { .viewFilterCameraID=${cameraID} .nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig} .overriddenLiveConfig=${this.overriddenLiveConfig} - .inBackground=${this.inBackground} .conditionControllerEpoch=${this.conditionControllerEpoch} .liveOverrides=${this.liveOverrides} .cardWideConfig=${this.cardWideConfig} @@ -423,9 +405,6 @@ export class FrigateCardLiveCarousel extends LitElement { @property({ attribute: false, hasChanged: contentsChanged }) public liveOverrides?: LiveOverrides; - @property({ attribute: false }) - public inBackground?: boolean; - @property({ attribute: false }) public conditionControllerEpoch?: ConditionControllerEpoch; @@ -443,38 +422,9 @@ export class FrigateCardLiveCarousel extends LitElement { // Index between camera name and slide number. protected _cameraToSlide: Record = {}; - protected _refMediaCarousel: Ref = createRef(); + protected _titleTimer = new Timer(); + protected _refTitleControl: Ref = createRef(); - /** - * The updated lifecycle callback for this element. - * @param changedProperties The properties that were changed in this render. - */ - updated(changedProperties: PropertyValues): void { - super.updated(changedProperties); - - if (changedProperties.has('inBackground')) { - this.updateComplete.then(async () => { - const frigateCardMediaCarousel = this._refMediaCarousel.value; - if (frigateCardMediaCarousel) { - await frigateCardMediaCarousel.updateComplete; - // If this has changed to be in the background (i.e. preloaded but not - // visible) take the appropriate play/pause/mute/unmute actions. - if (this.inBackground) { - frigateCardMediaCarousel.autoPause(); - frigateCardMediaCarousel.autoMute(); - } else { - frigateCardMediaCarousel.autoPlay(); - frigateCardMediaCarousel.autoUnmute(); - } - } - }); - } - } - - /** - * Get the transition effect to use. - * @returns An TransitionEffect object. - */ protected _getTransitionEffect(): TransitionEffect { return ( this.overriddenLiveConfig?.transition_effect ?? @@ -490,49 +440,19 @@ export class FrigateCardLiveCarousel extends LitElement { return Math.max(0, Array.from(cameraIDs).indexOf(this.view.camera)); } - /** - * Get the Embla options to use. - * @returns An EmblaOptionsType object or undefined for no options. - */ - protected _getOptions(): EmblaOptionsType { - return { - // If the carousel is being filtered to a single cameraID, it is never - // draggable. - draggable: !this.viewFilterCameraID && this.overriddenLiveConfig?.draggable, - loop: true, - }; - } - - /** - * Get the Embla plugins to use. - * @returns A list of EmblaOptionsTypes. - */ protected _getPlugins(): EmblaCarouselPlugins { - const cameraCount = this.viewFilterCameraID - ? 1 - : this.cameraManager?.getStore().getVisibleCameraCount() ?? 0; return [ - // Only enable wheel plugin if there is more than one camera. - ...(cameraCount > 1 - ? [ - WheelGesturesPlugin({ - // Whether the carousel is vertical or horizontal, interpret y-axis wheel - // gestures as scrolling for the carousel. - forceWheelAxis: 'y', - }), - ] - : []), - Lazyload({ + AutoLazyLoad({ ...(this.overriddenLiveConfig?.lazy_load && { lazyLoadCallback: (index, slide) => this._lazyloadOrUnloadSlide('load', index, slide), }), - lazyUnloadCondition: this.overriddenLiveConfig?.lazy_unload, lazyUnloadCallback: (index, slide) => this._lazyloadOrUnloadSlide('unload', index, slide), }), - AutoMediaPlugin({ + AutoMediaLoadedInfo(), + AutoMediaActions({ playerSelector: FRIGATE_CARD_LIVE_PROVIDER, ...(this.overriddenLiveConfig?.auto_play && { autoPlayCondition: this.overriddenLiveConfig.auto_play, @@ -547,6 +467,7 @@ export class FrigateCardLiveCarousel extends LitElement { autoUnmuteCondition: this.overriddenLiveConfig.auto_unmute, }), }), + AutoSize(), ]; } @@ -562,11 +483,6 @@ export class FrigateCardLiveCarousel extends LitElement { return this.overriddenLiveConfig?.lazy_load === false ? null : 0; } - /** - * Get 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[], Record] { let cameras: CameraConfigs | null = null; if (this.viewFilterCameraID) { @@ -595,7 +511,7 @@ export class FrigateCardLiveCarousel extends LitElement { : this.cameraManager?.getStore().getCameraConfig(liveCameraID); const slide = liveCameraConfig - ? this._renderLive(liveCameraID, liveCameraConfig, slides.length) + ? this._renderLive(liveCameraID, liveCameraConfig) : null; if (slide) { cameraToSlide[cameraID] = slides.length; @@ -605,10 +521,7 @@ export class FrigateCardLiveCarousel extends LitElement { return [slides, cameraToSlide]; } - /** - * Handle the user selecting a new slide in the carousel. - */ - protected _setViewHandler(ev: CustomEvent): void { + protected _setViewHandler(ev: CustomEvent): void { const cameras = this.cameraManager?.getStore().getVisibleCameras(); if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) { this._setViewCameraID(Array.from(cameras.keys())[ev.detail.index]); @@ -631,11 +544,6 @@ export class FrigateCardLiveCarousel extends LitElement { } } - /** - * Lazy load a slide. - * @param _index The slide number to lazy load. - * @param slide The slide to lazy load. - */ protected _lazyloadOrUnloadSlide( action: 'load' | 'unload', _index: number, @@ -649,14 +557,13 @@ export class FrigateCardLiveCarousel extends LitElement { FRIGATE_CARD_LIVE_PROVIDER, ) as FrigateCardLiveProvider | null; if (liveProvider) { - liveProvider.disabled = action !== 'load'; + liveProvider.load = action === 'load'; } } protected _renderLive( cameraID: string, cameraConfig: CameraConfig, - slideIndex: number, ): TemplateResult | void { if ( !this.overriddenLiveConfig || @@ -683,7 +590,7 @@ export class FrigateCardLiveCarousel extends LitElement { return html`
) => { - wrapMediaLoadedEventForCarousel(slideIndex, ev); - }} - @frigate-card:media:unloaded=${(ev: CustomEvent) => { - wrapMediaUnloadedEventForCarousel(slideIndex, ev); - }} >
@@ -728,10 +629,6 @@ export class FrigateCardLiveCarousel extends LitElement { ]; } - /** - * Render the element. - * @returns A template to display to the user. - */ protected render(): TemplateResult | void { if (!this.overriddenLiveConfig || !this.view || !this.hass || !this.cameraManager) { return; @@ -743,6 +640,7 @@ export class FrigateCardLiveCarousel extends LitElement { return; } + const hasMultipleCameras = slides.length > 1; const [prevID, nextID] = this._getCameraIDsOfNeighbors(); const overrideCameraID = (cameraID: string): string => { @@ -776,28 +674,25 @@ export class FrigateCardLiveCarousel extends LitElement { // little later). return html` - { // Fetch the thumbnails after the carousel has settled. dispatchViewContextChangeEvent(this, { thumbnails: { fetch: true } }); }} + @frigate-card:media:loaded=${() => { + if (this._refTitleControl.value) { + showTitleControlAfterDelay(this._refTitleControl.value, this._titleTimer); + } + }} > - + + ${cameraMetadataCurrent && titleConfig + ? html` + ` + : ``} `; } - /** - * Get styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(liveCarouselStyle); } @@ -857,10 +761,11 @@ export class FrigateCardLiveProvider @property({ attribute: false }) public liveConfig?: LiveConfig; - // Whether or not to disable this entity. If `true`, no contents are rendered - // until this attribute is set to `false` (this is useful for lazy loading). + // 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 disabled = false; + public load = false; // Label that is used for ARIA support and as tooltip. @property({ attribute: false }) @@ -995,8 +900,8 @@ export class FrigateCardLiveProvider * Called before each update. */ protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('disabled')) { - if (this.disabled) { + if (changedProps.has('load')) { + if (!this.load) { this._isVideoMediaLoaded = false; dispatchMediaUnloadedEvent(this); } @@ -1051,7 +956,7 @@ export class FrigateCardLiveProvider * @returns A rendered template. */ protected render(): TemplateResult | void { - if (this.disabled || !this.hass || !this.liveConfig || !this.cameraConfig) { + if (!this.load || !this.hass || !this.liveConfig || !this.cameraConfig) { return; } diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts deleted file mode 100644 index 6f9e5a82..00000000 --- a/src/components/media-carousel.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { EmblaOptionsType } from 'embla-carousel'; -import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; -import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import debounce from 'lodash-es/debounce'; -import mediaCarouselStyle from '../scss/media-carousel.scss'; -import type { - MediaLoadedInfo, - NextPreviousControlConfig, - TitleControlConfig, - TransitionEffect, -} from '../types.js'; -import { dispatchFrigateCardEvent } from '../utils/basic'; -import { - dispatchExistingMediaLoadedInfoAsEvent, - isValidMediaLoadedInfo, -} from '../utils/media-info.js'; -import { Timer } from '../utils/timer'; -import { CarouselSelect, EmblaCarouselPlugins, FrigateCardCarousel } from './carousel'; -import './carousel.js'; -import { AutoMediaType } from './embla-plugins/automedia.js'; -import './next-prev-control.js'; -import { FrigateCardNextPreviousControl } from './next-prev-control.js'; -import { FrigateCardTitleControl } from './title-control.js'; - -interface CarouselMediaLoadedInfo { - slide: number; - mediaLoadedInfo: MediaLoadedInfo; -} - -interface CarouselMediaUnloadedInfo { - slide: number; -} - -/** - * Dispatch a carousel media loaded event. - * @param target The target to send it from. - * @param carouselMediaLoadedInfo The CarouselMediaLoadedInfo. - */ -const dispatchFrigateCardCarouselMediaLoaded = ( - target: EventTarget, - carouselMediaLoadedInfo: CarouselMediaLoadedInfo, -): void => { - dispatchFrigateCardEvent( - target, - 'carousel:media:loaded', - carouselMediaLoadedInfo, - ); -}; - -/** - * Dispatch a carousel media UNloaded event. - * @param target The target to send it from. - * @param carouselMediaUnloadedInfo The CarouselMediaUnloadedInfo. - */ -const dispatchFrigateCardCarouselMediaUnloaded = ( - target: EventTarget, - carouselMediaUnloadedInfo: CarouselMediaUnloadedInfo, -): void => { - dispatchFrigateCardEvent( - target, - 'carousel:media:unloaded', - carouselMediaUnloadedInfo, - ); -}; - -/** - * Turn a MediaLoadedInfo into a CarouselMediaLoadedInfo. - * @param slide The slide number. - * @param event The MediaShowEvent. - */ -export const wrapMediaLoadedEventForCarousel = ( - slide: number, - event: CustomEvent, -) => { - event.stopPropagation(); - dispatchFrigateCardCarouselMediaLoaded(event.composedPath()[0], { - slide: slide, - mediaLoadedInfo: event.detail, - }); -}; - -/** - * Turn a MediaUnloadedInfo into a CarouselMediaUnloadedInfo. - * @param slide The slide number. - * @param event The MediaUnloadedEvent. - */ -export const wrapMediaUnloadedEventForCarousel = ( - slide: number, - event: CustomEvent, -) => { - event.stopPropagation(); - dispatchFrigateCardCarouselMediaUnloaded(event.composedPath()[0], { - slide: slide, - }); -}; - -@customElement('frigate-card-media-carousel') -export class FrigateCardMediaCarousel extends LitElement { - @property({ attribute: false }) - public nextPreviousConfig?: NextPreviousControlConfig; - - @property({ attribute: false }) - public carouselOptions?: EmblaOptionsType; - - @property({ attribute: false }) - public carouselPlugins?: EmblaCarouselPlugins; - - @property({ attribute: false, type: Number }) - public selected = 0; - - @property({ attribute: true }) - public transitionEffect?: TransitionEffect; - - @property({ attribute: false }) - public label?: string; - - @property({ attribute: false }) - public logo?: string; - - @property({ attribute: false }) - public titlePopupConfig?: TitleControlConfig; - - // A "map" from slide number to MediaLoadedInfo object. - protected _mediaLoadedInfo: Record = {}; - protected _nextControlRef: Ref = createRef(); - protected _previousControlRef: Ref = createRef(); - protected _titleControlRef: Ref = createRef(); - protected _titleTimer = new Timer(); - - protected _boundAutoPlayHandler = this.autoPlay.bind(this); - protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this); - protected _boundTitleHandler = this._titleHandler.bind(this); - - // Debounce multiple calls to adapt the container height. - protected _debouncedAdaptContainerHeightToSlide = debounce( - this._adaptContainerHeightToSlide.bind(this), - 1 * 250, - {trailing: true}); - - // This carousel may be resized by Lovelace resizes, window resizes, - // fullscreen, etc. Always call the adaptive height handler when the size - // changes. - protected _slideResizeObserver: ResizeObserver; - protected _intersectionObserver: IntersectionObserver; - - protected _refCarousel: Ref = createRef(); - - constructor() { - super(); - // 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._slideResizeObserver = new ResizeObserver( - this._reInitAndAdjustHeight.bind(this), - ); - this._intersectionObserver = new IntersectionObserver( - this._intersectionHandler.bind(this), - ); - } - - /** - * Get the underlying carousel. - */ - public frigateCardCarousel(): FrigateCardCarousel | null { - return this._refCarousel.value ?? null; - } - - /** - * Get the AutoMedia plugin (if any). - * @returns The plugin or `null`. - */ - protected _getAutoMediaPlugin(): AutoMediaType | null { - return this.frigateCardCarousel()?.carousel()?.plugins().autoMedia ?? null; - } - - /** - * Play the media on the selected slide. - */ - public autoPlay(): void { - const automediaOptions = this._getAutoMediaPlugin()?.options; - if ( - automediaOptions?.autoPlayCondition && - ['all', 'selected'].includes(automediaOptions?.autoPlayCondition) - ) { - this._getAutoMediaPlugin()?.play(); - } - } - - /** - * Pause the media on the selected slide. - */ - public autoPause(): void { - const automediaOptions = this._getAutoMediaPlugin()?.options; - if ( - automediaOptions?.autoPauseCondition && - ['all', 'selected'].includes(automediaOptions.autoPauseCondition) - ) { - this._getAutoMediaPlugin()?.pause(); - } - } - - /** - * Unmute the media on the selected slide. - */ - public autoUnmute(): void { - const automediaOptions = this._getAutoMediaPlugin()?.options; - if ( - automediaOptions?.autoUnmuteCondition && - ['all', 'selected'].includes(automediaOptions?.autoUnmuteCondition) - ) { - this._getAutoMediaPlugin()?.unmute(); - } - } - - /** - * Mute the media on the selected slide. - */ - public autoMute(): void { - const automediaOptions = this._getAutoMediaPlugin()?.options; - if ( - automediaOptions?.autoMuteCondition && - ['all', 'selected'].includes(automediaOptions?.autoMuteCondition) - ) { - this._getAutoMediaPlugin()?.mute(); - } - } - - /** - * Show the media title after the media loads. - */ - protected _titleHandler(): void { - const show = () => { - this._titleTimer.stop(); - this._titleControlRef.value?.show(); - }; - - if (this._titleControlRef.value?.isVisible()) { - // If it's already visible, update it immediately (but also update it - // after the timer expires to ensure it re-positions if necessary, see - // comment below). - show(); - } - - // Allow a brief pause after the media loads, but before the title is - // displayed. This allows for a pleasant appearance/disappear of the title, - // and allows for the browser to finish rendering the carousel. - this._titleTimer.start(0.5, show); - } - - /** - * Component connected callback. - */ - connectedCallback(): void { - super.connectedCallback(); - - this.addEventListener('frigate-card:media:loaded', this._boundAutoPlayHandler); - this.addEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler); - this.addEventListener( - 'frigate-card:media:loaded', - this._debouncedAdaptContainerHeightToSlide, - ); - this.addEventListener('frigate-card:media:loaded', this._boundTitleHandler); - this._intersectionObserver.observe(this); - } - - /** - * Component disconnected callback. - */ - disconnectedCallback(): void { - this.removeEventListener('frigate-card:media:loaded', this._boundAutoPlayHandler); - this.removeEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler); - this.removeEventListener( - 'frigate-card:media:loaded', - this._debouncedAdaptContainerHeightToSlide, - ); - this.removeEventListener('frigate-card:media:loaded', this._boundTitleHandler); - this._intersectionObserver.disconnect(); - - this._mediaLoadedInfo = {}; - super.disconnectedCallback(); - } - - /** - * ReInit the carousel and adapt the container height. - */ - protected _reInitAndAdjustHeight(): void { - this.frigateCardCarousel()?.carouselReInitWhenSafe(); - this._debouncedAdaptContainerHeightToSlide(); - } - - /** - * Called when the carousel intersects with the viewport. - * @param entries The IntersectionObserverEntry entries (should be only 1). - */ - protected _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/frigate-hass-card/issues/651 - */ - if (entries.some((entry) => entry.isIntersecting)) { - this._reInitAndAdjustHeight(); - } - } - - /** - * Set the the height of the component on media load in case the dimensions - * 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 that - * resizes the container only on selection rather than media load. - */ - protected _adaptContainerHeightToSlide(): void { - const selected = this.frigateCardCarousel()?.getCarouselSelected(); - if (selected) { - this.style.removeProperty('max-height'); - const height = selected.element.getBoundingClientRect().height; - if (height !== undefined && height > 0) { - this.style.maxHeight = `${height}px`; - } - } - } - - /** - * Fire a media show event when a slide is selected. - */ - protected _dispatchMediaLoadedInfo(selected: CarouselSelect): void { - const slideIndex = selected.index; - if (slideIndex !== undefined && slideIndex in this._mediaLoadedInfo) { - dispatchExistingMediaLoadedInfoAsEvent(this, this._mediaLoadedInfo[slideIndex]); - } - } - - /** - * Handle a media:loaded event that is generated by a child component, saving the - * contents for future use when the relevant slide is actually shown. - * @param slideIndex The relevant slide index. - * @param event The media:loaded event from the child component. - */ - protected _storeMediaLoadedInfo(event: CustomEvent): void { - // Don't allow the inbound event to propagate upwards, that will be - // automatically done at the appropriate time as the slide is shown. - event.stopPropagation(); - const mediaLoadedInfo = event.detail.mediaLoadedInfo; - const slideIndex = event.detail.slide; - - // isValidMediaLoadedInfo is used to prevent saving media info that will be - // rejected upstream (empty 1x1 images will be rejected here). - if (mediaLoadedInfo && isValidMediaLoadedInfo(mediaLoadedInfo)) { - this._mediaLoadedInfo[slideIndex] = mediaLoadedInfo; - if (this.frigateCardCarousel()?.getCarouselSelected()?.index === slideIndex) { - dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo); - } - } - } - - /** - * Remove a media loaded info (i.e. a media item has unloaded). - * @param event The CarouselMediaUnloadedInfo event. - */ - protected _removeMediaLoadedInfo(event: CustomEvent): void { - const slideIndex = event.detail.slide; - delete this._mediaLoadedInfo[slideIndex]; - - // If the slide that unloaded is not visible, don't propagate the event upwards. - if (this.frigateCardCarousel()?.getCarouselSelected()?.index !== slideIndex) { - event.stopPropagation(); - } - } - - protected render(): TemplateResult | void { - const selectSlide = (ev: CustomEvent): void => { - this._slideResizeObserver.disconnect(); - const parent = this.getRootNode(); - if (parent && parent instanceof ShadowRoot) { - this._slideResizeObserver.observe(parent.host); - } - - const selected = ev.detail; - this._slideResizeObserver.observe(selected.element); - - // Pass up the media-carousel select event first to allow parents to - // initialize/reset before the media info is dispatched. - dispatchFrigateCardEvent( - this, - 'media-carousel:select', - selected, - ); - - // Dispatch media info. - this._dispatchMediaLoadedInfo(selected); - } - - return html` ) => { - selectSlide(ev); - }} - @frigate-card:carousel:media:loaded=${this._storeMediaLoadedInfo.bind(this)} - @frigate-card:carousel:media:unloaded=${this._removeMediaLoadedInfo.bind(this)} - > - - - - - ${this.label && this.titlePopupConfig - ? html` - ` - : ``}`; - } - - static get styles(): CSSResultGroup { - return unsafeCSS(mediaCarouselStyle); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'frigate-card-media-carousel': FrigateCardMediaCarousel; - } -} diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index c7a20351..1248efe0 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -1,5 +1,3 @@ -import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; -import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { CSSResultGroup, html, @@ -10,18 +8,16 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { CameraManager } from '../camera-manager/manager.js'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; import { ExtendedHomeAssistant, ThumbnailsControlConfig } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; -import { View } from '../view/view.js'; +import { CarouselDirection } from '../utils/embla/carousel-controller.js'; import { MediaQueriesResults } from '../view/media-queries-results'; -import { FrigateCardCarousel } from './carousel.js'; -import './thumbnail.js'; +import { View } from '../view/view.js'; import './carousel.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; -import { CameraManager } from '../camera-manager/manager.js'; +import './thumbnail.js'; export interface ThumbnailCarouselTap { queryResults: MediaQueriesResults; @@ -38,83 +34,11 @@ export class FrigateCardThumbnailCarousel extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; - protected _refCarousel: Ref = createRef(); - - // Thumbnail carousels can expand (e.g. drawer-based carousels after the main - // media loads). The carousel must be re-initialized in these cases, or the - // dynamic sizing fails (and users can scroll past the end of the carousel). - protected _resizeObserver: ResizeObserver; - @property({ attribute: false }) public config?: ThumbnailsControlConfig; - @property({ attribute: false }) - public selected? = 0; + protected _thumbnailSlides: TemplateResult[] = []; - protected _carouselOptions?: EmblaOptionsType = { - containScroll: 'keepSnaps', - dragFree: true, - }; - - protected _carouselPlugins: EmblaPluginType[] = [ - WheelGesturesPlugin({ - // Whether the carousel is vertical or horizontal, interpret y-axis wheel - // gestures as scrolling for the carousel. - forceWheelAxis: 'y', - }), - ]; - - constructor() { - super(); - this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); - } - - /** - * Handle gallery resize. - */ - protected _resizeHandler(): void { - this._refCarousel.value?.carouselReInitWhenSafe(); - } - - /** - * Component connected callback. - */ - connectedCallback(): void { - super.connectedCallback(); - this._resizeObserver.observe(this); - } - - /** - * Component disconnected callback. - */ - disconnectedCallback(): void { - this._resizeObserver.disconnect(); - super.disconnectedCallback(); - } - - /** - * Get slides to include in the render. - * @returns The slides to include in the render. - */ - protected _getSlides(): TemplateResult[] { - if (!this.view?.query || !this.view.queryResults?.hasResults()) { - return []; - } - - const slides: TemplateResult[] = []; - for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) { - const thumbnail = this._renderThumbnail(i); - if (thumbnail) { - slides[i] = thumbnail; - } - } - return slides; - } - - /** - * Called when an update will occur. - * @param changedProps The changed properties - */ protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('config')) { if (this.config?.size) { @@ -128,95 +52,93 @@ export class FrigateCardThumbnailCarousel extends LitElement { } } - if (changedProps.has('selected')) { + const renderProperties = [ + 'cameraManager', + 'config', + 'transitionEffect', + 'view', + ] as const; + if (renderProperties.some((prop) => changedProps.has(prop))) { + this._thumbnailSlides = this._renderSlides(); + } + + if (changedProps.has('view')) { this.style.setProperty( '--frigate-card-carousel-thumbnail-opacity', - this.selected === undefined ? '1.0' : '0.4', + this._getSelectedSlide() === null ? '1.0' : '0.4', ); } } - /** - * Render a given thumbnail. - * @param mediaToRender The media item to render. - * @returns A template or void if the item could not be rendered. - */ - protected _renderThumbnail(index: number): TemplateResult | void { - const media = this.view?.queryResults?.getResult(index) ?? null; - if (!media || !this.view) { - return; - } - - const classes = { - embla__slide: true, - 'slide-selected': this.selected === index, - }; - - const seekTarget = this.view?.context?.mediaViewer?.seek; - return html` { - if (this.view && this.view.queryResults) { - dispatchFrigateCardEvent( - this, - 'thumbnail-carousel:tap', - { - queryResults: this.view.queryResults.clone().selectIndex(index), - }, - ); - } - stopEventFromActivatingCardWideActions(ev); - }} - > - `; + protected _getSelectedSlide(view?: View): number | null { + return (view ?? this.view)?.queryResults?.getSelectedIndex() ?? null; } - /** - * Get the direction of the thumbnail carousel. - * @returns `vertical`, `horizontal` or undefined. - */ - protected _getDirection(): 'horizontal' | 'vertical' | undefined { + protected _renderSlides(): TemplateResult[] { + const slides: TemplateResult[] = []; + const seekTarget = this.view?.context?.mediaViewer?.seek; + const selectedIndex = this._getSelectedSlide(); + + for (const media of this.view?.queryResults?.getResults() ?? []) { + const index = slides.length; + const classes = { + embla__slide: true, + 'slide-selected': selectedIndex === index, + }; + + slides.push(html` { + if (this.view && this.view.queryResults) { + dispatchFrigateCardEvent( + this, + 'thumbnail-carousel:tap', + { + queryResults: this.view.queryResults.clone().selectIndex(index), + }, + ); + } + stopEventFromActivatingCardWideActions(ev); + }} + > + `); + } + return slides; + } + + protected _getDirection(): CarouselDirection | null { if (this.config?.mode === 'left' || this.config?.mode === 'right') { return 'vertical'; } else if (this.config?.mode === 'above' || this.config?.mode === 'below') { return 'horizontal'; } - return undefined; + return null; } - /** - * Render the element. - * @returns A template to display to the user. - */ protected render(): TemplateResult | void { - const slides = this._getSlides(); - if (!slides.length || !this.config || this.config.mode === 'none') { + const direction = this._getDirection(); + if (!this._thumbnailSlides.length || !this.config || !direction) { return; } return html` - ${slides} + ${this._thumbnailSlides} `; } - /** - * Get element styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(thumbnailCarouselStyle); } diff --git a/src/components/title-control.ts b/src/components/title-control.ts index 7bbda1e9..d6423a68 100644 --- a/src/components/title-control.ts +++ b/src/components/title-control.ts @@ -3,12 +3,36 @@ import { customElement, property } from 'lit/decorators.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import titleStyle from '../scss/title-control.scss'; import { TitleControlConfig } from '../types.js'; +import { Timer } from '../utils/timer'; import { View } from '../view/view.js'; type PaperToast = HTMLElement & { opened: boolean; }; +export const showTitleControlAfterDelay = ( + control: FrigateCardTitleControl, + timer: Timer, + delay = 0.5, +): void => { + const show = () => { + timer.stop(); + control.show(); + }; + + if (control.isVisible()) { + // If it's already visible, update it immediately (but also update it + // after the timer expires to ensure it re-positions if necessary, see + // comment below). + show(); + } + + // Allow a brief pause after the media loads, but before the title is + // displayed. This allows for a pleasant appearance/disappear of the title, + // and allows for the browser to finish rendering the carousel. + timer.start(delay, show); +}; + export const getDefaultTitleConfigForView = ( view?: Readonly, baseConfig?: TitleControlConfig, @@ -39,10 +63,6 @@ export class FrigateCardTitleControl extends LitElement { protected _toastRef: Ref = createRef(); - /** - * Master render method. - * @returns A rendered template. - */ protected render(): TemplateResult { if (!this.text || !this.config || this.config.mode == 'none' || !this.fitInto) { return html``; @@ -64,17 +84,10 @@ export class FrigateCardTitleControl extends LitElement { `; } - /** - * Determine if the toast is visible. - * @returns `true` if the toast is visible, `false` otherwise. - */ public isVisible(): boolean { return this._toastRef.value?.opened ?? false; } - /** - * Show the toast. - */ public hide(): void { if (this._toastRef.value) { // Set it to false first, to ensure the timer resets. @@ -82,9 +95,6 @@ export class FrigateCardTitleControl extends LitElement { } } - /** - * Show the toast. - */ public show(): void { if (this._toastRef.value) { // Set it to false first, to ensure the timer resets. @@ -93,9 +103,6 @@ export class FrigateCardTitleControl extends LitElement { } } - /** - * Get element styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(titleStyle); } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index e20244be..1b01f5bd 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -1,5 +1,3 @@ -import { EmblaPluginType } from 'embla-carousel'; -import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { CSSResultGroup, html, @@ -12,11 +10,15 @@ import { customElement, property } from 'lit/decorators.js'; import { guard } from 'lit/directives/guard.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; -import basicBlockStyle from '../scss/basic-block.scss'; import { CameraManager } from '../camera-manager/manager.js'; -import { dispatchMessageEvent, renderMessage, renderProgressIndicator } from '../components/message.js'; +import { + dispatchMessageEvent, + renderMessage, + renderProgressIndicator, +} from '../components/message.js'; import { localize } from '../localize/localize.js'; import '../patches/ha-hls-player'; +import basicBlockStyle from '../scss/basic-block.scss'; import viewerCarouselStyle from '../scss/viewer-carousel.scss'; import viewerProviderStyle from '../scss/viewer-provider.scss'; import viewerStyle from '../scss/viewer.scss'; @@ -36,6 +38,11 @@ import { errorToConsole, setOrRemoveAttribute, } from '../utils/basic.js'; +import { CarouselSelected } from '../utils/embla/carousel-controller.js'; +import { AutoLazyLoad } from '../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js'; +import { AutoMediaActions } from '../utils/embla/plugins/auto-media-actions/auto-media-actions.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 { canonicalizeHAURL } from '../utils/ha/index.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; import { MediaGridSelected } from '../utils/media-grid-controller.js'; @@ -57,22 +64,21 @@ import { setControlsOnVideo, } from '../utils/media.js'; import { screenshotMedia } from '../utils/screenshot.js'; +import { Timer } from '../utils/timer'; import { ViewMediaClassifier } from '../view/media-classifier'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { MediaQueriesResults } from '../view/media-queries-results.js'; import { VideoContentType, ViewMedia } from '../view/media.js'; import { View } from '../view/view.js'; -import type { CarouselSelect } from './carousel.js'; -import { AutoMediaPlugin } from './embla-plugins/automedia.js'; -import { Lazyload } from './embla-plugins/lazyload.js'; -import { - FrigateCardMediaCarousel, - wrapMediaLoadedEventForCarousel, -} from './media-carousel.js'; +import type { EmblaCarouselPlugins } from './carousel.js'; import './next-prev-control.js'; import './surround.js'; import './title-control.js'; -import { getDefaultTitleConfigForView } from './title-control.js'; +import { + FrigateCardTitleControl, + getDefaultTitleConfigForView, + showTitleControlAfterDelay, +} from './title-control.js'; export interface MediaViewerViewContext { seek?: Date; @@ -114,10 +120,6 @@ export class FrigateCardViewer extends LitElement { @property({ attribute: false }) public cardWideConfig?: CardWideConfig; - /** - * Master render method. - * @returns A rendered template. - */ protected render(): TemplateResult | void { if ( !this.hass || @@ -186,9 +188,6 @@ export class FrigateCardViewer extends LitElement { `; } - /** - * Get element styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(viewerStyle); } @@ -227,8 +226,10 @@ export class FrigateCardViewerCarousel extends LitElement { @property({ attribute: false }) public selected = 0; - protected _refMediaCarousel: Ref = createRef(); protected _media: ViewMedia[] | null = null; + protected _titleTimer = new Timer(); + protected _refTitleControl: Ref = createRef(); + protected _player: FrigateCardMediaPlayer | null = null; /** * The updated lifecycle callback for this element. @@ -259,47 +260,19 @@ export class FrigateCardViewerCarousel extends LitElement { ); } - /** - * Get the media player on a slide (or current slide if not provided). - * @param slide An optional slide. - * @returns The FrigateCardMediaPlayer or null if not found. - */ - protected _getPlayer(slide?: HTMLElement | null): FrigateCardMediaPlayer | null { - if (!slide) { - slide = this._refMediaCarousel.value - ?.frigateCardCarousel() - ?.getCarouselSelected()?.element; - } - - return ( - (slide?.querySelector( - FRIGATE_CARD_VIEWER_PROVIDER, - ) as unknown as FrigateCardMediaPlayer) ?? null - ); - } - /** * Get the Embla plugins to use. * @returns A list of EmblaOptionsTypes. */ - protected _getPlugins(): EmblaPluginType[] { + protected _getPlugins(): EmblaCarouselPlugins { return [ - // Only enable wheel plugin if there is more than one media item. - ...(this._media && this._media.length > 1 - ? [ - WheelGesturesPlugin({ - // Whether the carousel is vertical or horizontal, interpret y-axis wheel - // gestures as scrolling for the carousel. - forceWheelAxis: 'y', - }), - ] - : []), - Lazyload({ + AutoLazyLoad({ ...(this.viewerConfig?.lazy_load && { lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide), }), }), - AutoMediaPlugin({ + AutoMediaLoadedInfo(), + AutoMediaActions({ playerSelector: FRIGATE_CARD_VIEWER_PROVIDER, ...(this.viewerConfig?.auto_play && { autoPlayCondition: this.viewerConfig.auto_play, @@ -314,6 +287,7 @@ export class FrigateCardViewerCarousel extends LitElement { autoUnmuteCondition: this.viewerConfig.auto_unmute, }), }), + AutoSize(), ]; } @@ -346,10 +320,6 @@ export class FrigateCardViewerCarousel extends LitElement { }; } - protected _setViewHandler(ev: CustomEvent): void { - this._setViewSelectedIndex(ev.detail.index); - } - protected _setViewSelectedIndex(index: number): void { if (!this._media) { return; @@ -396,7 +366,7 @@ export class FrigateCardViewerCarousel extends LitElement { 'frigate-card-viewer-provider', ) as FrigateCardViewerProvider | null; if (viewerProvider) { - viewerProvider.disabled = false; + viewerProvider.load = true; } } @@ -413,7 +383,7 @@ export class FrigateCardViewerCarousel extends LitElement { for (let i = 0; i < this._media.length; ++i) { const media = this._media[i]; if (media) { - const slide = this._renderMediaItem(media, i); + const slide = this._renderMediaItem(media); if (slide) { slides[i] = slide; } @@ -487,22 +457,24 @@ export class FrigateCardViewerCarousel extends LitElement { ); return html` - ({ - draggable: this.viewerConfig?.draggable ?? true, - }))} - .carouselPlugins=${guard( - [this.viewerConfig, this._media], - this._getPlugins.bind(this), - )} - .label=${selectedMedia.getTitle() ?? undefined} - .logo=${cameraMetadata?.engineLogo} - .titlePopupConfig=${titleConfig ?? undefined} + ) => { + this._setViewSelectedIndex(ev.detail.index); + }} + @frigate-card:media:loaded=${(ev: CustomEvent) => { + if (this._refTitleControl.value) { + showTitleControlAfterDelay(this._refTitleControl.value, this._titleTimer); + } + this._player = ev.detail.player ?? null; + this._seekHandler(); + }} + @frigate-card:media:unloaded=${() => { + this._player = null; + }} > - +
+ ${cameraMetadata && titleConfig + ? html` + ` + : ``} `; } @@ -544,21 +526,20 @@ export class FrigateCardViewerCarousel extends LitElement { */ protected async _seekHandler(): Promise { const seek = this.view?.context?.mediaViewer?.seek; - if (!this.hass || !seek || !this._media || this.selected === null) { + if (!this.hass || !seek || !this._media || this.selected === null || !this._player) { return; } const selectedMedia = this._media[this.selected]; - const player = this._getPlayer(); - if (!selectedMedia || !player) { + if (!selectedMedia) { return; } const seekTimeInMedia = selectedMedia.includesTime(seek); setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable'); - if (!seekTimeInMedia && !player.isPaused()) { - player.pause(); - } else if (seekTimeInMedia && player.isPaused()) { - player.play(); + if (!seekTimeInMedia && !this._player.isPaused()) { + this._player.pause(); + } else if (seekTimeInMedia && this._player.isPaused()) { + this._player.play(); } const seekTime = @@ -566,17 +547,11 @@ export class FrigateCardViewerCarousel extends LitElement { null; if (seekTime !== null) { - player.seek(seekTime); + this._player.seek(seekTime); } } - /** - * Render a single media item in the viewer carousel. - * @param media The ViewMedia to render. - * @param index The (slide|queryResult) index of the item to render. - * @returns A rendered template. - */ - protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null { + protected _renderMediaItem(media: ViewMedia): TemplateResult | null { if (!this.hass || !this.view || !this.viewerConfig) { return null; } @@ -589,11 +564,8 @@ export class FrigateCardViewerCarousel extends LitElement { .viewerConfig=${this.viewerConfig} .resolvedMediaCache=${this.resolvedMediaCache} .cameraManager=${this.cameraManager} - .disabled=${this.viewerConfig.lazy_load} + .load=${!this.viewerConfig.lazy_load} .cardWideConfig=${this.cardWideConfig} - @frigate-card:media:loaded=${(e: CustomEvent) => { - wrapMediaLoadedEventForCarousel(index, e); - }} >
`; } @@ -715,10 +687,11 @@ export class FrigateCardViewerProvider @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; - // Whether or not to disable this entity. If `true`, no contents are rendered - // until this attribute is set to `false` (this is useful for lazy loading). + // 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 disabled = false; + public load = false; @property({ attribute: false }) public cameraManager?: CameraManager; @@ -865,7 +838,7 @@ export class FrigateCardViewerProvider const mediaContentID = this.media ? this.media.getContentID() : null; if ( - (changedProps.has('disabled') || + (changedProps.has('load') || changedProps.has('media') || changedProps.has('viewerConfig') || changedProps.has('resolvedMediaCache') || @@ -873,7 +846,7 @@ export class FrigateCardViewerProvider this.hass && mediaContentID && !this.resolvedMediaCache?.has(mediaContentID) && - (!this.viewerConfig?.lazy_load || !this.disabled) + (!this.viewerConfig?.lazy_load || this.load) ) { resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => { this.requestUpdate(); @@ -897,7 +870,7 @@ export class FrigateCardViewerProvider } protected render(): TemplateResult | void { - if (this.disabled || !this.media || !this.hass || !this.view || !this.viewerConfig) { + if (!this.load || !this.media || !this.hass || !this.view || !this.viewerConfig) { return; } diff --git a/src/scss/carousel.scss b/src/scss/carousel.scss index 167146a8..2ea19de8 100644 --- a/src/scss/carousel.scss +++ b/src/scss/carousel.scss @@ -2,6 +2,9 @@ display: block; height: 100%; width: 100%; + + // Keep carousel controls relative to the media carousel itself. + position: relative; } .embla { diff --git a/src/scss/live-carousel.scss b/src/scss/live-carousel.scss index d4dafaf1..81c26c13 100644 --- a/src/scss/live-carousel.scss +++ b/src/scss/live-carousel.scss @@ -1,9 +1,13 @@ +:host { + --video-max-height: none; +} + // If the carousel has an unselected attribute set on it, do not let the // pointer interact (e.g. hover, scroll) with underlying elements. This is used // when the carousel is part of a media-grid. Without this next/prev controls // will enlarge on hover, and the wheel-gestures plugin may block scrolling. // See matching in viewer-carousel.scss . -:host([unselected]) frigate-card-media-carousel { +:host([unselected]) frigate-card-carousel { pointer-events: none; } diff --git a/src/scss/live-ha.scss b/src/scss/live-ha.scss index a9b9a68b..f0f64c07 100644 --- a/src/scss/live-ha.scss +++ b/src/scss/live-ha.scss @@ -2,6 +2,4 @@ width: 100%; height: 100%; display: block; - - --video-max-height: none; } \ No newline at end of file diff --git a/src/scss/media-carousel.scss b/src/scss/media-carousel.scss deleted file mode 100644 index 45964ad6..00000000 --- a/src/scss/media-carousel.scss +++ /dev/null @@ -1,9 +0,0 @@ -:host { - display: block; - width: 100%; - height: 100%; - --video-max-height: none; - - // Keep the controls relative to the media carousel itself. - position: relative; -} diff --git a/src/scss/viewer-carousel.scss b/src/scss/viewer-carousel.scss index b7bc6bfb..5d4a4501 100644 --- a/src/scss/viewer-carousel.scss +++ b/src/scss/viewer-carousel.scss @@ -1,6 +1,8 @@ :host { // Center unseekable icon. position: relative; + + --video-max-height: none; } // If the carousel has an unselected attribute set on it, do not let the @@ -8,17 +10,16 @@ // when the carousel is part of a media-grid. Without this next/prev controls // will enlarge on hover, and the wheel-gestures plugin may block scrolling. // See matching in live-carousel.scss . -:host([unselected]) frigate-card-media-carousel, -:host([unselected]) .seek-warning -{ +:host([unselected]) frigate-card-carousel, +:host([unselected]) .seek-warning { pointer-events: none; } -:host([unseekable]) frigate-card-media-carousel { +:host([unseekable]) frigate-card-carousel { filter: brightness(50%); } :host([unseekable]) .seek-warning { - display: block + display: block; } .seek-warning { diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 51efa4cc..7209771a 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -232,3 +232,11 @@ export const isTruthy = (x: T | false | undefined | null | '' | 0): x is T => */ export const isHTMLElement = (element: unknown): element is HTMLElement => element instanceof HTMLElement; + +export const getChildrenFromElement = (parent: HTMLElement): HTMLElement[] => { + const children = + parent instanceof HTMLSlotElement + ? parent.assignedElements({ flatten: true }) + : [...parent.children]; + return children.filter(isHTMLElement); +}; diff --git a/src/utils/camera.ts b/src/utils/camera.ts index a9b8c6d7..497d9355 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -65,8 +65,6 @@ export function getAllDependentCameras( } } }; - if (cameraID) { - getDependentCameras(cameraID); - } + getDependentCameras(cameraID); return cameraIDs; } diff --git a/src/utils/embla/carousel-controller.ts b/src/utils/embla/carousel-controller.ts new file mode 100644 index 00000000..acfb4849 --- /dev/null +++ b/src/utils/embla/carousel-controller.ts @@ -0,0 +1,178 @@ +import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel'; +import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; +import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; +import isEqual from 'lodash-es/isEqual'; +import { TransitionEffect } from '../../types'; +import { dispatchFrigateCardEvent, getChildrenFromElement } from '../basic.js'; + +export interface CarouselSelected { + index: number; + element: HTMLElement; +} + +type EmblaCarouselPlugins = CreatePluginType>[]; + +export type CarouselDirection = 'vertical' | 'horizontal'; + +export class CarouselController { + protected _parent: HTMLElement; + protected _root: HTMLElement; + protected _direction: CarouselDirection; + protected _startIndex: number; + protected _transitionEffect: TransitionEffect; + protected _loop: boolean; + protected _dragFree: boolean; + protected _draggable: boolean; + + protected _plugins: EmblaCarouselPlugins; + protected _carousel: EmblaCarouselType; + + protected _mutationObserver = new MutationObserver( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_mutations: MutationRecord[], _observer: MutationObserver) => + this._refreshCarouselContents(), + ); + + constructor( + root: HTMLElement, + parent: HTMLElement, + options?: { + direction?: CarouselDirection; + transitionEffect?: TransitionEffect; + startIndex?: number; + loop?: boolean; + dragEnabled?: boolean; + dragFree?: boolean; + plugins?: EmblaCarouselPlugins; + }, + ) { + this._root = root; + this._parent = parent; + this._direction = options?.direction ?? 'horizontal'; + this._transitionEffect = options?.transitionEffect ?? 'slide'; + this._startIndex = options?.startIndex ?? 0; + this._dragFree = options?.dragFree ?? false; + this._loop = options?.loop ?? false; + this._draggable = options?.dragEnabled ?? true; + this._plugins = options?.plugins ?? []; + + this._carousel = this._createCarousel(getChildrenFromElement(this._parent)); + + // Need to separately listen for slotchanges since mutation observer will + // not be called for shadom DOM slotted changes. + if (parent instanceof HTMLSlotElement) { + parent.addEventListener('slotchange', this._refreshCarouselContents); + } + this._mutationObserver.observe(this._parent, { childList: true }); + } + + public destroy() { + if (this._parent instanceof HTMLSlotElement) { + this._parent.removeEventListener('slotchange', this._refreshCarouselContents); + } + this._mutationObserver.disconnect(); + this._carousel.destroy(); + } + + public getSlide(index: number): HTMLElement | null { + return this._carousel.slideNodes()[index] ?? null; + } + + public getSelectedSlide(): HTMLElement | null { + return this.getSlide(this.getSelectedIndex()); + } + + public getSelectedIndex(): number { + return this._carousel.selectedScrollSnap(); + } + + public selectSlide(index: number): void { + this._carousel.scrollTo(index, this._transitionEffect === 'none'); + } + + protected _refreshCarouselContents = (): void => { + const newSlides = getChildrenFromElement(this._parent); + const slidesChanged = !isEqual(this._carousel.slideNodes(), newSlides); + if (slidesChanged) { + this._carousel.destroy(); + this._carousel = this._createCarousel(newSlides); + } + }; + + protected _createCarousel(slides: HTMLElement[]): EmblaCarouselType { + const carousel = EmblaCarousel( + this._root, + { + slides: slides, + + axis: this._direction === 'horizontal' ? 'x' : 'y', + duration: 20, + startIndex: this._startIndex, + dragFree: this._dragFree, + loop: this._loop, + + containScroll: 'trimSnaps', + + // This controller manages slide changes (including shadow DOM + // assignments, which the stock watcher does not handle). + watchSlides: false, + + // We use the auto-size plugin to manage resizes without carousel resets + // mid-scroll. + watchResize: false, + watchDrag: this._draggable, + }, + [ + ...this._plugins, + ...(slides.length > 1 + ? [ + WheelGesturesPlugin({ + // Whether the carousel is vertical or horizontal, interpret y-axis wheel + // gestures as scrolling for the carousel. + forceWheelAxis: 'y', + }), + ] + : []), + ], + ); + + const getCarouselSelectedObject = (): CarouselSelected | null => { + const selectedIndex = this.getSelectedIndex(); + const slide = this.getSlide(selectedIndex); + + if (selectedIndex !== null && slide) { + return { + index: selectedIndex, + element: slide, + }; + } + return null; + }; + + const selectSlide = (): void => { + const carouselSelected = getCarouselSelectedObject(); + if (carouselSelected) { + dispatchFrigateCardEvent( + this._parent, + 'carousel:select', + carouselSelected, + ); + } + }; + + carousel.on('init', () => selectSlide()); + carousel.on('select', () => selectSlide()); + carousel.on('settle', () => { + const carouselSelected = getCarouselSelectedObject(); + if (carouselSelected) { + dispatchFrigateCardEvent( + this._parent, + 'carousel:settle', + carouselSelected, + ); + } + }); + + return carousel; + } +} diff --git a/src/components/embla-plugins/lazyload.ts b/src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts similarity index 57% rename from src/components/embla-plugins/lazyload.ts rename to src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts index 311ee6cc..2941f983 100644 --- a/src/components/embla-plugins/lazyload.ts +++ b/src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts @@ -1,17 +1,26 @@ +import { EmblaCarouselType, EmblaEventType } from 'embla-carousel'; import { CreateOptionsType } from 'embla-carousel/components/Options'; -import { CreatePluginType } from 'embla-carousel/components/Plugins'; -import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel'; -import { LazyUnloadCondition } from '../../types'; +import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler'; +import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; +import { LazyUnloadCondition } from '../../../../types'; + +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; + lazyLoadCount: number; lazyUnloadCondition?: LazyUnloadCondition; lazyLoadCallback?: (index: number, slide: HTMLElement) => void; lazyUnloadCallback?: (index: number, slide: HTMLElement) => void; }>; +type AutoLazyLoadOptionsType = Partial; +type AutoLazyLoadType = CreatePluginType; const defaultOptions: OptionsType = { active: true, @@ -19,74 +28,54 @@ const defaultOptions: OptionsType = { lazyLoadCount: 0, }; -type LazyloadOptionsType = Partial; - -type LazyloadType = CreatePluginType< - { - hasLazyloaded(index: number): boolean; - }, - LazyloadOptionsType ->; - -declare module 'embla-carousel/components/Plugins' { - interface EmblaPluginsType { - lazyload?: LazyloadType; - } -} - -export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { - const optionsHandler = EmblaCarousel.optionsHandler(); - const optionsBase = optionsHandler.merge(defaultOptions, Lazyload.globalOptions); - let options: LazyloadType['options']; - - let carousel: EmblaCarouselType; +export function AutoLazyLoad( + userOptions: AutoLazyLoadOptionsType = {}, +): AutoLazyLoadType { + let options: OptionsType; + let emblaApi: EmblaCarouselType; let slides: HTMLElement[]; const lazyLoadedSlides: Set = new Set(); - const loadEvents: EmblaEventType[] = ['init', 'select', 'resize']; + const loadEvents: EmblaEventType[] = ['init', 'select']; const unloadEvents: EmblaEventType[] = ['select']; - /** - * Initialize the plugin. - */ - function init(embla: EmblaCarouselType): void { - carousel = embla; - options = optionsHandler.atMedia(self.options); - slides = carousel.slideNodes(); + 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) => carousel.on(evt, lazyLoadHandler)); + loadEvents.forEach((evt) => emblaApi.on(evt, lazyLoadHandler)); } if ( options.lazyUnloadCallback && options.lazyUnloadCondition && ['all', 'unselected'].includes(options.lazyUnloadCondition) ) { - unloadEvents.forEach((evt) => carousel.on(evt, lazyUnloadPreviousHandler)); + unloadEvents.forEach((evt) => emblaApi.on(evt, lazyUnloadPreviousHandler)); } document.addEventListener('visibilitychange', visibilityHandler); } - /** - * Destroy the plugin. - */ function destroy(): void { if (options.lazyLoadCallback) { - loadEvents.forEach((evt) => carousel.off(evt, lazyLoadHandler)); + loadEvents.forEach((evt) => emblaApi.off(evt, lazyLoadHandler)); } if (options.lazyUnloadCallback) { - unloadEvents.forEach((evt) => carousel.off(evt, lazyUnloadPreviousHandler)); + unloadEvents.forEach((evt) => emblaApi.off(evt, lazyUnloadPreviousHandler)); } document.removeEventListener('visibilitychange', visibilityHandler); } - /** - * Handle document visibility changes. - */ function visibilityHandler(): void { if ( document.visibilityState === 'hidden' && - options.lazyUnloadCallback && options.lazyUnloadCondition && ['all', 'hidden'].includes(options.lazyUnloadCondition) ) { @@ -96,21 +85,13 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { } } - /** - * Determine if a slide index has been lazily loaded. - * @param index Slide index. - * @returns `true` if the slide has been lazily loaded. - */ function hasLazyloaded(index: number): boolean { return lazyLoadedSlides.has(index); } - /** - * Lazily load media in the carousel. - */ function lazyLoadHandler(): void { - const lazyLoadCount = options.lazyLoadCount ?? 0; - const currentIndex = carousel.selectedScrollSnap(); + const lazyLoadCount = options.lazyLoadCount; + const currentIndex = emblaApi.selectedScrollSnap(); const slidesToLoad = new Set(); // Lazily load 'count' slides on either side of the slides in view. @@ -130,9 +111,6 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { }); } - /** - * Lazily unload all media in the carousel. - */ function lazyUnloadAllHandler(): void { lazyLoadedSlides.forEach((index) => { if (options.lazyUnloadCallback) { @@ -142,11 +120,8 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { }); } - /** - * Lazily unload the previously selected media in the carousel. - */ function lazyUnloadPreviousHandler(): void { - const index = carousel.previousScrollSnap(); + const index = emblaApi.previousScrollSnap(); if (hasLazyloaded(index) && options.lazyUnloadCallback) { options.lazyUnloadCallback(index, slides[index]); @@ -154,14 +129,11 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { } } - const self: LazyloadType = { - name: 'lazyload', - options: optionsHandler.merge(optionsBase, userOptions), + const self: AutoLazyLoadType = { + name: 'autoLazyLoad', + options: userOptions, init, destroy, - hasLazyloaded, }; return self; } - -Lazyload.globalOptions = undefined; diff --git a/src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts b/src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts new file mode 100644 index 00000000..fbb3f3cf --- /dev/null +++ b/src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts @@ -0,0 +1,232 @@ +import { EmblaCarouselType } from 'embla-carousel'; +import { CreateOptionsType } from 'embla-carousel/components/Options.js'; +import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler.js'; +import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins.js'; +import { + AutoMuteCondition, + AutoPauseCondition, + AutoPlayCondition, + AutoUnmuteCondition, + FrigateCardMediaPlayer, +} from '../../../../types.js'; + +declare module 'embla-carousel/components/Plugins' { + interface EmblaPluginsType { + autoMediaActions?: AutoMediaActionsType; + } +} + +type OptionsType = CreateOptionsType<{ + playerSelector?: string; + + autoPlayCondition?: AutoPlayCondition; + autoUnmuteCondition?: AutoUnmuteCondition; + autoPauseCondition?: AutoPauseCondition; + autoMuteCondition?: AutoMuteCondition; +}>; +export type AutoMediaActionsOptionsType = Partial; + +const defaultOptions: OptionsType = { + active: true, + breakpoints: {}, +}; + +export type AutoMediaActionsType = CreatePluginType< + LoosePluginType, + AutoMediaActionsOptionsType +>; + +export function AutoMediaActions( + userOptions: AutoMediaActionsOptionsType = {}, +): AutoMediaActionsType { + let options: OptionsType; + let emblaApi: EmblaCarouselType; + let slides: HTMLElement[]; + let hadInitialIntersectionCall: boolean | null = false; + + const intersectionObserver: IntersectionObserver = new IntersectionObserver( + intersectionHandler, + ); + + function init( + emblaApiInstance: EmblaCarouselType, + optionsHandler: OptionsHandlerType, + ): void { + emblaApi = emblaApiInstance; + + const { mergeOptions, optionsAtMedia } = optionsHandler; + options = optionsAtMedia(mergeOptions(defaultOptions, userOptions)); + + slides = emblaApi.slideNodes(); + + if ( + options.autoPlayCondition && + ['all', 'selected'].includes(options.autoPlayCondition) + ) { + // Auto play when the media loads not necessarily when the slide is + // selected (to allow for lazyloading). + emblaApi.containerNode().addEventListener('frigate-card:media:loaded', play); + } + + if ( + options.autoUnmuteCondition && + ['all', 'selected'].includes(options.autoUnmuteCondition) + ) { + // Auto unmute when the media loads not necessarily when the slide is + // selected (to allow for lazyloading). + emblaApi.containerNode().addEventListener('frigate-card:media:loaded', unmute); + } + + if ( + options.autoPauseCondition && + ['all', 'unselected'].includes(options.autoPauseCondition) + ) { + emblaApi.on('select', pausePrevious); + } + + if ( + options.autoMuteCondition && + ['all', 'unselected'].includes(options.autoMuteCondition) + ) { + emblaApi.on('select', mutePrevious); + } + + emblaApi.on('destroy', pause); + emblaApi.on('destroy', mute); + + document.addEventListener('visibilitychange', visibilityHandler); + intersectionObserver.observe(emblaApi.containerNode()); + } + + function destroy(): void { + if ( + options.autoPlayCondition && + ['all', 'selected'].includes(options.autoPlayCondition) + ) { + emblaApi.containerNode().removeEventListener('frigate-card:media:loaded', play); + } + + if ( + options.autoUnmuteCondition && + ['all', 'selected'].includes(options.autoUnmuteCondition) + ) { + emblaApi.containerNode().removeEventListener('frigate-card:media:loaded', unmute); + } + + if ( + options.autoPauseCondition && + ['all', 'unselected'].includes(options.autoPauseCondition) + ) { + emblaApi.off('select', pausePrevious); + } + + if ( + options.autoMuteCondition && + ['all', 'unselected'].includes(options.autoMuteCondition) + ) { + emblaApi.off('select', mutePrevious); + } + + emblaApi.off('destroy', pause); + emblaApi.off('destroy', mute); + + document.removeEventListener('visibilitychange', visibilityHandler); + intersectionObserver.disconnect(); + } + + function actOnVisibilityChange(visible: boolean): void { + if (visible) { + if ( + options.autoPlayCondition && + ['all', 'visible'].includes(options.autoPlayCondition) + ) { + play(); + } + if ( + options.autoUnmuteCondition && + ['all', 'visible'].includes(options.autoUnmuteCondition) + ) { + unmute(); + } + } else { + if ( + options.autoPauseCondition && + ['all', 'hidden'].includes(options.autoPauseCondition) + ) { + pauseAll(); + } + if ( + options.autoMuteCondition && + ['all', 'hidden'].includes(options.autoMuteCondition) + ) { + muteAll(); + } + } + } + + function visibilityHandler(): void { + actOnVisibilityChange(document.visibilityState === 'visible'); + } + + function intersectionHandler(entries: IntersectionObserverEntry[]): void { + if (!hadInitialIntersectionCall) { + hadInitialIntersectionCall = true; + return; + } + + // If the live view is preloaded (i.e. in the background) we may need to + // take media actions, e.g. muting a live stream that is now running in the + // background. + actOnVisibilityChange(entries.some((entry) => entry.isIntersecting)); + } + + function getPlayer(slide: HTMLElement | undefined): FrigateCardMediaPlayer | null { + return options.playerSelector + ? (slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null) + : null; + } + + function play(): void { + getPlayer(slides[emblaApi.selectedScrollSnap()])?.play(); + } + + function pause(): void { + getPlayer(slides[emblaApi.selectedScrollSnap()])?.pause(); + } + + function pausePrevious(): void { + getPlayer(slides[emblaApi.previousScrollSnap()])?.pause(); + } + + function pauseAll(): void { + for (const slide of slides) { + getPlayer(slide)?.pause(); + } + } + + function unmute(): void { + getPlayer(slides[emblaApi.selectedScrollSnap()])?.unmute(); + } + + function mute(): void { + getPlayer(slides[emblaApi.selectedScrollSnap()])?.mute(); + } + + function mutePrevious(): void { + getPlayer(slides[emblaApi.previousScrollSnap()])?.mute(); + } + + function muteAll(): void { + for (const slide of slides) { + getPlayer(slide)?.mute(); + } + } + + const self: AutoMediaActionsType = { + name: 'autoMediaActions', + options: userOptions, + init, + destroy, + }; + return self; +} diff --git a/src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.ts b/src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.ts new file mode 100644 index 00000000..402b9d9e --- /dev/null +++ b/src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.ts @@ -0,0 +1,94 @@ +import { EmblaCarouselType } from 'embla-carousel'; +import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; +import { MediaLoadedInfo } from '../../../../types'; +import { + dispatchExistingMediaLoadedInfoAsEvent, + FrigateMediaLoadedEventTarget, +} from '../../../media-info'; +import { LooseOptionsType } from 'embla-carousel/components/Options'; + +declare module 'embla-carousel/components/Plugins' { + interface EmblaPluginsType { + autoMediaLoadedInfo?: AutoMediaLoadedInfoType; + } +} + +type AutoMediaLoadedInfoType = CreatePluginType; + +function AutoMediaLoadedInfo(): AutoMediaLoadedInfoType { + let emblaApi: EmblaCarouselType; + let slides: (HTMLElement & FrigateMediaLoadedEventTarget)[] = []; + const mediaLoadedInfo: MediaLoadedInfo[] = []; + + function init(emblaApiInstance: EmblaCarouselType): void { + emblaApi = emblaApiInstance; + slides = emblaApi.slideNodes(); + + for (const slide of slides) { + slide.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler); + slide.addEventListener('frigate-card:media:unloaded', mediaUnloadedInfoHandler); + } + + emblaApi.on('init', slideSelectHandler); + emblaApi.on('select', slideSelectHandler); + } + + function destroy(): void { + for (const slide of slides) { + slide.removeEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler); + slide.removeEventListener('frigate-card:media:unloaded', mediaUnloadedInfoHandler); + } + + emblaApi.off('init', slideSelectHandler); + emblaApi.off('select', slideSelectHandler); + } + + function mediaLoadedInfoHandler(ev: CustomEvent): void { + const eventPath = ev.composedPath(); + + for (const [index, slide] of slides.entries()) { + if (eventPath.includes(slide)) { + mediaLoadedInfo[index] = ev.detail; + if (index !== emblaApi.selectedScrollSnap()) { + ev.stopPropagation(); + } + break; + } + } + } + + function mediaUnloadedInfoHandler(ev: CustomEvent): void { + const eventPath = ev.composedPath(); + + for (const [index, slide] of slides.entries()) { + if (eventPath.includes(slide)) { + delete mediaLoadedInfo[index]; + if (index !== emblaApi.selectedScrollSnap()) { + ev.stopPropagation(); + } + break; + } + } + } + + function slideSelectHandler(): void { + const index = emblaApi.selectedScrollSnap(); + const savedMediaLoadedInfo: MediaLoadedInfo | undefined = mediaLoadedInfo[index]; + if (savedMediaLoadedInfo) { + dispatchExistingMediaLoadedInfoAsEvent( + emblaApi.containerNode(), + savedMediaLoadedInfo, + ); + } + } + + const self: AutoMediaLoadedInfoType = { + name: 'autoMediaLoadedInfo', + options: {}, + init, + destroy, + }; + return self; +} + +export default AutoMediaLoadedInfo; diff --git a/src/utils/embla/plugins/auto-size/auto-size.ts b/src/utils/embla/plugins/auto-size/auto-size.ts new file mode 100644 index 00000000..bfadef33 --- /dev/null +++ b/src/utils/embla/plugins/auto-size/auto-size.ts @@ -0,0 +1,143 @@ +import { EmblaCarouselType } from 'embla-carousel'; +import { LooseOptionsType } from 'embla-carousel/components/Options'; +import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; +import { EmblaReInitController } from '../../reinit-controller'; + +declare module 'embla-carousel/components/Plugins' { + interface EmblaPluginsType { + AutoSize?: AutoSizeType; + } +} + +type AutoSizeType = CreatePluginType; +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 = new Map(); + + const resizeObserver: ResizeObserver = new ResizeObserver(resizeHandler); + const intersectionObserver: IntersectionObserver = new IntersectionObserver( + intersectionHandler, + ); + + 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); + } + + emblaApi.on('settle', setContainerHeight); + } + + function destroy(): void { + intersectionObserver.disconnect(); + resizeObserver.disconnect(); + reInitController?.destroy(); + + emblaApi.off('settle', setContainerHeight); + } + + 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/frigate-hass-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). + const callReInit = previousContainerIntersecting !== null; + previousContainerIntersecting = isContainerIntersectingNow; + if (callReInit) { + reInitController?.reinit(); + } + } + } + + function resizeHandler(entries: ResizeObserverEntry[]): void { + let callReInit = 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); + callReInit = true; + } + } + + if (callReInit) { + reInitController?.reinit(); + } + } + + function setContainerHeight(): 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`; + } + } + + const self: AutoSizeType = { + name: 'autoSize', + options: {}, + init, + destroy, + }; + return self; +} + +export default AutoSize; diff --git a/src/utils/embla/reinit-controller.ts b/src/utils/embla/reinit-controller.ts new file mode 100644 index 00000000..572e0f9c --- /dev/null +++ b/src/utils/embla/reinit-controller.ts @@ -0,0 +1,63 @@ +import { EmblaCarouselType } from 'embla-carousel'; +import debounce from 'lodash-es/debounce'; + +/** + * 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( + () => { + // Allow the browser a moment to paint components that are inflight, to + // ensure accurate measurements are taken during the carousel + // reinitialization. + this._scrolling = false; + this._shouldReInitOnScrollStop = false; + this._emblaApi?.reInit(); + }, + 200, + { trailing: true }, + ); +} diff --git a/src/utils/media-grid-controller.ts b/src/utils/media-grid-controller.ts index 32787d58..f39f013e 100644 --- a/src/utils/media-grid-controller.ts +++ b/src/utils/media-grid-controller.ts @@ -2,7 +2,7 @@ import isEqual from 'lodash-es/isEqual'; import throttle from 'lodash-es/throttle'; import Masonry from 'masonry-layout'; import { MediaLoadedInfo, ViewDisplayConfig } from '../types'; -import { dispatchFrigateCardEvent, setOrRemoveAttribute } from './basic'; +import { dispatchFrigateCardEvent, getChildrenFromElement, setOrRemoveAttribute } from './basic'; import { FrigateMediaLoadedEventTarget, dispatchExistingMediaLoadedInfoAsEvent, @@ -144,20 +144,11 @@ export class MediaGridController { } protected _calculateGridContentsFromHost = (): void => { - let childrenElements: Element[]; - - if (this._host instanceof HTMLSlotElement) { - childrenElements = this._host.assignedElements(); - } else { - childrenElements = [...this._host.children]; - } - + const children = getChildrenFromElement(this._host); const gridContents: MediaGridContents = new Map(); - for (const child of childrenElements) { - if (child instanceof HTMLElement) { - const id = child.getAttribute(this._idAttribute) || String(gridContents.size); - gridContents.set(id, child); - } + for (const child of children) { + const id = child.getAttribute(this._idAttribute) || String(gridContents.size); + gridContents.set(id, child); } this._setGridContents(gridContents); @@ -200,6 +191,7 @@ export class MediaGridController { const eventPath = ev.composedPath(); for (const [id, element] of this._gridContents.entries()) { + /* istanbul ignore else: the else path cannot be reached -- @preserve */ if (eventPath.includes(element)) { this._mediaLoadedInfoMap.set(id, ev.detail); if (id !== this._selected) { @@ -271,6 +263,7 @@ export class MediaGridController { const eventPath = ev.composedPath(); for (const [id, element] of this._gridContents.entries()) { + /* istanbul ignore else: the else path cannot be reached -- @preserve */ if (eventPath.includes(element)) { if (this._selected !== id) { this.selectCell(id); diff --git a/src/utils/media-info.ts b/src/utils/media-info.ts index 3e01807a..71886649 100644 --- a/src/utils/media-info.ts +++ b/src/utils/media-info.ts @@ -112,7 +112,7 @@ export function isValidMediaLoadedInfo(info: MediaLoadedInfo): boolean { ); } -// Facilities correct Typescript typing of media:loaded event handlers. +// Facilitates correct Typescript typing of media:loaded/unloaded event handlers. export interface FrigateMediaLoadedEventTarget extends EventTarget { addEventListener( event: 'frigate-card:media:loaded', @@ -122,6 +122,11 @@ export interface FrigateMediaLoadedEventTarget extends EventTarget { ) => void, options?: AddEventListenerOptions | boolean, ): void; + addEventListener( + event: 'frigate-card:media:unloaded', + listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void, + options?: AddEventListenerOptions | boolean, + ): void; addEventListener( type: string, callback: EventListenerOrEventListenerObject, @@ -135,6 +140,11 @@ export interface FrigateMediaLoadedEventTarget extends EventTarget { ) => void, options?: boolean | EventListenerOptions, ): void; + removeEventListener( + event: 'frigate-card:media:unloaded', + listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void, + options?: boolean | EventListenerOptions, + ): void; removeEventListener( type: string, callback: EventListenerOrEventListenerObject, diff --git a/src/utils/menu-controller.ts b/src/utils/menu-controller.ts index f069397f..92ca9bdc 100644 --- a/src/utils/menu-controller.ts +++ b/src/utils/menu-controller.ts @@ -82,7 +82,7 @@ export class MenuButtonController { ) as FrigateCardCustomAction, }); - if (visibleCameras) { + if (visibleCameras.size) { const menuItems = Array.from(visibleCameras, ([cameraID, config]) => { const action = createFrigateCardCustomAction('camera_select', { camera: cameraID, @@ -399,6 +399,7 @@ export class MenuButtonController { const action = createFrigateCardCustomAction('display_mode_select', { display_mode: isGrid ? 'single' : 'grid', }); + /* istanbul ignore else: the else path cannot be reached -- @preserve */ if (action) { buttons.push({ icon: isGrid ? 'mdi:grid-off' : 'mdi:grid', diff --git a/src/utils/zod.ts b/src/utils/zod.ts index d3d53e8b..9f8037d6 100644 --- a/src/utils/zod.ts +++ b/src/utils/zod.ts @@ -74,23 +74,22 @@ export const getParseErrorPaths = (error: z.ZodError): Set | null * available unions). This usually suggests the user specified an incorrect * type name entirely. */ const contenders = new Set(); - if (error && error.issues) { - for (let i = 0; i < error.issues.length; i++) { - const issue = error.issues[i]; - if (issue.code == 'invalid_union') { + if (error.issues.length) { + for (const issue of error.issues) { + if (issue.code === 'invalid_union') { const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors; - for (let j = 0; j < unionErrors.length; j++) { - const nestedErrors = getParseErrorPaths(unionErrors[j]); + for (const unionError of unionErrors) { + const nestedErrors = getParseErrorPaths(unionError); if (nestedErrors && nestedErrors.size) { nestedErrors.forEach(contenders.add, contenders); } } - } else if (issue.code == 'invalid_type') { - if (issue.path[issue.path.length - 1] == 'type') { + } else if (issue.code === 'invalid_type') { + if (issue.path[issue.path.length - 1] === 'type') { return null; } contenders.add(getParseErrorPathString(issue.path)); - } else if (issue.code != 'custom') { + } else { contenders.add(getParseErrorPathString(issue.path)); } } diff --git a/src/view/media-queries-results.ts b/src/view/media-queries-results.ts index 3eec71be..f3d2588d 100644 --- a/src/view/media-queries-results.ts +++ b/src/view/media-queries-results.ts @@ -26,7 +26,7 @@ class ResultSlice { if (options?.results && options.results.length) { if (!options?.selectApproach || options?.selectApproach === 'last') { return options.results.length - 1; - } else if (options.selectApproach === 'first') { + } else { return 0; } } diff --git a/src/view/view.ts b/src/view/view.ts index e41e1db4..e89d010f 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -111,13 +111,9 @@ export class View { const switchingToMediaFromMedia = curr?.is('media') && next.is('media'); if (hasNoQueryOrResults) { - if (switchingToGalleryFromViewer) { - if (curr.query) { - next.query = curr.query; - } - if (curr.queryResults) { - next.queryResults = curr.queryResults; - } + if (switchingToGalleryFromViewer && curr.query && curr.queryResults) { + next.query = curr.query; + next.queryResults = curr.queryResults; } else if (switchingToMediaFromMedia && currentQueriesView) { next.view = currentQueriesView === 'clips' diff --git a/tests/camera-manager/cache.test.ts b/tests/camera-manager/cache.test.ts index 70889913..6746ec8a 100644 --- a/tests/camera-manager/cache.test.ts +++ b/tests/camera-manager/cache.test.ts @@ -59,7 +59,7 @@ describe('RecordingSegmentsCache', () => { start: now, end: add(now, { hours: 1 }), }; - const badRange = { start: sub(now, { hours: 1 }), end: now }; + const pastRange = { start: sub(now, { hours: 1 }), end: now }; const createSegment = (date: Date, id: string): RecordingSegment => { return { start_time: date.getTime() / 1000, @@ -89,7 +89,7 @@ describe('RecordingSegmentsCache', () => { }); it('should not get for other range', () => { cache.add('camera-1', range, segments); - expect(cache.get('camera-1', badRange)).toBeNull(); + expect(cache.get('camera-1', pastRange)).toBeNull(); }); it('should have coverage when added', () => { @@ -102,7 +102,7 @@ describe('RecordingSegmentsCache', () => { }); it('should not have coverage for other range', () => { cache.add('camera-1', range, segments); - expect(cache.hasCoverage('camera-1', badRange)).toBeFalsy(); + expect(cache.hasCoverage('camera-1', pastRange)).toBeFalsy(); }); it('should be empty when cleared', () => { @@ -114,11 +114,11 @@ describe('RecordingSegmentsCache', () => { it('should get size', () => { cache.add('camera-1', range, segments); - expect(cache.getSize("camera-1")).toBe(3); + expect(cache.getSize('camera-1')).toBe(3); }); it('should not size for other camera', () => { cache.add('camera-1', range, segments); - expect(cache.getSize("camera-2")).toBeNull(); + expect(cache.getSize('camera-2')).toBeNull(); }); it('should return cameraIDs', () => { @@ -127,6 +127,24 @@ describe('RecordingSegmentsCache', () => { expect(sortBy(cache.getCameraIDs())).toEqual(sortBy(['camera-1', 'camera-2'])); }); + it('should add segments to existing range', () => { + cache.add('camera-1', range, segments); + cache.add('camera-1', range, [ + createSegment(add(now, { seconds: 15 }), 'segment-2.5'), + ]); + expect(cache.get('camera-1', range)?.length).toBe(4); + }); + it('should not get segments that are outside range', () => { + cache.add('camera-1', range, segments); + expect(cache.get('camera-1', range)?.length).toBe(3); + // Add a segment before and after the desired range. + cache.add('camera-1', range, [ + createSegment(sub(now, { seconds: 15 }), 'segment-0'), + createSegment(add(range.end, { seconds: 10 }), 'segment-4'), + ]); + expect(cache.get('camera-1', range)?.length).toBe(3); + }); + it('should remove expired matches', () => { cache.add('camera-1', range, segments); cache.expireMatches('camera-1', (segment) => segment === segments[0]); diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts index 24ebde87..db8df3e0 100644 --- a/tests/camera-manager/engine-factory.test.ts +++ b/tests/camera-manager/engine-factory.test.ts @@ -100,6 +100,11 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => { Engine.Frigate, ); }); + it('should get no engine from config with insufficient details', async () => { + const config = createCameraConfig({}); + expect(await createFactory().getEngineForCamera(createHASS(), config)).toBeNull(); + }); + it('should throw error on invalid entity', async () => { const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); const entityRegistryManager = new EntityRegistryManager(new EntityCache()); diff --git a/tests/camera-manager/range.test.ts b/tests/camera-manager/range.test.ts index 8051baad..5d2a40c4 100644 --- a/tests/camera-manager/range.test.ts +++ b/tests/camera-manager/range.test.ts @@ -135,6 +135,7 @@ describe('compressRanges', () => { compressRanges([ { start: now, end: nowPlusOne }, { start: nowPlusOne, end: nowPlusTwo }, + { start: now, end: nowPlusOne }, ]), ).toEqual([{ start: now, end: nowPlusTwo }]); }); @@ -162,4 +163,8 @@ describe('compressRanges', () => { ]; expect(compressRanges(input)).toEqual([{ start: 1, end: 3 }]); }); + + it('should return nothing with no input', () => { + expect(compressRanges([])).toEqual([]); + }); }); diff --git a/tests/conditions.test.ts b/tests/conditions.test.ts index d5eb3fbc..de76966b 100644 --- a/tests/conditions.test.ts +++ b/tests/conditions.test.ts @@ -99,6 +99,13 @@ describe('getOverriddenConfig', () => { }, }); }); + + it('should do nothing without overrides', () => { + const controller = new ConditionController(); + controller.setState({ fullscreen: true }); + + expect(getOverriddenConfig(controller, config)).toBe(config); + }); }); describe('getOverridesByKey', () => { diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 1dc0bcec..d55088ec 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -239,17 +239,53 @@ export class TestViewMedia extends ViewMedia { } } -export const createResizeObserverImplementation = (): (() => void) => { - return () => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - }); +export const ResizeObserverMock = vi.fn(() => ({ + disconnect: vi.fn(), + observe: vi.fn(), + unobserve: vi.fn(), +})); + +export const IntersectionObserverMock = vi.fn(() => ({ + disconnect: vi.fn(), + observe: vi.fn(), + unobserve: vi.fn(), +})); + +export const MutationObserverMock = vi.fn(() => ({ + disconnect: vi.fn(), + observe: vi.fn(), + unobserve: vi.fn(), +})); + +export const requestAnimationFrameMock = (callback: FrameRequestCallback) => { + callback(new Date().getTime()); + return 1; }; -export const createMutationObserverImplementation = (): (() => void) => { - return () => ({ - observe: vi.fn(), - disconnect: vi.fn(), - }); +export const createSlotHost = (options?: { + slot?: HTMLSlotElement; + children?: HTMLElement[]; +}): HTMLElement => { + const parent = document.createElement('div'); + parent.attachShadow({ mode: 'open' }); + + if (options?.slot) { + parent.shadowRoot?.append(options.slot); + } + if (options?.children) { + // Children will automatically be slotted into the default slot when it is + // created. + parent.append(...options.children); + } + return parent; +}; + +export const createSlot = (): HTMLSlotElement => { + return document.createElement('slot'); +}; + +export const createParent = (options?: { children?: HTMLElement[] }): HTMLElement => { + const parent = document.createElement('div'); + parent.append(...(options?.children ?? [])); + return parent; }; diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts index 574a9803..0a58c87c 100644 --- a/tests/utils/basic.test.ts +++ b/tests/utils/basic.test.ts @@ -1,27 +1,29 @@ -import { describe, it, expect, vi, afterAll } from 'vitest'; +import { afterAll, describe, expect, it, vi } from 'vitest'; import { FrigateCardError } from '../../src/types'; import { allPromises, - arrayify, arrayMove, + arrayify, contentsChanged, dayToDate, dispatchFrigateCardEvent, errorToConsole, - isTruthy, formatDate, formatDateAndTime, + getChildrenFromElement, getDurationString, + isHTMLElement, isHoverableDevice, isSuperset, + isTruthy, isValidDate, prettifyTitle, runWhenIdleIfSupported, - setify, setOrRemoveAttribute, + setify, sleep, - isHTMLElement, } from '../../src/utils/basic'; +import { createSlot, createSlotHost } from '../test-utils'; // @vitest-environment jsdom describe('dispatchFrigateCardEvent', () => { @@ -183,6 +185,11 @@ describe('getDurationString', () => { const end = new Date(2023, 3, 14, 15, 37, 20); expect(getDurationString(start, end)).toBe('2h 2m 20s'); }); + it('should return very short duration', () => { + const start = new Date(2023, 3, 14, 13, 35, 10); + const end = new Date(2023, 3, 14, 13, 35, 12); + expect(getDurationString(start, end)).toBe('2s'); + }); }); describe('allPromises', () => { @@ -268,3 +275,19 @@ describe('isHTMLElement', () => { expect(isHTMLElement(svgElement)).toBeFalsy(); }); }); + +describe('getChildrenFromElement', () => { + it('should return children for simple parent', () => { + const children = [document.createElement('div'), document.createElement('div')]; + const parent = document.createElement('div'); + children.forEach((child) => parent.appendChild(child)); + expect(getChildrenFromElement(parent)).toEqual(children); + }); + + it('should return children for slot', () => { + const children = [document.createElement('div'), document.createElement('div')]; + const slot = createSlot(); + createSlotHost({ slot: slot, children: children }); + expect(getChildrenFromElement(slot)).toEqual(children); + }); +}); diff --git a/tests/utils/download.test.ts b/tests/utils/download.test.ts index 850528f3..c5c9ac0b 100644 --- a/tests/utils/download.test.ts +++ b/tests/utils/download.test.ts @@ -12,13 +12,66 @@ vi.mock('../../src/utils/ha'); const media = new ViewMedia('clip', 'camera-1'); // @vitest-environment jsdom +describe('downloadURL', () => { + afterEach(() => { + vi.restoreAllMocks(); + global.window.location = mock(); + }); + + it('should download same origin via link', () => { + const location: Location & { origin: string } = mock(); + location.origin = 'http://foo'; + global.window.location = location; + + const link = document.createElement('a'); + link.click = vi.fn(); + link.setAttribute = vi.fn(); + vi.spyOn(document, 'createElement').mockReturnValue(link); + + downloadURL('http://foo/url.mp4'); + + expect(link.href).toBe('http://foo/url.mp4'); + expect(link.setAttribute).toBeCalledWith('download', 'download'); + expect(link.click).toBeCalled(); + }); + + it('should download data URL via link', () => { + const link = document.createElement('a'); + link.click = vi.fn(); + link.setAttribute = vi.fn(); + vi.spyOn(document, 'createElement').mockReturnValue(link); + + downloadURL('data:text/plain;charset=utf-8;base64,VEhJUyBJUyBEQVRB'); + + expect(link.href).toBe('data:text/plain;charset=utf-8;base64,VEhJUyBJUyBEQVRB'); + expect(link.setAttribute).toBeCalledWith('download', 'download'); + expect(link.click).toBeCalled(); + }); + + it('should download in apps via window.open', () => { + // Set the origin to the same. + const location: Location & { origin: string } = mock(); + location.origin = 'http://foo'; + global.window.location = location; + + vi.stubGlobal('navigator', { + userAgent: 'Home Assistant/2023.3.0-3260 (Android 13; Pixel 7 Pro)', + }); + + const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null); + + downloadURL('http://foo/url.mp4'); + expect(windowSpy).toBeCalledWith('http://foo/url.mp4', '_blank'); + }); +}); + describe('downloadMedia', () => { afterEach(() => { vi.resetAllMocks(); global.window.location = mock(); }); - it('should throw error when no media', async () => { + it('should throw error when no media', () => { const cameraManager = createCameraManager(); mock(cameraManager).getMediaDownloadPath.mockResolvedValue(null); @@ -55,44 +108,16 @@ describe('downloadMedia', () => { await downloadMedia(createHASS(), cameraManager, media); expect(windowSpy).toBeCalledWith('http://foo/signed-url', '_blank'); }); -}); -describe('downloadURL', () => { - afterEach(() => { - vi.resetAllMocks(); - global.window.location = mock(); - }); - - it('should download same origin via link', async () => { - const location: Location & { origin: string } = mock(); - location.origin = 'http://foo'; - global.window.location = location; - - const link = document.createElement('a'); - link.click = vi.fn(); - link.setAttribute = vi.fn(); - vi.spyOn(document, 'createElement').mockReturnValue(link); - - downloadURL('http://foo/url.mp4'); - - expect(link.href).toBe('http://foo/url.mp4'); - expect(link.setAttribute).toBeCalledWith('download', 'download'); - expect(link.click).toBeCalled(); - }); - - it('should download in apps via window.open', async () => { - // Set the origin to the same. - const location: Location & { origin: string } = mock(); - location.origin = 'http://foo'; - global.window.location = location; - - vi.stubGlobal('navigator', { - userAgent: 'Home Assistant/2023.3.0-3260 (Android 13; Pixel 7 Pro)', + it('should download media without signing', async () => { + const cameraManager = createCameraManager(); + mock(cameraManager).getMediaDownloadPath.mockResolvedValue({ + sign: false, + endpoint: 'https://foo/', }); - const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null); - downloadURL('http://foo/url.mp4'); - expect(windowSpy).toBeCalledWith('http://foo/url.mp4', '_blank'); + await downloadMedia(createHASS(), cameraManager, media); + expect(windowSpy).toBeCalledWith('https://foo/', '_blank'); }); }); diff --git a/tests/utils/embla/carousel-controller.test.ts b/tests/utils/embla/carousel-controller.test.ts new file mode 100644 index 00000000..bc5eb5be --- /dev/null +++ b/tests/utils/embla/carousel-controller.test.ts @@ -0,0 +1,291 @@ +import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel'; +import { MockedObject, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CarouselController } from '../../../src/utils/embla/carousel-controller'; +import AutoMediaLoadedInfo from '../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info'; +import { + MutationObserverMock, + createParent, + createSlot, + createSlotHost, +} from '../../test-utils'; +import { + callEmblaHandler, + callMutationHandler, + createEmblaApiInstance, + createTestSlideNodes, +} from './test-utils'; + +vi.mock('embla-carousel', () => ({ + default: vi.fn().mockImplementation(() => { + return createEmblaApiInstance(); + }), +})); + +// Get the nth most recently constructed EmblaAPI instance. +const getEmblaApi = (n = 0): MockedObject | null => { + const constructions = vi.mocked(EmblaCarousel).mock.results; + const mostRecentResult = constructions[constructions.length - 1 - n] ?? null; + if (mostRecentResult && mostRecentResult.type === 'return') { + return vi.mocked(mostRecentResult.value); + } + return null; +}; + +const createRoot = (): HTMLElement => { + return document.createElement('div'); +}; + +// @vitest-environment jsdom +describe('CarouselController', () => { + beforeAll(() => { + vi.stubGlobal('MutationObserver', MutationObserverMock); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should construct', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + const carousel = new CarouselController(createRoot(), parent); + expect(carousel).toBeTruthy(); + }); + + it('should construct with slot parent', () => { + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: createTestSlideNodes() }); + const carousel = new CarouselController(host, slot); + expect(carousel).toBeTruthy(); + }); + + it('should destroy', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + const carousel = new CarouselController(createRoot(), parent); + + carousel.destroy(); + + expect(getEmblaApi()?.destroy).toBeCalled(); + }); + + it('should destroy with slot', () => { + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: createTestSlideNodes() }); + const carousel = new CarouselController(host, slot); + + carousel.destroy(); + + expect(getEmblaApi()?.destroy).toBeCalled(); + }); + + it('should get slide by index', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + const carousel = new CarouselController(createRoot(), parent); + + getEmblaApi()?.slideNodes.mockReturnValue(children); + expect(carousel.getSlide(2)).toBe(children[2]); + }); + + it('should get slide by index when index is invalid', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + const carousel = new CarouselController(createRoot(), parent); + + expect(carousel.getSlide(1000)).toBeNull(); + }); + + it('should get selected slide', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + const carousel = new CarouselController(createRoot(), parent); + + getEmblaApi()?.slideNodes.mockReturnValue(children); + getEmblaApi()?.selectedScrollSnap.mockReturnValue(3); + expect(carousel.getSelectedIndex()).toBe(3); + expect(carousel.getSelectedSlide()).toBe(children[3]); + }); + + it('should select given slide', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + const carousel = new CarouselController(createRoot(), parent); + + carousel.selectSlide(4); + + expect(getEmblaApi()?.scrollTo).toBeCalledWith(4, false); + }); + + it('should dispatch settle event', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + new CarouselController(createRoot(), parent); + + const settleHandler = vi.fn(); + parent.addEventListener('frigate-card:carousel:settle', settleHandler); + + callEmblaHandler(getEmblaApi(), 'settle'); + + expect(settleHandler).toBeCalled(); + }); + + describe('should dispatch select event on', () => { + it.each([['init' as const], ['select' as const]])( + '%s', + (emblaApiEvt: 'init' | 'select') => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + new CarouselController(createRoot(), parent); + + const selectHandler = vi.fn(); + parent.addEventListener('frigate-card:carousel:select', selectHandler); + + getEmblaApi()?.selectedScrollSnap.mockReturnValue(6); + getEmblaApi()?.slideNodes.mockReturnValue(children); + callEmblaHandler(getEmblaApi(), emblaApiEvt); + + expect(selectHandler).toBeCalledWith( + expect.objectContaining({ + detail: { + index: 6, + element: children[6], + }, + }), + ); + }, + ); + }); + + it('should not dispatch anything with an invalid scroll snap', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + new CarouselController(createRoot(), parent); + + const selectHandler = vi.fn(); + parent.addEventListener('frigate-card:carousel:select', selectHandler); + + getEmblaApi()?.selectedScrollSnap.mockReturnValue(1000); + getEmblaApi()?.slideNodes.mockReturnValue(children); + callEmblaHandler(getEmblaApi(), 'init'); + callEmblaHandler(getEmblaApi(), 'select'); + callEmblaHandler(getEmblaApi(), 'settle'); + + expect(selectHandler).not.toBeCalled(); + }); + + it('should honor creation options', () => { + const children = createTestSlideNodes({ n: 1 }); + const root = createRoot(); + const parent = createParent({ children: children }); + const plugins = [AutoMediaLoadedInfo()]; + + new CarouselController(root, parent, { + direction: 'vertical', + transitionEffect: 'none', + startIndex: 7, + dragFree: true, + loop: true, + dragEnabled: false, + plugins: plugins, + }); + + expect(EmblaCarousel).toBeCalledWith( + root, + { + slides: children, + axis: 'y', + duration: 20, + startIndex: 7, + dragFree: true, + loop: true, + containScroll: 'trimSnaps', + watchSlides: false, + watchResize: false, + watchDrag: false, + }, + plugins, + ); + }); + + it('should include wheel plugin when slides > 1', () => { + const children = createTestSlideNodes(); + const root = createRoot(); + const parent = createParent({ children: children }); + new CarouselController(root, parent); + + expect(EmblaCarousel).toBeCalledWith( + root, + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ + name: 'wheelGestures', + }), + ]), + ); + }); + + it('should recreate carousel when children are added', () => { + const children = createTestSlideNodes(); + const root = createRoot(); + const parent = createParent({ children: children }); + new CarouselController(root, parent); + + expect(EmblaCarousel).toBeCalledTimes(1); + + const originalEmblaApi = getEmblaApi(); + expect(originalEmblaApi).toBeTruthy(); + + originalEmblaApi?.slideNodes.mockReturnValue(children); + parent.appendChild(document.createElement('div')); + callMutationHandler(); + + expect(originalEmblaApi?.destroy).toBeCalled(); + expect(getEmblaApi()).not.toBe(originalEmblaApi); + + expect(EmblaCarousel).toBeCalledTimes(2); + }); + + it('should not recreate carousel when children have not changed', () => { + const children = createTestSlideNodes(); + const root = createRoot(); + const parent = createParent({ children: children }); + new CarouselController(root, parent); + + expect(EmblaCarousel).toBeCalledTimes(1); + + const originalEmblaApi = getEmblaApi(); + expect(originalEmblaApi).toBeTruthy(); + + originalEmblaApi?.slideNodes.mockReturnValue(children); + callMutationHandler(); + + expect(originalEmblaApi?.destroy).not.toBeCalled(); + expect(getEmblaApi()).toBe(originalEmblaApi); + + expect(EmblaCarousel).toBeCalledTimes(1); + }); + + it('should recreate carousel when children are added to slot', () => { + const children = createTestSlideNodes(); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); + + new CarouselController(host, slot); + + expect(EmblaCarousel).toBeCalledTimes(1); + + const originalEmblaApi = getEmblaApi(); + expect(originalEmblaApi).toBeTruthy(); + + originalEmblaApi?.slideNodes.mockReturnValue(children); + + host.appendChild(document.createElement('div')); + slot.dispatchEvent(new Event('slotchange')); + + expect(originalEmblaApi?.destroy).toBeCalled(); + expect(getEmblaApi()).not.toBe(originalEmblaApi); + + expect(EmblaCarousel).toBeCalledTimes(2); + }); +}); diff --git a/tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts b/tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts new file mode 100644 index 00000000..d2ec3916 --- /dev/null +++ b/tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest'; +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, + lazyUnloadCondition: 'all', + }); + + 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, + lazyUnloadCondition: 'all', + }); + + 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, + lazyUnloadCondition: 'all', + // 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({ + lazyUnloadCondition: 'all', + // No callbacks provided. + }); + + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + }); + callVisibilityHandler(); + }); +}); diff --git a/tests/utils/embla/plugins/auto-media-actions/auto-media-actions.test.ts b/tests/utils/embla/plugins/auto-media-actions/auto-media-actions.test.ts new file mode 100644 index 00000000..9e8b7092 --- /dev/null +++ b/tests/utils/embla/plugins/auto-media-actions/auto-media-actions.test.ts @@ -0,0 +1,304 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FrigateCardMediaPlayer } from '../../../../../src/types'; +import { + AutoMediaActions, + AutoMediaActionsOptionsType, + AutoMediaActionsType, +} from '../../../../../src/utils/embla/plugins/auto-media-actions/auto-media-actions'; +import { dispatchExistingMediaLoadedInfoAsEvent } from '../../../../../src/utils/media-info'; +import { + IntersectionObserverMock, + createMediaLoadedInfo, + createParent, +} from '../../../../test-utils'; +import { + callEmblaHandler, + callIntersectionHandler, + callVisibilityHandler, + createEmblaApiInstance, + createTestEmblaOptionHandler, + createTestSlideNodes, +} from '../../test-utils'; + +const getPlayer = ( + element: HTMLElement, + selector: string, +): (HTMLElement & FrigateCardMediaPlayer) | null => { + return element.querySelector(selector); +}; + +const createPlayerSlideNodes = (n = 10): HTMLElement[] => { + const slides = createTestSlideNodes({ n: n }); + for (const slide of slides) { + const player = document.createElement('video'); + + player['play'] = vi.fn(); + player['pause'] = vi.fn(); + player['mute'] = vi.fn(); + player['unmute'] = vi.fn(); + player['isMuted'] = vi.fn().mockReturnValue(true); + player['seek'] = vi.fn(); + player['getScreenshotURL'] = vi.fn(); + player['setControls'] = vi.fn(); + player['isPaused'] = vi.fn(); + + slide.appendChild(player); + } + return slides; +}; + +const createPlugin = (options?: AutoMediaActionsOptionsType): AutoMediaActionsType => { + return AutoMediaActions({ + playerSelector: 'video', + autoPlayCondition: 'all', + autoUnmuteCondition: 'all', + autoPauseCondition: 'all', + autoMuteCondition: 'all', + ...options, + }); +}; + +// @vitest-environment jsdom +describe('AutoMediaActions', () => { + beforeAll(() => { + vi.stubGlobal('IntersectionObserver', IntersectionObserverMock); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should construct', () => { + const plugin = AutoMediaActions(); + expect(plugin.name).toBe('autoMediaActions'); + }); + + it('should init without any conditions', () => { + const plugin = AutoMediaActions(); + + const parent = createParent(); + const addEventListener = vi.fn(); + parent.addEventListener = addEventListener; + const emblaApi = createEmblaApiInstance({ containerNode: parent }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + expect(emblaApi.on).toBeCalledWith('destroy', expect.anything()); + expect(emblaApi.on).not.toBeCalledWith('select', expect.anything()); + expect(addEventListener).not.toBeCalled(); + }); + + it('should destroy', () => { + const plugin = createPlugin(); + const parent = createParent(); + const removeEventListener = vi.fn(); + parent.removeEventListener = removeEventListener; + const emblaApi = createEmblaApiInstance({ containerNode: parent }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + plugin.destroy(); + + expect(emblaApi.off).toBeCalledWith('destroy', expect.anything()); + expect(emblaApi.off).toBeCalledWith('select', expect.anything()); + expect(removeEventListener).toBeCalled(); + }); + + it('should destroy without any conditions', () => { + const plugin = AutoMediaActions(); + const parent = createParent(); + const removeEventListener = vi.fn(); + parent.removeEventListener = removeEventListener; + const emblaApi = createEmblaApiInstance({ containerNode: parent }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + plugin.destroy(); + + expect(emblaApi.off).toBeCalledWith('destroy', expect.anything()); + expect(emblaApi.off).not.toBeCalledWith('select', expect.anything()); + expect(removeEventListener).not.toBeCalled(); + }); + + it('should mute and pause on destroy', () => { + const plugin = createPlugin(); + const children = createPlayerSlideNodes(); + const parent = createParent({ children: children }); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + containerNode: parent, + selectedScrollSnap: 5, + }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + callEmblaHandler(emblaApi, 'destroy'); + + expect(getPlayer(children[5], 'video')?.pause).toBeCalled(); + expect(getPlayer(children[5], 'video')?.mute).toBeCalled(); + }); + + it('should play and unmute on media load', () => { + const plugin = createPlugin(); + const children = createPlayerSlideNodes(); + const parent = createParent({ children: children }); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + containerNode: parent, + selectedScrollSnap: 5, + }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + dispatchExistingMediaLoadedInfoAsEvent(parent, createMediaLoadedInfo()); + + expect(getPlayer(children[5], 'video')?.play).toBeCalled(); + expect(getPlayer(children[5], 'video')?.unmute).toBeCalled(); + }); + + it('should not play or unmute on media load when player selecter not provided', () => { + const plugin = createPlugin({ playerSelector: undefined }); + const children = createPlayerSlideNodes(); + const parent = createParent({ children: children }); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + containerNode: parent, + selectedScrollSnap: 5, + }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + dispatchExistingMediaLoadedInfoAsEvent(parent, createMediaLoadedInfo()); + + expect(getPlayer(children[5], 'video')?.play).not.toBeCalled(); + expect(getPlayer(children[5], 'video')?.unmute).not.toBeCalled(); + }); + + it('should pause and mute previous on select', () => { + const plugin = createPlugin(); + const children = createPlayerSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + previousScrollSnap: 4, + }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + callEmblaHandler(emblaApi, 'select'); + + expect(getPlayer(children[4], 'video')?.pause).toBeCalled(); + expect(getPlayer(children[4], 'video')?.mute).toBeCalled(); + }); + + it('should play and unmute on visibility change to visible', () => { + vi.spyOn(global.document, 'addEventListener'); + + const plugin = createPlugin(); + const children = createPlayerSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + selectedScrollSnap: 5, + }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + }); + callVisibilityHandler(); + + expect(getPlayer(children[5], 'video')?.play).toBeCalled(); + expect(getPlayer(children[5], 'video')?.unmute).toBeCalled(); + }); + + it('should pause and unmute on visibility change to hidden', () => { + vi.spyOn(global.document, 'addEventListener'); + + const plugin = createPlugin(); + const children = createPlayerSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + writable: true, + }); + callVisibilityHandler(); + + for (const child of children) { + expect(getPlayer(child, 'video')?.pause).toBeCalled(); + expect(getPlayer(child, 'video')?.mute).toBeCalled(); + } + }); + + describe('should take no action on visibility change without callbacks', () => { + it.each([['visible' as const], ['hidden' as const]])( + '%s', + (visibilityState: 'visible' | 'hidden') => { + vi.spyOn(global.document, 'addEventListener'); + + const plugin = AutoMediaActions(); + const children = createPlayerSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + Object.defineProperty(document, 'visibilityState', { + value: visibilityState, + writable: true, + }); + callVisibilityHandler(); + + for (const child of children) { + expect(getPlayer(child, 'video')?.play).not.toBeCalled(); + expect(getPlayer(child, 'video')?.pause).not.toBeCalled(); + expect(getPlayer(child, 'video')?.mute).not.toBeCalled(); + expect(getPlayer(child, 'video')?.unmute).not.toBeCalled(); + } + }, + ); + }); + + it('should play and unmute on intersection', () => { + const plugin = createPlugin(); + const children = createPlayerSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + selectedScrollSnap: 5, + }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + // Intersection observer always calls handler on creation (and we ignore + // these first calls). + callIntersectionHandler(true); + callIntersectionHandler(true); + + expect(getPlayer(children[5], 'video')?.play).toBeCalled(); + expect(getPlayer(children[5], 'video')?.unmute).toBeCalled(); + }); + + it('should pause and mute on intersection', () => { + vi.spyOn(global.document, 'addEventListener'); + + const plugin = createPlugin(); + const children = createPlayerSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + // Intersection observer always calls handler on creation (and we ignore + // these first calls). + callIntersectionHandler(true); + callIntersectionHandler(false); + + for (const child of children) { + expect(getPlayer(child, 'video')?.pause).toBeCalled(); + expect(getPlayer(child, 'video')?.mute).toBeCalled(); + } + }); +}); diff --git a/tests/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.test.ts b/tests/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.test.ts new file mode 100644 index 00000000..df0e145c --- /dev/null +++ b/tests/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import AutoMediaLoadedInfo from '../../../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info'; +import { + dispatchExistingMediaLoadedInfoAsEvent, + dispatchMediaUnloadedEvent, +} from '../../../../../src/utils/media-info'; +import { createMediaLoadedInfo, createParent } from '../../../../test-utils'; +import { + callEmblaHandler, + createEmblaApiInstance, + createTestEmblaOptionHandler, + createTestSlideNodes, +} from '../../test-utils'; + +// @vitest-environment jsdom +describe('AutoMediaLoadedInfo', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should construct', () => { + const plugin = AutoMediaLoadedInfo(); + expect(plugin.name).toBe('autoMediaLoadedInfo'); + }); + + it('should destroy', () => { + const plugin = AutoMediaLoadedInfo(); + const emblaApi = createEmblaApiInstance(); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + plugin.destroy(); + + expect(emblaApi.off).toBeCalledWith('init', expect.anything()); + expect(emblaApi.off).toBeCalledWith('select', expect.anything()); + }); + + describe('should correctly propogate media load/unload depending on whether media is currently selected', () => { + it.each([ + ['loaded' as const, true], + ['unloaded' as const, true], + ['loaded' as const, false], + ['unloaded' as const, false], + ])('%s', (type: string, selected: boolean) => { + const plugin = AutoMediaLoadedInfo(); + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + + const emblaApi = createEmblaApiInstance({ + containerNode: parent, + slideNodes: children, + selectedScrollSnap: selected ? 5 : 4, + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + const mediaLoadedHandler = vi.fn(); + parent.addEventListener('frigate-card:media:' + type, mediaLoadedHandler); + if (type === 'loaded') { + dispatchExistingMediaLoadedInfoAsEvent(children[5], createMediaLoadedInfo()); + } else if (type === 'unloaded') { + dispatchMediaUnloadedEvent(children[5]); + } + + if (selected) { + expect(mediaLoadedHandler).toBeCalled(); + } else { + expect(mediaLoadedHandler).not.toBeCalled(); + } + }); + }); + + it('selecting a slide should dispatch a previously saved media loaded info if present', () => { + const plugin = AutoMediaLoadedInfo(); + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + const emblaApi = createEmblaApiInstance({ + containerNode: parent, + slideNodes: children, + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + const mediaLoadedHandler = vi.fn(); + parent.addEventListener('frigate-card:media:loaded', mediaLoadedHandler); + dispatchExistingMediaLoadedInfoAsEvent(children[5], createMediaLoadedInfo()); + + vi.mocked(emblaApi.selectedScrollSnap).mockReturnValue(4); + callEmblaHandler(emblaApi, 'select'); + expect(mediaLoadedHandler).not.toBeCalled(); + + vi.mocked(emblaApi.selectedScrollSnap).mockReturnValue(5); + callEmblaHandler(emblaApi, 'select'); + expect(mediaLoadedHandler).toBeCalled(); + }); +}); diff --git a/tests/utils/embla/plugins/auto-size/auto-size.test.ts b/tests/utils/embla/plugins/auto-size/auto-size.test.ts new file mode 100644 index 00000000..d8e06169 --- /dev/null +++ b/tests/utils/embla/plugins/auto-size/auto-size.test.ts @@ -0,0 +1,161 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import AutoSize from '../../../../../src/utils/embla/plugins/auto-size/auto-size'; +import { + IntersectionObserverMock, + ResizeObserverMock, + createParent, + requestAnimationFrameMock, +} from '../../../../test-utils'; +import { + callEmblaHandler, + callIntersectionHandler, + callResizeHandler, + createEmblaApiInstance, + createTestEmblaOptionHandler, + createTestSlideNodes, +} from '../../test-utils'; + +// Mock out debouncing (used in the reinit controller). +vi.mock('lodash-es/debounce', () => ({ + default: vi.fn((fn) => fn), +})); + +// @vitest-environment jsdom +describe('AutoSize', () => { + beforeAll(() => { + // Mock out requestAnimationFrame (used in the reinit controller). + window.requestAnimationFrame = requestAnimationFrameMock; + vi.stubGlobal('IntersectionObserver', IntersectionObserverMock); + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should construct', () => { + const plugin = AutoSize(); + expect(plugin.name).toBe('autoSize'); + }); + + it('should destroy', () => { + const plugin = AutoSize(); + const emblaApi = createEmblaApiInstance(); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + plugin.destroy(); + + expect(emblaApi.off).toBeCalledWith('settle', expect.anything()); + + expect( + vi.mocked(IntersectionObserver).mock.results[0].value.disconnect, + ).toBeCalled(); + expect(vi.mocked(ResizeObserver).mock.results[0].value.disconnect).toBeCalled(); + }); + + it('should correctly handle intersection', () => { + const plugin = AutoSize(); + const emblaApi = createEmblaApiInstance(); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + // First intersection handler call sets the state only. + callIntersectionHandler(true); + + callIntersectionHandler(false); + callIntersectionHandler(false); + callIntersectionHandler(false); + + expect(emblaApi.reInit).toBeCalledTimes(1); + }); + + it('should correctly handle resize', () => { + const plugin = AutoSize(); + const parent = createParent(); + const emblaApi = createEmblaApiInstance({ containerNode: parent }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + callResizeHandler([{ target: parent, width: 10, height: 20 }]); + callResizeHandler([{ target: parent, width: 10, height: 20 }]); + callResizeHandler([{ target: parent, width: 10, height: 20 }]); + + expect(emblaApi.reInit).toBeCalledTimes(1); + + callResizeHandler([{ target: parent, width: 20, height: 40 }]); + + expect(emblaApi.reInit).toBeCalledTimes(2); + }); + + it('should set container height on slide settle', () => { + const plugin = AutoSize(); + const parent = createParent(); + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ + containerNode: parent, + selectedScrollSnap: 0, + slideNodes: children, + // 0th scroll snap shows the 0th slide only. + slideRegistry: [[0]], + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + children[0].getBoundingClientRect = vi.fn().mockReturnValue({ + width: 200, + height: 800, + }); + + // select should not do anything, we wait for it to have settled for + // smoothness. + callEmblaHandler(emblaApi, 'select'); + expect(parent.style.maxHeight).toBeFalsy(); + + callEmblaHandler(emblaApi, 'settle'); + expect(parent.style.maxHeight).toBe('800px'); + }); + + it('should not set container height on horizontal carousel', () => { + const plugin = AutoSize(); + const parent = createParent(); + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ + containerNode: parent, + selectedScrollSnap: 0, + slideNodes: children, + axis: 'y', + // 0th scroll snap shows the 0th slide only. + slideRegistry: [[0]], + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + children[0].getBoundingClientRect = vi.fn().mockReturnValue({ + width: 200, + height: 800, + }); + + callEmblaHandler(emblaApi, 'settle'); + + expect(parent.style.maxHeight).toBeFalsy(); + }); + + it('should not set container height when slide dimensions are invalid', () => { + const plugin = AutoSize(); + const parent = createParent(); + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ + containerNode: parent, + selectedScrollSnap: 0, + slideNodes: children, + axis: 'x', + // 0th scroll snap shows the 0th slide only. + slideRegistry: [[0]], + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + children[0].getBoundingClientRect = vi.fn().mockReturnValue(NaN); + callEmblaHandler(emblaApi, 'settle'); + + children[0].getBoundingClientRect = vi.fn().mockReturnValue(0); + callEmblaHandler(emblaApi, 'settle'); + + expect(parent.style.maxHeight).toBeFalsy(); + }); +}); diff --git a/tests/utils/embla/reinit-controller.test.ts b/tests/utils/embla/reinit-controller.test.ts new file mode 100644 index 00000000..38e03ba7 --- /dev/null +++ b/tests/utils/embla/reinit-controller.test.ts @@ -0,0 +1,59 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EmblaReInitController } from '../../../src/utils/embla/reinit-controller'; +import { requestAnimationFrameMock } from '../../test-utils'; +import { callEmblaHandler, createEmblaApiInstance } from './test-utils'; + +vi.mock('lodash-es/debounce', () => ({ + default: vi.fn((fn) => fn), +})); + +// @vitest-environment jsdom +describe('EmblaReInitController', () => { + beforeAll(() => { + window.requestAnimationFrame = requestAnimationFrameMock; + }); + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should construct', () => { + const emblaApi = createEmblaApiInstance(); + new EmblaReInitController(emblaApi); + expect(emblaApi.on).toBeCalledWith('scroll', expect.anything()); + expect(emblaApi.on).toBeCalledWith('settle', expect.anything()); + expect(emblaApi.on).toBeCalledWith('destroy', expect.anything()); + }); + + it('should destroy', () => { + const emblaApi = createEmblaApiInstance(); + const controller = new EmblaReInitController(emblaApi); + + controller.destroy(); + + expect(emblaApi.off).toBeCalledWith('scroll', expect.anything()); + expect(emblaApi.off).toBeCalledWith('settle', expect.anything()); + expect(emblaApi.off).toBeCalledWith('destroy', expect.anything()); + }); + + it('should reinit when not scrolling', () => { + const emblaApi = createEmblaApiInstance(); + const controller = new EmblaReInitController(emblaApi); + + controller.reinit(); + + expect(emblaApi.reInit).toBeCalled(); + }); + + it('should carefully reinit when scrolling', () => { + const emblaApi = createEmblaApiInstance(); + const controller = new EmblaReInitController(emblaApi); + + callEmblaHandler(emblaApi, 'scroll'); + + controller.reinit(); + expect(emblaApi.reInit).not.toBeCalled(); + + callEmblaHandler(emblaApi, 'settle'); + expect(emblaApi.reInit).toBeCalled(); + }); +}); diff --git a/tests/utils/embla/test-utils.ts b/tests/utils/embla/test-utils.ts new file mode 100644 index 00000000..e3ab0389 --- /dev/null +++ b/tests/utils/embla/test-utils.ts @@ -0,0 +1,132 @@ +import { EmblaCarouselType, EmblaEventType } from 'embla-carousel'; +import { EngineType } from 'embla-carousel/components/Engine'; +import { LooseOptionsType } from 'embla-carousel/components/Options'; +import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler'; +import merge from 'lodash-es/merge'; +import { vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; + +export const createTestEmblaOptionHandler = (): OptionsHandlerType => ({ + mergeOptions: ( + optionsA: TypeA, + optionsB?: TypeB, + ): TypeA => { + return merge({}, optionsA, optionsB); + }, + optionsAtMedia: (options: Type): Type => { + return options; + }, + optionsMediaQueries: (_optionsList: LooseOptionsType[]): MediaQueryList[] => [], +}); + +export const callEmblaHandler = ( + emblaApi: EmblaCarouselType | null, + eventName: EmblaEventType, +): void => { + if (!emblaApi) { + return; + } + const mock = vi.mocked(emblaApi.on).mock; + for (const [evt, cb] of mock.calls) { + if (evt === eventName) { + cb(emblaApi, evt); + } + } +}; + +export const callVisibilityHandler = (): void => { + const mock = vi.mocked(global.document.addEventListener).mock; + for (const [evt, cb] of mock.calls) { + if (evt === 'visibilitychange' && typeof cb === 'function') { + cb(new Event('foo')); + } + } +}; + +export const callIntersectionHandler = (intersecting = true, n = 0): void => { + const mockResult = vi.mocked(IntersectionObserver).mock.results[n]; + if (mockResult.type !== 'return') { + return; + } + const observer = mockResult.value; + vi.mocked(IntersectionObserver).mock.calls[n][0]( + // Note this is a very incomplete / invalid IntersectionObserverEntry that + // just provides the bare basics current implementation uses. + intersecting ? [{ isIntersecting: true } as IntersectionObserverEntry] : [], + observer, + ); +}; + +export const callMutationHandler = (n = 0): void => { + const mockResult = vi.mocked(MutationObserver).mock.results[n]; + if (mockResult.type !== 'return') { + return; + } + const observer = mockResult.value; + vi.mocked(MutationObserver).mock.calls[n][0]( + // Note this is a very incomplete / invalid IntersectionObserverEntry that + // just provides the bare basics current implementation uses. + [], + observer, + ); +}; + +export const callResizeHandler = ( + entries: { + target: HTMLElement; + width: number; + height: number; + }[], + n = 0, +): void => { + const mockResult = vi.mocked(ResizeObserver).mock.results[n]; + if (mockResult.type !== 'return') { + return; + } + const observer = mockResult.value; + vi.mocked(ResizeObserver).mock.calls[n][0]( + // Note this is a very incomplete / invalid ResizeObserverEntry that + // just provides the bare basics current implementation uses. + entries.map( + (entry) => + ({ + target: entry.target, + contentRect: { + height: entry.height, + width: entry.width, + }, + } as unknown as ResizeObserverEntry), + ), + observer, + ); +}; + +export const createEmblaApiInstance = (options?: { + slideNodes?: HTMLElement[]; + selectedScrollSnap?: number; + previousScrollSnap?: number; + containerNode?: HTMLElement; + axis?: 'x' | 'y'; + slideRegistry?: number[][]; +}): EmblaCarouselType => { + const emblaApi = mock(); + emblaApi.slideNodes.mockReturnValue(options?.slideNodes ?? createTestSlideNodes()); + emblaApi.selectedScrollSnap.mockReturnValue(options?.selectedScrollSnap ?? 0); + emblaApi.previousScrollSnap.mockReturnValue(options?.previousScrollSnap ?? 0); + emblaApi.containerNode.mockReturnValue( + options?.containerNode ?? document.createElement('div'), + ); + emblaApi.internalEngine.mockReturnValue({ + options: { axis: options?.axis ?? 'x' }, + ...(options?.slideRegistry && { slideRegistry: options.slideRegistry }), + } as EngineType); + return emblaApi; +}; + +export const createTestSlideNodes = (options?: { + n?: number; +}): HTMLElement[] => { + return [...Array(options?.n ?? 10).keys()].map((_) => + document.createElement('div'), + ); +}; diff --git a/tests/utils/media-grid-controller.test.ts b/tests/utils/media-grid-controller.test.ts index 2066647b..4b19b6f9 100644 --- a/tests/utils/media-grid-controller.test.ts +++ b/tests/utils/media-grid-controller.test.ts @@ -8,8 +8,10 @@ import { } from '../../src/utils/media-grid-controller'; import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info'; import { - createMutationObserverImplementation, - createResizeObserverImplementation, + MutationObserverMock, + ResizeObserverMock, + createSlot, + createSlotHost, } from '../test-utils'; vi.mock('lodash-es/throttle', () => ({ @@ -41,7 +43,7 @@ const setElementWidth = (element: HTMLElement, width: number): void => { }); }; -const createHost = (options?: { +const createParent = (options?: { children?: HTMLElement[]; width?: number; }): HTMLElement => { @@ -54,28 +56,6 @@ const createHost = (options?: { return host; }; -const createSlotParent = (): HTMLElement => { - const parent = document.createElement('div'); - parent.attachShadow({ mode: 'open' }); - return parent; -}; - -const createSlotHost = (options?: { - children?: HTMLElement[]; - parent?: HTMLElement; -}): HTMLSlotElement => { - const parent = options?.parent ?? createSlotParent(); - const slot = document.createElement('slot'); - parent.shadowRoot?.append(slot); - - if (options?.children) { - // Children will automatically be slotted into the default slot. - parent.append(...options.children); - } - - return slot; -}; - const createController = (host: HTMLElement, options?: MediaGridConstructorOptions) => { return new MediaGridController(host, options); }; @@ -101,30 +81,20 @@ describe('MediaGridController', () => { beforeEach(() => { vi.clearAllMocks(); - - global.ResizeObserver = vi - .fn() - // Caution: Order must match the order of initialization in - // media-grid-controller.ts . - .mockImplementationOnce(createResizeObserverImplementation()) - .mockImplementationOnce(createResizeObserverImplementation()); - - global.MutationObserver = vi - .fn() - .mockImplementation(createMutationObserverImplementation()); - //global.MutationObserver = mock(); + vi.stubGlobal('MutationObserver', MutationObserverMock); + vi.stubGlobal('ResizeObserver', ResizeObserverMock); }); it('should be constructable', () => { - const controller = createController(createHost()); + const controller = createController(createParent()); expect(controller).toBeTruthy(); expect(masonry.layout).toBeCalled(); }); it('should set grid contents correctly from regular elements', () => { const children = createChildren(); - const host = createHost({ children: children }); - const controller = createController(host); + const parent = createParent({ children: children }); + const controller = createController(parent); expect(controller.getGridContents()).toEqual( new Map([ ['0', children[0]], @@ -138,7 +108,8 @@ describe('MediaGridController', () => { it('should set grid contents correctly from slotted elements', () => { const children = createChildren(); - const host = createSlotHost({ children: children }); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); const controller = createController(host); expect(controller.getGridContents()).toEqual( new Map([ @@ -152,7 +123,10 @@ describe('MediaGridController', () => { it('should select element', () => { const children = createChildren(); - const controller = createController(createSlotHost({ children: children })); + const slot = createSlot(); + createSlotHost({ slot: slot, children: children }); + + const controller = createController(slot); // All children should be unselected. expect(controller.getSelected()).toBeNull(); @@ -176,7 +150,10 @@ describe('MediaGridController', () => { }); it('should re-select element', () => { - const controller = createController(createSlotHost({ children: createChildren() })); + const children = createChildren(); + const slot = createSlot(); + createSlotHost({ slot: slot, children: children }); + const controller = createController(slot); // All children should be unselected. expect(controller.getSelected()).toBeNull(); @@ -190,16 +167,14 @@ describe('MediaGridController', () => { it('should dispatch media loaded info on selection', () => { const children = createChildren(); - const host = createSlotHost({ children: children }); - const controller = createController(host); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); + const controller = createController(slot); const mediaLoadedInfoHandler = vi.fn(); host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler); dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo); - // Nothing is selected, so the event should not have propagated. - expect(mediaLoadedInfoHandler).not.toBeCalled(); - controller.selectCell('0'); expect(mediaLoadedInfoHandler).toBeCalledWith( expect.objectContaining({ @@ -208,9 +183,45 @@ describe('MediaGridController', () => { ); }); + it('should dispatch media loaded info when cell is selected', () => { + const children = createChildren(); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); + const controller = createController(slot); + + controller.selectCell('0'); + + const mediaLoadedInfoHandler = vi.fn(); + host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler); + dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo); + + expect(mediaLoadedInfoHandler).toBeCalledWith( + expect.objectContaining({ + detail: mediaLoadedInfo, + }), + ); + }); + + it('should not dispatch media loaded info when cell is not selected', () => { + const children = createChildren(); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); + const controller = createController(host); + + controller.selectCell('1'); + + const mediaLoadedInfoHandler = vi.fn(); + host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler); + dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo); + + // Another element is selected, so the event should not have propagated. + expect(mediaLoadedInfoHandler).not.toBeCalled(); + }); + it('should unselect', () => { const children = createChildren(); - const host = createSlotHost({ children: children }); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); const controller = createController(host); const unselectedHandler = vi.fn(); @@ -234,20 +245,29 @@ describe('MediaGridController', () => { } // Expect handlers to have been called. - expect(unselectedHandler).toBeCalled(); - expect(unloadMediaHandler).toBeCalled(); + expect(unselectedHandler).toBeCalledTimes(1); + expect(unloadMediaHandler).toBeCalledTimes(1); + + // Unselecting a second time should do nothing. + controller.unselectAll(); + + expect(unselectedHandler).toBeCalledTimes(1); + expect(unloadMediaHandler).toBeCalledTimes(1); }); it('should select in constructor', () => { const children = createChildren(); - const host = createSlotHost({ children: children }); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); const controller = createController(host, { selected: '2' }); + expect(controller.getSelected()).toBe('2'); }); it('should respect grid attribute option', () => { const children = createChildren(['one', 'two', 'three'], 'test-id'); - const host = createSlotHost({ children: children }); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); const controller = createController(host, { idAttribute: 'test-id' }); expect(controller.getGridContents()).toEqual( new Map([ @@ -258,10 +278,21 @@ describe('MediaGridController', () => { ); }); - it('should destroy', () => { + it('should destroy with regular elements', () => { const children = createChildren(); - const host = createSlotHost({ children: children }); - const controller = createController(host); + const parent = createParent({ children: children }); + const controller = createController(parent); + expect(controller.getGridSize()).toBe(3); + controller.destroy(); + expect(controller.getGridSize()).toBe(0); + }); + + it('should destroy with slotted elements', () => { + const children = createChildren(); + const slot = createSlot(); + createSlotHost({ slot: slot, children: children }); + const controller = createController(slot); + expect(controller.getGridSize()).toBe(3); controller.destroy(); expect(controller.getGridSize()).toBe(0); @@ -269,16 +300,16 @@ describe('MediaGridController', () => { it('should replace children when they change', () => { const children = createChildren(); - const host = createHost({ children: children }); - const controller = createController(host, { selected: '1' }); + const parent = createParent({ children: children }); + const controller = createController(parent, { selected: '1' }); dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo); expect(controller.getSelected()).toBe('1'); expect(controller.getGridSize()).toBe(3); - children.forEach((child) => host.removeChild(child)); + children.forEach((child) => parent.removeChild(child)); const newChildren = createChildren(['one', 'two', 'three']); - newChildren.forEach((child) => host.appendChild(child)); + newChildren.forEach((child) => parent.appendChild(child)); triggerMutationObserver(); @@ -294,19 +325,19 @@ describe('MediaGridController', () => { it('should replace children of a slot when they change', () => { const children = createChildren(); - const slotParent = createSlotParent(); - const host = createSlotHost({ children: children, parent: slotParent }); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); - const controller = createController(host, { selected: '1' }); + const controller = createController(slot, { selected: '1' }); expect(controller.getSelected()).toBe('1'); expect(controller.getGridSize()).toBe(3); - children.forEach((child) => slotParent.removeChild(child)); + children.forEach((child) => host.removeChild(child)); const newChildren = createChildren(['one', 'two', 'three']); - newChildren.forEach((child) => slotParent.append(child)); + newChildren.forEach((child) => host.append(child)); - host.dispatchEvent(new Event('slotchange')); + slot.dispatchEvent(new Event('slotchange')); expect(controller.getGridContents()).toEqual( new Map([ @@ -320,10 +351,10 @@ describe('MediaGridController', () => { it('should construct masonry correctly', () => { const children = createChildren(); - const host = createHost({ children: children }); - createController(host); + const parent = createParent({ children: children }); + createController(parent); expect(Masonry).toBeCalledWith( - host, + parent, expect.objectContaining({ initLayout: false, percentPosition: true, @@ -333,57 +364,85 @@ describe('MediaGridController', () => { }); it('should set default column size correctly', () => { - const host = createHost({ children: createChildren() }); - createController(host); + const parent = createParent({ children: createChildren() }); + createController(parent); expect(Masonry).toBeCalledWith( - host, + parent, expect.objectContaining({ columnWidth: 246, }), ); - expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px'); + expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px'); }); it('should respect exact columns', () => { - const host = createHost({ children: createChildren(), width: 2000 }); - const controller = createController(host); + const parent = createParent({ children: createChildren(), width: 2000 }); + const controller = createController(parent); controller.setDisplayConfig({ mode: 'grid', grid_columns: 2 }); // Will have been called once on construction, and then again when the // number of columns changes. expect(Masonry).toBeCalledTimes(2); expect(Masonry).toBeCalledWith( - host, + parent, expect.objectContaining({ columnWidth: 1000, }), ); - expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe( + expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe( '1000px', ); }); it('should respect selected width factor', () => { - const host = createHost({ children: createChildren(), width: 2000 }); - const controller = createController(host); + const parent = createParent({ children: createChildren(), width: 2000 }); + const controller = createController(parent); controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 }); expect( - host.style.getPropertyValue('--frigate-card-grid-selected-width-factor'), + parent.style.getPropertyValue('--frigate-card-grid-selected-width-factor'), + ).toBe('3'); + + // Setting the same config again should do nothing. + controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 }); + expect( + parent.style.getPropertyValue('--frigate-card-grid-selected-width-factor'), ).toBe('3'); }); - it('should select cell with interacted with', () => { + it('should select cell when interacted with', () => { const children = createChildren(); - const host = createHost({ children: children, width: 2000 }); - const controller = createController(host); + const parent = createParent({ children: children, width: 2000 }); + const controller = createController(parent); expect(controller.getSelected()).toBeNull(); + + const clickHandler = vi.fn(); + parent.addEventListener('click', clickHandler); + children[1].click(); + + // Click will not be allowed through. + expect(clickHandler).not.toBeCalled(); + expect(controller.getSelected()).toBe('1'); + }); + + it('should ignore interaction events on already selected cell', () => { + const children = createChildren(); + const parent = createParent({ children: children, width: 2000 }); + const controller = createController(parent); + controller.selectCell('1'); + + const clickHandler = vi.fn(); + parent.addEventListener('click', clickHandler); + children[1].click(); + + // Click will be allowed through. + expect(clickHandler).toBeCalled(); expect(controller.getSelected()).toBe('1'); }); it('should re-layout when child size changes', () => { - createController(createHost({ children: createChildren() })); + createController(createParent({ children: createChildren() })); vi.mocked(masonry.layout)?.mockClear(); triggerResizeObserver('cell'); @@ -392,32 +451,41 @@ describe('MediaGridController', () => { it('should re-create masonry when host size changes', () => { const children = createChildren(); - const host = createHost({ children: children }); - const controller = createController(host); + const parent = createParent({ children: children }); + createController(parent); expect(Masonry).toBeCalledWith( - host, + parent, expect.objectContaining({ columnWidth: 246, }), ); - expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px'); + expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px'); // Clear mock state. vi.mocked(Masonry).mockClear(); vi.mocked(masonry.layout)?.mockClear(); // Resize the host. - setElementWidth(host, 2000); + setElementWidth(parent, 2000); triggerResizeObserver('host'); // Masonry should be reconstructed, styles set and layout called. expect(Masonry).toBeCalledWith( - host, + parent, expect.objectContaining({ columnWidth: 667, }), ); - expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('667px'); + expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('667px'); expect(masonry.layout).toBeCalled(); + + // Clear mock state. + vi.mocked(Masonry).mockClear(); + vi.mocked(masonry.layout)?.mockClear(); + + // Triger with the same sizes. + triggerResizeObserver('host'); + expect(Masonry).not.toBeCalled(); + expect(masonry.layout).not.toBeCalled(); }); }); diff --git a/tests/utils/media.test.ts b/tests/utils/media.test.ts index 5e4aefc9..01859e85 100644 --- a/tests/utils/media.test.ts +++ b/tests/utils/media.test.ts @@ -108,6 +108,16 @@ describe('playMediaMutingIfNecessary', () => { expect(player.isMuted).toBeCalled(); expect(player.mute).toBeCalled(); }); + + it('should ignore calls without a video', async () => { + const player = mock(); + player.isMuted.mockReturnValue(false); + + await playMediaMutingIfNecessary(player); + + expect(player.isMuted).not.toBeCalled(); + expect(player.mute).not.toBeCalled(); + }); }); describe('constants', () => { diff --git a/tests/utils/menu-controller.test.ts b/tests/utils/menu-controller.test.ts index 00c578c5..ef193ef0 100644 --- a/tests/utils/menu-controller.test.ts +++ b/tests/utils/menu-controller.test.ts @@ -1,4 +1,5 @@ import { HomeAssistant } from 'custom-card-helpers'; +import isEqual from 'lodash-es/isEqual'; import screenfull from 'screenfull'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; @@ -160,6 +161,26 @@ describe('MenuButtonController', () => { }); }); + it('should not have a cameras menu without a visible camera', () => { + const cameraManager = createCameraManager({ + configs: new Map([ + ['camera-1', createCameraConfig()], + ['camera-2', createCameraConfig()], + ]), + }); + + vi.mocked(cameraManager.getStore()).getVisibleCameras.mockReturnValue(new Map()); + + const buttons = calculateButtons(controller, { cameraManager: cameraManager }); + expect(buttons).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: 'Cameras', + }), + ]), + ); + }); + it('should have substream button with single dependency', () => { const cameraManager = createCameraManager({ configs: new Map([ @@ -1122,7 +1143,17 @@ describe('MenuButtonController', () => { style: {}, }; controller.addDynamicMenuButton(button); - expect(calculateButtons(controller)).toContainEqual(button); + expect( + calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton)) + .length, + ).toBe(1); + + // Adding it again will have no effect. + controller.addDynamicMenuButton(button); + expect( + calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton)) + .length, + ).toBe(1); controller.removeDynamicMenuButton(button); expect(calculateButtons(controller)).not.toContainEqual(button); diff --git a/tests/utils/substream.test.ts b/tests/utils/substream.test.ts index 5a4dd1bd..bc0227b1 100644 --- a/tests/utils/substream.test.ts +++ b/tests/utils/substream.test.ts @@ -52,6 +52,20 @@ describe('createViewWithoutSubstream', () => { const newView = createViewWithoutSubstream(view); expect(newView?.context?.live?.overrides).toEqual(new Map()); }); + + it('should create view with overrides untouched', () => { + const view = new View({ + view: 'live', + camera: 'camera-1', + context: { + live: { + overrides: new Map([['camera-2', 'camera-3']]), + }, + }, + }); + const newView = createViewWithoutSubstream(view); + expect(newView?.context?.live?.overrides).toEqual(view.context?.live?.overrides); + }); }); describe('hasSubstream', () => { @@ -95,7 +109,7 @@ describe('createViewWithNextStream', () => { camera: 'camera', }); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera'])); - const cameraManager = createCameraManager() + const cameraManager = createCameraManager(); const newView = createViewWithNextStream(cameraManager, view); expect(newView.camera).toBe(view.camera); expect(newView.view).toBe(view.view); @@ -107,7 +121,7 @@ describe('createViewWithNextStream', () => { camera: 'camera', }); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); - const cameraManager = createCameraManager() + const cameraManager = createCameraManager(); const newView = createViewWithNextStream(cameraManager, view); expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']])); }); @@ -122,7 +136,7 @@ describe('createViewWithNextStream', () => { }, }); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); - const cameraManager = createCameraManager() + const cameraManager = createCameraManager(); const newView = createViewWithNextStream(cameraManager, view); expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']])); }); @@ -137,7 +151,7 @@ describe('createViewWithNextStream', () => { }, }); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); - const cameraManager = createCameraManager() + const cameraManager = createCameraManager(); const newView = createViewWithNextStream(cameraManager, view); expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']])); }); diff --git a/tests/utils/zod.test.ts b/tests/utils/zod.test.ts index 0d55f48e..b2d1b837 100644 --- a/tests/utils/zod.test.ts +++ b/tests/utils/zod.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { z } from 'zod'; +import { z, ZodError } from 'zod'; import { - deepRemoveDefaults, - getParseErrorKeys, - getParseErrorPaths, + deepRemoveDefaults, + getParseErrorKeys, + getParseErrorPaths, } from '../../src/utils/zod'; describe('deepRemoveDefaults', () => { @@ -88,4 +88,7 @@ describe('getParseErrorPaths', () => { new Set(['array[0] -> type', 'array[0] -> data']), ); }); + it('should get no paths for empty error', () => { + expect(getParseErrorPaths(new ZodError([]))).toEqual(new Set()); + }); }); diff --git a/tests/utils/zoom.test.ts b/tests/utils/zoom.test.ts index 9c81d8fe..f8817e2c 100644 --- a/tests/utils/zoom.test.ts +++ b/tests/utils/zoom.test.ts @@ -92,6 +92,27 @@ describe('Zoom', () => { expect(panzoom.handleUp).toBeCalledWith(ev_5); }); + it('should not respond to pointer when not zoomed', () => { + const element = document.createElement('div'); + + const panzoom = createMockPanZoom(); + vi.mocked(Panzoom).mockReturnValueOnce(panzoom); + + createAndRegisterZoom(element); + + const ev_1 = new PointerEvent('pointerdown'); + element.dispatchEvent(ev_1); + expect(panzoom.handleDown).not.toBeCalledWith(ev_1); + + const ev_2 = new PointerEvent('pointermove'); + element.dispatchEvent(ev_2); + expect(panzoom.handleDown).not.toBeCalledWith(ev_2); + + const ev_3 = new PointerEvent('pointerup'); + element.dispatchEvent(ev_3); + expect(panzoom.handleDown).not.toBeCalledWith(ev_3); + }); + it('should respond with touch', () => { mediaMediSpy.mockReturnValue({ matches: false }); @@ -252,4 +273,50 @@ describe('Zoom', () => { element.dispatchEvent(ev_2); expect(element.style.touchAction).toBeFalsy(); }); + + it('should not fire frigate cards when state has not changed or spurious events received', () => { + const element = document.createElement('div'); + + const zoomedFunc = vi.fn(); + const unzoomedFunc = vi.fn(); + + element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc); + element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc); + + vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom()); + + createAndRegisterZoom(element); + + const ev_1 = new CustomEvent('panzoomzoom', { + detail: { + x: 0, + y: 0, + scale: 1, + isSVG: false, + originalEvent: new PointerEvent('pointermove'), + }, + }); + element.dispatchEvent(ev_1); + + // Unzoomed event with scale === 1, this._zoomed will already be false. + expect(unzoomedFunc).not.toBeCalled(); + expect(zoomedFunc).not.toBeCalled(); + + const ev_2 = new CustomEvent('panzoomzoom', { + detail: { + x: 0, + y: 0, + scale: 1.2, + isSVG: false, + originalEvent: new PointerEvent('pointermove'), + }, + }); + element.dispatchEvent(ev_2); + expect(zoomedFunc).toBeCalledTimes(1); + expect(unzoomedFunc).not.toBeCalled(); + + // Another call when already zoomed will be ignored. + element.dispatchEvent(ev_2); + expect(zoomedFunc).toBeCalledTimes(1); + }); }); diff --git a/tests/view/media-queries-results.test.ts b/tests/view/media-queries-results.test.ts index aae224ef..4129af3a 100644 --- a/tests/view/media-queries-results.test.ts +++ b/tests/view/media-queries-results.test.ts @@ -243,4 +243,40 @@ describe('dispatchViewContextChangeEvent', () => { .map((media) => media.getID()), ).toEqual(['id-office-99', 'id-kitchen-99', 'id-office-99']); }); + + it('should get multiple selected results without main', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + + expect( + results + .getMultipleSelectedResults({ main: false, allCameras: true }) + .map((media) => media.getID()), + ).toEqual(['id-kitchen-99', 'id-office-99']); + }); + + it('should get no results with invalid camera ID without main', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + + expect( + results + .getMultipleSelectedResults({ main: false, cameraID: 'not-a-real-camera' }) + .map((media) => media.getID()), + ).toEqual([]); + }); + + it('should not demote main selection when selecting from a specific camera', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + + results.selectIndex(42); + results.selectIndex(24, 'office'); + + expect(results.getSelectedIndex()).toBe(42); + expect(results.getSelectedIndex('office')).toBe(24); + }); }); diff --git a/tests/view/view.test.ts b/tests/view/view.test.ts index e19f5da3..0703f226 100644 --- a/tests/view/view.test.ts +++ b/tests/view/view.test.ts @@ -4,7 +4,7 @@ import { ViewMedia } from '../../src/view/media'; import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { View, dispatchViewContextChangeEvent } from '../../src/view/view'; -import { createView, generateViewMediaArray } from '../test-utils'; +import { createView } from '../test-utils'; // @vitest-environment jsdom describe('View Basics', () => { @@ -122,6 +122,14 @@ describe('View Basics', () => { expect(view.context).toEqual({}); }); + it('should not remove context when no context', () => { + const view = createView(); + expect(view.context).toBeNull(); + + view.removeContext('live'); + expect(view.context).toBeNull(); + }); + it('should remove context property', () => { const view = createView({ context: { live: { overrides: new Map() } } }); @@ -129,6 +137,13 @@ describe('View Basics', () => { expect(view.context).toEqual({ live: {} }); }); + it('should not remove context property that does not exist', () => { + const view = createView({ context: {} }); + + view.removeContextProperty('live', 'overrides'); + expect(view.context).toEqual({}); + }); + it('should detect gallery views', () => { expect(createView({ view: 'clips' }).isGalleryView()).toBeTruthy(); expect(createView({ view: 'snapshots' }).isGalleryView()).toBeTruthy(); @@ -292,6 +307,28 @@ describe('View.adoptFromViewIfAppropriate', () => { expect(next.queryResults).toBe(queryResults); }); + it('should not adopt for gallery case if neither query nor results in current view', () => { + const current = createView({ + view: 'clip', + query: null, + queryResults: null, + }); + + const nextQuery = new EventMediaQueries([ + { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, + ]); + const nextResults = new MediaQueriesResults(); + const next = createView({ + view: 'clips', + query: nextQuery, + queryResults: nextResults, + }); + View.adoptFromViewIfAppropriate(next, current); + expect(next.view).toBe('clips'); + expect(next.query).toBe(nextQuery); + expect(next.queryResults).toBe(nextResults); + }); + it.each([ [ new EventMediaQueries([ @@ -311,7 +348,7 @@ describe('View.adoptFromViewIfAppropriate', () => { ]), 'recording', ], - ])('should adopt for media case', (mediaQueries, expectedView) => { + ])('should adopt in media case', (mediaQueries, expectedView) => { const current = createView({ view: 'media', query: mediaQueries, @@ -324,6 +361,54 @@ describe('View.adoptFromViewIfAppropriate', () => { expect(next.queryResults).toBeFalsy(); }); + it('should not adopt for mixed queries in media case', () => { + const query = new EventMediaQueries([ + { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, + { type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true }, + ]); + const results = new MediaQueriesResults(); + const current = createView({ + view: 'media', + query: query, + queryResults: results, + }); + const next = createView({ view: 'media' }); + View.adoptFromViewIfAppropriate(next, current); + + expect(next.view).toBe('media'); + expect(next.query).toBeNull(); + expect(next.queryResults).toBeNull(); + }); + + it('should not adopt when queries and results present in next view in media case', () => { + const currentQuery = new EventMediaQueries([ + { type: QueryType.Event, cameraIDs: new Set(['camera-1']), hasClip: true }, + { type: QueryType.Event, cameraIDs: new Set(['camera-1']), hasSnapshot: true }, + ]); + const currentResults = new MediaQueriesResults(); + const current = createView({ + view: 'media', + query: currentQuery, + queryResults: currentResults, + }); + + const nextQuery = new EventMediaQueries([ + { type: QueryType.Event, cameraIDs: new Set(['camera-2']), hasClip: true }, + { type: QueryType.Event, cameraIDs: new Set(['camera-2']), hasSnapshot: true }, + ]); + const nextResults = new MediaQueriesResults(); + const next = createView({ + view: 'media', + query: nextQuery, + queryResults: nextResults, + }); + View.adoptFromViewIfAppropriate(next, current); + + expect(next.view).toBe('media'); + expect(next.query).toBe(nextQuery); + expect(next.queryResults).toBe(nextResults); + }); + it('should not adopt for other case', () => { const query = new EventMediaQueries([ { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, @@ -431,11 +516,15 @@ describe('View.adoptFromViewIfAppropriate', () => { expect(createView({ view: 'media' }).supportsMultipleDisplayModes()).toBeTruthy(); expect(createView({ view: 'clip' }).supportsMultipleDisplayModes()).toBeTruthy(); expect(createView({ view: 'snapshot' }).supportsMultipleDisplayModes()).toBeTruthy(); - expect(createView({ view: 'recording' }).supportsMultipleDisplayModes()).toBeTruthy(); + expect( + createView({ view: 'recording' }).supportsMultipleDisplayModes(), + ).toBeTruthy(); expect(createView({ view: 'clips' }).supportsMultipleDisplayModes()).toBeFalsy(); expect(createView({ view: 'snapshots' }).supportsMultipleDisplayModes()).toBeFalsy(); - expect(createView({ view: 'recordings' }).supportsMultipleDisplayModes()).toBeFalsy(); + expect( + createView({ view: 'recordings' }).supportsMultipleDisplayModes(), + ).toBeFalsy(); expect(createView({ view: 'image' }).supportsMultipleDisplayModes()).toBeFalsy(); expect(createView({ view: 'timeline' }).supportsMultipleDisplayModes()).toBeFalsy(); }); diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..c5d2f145 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +// ts-prune-ignore-next +export default defineConfig({ + test: { + coverage: { + // Favor istanbul for coverage over v8 due to better accuracy. + provider: 'istanbul', + }, + }, +}); diff --git a/yarn.lock b/yarn.lock index 7bc02b0e..4d52591e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22,6 +22,16 @@ __metadata: languageName: node linkType: hard +"@ampproject/remapping@npm:^2.2.0": + version: 2.2.1 + resolution: "@ampproject/remapping@npm:2.2.1" + dependencies: + "@jridgewell/gen-mapping": ^0.3.0 + "@jridgewell/trace-mapping": ^0.3.9 + checksum: 03c04fd526acc64a1f4df22651186f3e5ef0a9d6d6530ce4482ec9841269cf7a11dbb8af79237c282d721c5312024ff17529cd72cc4768c11e999b58e2302079 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.18.6": version: 7.18.6 resolution: "@babel/code-frame@npm:7.18.6" @@ -31,6 +41,16 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.22.10, @babel/code-frame@npm:^7.22.5": + version: 7.22.13 + resolution: "@babel/code-frame@npm:7.22.13" + dependencies: + "@babel/highlight": ^7.22.13 + chalk: ^2.4.2 + checksum: 22e342c8077c8b77eeb11f554ecca2ba14153f707b85294fcf6070b6f6150aae88a7b7436dd88d8c9289970585f3fe5b9b941c5aa3aa26a6d5a8ef3f292da058 + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.20.5": version: 7.20.14 resolution: "@babel/compat-data@npm:7.20.14" @@ -38,6 +58,36 @@ __metadata: languageName: node linkType: hard +"@babel/compat-data@npm:^7.22.9": + version: 7.22.9 + resolution: "@babel/compat-data@npm:7.22.9" + checksum: bed77d9044ce948b4327b30dd0de0779fa9f3a7ed1f2d31638714ed00229fa71fc4d1617ae0eb1fad419338d3658d0e9a5a083297451e09e73e078d0347ff808 + languageName: node + linkType: hard + +"@babel/core@npm:^7.12.3": + version: 7.22.11 + resolution: "@babel/core@npm:7.22.11" + dependencies: + "@ampproject/remapping": ^2.2.0 + "@babel/code-frame": ^7.22.10 + "@babel/generator": ^7.22.10 + "@babel/helper-compilation-targets": ^7.22.10 + "@babel/helper-module-transforms": ^7.22.9 + "@babel/helpers": ^7.22.11 + "@babel/parser": ^7.22.11 + "@babel/template": ^7.22.5 + "@babel/traverse": ^7.22.11 + "@babel/types": ^7.22.11 + convert-source-map: ^1.7.0 + debug: ^4.1.0 + gensync: ^1.0.0-beta.2 + json5: ^2.2.3 + semver: ^6.3.1 + checksum: f258b2539ea2e5bfe55a708c2f3e1093a1b4744f12becc35abeb896037b66210de9a8ad6296a706046d5dc3a24e564362b73a9b814e5bfe500c8baab60c22d2e + languageName: node + linkType: hard + "@babel/core@npm:^7.19.0": version: 7.20.12 resolution: "@babel/core@npm:7.20.12" @@ -72,6 +122,18 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.22.10": + version: 7.22.10 + resolution: "@babel/generator@npm:7.22.10" + dependencies: + "@babel/types": ^7.22.10 + "@jridgewell/gen-mapping": ^0.3.2 + "@jridgewell/trace-mapping": ^0.3.17 + jsesc: ^2.5.1 + checksum: 59a79730abdff9070692834bd3af179e7a9413fa2ff7f83dff3eb888765aeaeb2bfc7b0238a49613ed56e1af05956eff303cc139f2407eda8df974813e486074 + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-annotate-as-pure@npm:7.18.6" @@ -96,6 +158,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-compilation-targets@npm:^7.22.10": + version: 7.22.10 + resolution: "@babel/helper-compilation-targets@npm:7.22.10" + dependencies: + "@babel/compat-data": ^7.22.9 + "@babel/helper-validator-option": ^7.22.5 + browserslist: ^4.21.9 + lru-cache: ^5.1.1 + semver: ^6.3.1 + checksum: f6f1896816392bcff671bbe6e277307729aee53befb4a66ea126e2a91eda78d819a70d06fa384c74ef46c1595544b94dca50bef6c78438d9ffd31776dafbd435 + languageName: node + linkType: hard + "@babel/helper-create-class-features-plugin@npm:^7.18.6, @babel/helper-create-class-features-plugin@npm:^7.20.12": version: 7.20.12 resolution: "@babel/helper-create-class-features-plugin@npm:7.20.12" @@ -121,6 +196,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-environment-visitor@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-environment-visitor@npm:7.22.5" + checksum: 248532077d732a34cd0844eb7b078ff917c3a8ec81a7f133593f71a860a582f05b60f818dc5049c2212e5baa12289c27889a4b81d56ef409b4863db49646c4b1 + languageName: node + linkType: hard + "@babel/helper-function-name@npm:^7.19.0": version: 7.19.0 resolution: "@babel/helper-function-name@npm:7.19.0" @@ -131,6 +213,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-function-name@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-function-name@npm:7.22.5" + dependencies: + "@babel/template": ^7.22.5 + "@babel/types": ^7.22.5 + checksum: 6b1f6ce1b1f4e513bf2c8385a557ea0dd7fa37971b9002ad19268ca4384bbe90c09681fe4c076013f33deabc63a53b341ed91e792de741b4b35e01c00238177a + languageName: node + linkType: hard + "@babel/helper-hoist-variables@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-hoist-variables@npm:7.18.6" @@ -140,6 +232,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-hoist-variables@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-hoist-variables@npm:7.22.5" + dependencies: + "@babel/types": ^7.22.5 + checksum: 394ca191b4ac908a76e7c50ab52102669efe3a1c277033e49467913c7ed6f7c64d7eacbeabf3bed39ea1f41731e22993f763b1edce0f74ff8563fd1f380d92cc + languageName: node + linkType: hard + "@babel/helper-member-expression-to-functions@npm:^7.20.7": version: 7.20.7 resolution: "@babel/helper-member-expression-to-functions@npm:7.20.7" @@ -158,6 +259,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-imports@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-module-imports@npm:7.22.5" + dependencies: + "@babel/types": ^7.22.5 + checksum: 9ac2b0404fa38b80bdf2653fbeaf8e8a43ccb41bd505f9741d820ed95d3c4e037c62a1bcdcb6c9527d7798d2e595924c4d025daed73283badc180ada2c9c49ad + languageName: node + linkType: hard + "@babel/helper-module-transforms@npm:^7.20.11": version: 7.20.11 resolution: "@babel/helper-module-transforms@npm:7.20.11" @@ -174,6 +284,21 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-transforms@npm:^7.22.9": + version: 7.22.9 + resolution: "@babel/helper-module-transforms@npm:7.22.9" + dependencies: + "@babel/helper-environment-visitor": ^7.22.5 + "@babel/helper-module-imports": ^7.22.5 + "@babel/helper-simple-access": ^7.22.5 + "@babel/helper-split-export-declaration": ^7.22.6 + "@babel/helper-validator-identifier": ^7.22.5 + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 2751f77660518cf4ff027514d6f4794f04598c6393be7b04b8e46c6e21606e11c19f3f57ab6129a9c21bacdf8b3ffe3af87bb401d972f34af2d0ffde02ac3001 + languageName: node + linkType: hard + "@babel/helper-optimise-call-expression@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-optimise-call-expression@npm:7.18.6" @@ -213,6 +338,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-simple-access@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-simple-access@npm:7.22.5" + dependencies: + "@babel/types": ^7.22.5 + checksum: fe9686714caf7d70aedb46c3cce090f8b915b206e09225f1e4dbc416786c2fdbbee40b38b23c268b7ccef749dd2db35f255338fb4f2444429874d900dede5ad2 + languageName: node + linkType: hard + "@babel/helper-skip-transparent-expression-wrappers@npm:^7.20.0": version: 7.20.0 resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.20.0" @@ -231,6 +365,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-split-export-declaration@npm:^7.22.6": + version: 7.22.6 + resolution: "@babel/helper-split-export-declaration@npm:7.22.6" + dependencies: + "@babel/types": ^7.22.5 + checksum: e141cace583b19d9195f9c2b8e17a3ae913b7ee9b8120246d0f9ca349ca6f03cb2c001fd5ec57488c544347c0bb584afec66c936511e447fd20a360e591ac921 + languageName: node + linkType: hard + "@babel/helper-string-parser@npm:^7.19.4": version: 7.19.4 resolution: "@babel/helper-string-parser@npm:7.19.4" @@ -238,6 +381,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-string-parser@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-string-parser@npm:7.22.5" + checksum: 836851ca5ec813077bbb303acc992d75a360267aa3b5de7134d220411c852a6f17de7c0d0b8c8dcc0f567f67874c00f4528672b2a4f1bc978a3ada64c8c78467 + languageName: node + linkType: hard + "@babel/helper-validator-identifier@npm:^7.18.6, @babel/helper-validator-identifier@npm:^7.19.1": version: 7.19.1 resolution: "@babel/helper-validator-identifier@npm:7.19.1" @@ -245,6 +395,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-validator-identifier@npm:7.22.5" + checksum: 7f0f30113474a28298c12161763b49de5018732290ca4de13cdaefd4fd0d635a6fe3f6686c37a02905fb1e64f21a5ee2b55140cf7b070e729f1bd66866506aea + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-validator-option@npm:7.18.6" @@ -252,6 +409,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-option@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-validator-option@npm:7.22.5" + checksum: bbeca8a85ee86990215c0424997438b388b8d642d69b9f86c375a174d3cdeb270efafd1ff128bc7a1d370923d13b6e45829ba8581c027620e83e3a80c5c414b3 + languageName: node + linkType: hard + "@babel/helpers@npm:^7.20.7": version: 7.20.13 resolution: "@babel/helpers@npm:7.20.13" @@ -263,6 +427,17 @@ __metadata: languageName: node linkType: hard +"@babel/helpers@npm:^7.22.11": + version: 7.22.11 + resolution: "@babel/helpers@npm:7.22.11" + dependencies: + "@babel/template": ^7.22.5 + "@babel/traverse": ^7.22.11 + "@babel/types": ^7.22.11 + checksum: 93186544228b5e371486466ec3b86a77cce91beeff24a5670ca8ec46d50328f7700dab82d532351286e9d68624dc51d6d71589b051dd9535e44be077a43ec013 + languageName: node + linkType: hard + "@babel/highlight@npm:^7.18.6": version: 7.18.6 resolution: "@babel/highlight@npm:7.18.6" @@ -274,6 +449,26 @@ __metadata: languageName: node linkType: hard +"@babel/highlight@npm:^7.22.13": + version: 7.22.13 + resolution: "@babel/highlight@npm:7.22.13" + dependencies: + "@babel/helper-validator-identifier": ^7.22.5 + chalk: ^2.4.2 + js-tokens: ^4.0.0 + checksum: 7266d2bff8aa8fc78eb65b6e92a8211e12897a731126a282d2f9bb50d8fcaa4c1b02af2284f990ac7e3ab8d892d448a2cab8f5ed0ea8a90bce2c025b11ebe802 + languageName: node + linkType: hard + +"@babel/parser@npm:^7.14.7, @babel/parser@npm:^7.22.11, @babel/parser@npm:^7.22.5": + version: 7.22.14 + resolution: "@babel/parser@npm:7.22.14" + bin: + parser: ./bin/babel-parser.js + checksum: a2293971f0889726a3d5a35fcceedc71d2fa4c8d97f438fc348fe0cf7e739affc6e2665e4c6ddd4900714772e19bfd5d6feb967ca1f623b894c0099ecb148b52 + languageName: node + linkType: hard + "@babel/parser@npm:^7.20.13, @babel/parser@npm:^7.20.7": version: 7.20.15 resolution: "@babel/parser@npm:7.20.15" @@ -332,6 +527,17 @@ __metadata: languageName: node linkType: hard +"@babel/template@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/template@npm:7.22.5" + dependencies: + "@babel/code-frame": ^7.22.5 + "@babel/parser": ^7.22.5 + "@babel/types": ^7.22.5 + checksum: c5746410164039aca61829cdb42e9a55410f43cace6f51ca443313f3d0bdfa9a5a330d0b0df73dc17ef885c72104234ae05efede37c1cc8a72dc9f93425977a3 + languageName: node + linkType: hard + "@babel/traverse@npm:^7.20.10, @babel/traverse@npm:^7.20.12, @babel/traverse@npm:^7.20.13, @babel/traverse@npm:^7.20.7": version: 7.20.13 resolution: "@babel/traverse@npm:7.20.13" @@ -350,6 +556,24 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.22.11": + version: 7.22.11 + resolution: "@babel/traverse@npm:7.22.11" + dependencies: + "@babel/code-frame": ^7.22.10 + "@babel/generator": ^7.22.10 + "@babel/helper-environment-visitor": ^7.22.5 + "@babel/helper-function-name": ^7.22.5 + "@babel/helper-hoist-variables": ^7.22.5 + "@babel/helper-split-export-declaration": ^7.22.6 + "@babel/parser": ^7.22.11 + "@babel/types": ^7.22.11 + debug: ^4.1.0 + globals: ^11.1.0 + checksum: 4ad62d548ca8b95dbf45bae16cc167428f174f3c837d55a5878b1f17bdbc8b384d6df741dc7c461b62c04d881cf25644d3ab885909ba46e3ac43224e2b15b504 + languageName: node + linkType: hard + "@babel/types@npm:^7.18.6, @babel/types@npm:^7.19.0, @babel/types@npm:^7.20.0, @babel/types@npm:^7.20.2, @babel/types@npm:^7.20.7, @babel/types@npm:^7.8.3": version: 7.20.7 resolution: "@babel/types@npm:7.20.7" @@ -361,10 +585,14 @@ __metadata: languageName: node linkType: hard -"@bcoe/v8-coverage@npm:^0.2.3": - version: 0.2.3 - resolution: "@bcoe/v8-coverage@npm:0.2.3" - checksum: 850f9305536d0f2bd13e9e0881cb5f02e4f93fad1189f7b2d4bebf694e3206924eadee1068130d43c11b750efcc9405f88a8e42ef098b6d75239c0f047de1a27 +"@babel/types@npm:^7.22.10, @babel/types@npm:^7.22.11, @babel/types@npm:^7.22.5": + version: 7.22.11 + resolution: "@babel/types@npm:7.22.11" + dependencies: + "@babel/helper-string-parser": ^7.22.5 + "@babel/helper-validator-identifier": ^7.22.5 + to-fast-properties: ^2.0.0 + checksum: 431a6446896adb62c876d0fe75263835735d3c974aae05356a87eb55f087c20a777028cf08eadcace7993e058bbafe3b21ce2119363222c6cef9eedd7a204810 languageName: node linkType: hard @@ -672,13 +900,22 @@ __metadata: languageName: node linkType: hard -"@istanbuljs/schema@npm:^0.1.2, @istanbuljs/schema@npm:^0.1.3": +"@istanbuljs/schema@npm:^0.1.2": version: 0.1.3 resolution: "@istanbuljs/schema@npm:0.1.3" checksum: 5282759d961d61350f33d9118d16bcaed914ebf8061a52f4fa474b2cb08720c9c81d165e13b82f2e5a8a212cc5af482f0c6fc1ac27b9e067e5394c9a6ed186c9 languageName: node linkType: hard +"@jest/schemas@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/schemas@npm:29.6.3" + dependencies: + "@sinclair/typebox": ^0.27.8 + checksum: 910040425f0fc93cd13e68c750b7885590b8839066dfa0cd78e7def07bbb708ad869381f725945d66f2284de5663bbecf63e8fdd856e2ae6e261ba30b1687e93 + languageName: node + linkType: hard + "@jridgewell/gen-mapping@npm:^0.1.0": version: 0.1.1 resolution: "@jridgewell/gen-mapping@npm:0.1.1" @@ -707,6 +944,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/resolve-uri@npm:^3.1.0": + version: 3.1.1 + resolution: "@jridgewell/resolve-uri@npm:3.1.1" + checksum: f5b441fe7900eab4f9155b3b93f9800a916257f4e8563afbcd3b5a5337b55e52bd8ae6735453b1b745457d9f6cdb16d74cd6220bbdd98cf153239e13f6cbb653 + languageName: node + linkType: hard + "@jridgewell/set-array@npm:^1.0.0, @jridgewell/set-array@npm:^1.0.1": version: 1.1.2 resolution: "@jridgewell/set-array@npm:1.1.2" @@ -731,7 +975,24 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.9": +"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.4.15": + version: 1.4.15 + resolution: "@jridgewell/sourcemap-codec@npm:1.4.15" + checksum: b881c7e503db3fc7f3c1f35a1dd2655a188cc51a3612d76efc8a6eb74728bef5606e6758ee77423e564092b4a518aba569bbb21c9bac5ab7a35b0c6ae7e344c8 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.17": + version: 0.3.19 + resolution: "@jridgewell/trace-mapping@npm:0.3.19" + dependencies: + "@jridgewell/resolve-uri": ^3.1.0 + "@jridgewell/sourcemap-codec": ^1.4.14 + checksum: 956a6f0f6fec060fb48c6bf1f5ec2064e13cd38c8be3873877d4b92b4a27ba58289a34071752671262a3e3c202abcc3fa2aac64d8447b4b0fa1ba3c9047f1c20 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.9": version: 0.3.17 resolution: "@jridgewell/trace-mapping@npm:0.3.17" dependencies: @@ -957,6 +1218,13 @@ __metadata: languageName: node linkType: hard +"@sinclair/typebox@npm:^0.27.8": + version: 0.27.8 + resolution: "@sinclair/typebox@npm:0.27.8" + checksum: 00bd7362a3439021aa1ea51b0e0d0a0e8ca1351a3d54c606b115fdcc49b51b16db6e5f43b4fe7a28c38688523e22a94d49dd31168868b655f0d4d50f032d07a1 + languageName: node + linkType: hard + "@stencil/core@npm:^2.20.0, @stencil/core@npm:^2.3.0": version: 2.22.2 resolution: "@stencil/core@npm:2.22.2" @@ -1008,13 +1276,20 @@ __metadata: languageName: node linkType: hard -"@types/chai@npm:*, @types/chai@npm:^4.3.4": +"@types/chai@npm:*": version: 4.3.4 resolution: "@types/chai@npm:4.3.4" checksum: 571184967beb03bf64c4392a13a7d44e72da9af5a1e83077ff81c39cf59c0fda2a5c78d2005084601cf8f3d11726608574d8b5b4a0e3e9736792807afd926cd0 languageName: node linkType: hard +"@types/chai@npm:^4.3.5": + version: 4.3.5 + resolution: "@types/chai@npm:4.3.5" + checksum: c8f26a88c6b5b53a3275c7f5ff8f107028e3cbb9ff26795fff5f3d9dea07106a54ce9e2dce5e40347f7c4cc35657900aaf0c83934a25a1ae12e61e0f5516e431 + languageName: node + linkType: hard + "@types/cssnano@npm:^5.0.0": version: 5.1.0 resolution: "@types/cssnano@npm:5.1.0" @@ -1045,13 +1320,6 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-lib-coverage@npm:^2.0.1": - version: 2.0.4 - resolution: "@types/istanbul-lib-coverage@npm:2.0.4" - checksum: a25d7589ee65c94d31464c16b72a9dc81dfa0bea9d3e105ae03882d616e2a0712a9c101a599ec482d297c3591e16336962878cb3eb1a0a62d5b76d277a890ce7 - languageName: node - linkType: hard - "@types/jquery@npm:*": version: 3.5.16 resolution: "@types/jquery@npm:3.5.16" @@ -1265,59 +1533,72 @@ __metadata: languageName: node linkType: hard -"@vitest/coverage-c8@npm:^0.29.8": - version: 0.29.8 - resolution: "@vitest/coverage-c8@npm:0.29.8" +"@vitest/coverage-istanbul@npm:^0.34.3": + version: 0.34.3 + resolution: "@vitest/coverage-istanbul@npm:0.34.3" dependencies: - c8: ^7.13.0 - picocolors: ^1.0.0 - std-env: ^3.3.1 + istanbul-lib-coverage: ^3.2.0 + istanbul-lib-instrument: ^6.0.0 + istanbul-lib-report: ^3.0.1 + istanbul-lib-source-maps: ^4.0.1 + istanbul-reports: ^3.1.5 + test-exclude: ^6.0.0 peerDependencies: - vitest: ">=0.29.0 <1" - checksum: 610bd2917aa160f5bf641f4d4dec539524039ea98fdc98a8d9288c7ea05090d7028d4504b8e1ecc1de1d1700c9a61a55abf285559da2c4bc8df238e559a505f7 + vitest: ">=0.32.0 <1" + checksum: 6e5f437d8b3d1e93aa25180661d47339b2a1df24686a5d7154cd266db47fbf0c2a2faa58290f79dd009e531e16c14bfb8a0fb49031d9a14b6e962de82155eb12 languageName: node linkType: hard -"@vitest/expect@npm:0.29.8": - version: 0.29.8 - resolution: "@vitest/expect@npm:0.29.8" +"@vitest/expect@npm:0.34.3": + version: 0.34.3 + resolution: "@vitest/expect@npm:0.34.3" dependencies: - "@vitest/spy": 0.29.8 - "@vitest/utils": 0.29.8 + "@vitest/spy": 0.34.3 + "@vitest/utils": 0.34.3 chai: ^4.3.7 - checksum: a80f9c352a979eb46690be2ea54b5ca391d3575b4053be80c1359325fb0cea913d6217f48d54e64ff5dda3b15bd7a6873a5f8128e8c098f7ebad1365d4065c5e + checksum: 79afaa37d2efb7bb5503332caf389860b2261f198dbe61557e8061262b628d18658e59eb51d1808ecd35fc59f4bb4d04c0e0f97a27c7db02584ab5b424147b8d languageName: node linkType: hard -"@vitest/runner@npm:0.29.8": - version: 0.29.8 - resolution: "@vitest/runner@npm:0.29.8" +"@vitest/runner@npm:0.34.3": + version: 0.34.3 + resolution: "@vitest/runner@npm:0.34.3" dependencies: - "@vitest/utils": 0.29.8 + "@vitest/utils": 0.34.3 p-limit: ^4.0.0 - pathe: ^1.1.0 - checksum: 8305370ff6c3fc6aea7189bd138ee4ff0e040a959c0fe6ab64bcb9e70ae5bf836b8dc058b1de288aa75c9d1cd648e5f112e7cd5691c03b7a1d32466d8bfc71a9 + pathe: ^1.1.1 + checksum: 945580eaa58e8edbe29a64059bc2a524a9e85117b6d600fdb457cfe84cbfb81bf6d7e98e1227e7cb4e7399992c8fe8d83d0791d0385ff005dc1a4d9da125443b languageName: node linkType: hard -"@vitest/spy@npm:0.29.8": - version: 0.29.8 - resolution: "@vitest/spy@npm:0.29.8" +"@vitest/snapshot@npm:0.34.3": + version: 0.34.3 + resolution: "@vitest/snapshot@npm:0.34.3" dependencies: - tinyspy: ^1.0.2 - checksum: 7b1607b696275bf94a497e92d7d10c466b9b3d08726bbedb3735bdf57f003763a9516e328af22746829526ce573f87eb6119ab64ce7db95794b2d220aa53b607 + magic-string: ^0.30.1 + pathe: ^1.1.1 + pretty-format: ^29.5.0 + checksum: 234893e91a1efd4bdbbde047a68de40975e02ead8407724ce8ca4a24edf0fb2d725f8a3efceb104965388407b598faf22407aadfbf4164cc74b3cf1e0e9f4543 languageName: node linkType: hard -"@vitest/utils@npm:0.29.8": - version: 0.29.8 - resolution: "@vitest/utils@npm:0.29.8" +"@vitest/spy@npm:0.34.3": + version: 0.34.3 + resolution: "@vitest/spy@npm:0.34.3" dependencies: - cli-truncate: ^3.1.0 - diff: ^5.1.0 + tinyspy: ^2.1.1 + checksum: a2b64b9c357a56ad2f2340ecd225ffe787e61afba4ffb24a6670aad3fc90ea2606ed48daa188ed62b3ef67d55c0259fda6b101143d6c91b58c9ac4298d8be4f9 + languageName: node + linkType: hard + +"@vitest/utils@npm:0.34.3": + version: 0.34.3 + resolution: "@vitest/utils@npm:0.34.3" + dependencies: + diff-sequences: ^29.4.3 loupe: ^2.3.6 - pretty-format: ^27.5.1 - checksum: fa18cccb6ab5295e43a1a43b9c022f070646a893adb0561c50b3e0c39f05ea74cbf379aef22ef485ea9acbf2bb8f0a224d457fd4f16b9e1bf509c13052c7f08b + pretty-format: ^29.5.0 + checksum: aeb8ef7fd98b32cb6c403796880d0aa8f5411bbdb249bb23b3301a70e1b7d1ee025ddb204aae8c1db5756f6ac428c49ebbb8e2ed23ce185c8a659b67413efa85 languageName: node linkType: hard @@ -1361,7 +1642,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.1.0, acorn@npm:^8.5.0, acorn@npm:^8.8.0, acorn@npm:^8.8.1, acorn@npm:^8.8.2": +"acorn@npm:^8.1.0, acorn@npm:^8.5.0, acorn@npm:^8.8.0, acorn@npm:^8.8.2": version: 8.8.2 resolution: "acorn@npm:8.8.2" bin: @@ -1370,6 +1651,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^8.10.0, acorn@npm:^8.9.0": + version: 8.10.0 + resolution: "acorn@npm:8.10.0" + bin: + acorn: bin/acorn + checksum: 538ba38af0cc9e5ef983aee196c4b8b4d87c0c94532334fa7e065b2c8a1f85863467bb774231aae91613fcda5e68740c15d97b1967ae3394d20faddddd8af61d + languageName: node + linkType: hard + "agent-base@npm:6, agent-base@npm:^6.0.2": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -1419,13 +1709,6 @@ __metadata: languageName: node linkType: hard -"ansi-regex@npm:^6.0.1": - version: 6.0.1 - resolution: "ansi-regex@npm:6.0.1" - checksum: 1ff8b7667cded1de4fa2c9ae283e979fc87036864317da86a2e546725f96406746411d0d85e87a2d12fa5abd715d90006de7fa4fa0477c92321ad3b4c7d4e169 - languageName: node - linkType: hard - "ansi-styles@npm:^3.2.1": version: 3.2.1 resolution: "ansi-styles@npm:3.2.1" @@ -1451,13 +1734,6 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^6.0.0": - version: 6.2.1 - resolution: "ansi-styles@npm:6.2.1" - checksum: ef940f2f0ced1a6347398da88a91da7930c33ecac3c77b72c5905f8b8fe402c52e6fde304ff5347f616e27a742da3f1dc76de98f6866c69251ad0b07a66776d9 - languageName: node - linkType: hard - "anymatch@npm:~3.1.2": version: 3.1.3 resolution: "anymatch@npm:3.1.3" @@ -1620,6 +1896,20 @@ __metadata: languageName: node linkType: hard +"browserslist@npm:^4.21.9": + version: 4.21.10 + resolution: "browserslist@npm:4.21.10" + dependencies: + caniuse-lite: ^1.0.30001517 + electron-to-chromium: ^1.4.477 + node-releases: ^2.0.13 + update-browserslist-db: ^1.0.11 + bin: + browserslist: cli.js + checksum: 1e27c0f111a35d1dd0e8fc2c61781b0daefabc2c9471b0b10537ce54843014bceb2a1ce4571af1a82b2bf1e6e6e05d38865916689a158f03bc2c7a4ec2577db8 + languageName: node + linkType: hard + "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -1634,28 +1924,6 @@ __metadata: languageName: node linkType: hard -"c8@npm:^7.13.0": - version: 7.13.0 - resolution: "c8@npm:7.13.0" - dependencies: - "@bcoe/v8-coverage": ^0.2.3 - "@istanbuljs/schema": ^0.1.3 - find-up: ^5.0.0 - foreground-child: ^2.0.0 - istanbul-lib-coverage: ^3.2.0 - istanbul-lib-report: ^3.0.0 - istanbul-reports: ^3.1.4 - rimraf: ^3.0.2 - test-exclude: ^6.0.0 - v8-to-istanbul: ^9.0.0 - yargs: ^16.2.0 - yargs-parser: ^20.2.9 - bin: - c8: bin/c8.js - checksum: 491abf4cf3097cdcfd24dbac49162f1383861c22c77fdd9280bcd38240e1e07d2c6a59da5d4df59a61a8204e2fc297d31fd526e495faf8d2f20dcc12a37b144c - languageName: node - linkType: hard - "cac@npm:^6.7.14": version: 6.7.14 resolution: "cac@npm:6.7.14" @@ -1725,6 +1993,13 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001517": + version: 1.0.30001525 + resolution: "caniuse-lite@npm:1.0.30001525" + checksum: a0d190c185b8e1220dbc72e42f310633059aa175ca3396eb781b249ac3da6c62b30cb8efc5fa24d632cb938f58d90b0c7772d1c9942b6643cf418c27c2cb8632 + languageName: node + linkType: hard + "chai@npm:^4.3.7": version: 4.3.7 resolution: "chai@npm:4.3.7" @@ -1740,7 +2015,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^2.0.0": +"chalk@npm:^2.0.0, chalk@npm:^2.4.2": version: 2.4.2 resolution: "chalk@npm:2.4.2" dependencies: @@ -1801,27 +2076,6 @@ __metadata: languageName: node linkType: hard -"cli-truncate@npm:^3.1.0": - version: 3.1.0 - resolution: "cli-truncate@npm:3.1.0" - dependencies: - slice-ansi: ^5.0.0 - string-width: ^5.0.0 - checksum: c3243e41974445691c63f8b405df1d5a24049dc33d324fe448dc572e561a7b772ae982692900b1a5960901cc4fc7def25a629b9c69a4208ee89d12ab3332617a - languageName: node - linkType: hard - -"cliui@npm:^7.0.2": - version: 7.0.4 - resolution: "cliui@npm:7.0.4" - dependencies: - string-width: ^4.2.0 - strip-ansi: ^6.0.0 - wrap-ansi: ^7.0.0 - checksum: ce2e8f578a4813806788ac399b9e866297740eecd4ad1823c27fd344d78b22c5f8597d548adbcc46f0573e43e21e751f39446c5a5e804a12aace402b7a315d7f - languageName: node - linkType: hard - "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -1953,7 +2207,7 @@ __metadata: languageName: node linkType: hard -"convert-source-map@npm:^1.6.0, convert-source-map@npm:^1.7.0": +"convert-source-map@npm:^1.7.0": version: 1.9.0 resolution: "convert-source-map@npm:1.9.0" checksum: dc55a1f28ddd0e9485ef13565f8f756b342f9a46c4ae18b843fe3c30c675d058d6a4823eff86d472f187b176f0adf51ea7b69ea38be34be4a63cbbf91b0593c8 @@ -1973,7 +2227,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2": +"cross-spawn@npm:^7.0.2": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" dependencies: @@ -2270,10 +2524,10 @@ __metadata: languageName: node linkType: hard -"diff@npm:^5.1.0": - version: 5.1.0 - resolution: "diff@npm:5.1.0" - checksum: c7bf0df7c9bfbe1cf8a678fd1b2137c4fb11be117a67bc18a0e03ae75105e8533dbfb1cda6b46beb3586ef5aed22143ef9d70713977d5fb1f9114e21455fba90 +"diff-sequences@npm:^29.4.3": + version: 29.6.3 + resolution: "diff-sequences@npm:29.6.3" + checksum: f4914158e1f2276343d98ff5b31fc004e7304f5470bf0f1adb2ac6955d85a531a6458d33e87667f98f6ae52ebd3891bb47d420bb48a5bd8b7a27ee25b20e33aa languageName: node linkType: hard @@ -2351,13 +2605,6 @@ __metadata: languageName: node linkType: hard -"eastasianwidth@npm:^0.2.0": - version: 0.2.0 - resolution: "eastasianwidth@npm:0.2.0" - checksum: 7d00d7cd8e49b9afa762a813faac332dee781932d6f2c848dc348939c4253f1d4564341b7af1d041853bc3f32c2ef141b58e0a4d9862c17a7f08f68df1e0f1ed - languageName: node - linkType: hard - "electron-to-chromium@npm:^1.4.284": version: 1.4.288 resolution: "electron-to-chromium@npm:1.4.288" @@ -2365,21 +2612,28 @@ __metadata: languageName: node linkType: hard -"embla-carousel-wheel-gestures@npm:^3.0.0": - version: 3.0.0 - resolution: "embla-carousel-wheel-gestures@npm:3.0.0" - dependencies: - wheel-gestures: ^2.2.5 - peerDependencies: - embla-carousel: ^7.0.0 - checksum: 109c1d10fa6f892fe4c627212ee5d24ae2d2d0c28210af16a9770901c2b65faf32eb1a5c95e87451aec9407ec8e3248e49789d6998b7079c83fd13b50532431d +"electron-to-chromium@npm:^1.4.477": + version: 1.4.508 + resolution: "electron-to-chromium@npm:1.4.508" + checksum: 4475eb18f5805d43f84d9542364045a39b183a14cd9f4626e0951ea61d0fa4f84a5ed579c2c32189f9af4a27a31041d09fed78f60930ac36b3baa08547dd3aa6 languageName: node linkType: hard -"embla-carousel@npm:^7.0.9": - version: 7.0.9 - resolution: "embla-carousel@npm:7.0.9" - checksum: dabb371a44c2d67f21aa7a0ff3f452a5c2678b79e80cb251e0a83b707c4b684fbdbe010927142cdcae3d1c9711a269e05655bd05c39608337fd1b6f5d2c20371 +"embla-carousel-wheel-gestures@npm:8.0.0-rc04": + version: 8.0.0-rc04 + resolution: "embla-carousel-wheel-gestures@npm:8.0.0-rc04" + dependencies: + wheel-gestures: ^2.2.5 + peerDependencies: + embla-carousel: 8.0.0-rc03 + checksum: ace8ab88b661e8d360c2d99ae61c0fe61085ccd71f059ac7c6b8dc1ebb52c17919a00025010502d86012273490940c25c6c1fdb143f932670ab36c09fb4c6bca + languageName: node + linkType: hard + +"embla-carousel@npm:8.0.0-rc12": + version: 8.0.0-rc12 + resolution: "embla-carousel@npm:8.0.0-rc12" + checksum: 553f33f562458d7fd0cd4eff1db96ac3a02352b620705d892eb3d9a297924fa12fedaf5a5aa03418dd41905ce80997a99f8aeca955d024d5a49a56f5c7fbe63b languageName: node linkType: hard @@ -2390,13 +2644,6 @@ __metadata: languageName: node linkType: hard -"emoji-regex@npm:^9.2.2": - version: 9.2.2 - resolution: "emoji-regex@npm:9.2.2" - checksum: 8487182da74aabd810ac6d6f1994111dfc0e331b01271ae01ec1eb0ad7b5ecc2bbbbd2f053c05cb55a1ac30449527d819bbfbf0e3de1023db308cbcb47f86601 - languageName: node - linkType: hard - "emojis-list@npm:^3.0.0": version: 3.0.0 resolution: "emojis-list@npm:3.0.0" @@ -3058,16 +3305,6 @@ __metadata: languageName: node linkType: hard -"foreground-child@npm:^2.0.0": - version: 2.0.0 - resolution: "foreground-child@npm:2.0.0" - dependencies: - cross-spawn: ^7.0.0 - signal-exit: ^3.0.2 - checksum: f77ec9aff621abd6b754cb59e690743e7639328301fbea6ff09df27d2befaf7dd5b77cec51c32323d73a81a7d91caaf9413990d305cbe3d873eec4fe58960956 - languageName: node - linkType: hard - "form-data@npm:^4.0.0": version: 4.0.0 resolution: "form-data@npm:4.0.0" @@ -3103,14 +3340,14 @@ __metadata: "@types/masonry-layout": ^4.2.5 "@typescript-eslint/eslint-plugin": ^5.36.2 "@typescript-eslint/parser": ^5.36.2 - "@vitest/coverage-c8": ^0.29.8 + "@vitest/coverage-istanbul": ^0.34.3 component-emitter: ^1.3.0 crypto: ^1.0.1 custom-card-helpers: ^1.9.0 date-fns: ^2.29.2 date-fns-tz: ^1.3.7 - embla-carousel: ^7.0.9 - embla-carousel-wheel-gestures: ^3.0.0 + embla-carousel: 8.0.0-rc12 + embla-carousel-wheel-gestures: 8.0.0-rc04 eslint: ^8.23.0 eslint-config-airbnb-base: ^15.0.0 eslint-config-prettier: ^8.5.0 @@ -3143,8 +3380,8 @@ __metadata: vis-data: ^7.1.4 vis-timeline: ^7.7.0 vis-util: ^5.0.2 - vitest: ^0.29.8 - vitest-mock-extended: ^1.1.3 + vitest: ^0.34.3 + vitest-mock-extended: ^1.2.1 web-dialog: ^0.0.11 xss: ^1.0.14 zod: ^3.21.4 @@ -3744,13 +3981,6 @@ __metadata: languageName: node linkType: hard -"is-fullwidth-code-point@npm:^4.0.0": - version: 4.0.0 - resolution: "is-fullwidth-code-point@npm:4.0.0" - checksum: 8ae89bf5057bdf4f57b346fb6c55e9c3dd2549983d54191d722d5c739397a903012cc41a04ee3403fd872e811243ef91a7c5196da7b5841dc6b6aae31a264a8d - languageName: node - linkType: hard - "is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1": version: 4.0.3 resolution: "is-glob@npm:4.0.3" @@ -3902,6 +4132,19 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-instrument@npm:^6.0.0": + version: 6.0.0 + resolution: "istanbul-lib-instrument@npm:6.0.0" + dependencies: + "@babel/core": ^7.12.3 + "@babel/parser": ^7.14.7 + "@istanbuljs/schema": ^0.1.2 + istanbul-lib-coverage: ^3.2.0 + semver: ^7.5.4 + checksum: b9dc3723a769e65dbe1b912f935088ffc07cf393fa78a3ce79022c91aabb0ad01405ffd56083cdd822e514798e9daae3ea7bfe85633b094ecb335d28eb0a3f97 + languageName: node + linkType: hard + "istanbul-lib-report@npm:^3.0.0": version: 3.0.0 resolution: "istanbul-lib-report@npm:3.0.0" @@ -3913,13 +4156,35 @@ __metadata: languageName: node linkType: hard -"istanbul-reports@npm:^3.1.4": - version: 3.1.5 - resolution: "istanbul-reports@npm:3.1.5" +"istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: ^3.0.0 + make-dir: ^4.0.0 + supports-color: ^7.1.0 + checksum: fd17a1b879e7faf9bb1dc8f80b2a16e9f5b7b8498fe6ed580a618c34df0bfe53d2abd35bf8a0a00e628fb7405462576427c7df20bbe4148d19c14b431c974b21 + languageName: node + linkType: hard + +"istanbul-lib-source-maps@npm:^4.0.1": + version: 4.0.1 + resolution: "istanbul-lib-source-maps@npm:4.0.1" + dependencies: + debug: ^4.1.1 + istanbul-lib-coverage: ^3.0.0 + source-map: ^0.6.1 + checksum: 21ad3df45db4b81852b662b8d4161f6446cd250c1ddc70ef96a585e2e85c26ed7cd9c2a396a71533cfb981d1a645508bc9618cae431e55d01a0628e7dec62ef2 + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.1.5": + version: 3.1.6 + resolution: "istanbul-reports@npm:3.1.6" dependencies: html-escaper: ^2.0.0 istanbul-lib-report: ^3.0.0 - checksum: 7867228f83ed39477b188ea07e7ccb9b4f5320b6f73d1db93a0981b7414fa4ef72d3f80c4692c442f90fc250d9406e71d8d7ab65bb615cb334e6292b73192b89 + checksum: 44c4c0582f287f02341e9720997f9e82c071627e1e862895745d5f52ec72c9b9f38e1d12370015d2a71dcead794f34c7732aaef3fab80a24bc617a21c3d911d6 languageName: node linkType: hard @@ -4039,7 +4304,7 @@ __metadata: languageName: node linkType: hard -"json5@npm:^2.1.3, json5@npm:^2.2.2": +"json5@npm:^2.1.3, json5@npm:^2.2.2, json5@npm:^2.2.3": version: 2.2.3 resolution: "json5@npm:2.2.3" bin: @@ -4150,7 +4415,7 @@ __metadata: languageName: node linkType: hard -"local-pkg@npm:^0.4.2": +"local-pkg@npm:^0.4.3": version: 0.4.3 resolution: "local-pkg@npm:0.4.3" checksum: 7825aca531dd6afa3a3712a0208697aa4a5cd009065f32e3fb732aafcc42ed11f277b5ac67229222e96f4def55197171cdf3d5522d0381b489d2e5547b407d55 @@ -4253,6 +4518,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^0.30.1": + version: 0.30.3 + resolution: "magic-string@npm:0.30.3" + dependencies: + "@jridgewell/sourcemap-codec": ^1.4.15 + checksum: a5a9ddf9bd3bf49a2de1048bf358464f1bda7b3cc1311550f4a0ba8f81a4070e25445d53a5ee28850161336f1bff3cf28aa3320c6b4aeff45ce3e689f300b2f3 + languageName: node + linkType: hard + "make-dir@npm:^3.0.0, make-dir@npm:^3.0.2": version: 3.1.0 resolution: "make-dir@npm:3.1.0" @@ -4262,6 +4536,15 @@ __metadata: languageName: node linkType: hard +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: ^7.5.3 + checksum: bf0731a2dd3aab4db6f3de1585cea0b746bb73eb5a02e3d8d72757e376e64e6ada190b1eddcde5b2f24a81b688a9897efd5018737d05e02e2a671dda9cff8a8a + languageName: node + linkType: hard + "make-fetch-happen@npm:^10.0.3": version: 10.2.1 resolution: "make-fetch-happen@npm:10.2.1" @@ -4472,15 +4755,15 @@ __metadata: languageName: node linkType: hard -"mlly@npm:^1.1.0, mlly@npm:^1.1.1": - version: 1.2.0 - resolution: "mlly@npm:1.2.0" +"mlly@npm:^1.2.0, mlly@npm:^1.4.0": + version: 1.4.1 + resolution: "mlly@npm:1.4.1" dependencies: - acorn: ^8.8.2 - pathe: ^1.1.0 - pkg-types: ^1.0.2 - ufo: ^1.1.1 - checksum: 640b019eb20e8e556bd623141b861d47e5c05f8af00210376ce1015912695dbd93a38cfe7ba18ca04f00e75645378f0f94a48a90bfa6e1b5dee1f0ec9c14eed1 + acorn: ^8.10.0 + pathe: ^1.1.1 + pkg-types: ^1.0.3 + ufo: ^1.3.0 + checksum: b2b59ab3d70196127be4e54609d2a442bd252345727138940fb245672a238b2fbdd431e8c75ec5c741ff90410ce488c5fd6446d5d3e6476d21dbf4c3fa35d4a0 languageName: node linkType: hard @@ -4555,6 +4838,13 @@ __metadata: languageName: node linkType: hard +"node-releases@npm:^2.0.13": + version: 2.0.13 + resolution: "node-releases@npm:2.0.13" + checksum: 17ec8f315dba62710cae71a8dad3cd0288ba943d2ece43504b3b1aa8625bf138637798ab470b1d9035b0545996f63000a8a926e0f6d35d0996424f8b6d36dda3 + languageName: node + linkType: hard + "node-releases@npm:^2.0.8": version: 2.0.10 resolution: "node-releases@npm:2.0.10" @@ -4897,6 +5187,13 @@ __metadata: languageName: node linkType: hard +"pathe@npm:^1.1.1": + version: 1.1.1 + resolution: "pathe@npm:1.1.1" + checksum: 34ab3da2e5aa832ebc6a330ffe3f73d7ba8aec6e899b53b8ec4f4018de08e40742802deb12cf5add9c73b7bf719b62c0778246bd376ca62b0fb23e0dde44b759 + languageName: node + linkType: hard + "pathval@npm:^1.1.1": version: 1.1.1 resolution: "pathval@npm:1.1.1" @@ -4927,14 +5224,14 @@ __metadata: languageName: node linkType: hard -"pkg-types@npm:^1.0.2": - version: 1.0.2 - resolution: "pkg-types@npm:1.0.2" +"pkg-types@npm:^1.0.3": + version: 1.0.3 + resolution: "pkg-types@npm:1.0.3" dependencies: jsonc-parser: ^3.2.0 - mlly: ^1.1.1 + mlly: ^1.2.0 pathe: ^1.1.0 - checksum: 2d0a70c1721c2ebbe075b912531a4f43136e6658fdcc59dc76c39966201ab5ddf265868d1211943183406d4b70d373c17e3b176487bc2020ea737d030b0fd080 + checksum: 4b305c834b912ddcc8a0fe77530c0b0321fe340396f84cbb87aecdbc126606f47f2178f23b8639e71a4870f9631c7217aef52ffed0ae17ea2dbbe7e43d116a6e languageName: node linkType: hard @@ -5347,14 +5644,14 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^27.5.1": - version: 27.5.1 - resolution: "pretty-format@npm:27.5.1" +"pretty-format@npm:^29.5.0": + version: 29.6.3 + resolution: "pretty-format@npm:29.6.3" dependencies: - ansi-regex: ^5.0.1 + "@jest/schemas": ^29.6.3 ansi-styles: ^5.0.0 - react-is: ^17.0.1 - checksum: cf610cffcb793885d16f184a62162f2dd0df31642d9a18edf4ca298e909a8fe80bdbf556d5c9573992c102ce8bf948691da91bf9739bee0ffb6e79c8a8a6e088 + react-is: ^18.0.0 + checksum: 4e1c0db48e65571c22e80ff92123925ff8b3a2a89b71c3a1683cfde711004d492de32fe60c6bc10eea8bf6c678e5cbe544ac6c56cb8096e1eb7caf856928b1c4 languageName: node linkType: hard @@ -5440,10 +5737,10 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^17.0.1": - version: 17.0.2 - resolution: "react-is@npm:17.0.2" - checksum: 9d6d111d8990dc98bc5402c1266a808b0459b5d54830bbea24c12d908b536df7883f268a7868cfaedde3dd9d4e0d574db456f84d2e6df9c4526f99bb4b5344d8 +"react-is@npm:^18.0.0": + version: 18.2.0 + resolution: "react-is@npm:18.2.0" + checksum: e72d0ba81b5922759e4aff17e0252bd29988f9642ed817f56b25a3e217e13eea8a7f2322af99a06edb779da12d5d636e9fda473d620df9a3da0df2a74141d53e languageName: node linkType: hard @@ -5759,6 +6056,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^6.3.1": + version: 6.3.1 + resolution: "semver@npm:6.3.1" + bin: + semver: bin/semver.js + checksum: ae47d06de28836adb9d3e25f22a92943477371292d9b665fb023fae278d345d508ca1958232af086d85e0155aee22e313e100971898bbb8d5d89b8b1d4054ca2 + languageName: node + linkType: hard + "semver@npm:^7.3.5, semver@npm:^7.3.7": version: 7.3.8 resolution: "semver@npm:7.3.8" @@ -5770,6 +6076,17 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.5.3, semver@npm:^7.5.4": + version: 7.5.4 + resolution: "semver@npm:7.5.4" + dependencies: + lru-cache: ^6.0.0 + bin: + semver: bin/semver.js + checksum: 12d8ad952fa353b0995bf180cdac205a4068b759a140e5d3c608317098b3575ac2f1e09182206bf2eb26120e1c0ed8fb92c48c592f6099680de56bb071423ca3 + languageName: node + linkType: hard + "serialize-javascript@npm:^4.0.0": version: 4.0.0 resolution: "serialize-javascript@npm:4.0.0" @@ -5827,7 +6144,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.7": +"signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 @@ -5841,16 +6158,6 @@ __metadata: languageName: node linkType: hard -"slice-ansi@npm:^5.0.0": - version: 5.0.0 - resolution: "slice-ansi@npm:5.0.0" - dependencies: - ansi-styles: ^6.0.0 - is-fullwidth-code-point: ^4.0.0 - checksum: 7e600a2a55e333a21ef5214b987c8358fe28bfb03c2867ff2cbf919d62143d1812ac27b4297a077fdaf27a03da3678e49551c93e35f9498a3d90221908a1180e - languageName: node - linkType: hard - "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -5947,10 +6254,10 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.3.1": - version: 3.3.2 - resolution: "std-env@npm:3.3.2" - checksum: c02256bb041ba1870d23f8360bc7e47a9cf1fabcd02c8b7c4246d48f2c6bb47b4f45c70964348844e6d36521df84c4a9d09d468654b51e0eb5c600e3392b4570 +"std-env@npm:^3.3.3": + version: 3.4.3 + resolution: "std-env@npm:3.4.3" + checksum: bef186fb2baddda31911234b1e58fa18f181eb6930616aaec3b54f6d5db65f2da5daaa5f3b326b98445a7d50ca81d6fe8809ab4ebab85ecbe4a802f1b40921bf languageName: node linkType: hard @@ -5972,17 +6279,6 @@ __metadata: languageName: node linkType: hard -"string-width@npm:^5.0.0": - version: 5.1.2 - resolution: "string-width@npm:5.1.2" - dependencies: - eastasianwidth: ^0.2.0 - emoji-regex: ^9.2.2 - strip-ansi: ^7.0.1 - checksum: 7369deaa29f21dda9a438686154b62c2c5f661f8dda60449088f9f980196f7908fc39fdd1803e3e01541970287cf5deae336798337e9319a7055af89dafa7193 - languageName: node - linkType: hard - "string.prototype.trimend@npm:^1.0.6": version: 1.0.6 resolution: "string.prototype.trimend@npm:1.0.6" @@ -6023,15 +6319,6 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^7.0.1": - version: 7.0.1 - resolution: "strip-ansi@npm:7.0.1" - dependencies: - ansi-regex: ^6.0.1 - checksum: 257f78fa433520e7f9897722731d78599cb3fce29ff26a20a5e12ba4957463b50a01136f37c43707f4951817a75e90820174853d6ccc240997adc5df8f966039 - languageName: node - linkType: hard - "strip-bom@npm:^3.0.0": version: 3.0.0 resolution: "strip-bom@npm:3.0.0" @@ -6046,12 +6333,12 @@ __metadata: languageName: node linkType: hard -"strip-literal@npm:^1.0.0": - version: 1.0.1 - resolution: "strip-literal@npm:1.0.1" +"strip-literal@npm:^1.0.1": + version: 1.3.0 + resolution: "strip-literal@npm:1.3.0" dependencies: - acorn: ^8.8.2 - checksum: ab40496820f02220390d95cdd620a997168efb69d5bd7d180bc4ef83ca562a95447843d8c7c88b8284879a29cf4eedc89d8001d1e098c1a1e23d12a9c755dff4 + acorn: ^8.10.0 + checksum: f5fa7e289df8ebe82e90091fd393974faf8871be087ca50114327506519323cf15f2f8fee6ebe68b5e58bfc795269cae8bdc7cb5a83e27b02b3fe953f37b0a89 languageName: node linkType: hard @@ -6169,24 +6456,24 @@ __metadata: languageName: node linkType: hard -"tinybench@npm:^2.3.1": - version: 2.4.0 - resolution: "tinybench@npm:2.4.0" - checksum: cfbe90f75755488653dde256019cc810f65e90f63fdd962e71e8b209b49598c5fc90c2227d2087eb807944895fafe7f12fe9ecae2b5e89db5adde66415e9b836 +"tinybench@npm:^2.5.0": + version: 2.5.0 + resolution: "tinybench@npm:2.5.0" + checksum: 284bb9428f197ec8b869c543181315e65e41ccfdad3c4b6c916bb1fdae1b5c6785661b0d90cf135b48d833b03cb84dc5357b2d33ec65a1f5971fae0ab2023821 languageName: node linkType: hard -"tinypool@npm:^0.4.0": - version: 0.4.0 - resolution: "tinypool@npm:0.4.0" - checksum: 8abcac9e784793499f1eeeace8290c026454b9d7338c74029ce6a821643bab8dcab7caeb4051e39006baf681d6a62d57c3319e9c0f6e2317a45ab0fdbd76ee26 +"tinypool@npm:^0.7.0": + version: 0.7.0 + resolution: "tinypool@npm:0.7.0" + checksum: fdcccd5c750574fce51f8801a877f8284e145d12b79cd5f2d72bfbddfe20c895e915555bc848e122bb6aa968098e7ac4fe1e8e88104904d518dc01cccd18a510 languageName: node linkType: hard -"tinyspy@npm:^1.0.2": - version: 1.1.1 - resolution: "tinyspy@npm:1.1.1" - checksum: 4ea908fdfddb92044c4454193ec543f5980ced0bd25c5b3d240a94c1511e47e765ad39cd13ae6d3370fb730f62038eedc357f55e4e239416e126bc418f0eee79 +"tinyspy@npm:^2.1.1": + version: 2.1.1 + resolution: "tinyspy@npm:2.1.1" + checksum: cfe669803a7f11ca912742b84c18dcc4ceecaa7661c69bc5eb608a8a802d541c48aba220df8929f6c8cd09892ad37cb5ba5958ddbbb57940e91d04681d3cee73 languageName: node linkType: hard @@ -6234,12 +6521,15 @@ __metadata: languageName: node linkType: hard -"ts-essentials@npm:^9.3.1": - version: 9.3.1 - resolution: "ts-essentials@npm:9.3.1" +"ts-essentials@npm:^9.3.2": + version: 9.4.0 + resolution: "ts-essentials@npm:9.4.0" peerDependencies: typescript: ">=4.1.0" - checksum: 5accaec3eeea93cf19ad9e1959e763ba8a8253c977cd3f88cd7d8a2c04e697e77cc65fd742d9e88482b564a78b3c2dc1269119505bf7ad06320175b490dd49de + peerDependenciesMeta: + typescript: + optional: true + checksum: 50719782f9bfa31678fd1607de783a92b322ed8c26e6229bd0d1786c0649b9f1a7b9a6ac5bc60435053c4952bd3ef2356b5c930cb3a8d3519dc8f41c5576328f languageName: node linkType: hard @@ -6369,10 +6659,10 @@ __metadata: languageName: node linkType: hard -"ufo@npm:^1.1.1": - version: 1.1.1 - resolution: "ufo@npm:1.1.1" - checksum: 6bd210ed93d8c0dedd76c456b1d1dfb0e3b08c2216ee6080e61f0f545de0bac24b3d3a5530cd6a403810855f8d8fc3922583965296142e04cfc287442635e6c7 +"ufo@npm:^1.3.0": + version: 1.3.0 + resolution: "ufo@npm:1.3.0" + checksum: 01f0be86cd5c205ad1b49ebea985e000a4542c503ee75398302b0f5e4b9a6d9cd8e77af2dc614ab7bea08805fdfd9a85191fb3b5ee3df383cb936cf65e9db30d languageName: node linkType: hard @@ -6434,6 +6724,20 @@ __metadata: languageName: node linkType: hard +"update-browserslist-db@npm:^1.0.11": + version: 1.0.11 + resolution: "update-browserslist-db@npm:1.0.11" + dependencies: + escalade: ^3.1.1 + picocolors: ^1.0.0 + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: b98327518f9a345c7cad5437afae4d2ae7d865f9779554baf2a200fdf4bac4969076b679b1115434bd6557376bdd37ca7583d0f9b8f8e302d7d4cc1e91b5f231 + languageName: node + linkType: hard + "uri-js@npm:^4.2.2": version: 4.4.1 resolution: "uri-js@npm:4.4.1" @@ -6469,17 +6773,6 @@ __metadata: languageName: node linkType: hard -"v8-to-istanbul@npm:^9.0.0": - version: 9.1.0 - resolution: "v8-to-istanbul@npm:9.1.0" - dependencies: - "@jridgewell/trace-mapping": ^0.3.12 - "@types/istanbul-lib-coverage": ^2.0.1 - convert-source-map: ^1.6.0 - checksum: 2069d59ee46cf8d83b4adfd8a5c1a90834caffa9f675e4360f1157ffc8578ef0f763c8f32d128334424159bb6b01f3876acd39cd13297b2769405a9da241f8d1 - languageName: node - linkType: hard - "vis-data@npm:^7.1.4": version: 7.1.4 resolution: "vis-data@npm:7.1.4" @@ -6517,19 +6810,19 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:0.29.8": - version: 0.29.8 - resolution: "vite-node@npm:0.29.8" +"vite-node@npm:0.34.3": + version: 0.34.3 + resolution: "vite-node@npm:0.34.3" dependencies: cac: ^6.7.14 debug: ^4.3.4 - mlly: ^1.1.0 - pathe: ^1.1.0 + mlly: ^1.4.0 + pathe: ^1.1.1 picocolors: ^1.0.0 vite: ^3.0.0 || ^4.0.0 bin: vite-node: vite-node.mjs - checksum: b0981d4d63b1f373579eb9da69ca5af9123bf27c81ac246c541cdecf879ef4ef542e0b521cb6ceaafd5ead2cc3d243105d1fb8bf076953d42a6b2203607ce928 + checksum: 366c4f3fb7c038e2180abc6b18cfbac3b8684cd878eaf7ebf1ffb07d95d2ea325713fc575a7949a13bb00cfe264acbc28c02e2836b8647e1f443fe631c17805a languageName: node linkType: hard @@ -6571,45 +6864,45 @@ __metadata: languageName: node linkType: hard -"vitest-mock-extended@npm:^1.1.3": - version: 1.1.3 - resolution: "vitest-mock-extended@npm:1.1.3" +"vitest-mock-extended@npm:^1.2.1": + version: 1.2.1 + resolution: "vitest-mock-extended@npm:1.2.1" dependencies: - ts-essentials: ^9.3.1 + ts-essentials: ^9.3.2 peerDependencies: typescript: 3.x || 4.x || 5.x - vitest: ">=0.29.2" - checksum: d457dc1dcbf6b0570e9845dff7423c2b88504ce6bb189e71c0984b09b4d6c5894d883706a7639262c92e12b0b4b0d5db747e1d0b93e253b4d6c189cba15c70f9 + vitest: ">=0.31.1" + checksum: d3dc3940513cc053166bc912b0b9fdda0b073c5a5d2c2deba4f87ddae7a3b8c53a31227030a9f0eeb9f48c443b6978c451946ef0d36eea6976ef77e5bb4d6c04 languageName: node linkType: hard -"vitest@npm:^0.29.8": - version: 0.29.8 - resolution: "vitest@npm:0.29.8" +"vitest@npm:^0.34.3": + version: 0.34.3 + resolution: "vitest@npm:0.34.3" dependencies: - "@types/chai": ^4.3.4 + "@types/chai": ^4.3.5 "@types/chai-subset": ^1.3.3 "@types/node": "*" - "@vitest/expect": 0.29.8 - "@vitest/runner": 0.29.8 - "@vitest/spy": 0.29.8 - "@vitest/utils": 0.29.8 - acorn: ^8.8.1 + "@vitest/expect": 0.34.3 + "@vitest/runner": 0.34.3 + "@vitest/snapshot": 0.34.3 + "@vitest/spy": 0.34.3 + "@vitest/utils": 0.34.3 + acorn: ^8.9.0 acorn-walk: ^8.2.0 cac: ^6.7.14 chai: ^4.3.7 debug: ^4.3.4 - local-pkg: ^0.4.2 - pathe: ^1.1.0 + local-pkg: ^0.4.3 + magic-string: ^0.30.1 + pathe: ^1.1.1 picocolors: ^1.0.0 - source-map: ^0.6.1 - std-env: ^3.3.1 - strip-literal: ^1.0.0 - tinybench: ^2.3.1 - tinypool: ^0.4.0 - tinyspy: ^1.0.2 + std-env: ^3.3.3 + strip-literal: ^1.0.1 + tinybench: ^2.5.0 + tinypool: ^0.7.0 vite: ^3.0.0 || ^4.0.0 - vite-node: 0.29.8 + vite-node: 0.34.3 why-is-node-running: ^2.2.2 peerDependencies: "@edge-runtime/vm": "*" @@ -6639,7 +6932,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 203e33bf093fdb99a6832c905a6c78175bb15313e06e1dcfbeb010a0e3efb8ff0aba4d317efedb4de76bd0086691bbd2c4bc7d6631f60fb1634b96832cba144f + checksum: 4535d080feede94db5015eb60c6ed5f7b0d8cd67f12072de5ae1faded133cc640043c0c2646ef51ab9b61c2f885589da57458a65e82cf91a25cf954470018a40 languageName: node linkType: hard @@ -6854,13 +7147,6 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.9": - version: 20.2.9 - resolution: "yargs-parser@npm:20.2.9" - checksum: 8bb69015f2b0ff9e17b2c8e6bfe224ab463dd00ca211eece72a4cd8a906224d2703fb8a326d36fdd0e68701e201b2a60ed7cf81ce0fd9b3799f9fe7745977ae3 - languageName: node - linkType: hard - "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -6868,21 +7154,6 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^16.2.0": - version: 16.2.0 - resolution: "yargs@npm:16.2.0" - dependencies: - cliui: ^7.0.2 - escalade: ^3.1.1 - get-caller-file: ^2.0.5 - require-directory: ^2.1.1 - string-width: ^4.2.0 - y18n: ^5.0.5 - yargs-parser: ^20.2.2 - checksum: b14afbb51e3251a204d81937c86a7e9d4bdbf9a2bcee38226c900d00f522969ab675703bee2a6f99f8e20103f608382936034e64d921b74df82b63c07c5e8f59 - languageName: node - linkType: hard - "yargs@npm:^17.5.1": version: 17.6.2 resolution: "yargs@npm:17.6.2"