Complete carousel refactor.
Reduces one layer of DOM nesting for simplication, uses the latest Embla version, unittests for everything.
This commit is contained in:
+5
-5
@@ -27,8 +27,8 @@
|
|||||||
"custom-card-helpers": "^1.9.0",
|
"custom-card-helpers": "^1.9.0",
|
||||||
"date-fns": "^2.29.2",
|
"date-fns": "^2.29.2",
|
||||||
"date-fns-tz": "^1.3.7",
|
"date-fns-tz": "^1.3.7",
|
||||||
"embla-carousel": "^7.0.9",
|
"embla-carousel": "8.0.0-rc12",
|
||||||
"embla-carousel-wheel-gestures": "^3.0.0",
|
"embla-carousel-wheel-gestures": "8.0.0-rc04",
|
||||||
"home-assistant-js-websocket": "^8.0.0",
|
"home-assistant-js-websocket": "^8.0.0",
|
||||||
"keycharm": "^0.4.0",
|
"keycharm": "^0.4.0",
|
||||||
"lit": "^2.3.1",
|
"lit": "^2.3.1",
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"@types/masonry-layout": "^4.2.5",
|
"@types/masonry-layout": "^4.2.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.36.2",
|
"@typescript-eslint/eslint-plugin": "^5.36.2",
|
||||||
"@typescript-eslint/parser": "^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": "^8.23.0",
|
||||||
"eslint-config-airbnb-base": "^15.0.0",
|
"eslint-config-airbnb-base": "^15.0.0",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^8.5.0",
|
||||||
@@ -80,8 +80,8 @@
|
|||||||
"sass": "^1.54.9",
|
"sass": "^1.54.9",
|
||||||
"ts-prune": "^0.10.3",
|
"ts-prune": "^0.10.3",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
"vitest": "^0.29.8",
|
"vitest": "^0.34.3",
|
||||||
"vitest-mock-extended": "^1.1.3"
|
"vitest-mock-extended": "^1.2.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "rollup -c --watch",
|
"start": "rollup -c --watch",
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import type {
|
|||||||
import { noChange } from 'lit';
|
import { noChange } from 'lit';
|
||||||
import {
|
import {
|
||||||
AttributePart,
|
AttributePart,
|
||||||
directive,
|
|
||||||
Directive,
|
Directive,
|
||||||
DirectiveParameters,
|
DirectiveParameters,
|
||||||
|
directive,
|
||||||
} from 'lit/directive.js';
|
} from 'lit/directive.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from './utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from './utils/action.js';
|
||||||
import { Timer } from './utils/timer.js';
|
import { Timer } from './utils/timer.js';
|
||||||
|
|||||||
@@ -69,14 +69,12 @@ export class CachedValueController<T> implements ReactiveController {
|
|||||||
public startTimer(): void {
|
public startTimer(): void {
|
||||||
this.stopTimer();
|
this.stopTimer();
|
||||||
|
|
||||||
if (this._timerSeconds > 0) {
|
|
||||||
this._timerStartCallback?.();
|
this._timerStartCallback?.();
|
||||||
this._timer.startRepeated(this._timerSeconds, () => {
|
this._timer.startRepeated(this._timerSeconds, () => {
|
||||||
this.updateValue();
|
this.updateValue();
|
||||||
this._host.requestUpdate();
|
this._host.requestUpdate();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public hasTimer(): boolean {
|
public hasTimer(): boolean {
|
||||||
return this._timer.isRunning();
|
return this._timer.isRunning();
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export class CameraManagerEngineFactory {
|
|||||||
engine = Engine.MotionEye;
|
engine = Engine.MotionEye;
|
||||||
} else if (cameraConfig.engine === 'generic') {
|
} else if (cameraConfig.engine === 'generic') {
|
||||||
engine = Engine.Generic;
|
engine = Engine.Generic;
|
||||||
} else if (cameraConfig.engine === 'auto') {
|
} else {
|
||||||
const cameraEntity = getCameraEntityFromConfig(cameraConfig);
|
const cameraEntity = getCameraEntityFromConfig(cameraConfig);
|
||||||
|
|
||||||
if (cameraEntity) {
|
if (cameraEntity) {
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ export class ExpiringMemoryRangeSet
|
|||||||
}
|
}
|
||||||
|
|
||||||
public add(range: ExpiringRange<Date>): void {
|
public add(range: ExpiringRange<Date>): void {
|
||||||
this._expireOldRanges();
|
|
||||||
this._ranges.push(range);
|
this._ranges.push(range);
|
||||||
|
this._expireOldRanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _expireOldRanges(): void {
|
protected _expireOldRanges(): void {
|
||||||
@@ -95,26 +95,25 @@ export const compressRanges = <T extends Date | number>(
|
|||||||
ranges = orderBy(ranges, (range) => range.start, 'asc');
|
ranges = orderBy(ranges, (range) => range.start, 'asc');
|
||||||
|
|
||||||
let current: Range<T> | null = null;
|
let current: Range<T> | null = null;
|
||||||
for (let i = 0; i < ranges.length; ++i) {
|
for (const range of ranges) {
|
||||||
const item = ranges[i];
|
const rangeStartSeconds: number =
|
||||||
const itemStartSeconds =
|
range.start instanceof Date ? range.start.getTime() : range.start;
|
||||||
item.start instanceof Date ? item.start.getTime() : item.start;
|
|
||||||
|
|
||||||
if (!current) {
|
if (!current) {
|
||||||
current = { ...item };
|
current = { ...range };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentEndSeconds =
|
const currentEndSeconds =
|
||||||
current.end instanceof Date ? current.end.getTime() : (current.end as number);
|
current.end instanceof Date ? current.end.getTime() : (current.end as number);
|
||||||
|
|
||||||
if (currentEndSeconds + toleranceSeconds * 1000 >= itemStartSeconds) {
|
if (currentEndSeconds + toleranceSeconds * 1000 >= rangeStartSeconds) {
|
||||||
if (item.end > current.end) {
|
if (range.end > current.end) {
|
||||||
current.end = item.end;
|
current.end = range.end;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
compressedRanges.push(current);
|
compressedRanges.push(current);
|
||||||
current = { ...item };
|
current = { ...range };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (current) {
|
if (current) {
|
||||||
|
|||||||
+54
-238
@@ -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 { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
|
||||||
import {
|
import {
|
||||||
CSSResultGroup,
|
CSSResultGroup,
|
||||||
@@ -11,16 +9,12 @@ import {
|
|||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { Ref, createRef, ref } from 'lit/directives/ref.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 carouselStyle from '../scss/carousel.scss';
|
||||||
import { TransitionEffect } from '../types';
|
import { TransitionEffect } from '../types';
|
||||||
import { dispatchFrigateCardEvent, isHTMLElement } from '../utils/basic.js';
|
import {
|
||||||
|
CarouselController,
|
||||||
export interface CarouselSelect {
|
CarouselDirection,
|
||||||
index: number;
|
} from '../utils/embla/carousel-controller';
|
||||||
element: HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EmblaCarouselPlugins = CreatePluginType<
|
export type EmblaCarouselPlugins = CreatePluginType<
|
||||||
LoosePluginType,
|
LoosePluginType,
|
||||||
@@ -30,269 +24,91 @@ export type EmblaCarouselPlugins = CreatePluginType<
|
|||||||
@customElement('frigate-card-carousel')
|
@customElement('frigate-card-carousel')
|
||||||
export class FrigateCardCarousel extends LitElement {
|
export class FrigateCardCarousel extends LitElement {
|
||||||
@property({ attribute: true, reflect: true })
|
@property({ attribute: true, reflect: true })
|
||||||
public direction: 'vertical' | 'horizontal' = 'horizontal';
|
public direction: CarouselDirection = 'horizontal';
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public carouselOptions?: EmblaOptionsType;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public carouselPlugins?: EmblaCarouselPlugins;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public selected = 0;
|
|
||||||
|
|
||||||
@property({ attribute: true })
|
@property({ attribute: true })
|
||||||
public transitionEffect?: TransitionEffect;
|
public transitionEffect?: TransitionEffect;
|
||||||
|
|
||||||
protected _refSlot: Ref<HTMLSlotElement> = createRef();
|
@property({ attribute: false })
|
||||||
|
public loop?: boolean;
|
||||||
|
|
||||||
protected _carousel?: EmblaCarouselType;
|
@property({ attribute: false })
|
||||||
|
public dragFree?: boolean;
|
||||||
|
|
||||||
// Whether the carousel is actively scrolling.
|
@property({ attribute: false })
|
||||||
protected _scrolling = false;
|
public dragEnabled = true;
|
||||||
|
|
||||||
// Whether to reinit the carousel when it settles.
|
@property({ attribute: false })
|
||||||
protected _reInitOnSettle = false;
|
public plugins?: EmblaCarouselPlugins;
|
||||||
|
|
||||||
protected _carouselReInitInPlace = throttle(
|
@property({ attribute: false })
|
||||||
this._carouselReInitInPlaceInternal.bind(this),
|
public selected = 0;
|
||||||
500,
|
|
||||||
{ trailing: true },
|
protected _refParent: Ref<HTMLSlotElement> = createRef();
|
||||||
);
|
protected _refRoot: Ref<HTMLElement> = createRef();
|
||||||
|
protected _carousel: CarouselController | null = null;
|
||||||
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
// Guarantee a re-render if the component is reconnected. See note in
|
// Guarantee recreation of carousel if the component is reconnected.
|
||||||
// disconnectedCallback().
|
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Component disconnected callback.
|
|
||||||
*/
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
// Destroy the carousel when the component is disconnected, which forces the
|
// Destroy the carousel when the component is disconnected, which forces the
|
||||||
// plugins (which may have registered event handlers) to also be destroyed.
|
// plugins (which may have registered event handlers) to also be destroyed.
|
||||||
// The carousel will automatically reconstruct if the component is re-rendered.
|
// The carousel will automatically reconstruct if the component is re-rendered.
|
||||||
this._destroyCarousel();
|
this._carousel?.destroy();
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy the carousel if certain properties change.
|
|
||||||
* @param changedProps The changed properties
|
|
||||||
*/
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
const destroyProperties = [
|
if (changedProps.has('direction')) {
|
||||||
'direction',
|
this.setAttribute('direction', this.direction);
|
||||||
'carouselOptions',
|
}
|
||||||
'carouselPlugins',
|
|
||||||
] as const;
|
const destroyProperties = ['direction', 'dragFree', 'transitionEffect'] as const;
|
||||||
if (destroyProperties.some((prop) => changedProps.has(prop))) {
|
if (destroyProperties.some((prop) => changedProps.has(prop))) {
|
||||||
this._destroyCarousel();
|
this._carousel?.destroy();
|
||||||
}
|
this._carousel = null;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<CarouselSelect>(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<CarouselSelect>(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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
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` <div class="embla">
|
return html` <div class="embla">
|
||||||
${showPrevious ? html`<slot name="previous"></slot>` : ``}
|
<slot name="previous"></slot>
|
||||||
<div class="embla__viewport">
|
<div ${ref(this._refRoot)} class="embla__viewport">
|
||||||
<div class="embla__container">
|
<div class="embla__container">
|
||||||
<slot ${ref(this._refSlot)} @slotchange=${this._slotChanged.bind(this)}></slot>
|
<slot ${ref(this._refParent)}></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${showNext ? html`<slot name="next"></slot>` : ``}
|
<slot name="next"></slot>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected updated(changedProps: PropertyValues): void {
|
||||||
* Get element styles.
|
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 {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(carouselStyle);
|
return unsafeCSS(carouselStyle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { SideDrawer } from 'side-drawer';
|
|||||||
import drawerInjectStyle from '../scss/drawer-inject.scss';
|
import drawerInjectStyle from '../scss/drawer-inject.scss';
|
||||||
import drawerStyle from '../scss/drawer.scss';
|
import drawerStyle from '../scss/drawer.scss';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action';
|
import { stopEventFromActivatingCardWideActions } from '../utils/action';
|
||||||
import { isHoverableDevice } from '../utils/basic';
|
import { getChildrenFromElement, isHoverableDevice } from '../utils/basic';
|
||||||
|
|
||||||
export interface DrawerIcons {
|
export interface DrawerIcons {
|
||||||
open?: string;
|
open?: string;
|
||||||
@@ -67,12 +67,14 @@ export class FrigateCardDrawer extends LitElement {
|
|||||||
* Called when the slotted children in the drawer change.
|
* Called when the slotted children in the drawer change.
|
||||||
*/
|
*/
|
||||||
protected _slotChanged(): void {
|
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.
|
// Watch all slot children for size changes.
|
||||||
this._resizeObserver.disconnect();
|
this._resizeObserver.disconnect();
|
||||||
for (const element of elements ?? []) {
|
for (const child of children) {
|
||||||
this._resizeObserver.observe(element);
|
this._resizeObserver.observe(child);
|
||||||
}
|
}
|
||||||
this._hideDrawerIfNecessary();
|
this._hideDrawerIfNecessary();
|
||||||
}
|
}
|
||||||
@@ -86,11 +88,13 @@ export class FrigateCardDrawer extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const elements = this._refSlot.value?.assignedElements({ flatten: true });
|
const children = this._refSlot.value
|
||||||
|
? getChildrenFromElement(this._refSlot.value)
|
||||||
|
: null;
|
||||||
this.empty =
|
this.empty =
|
||||||
!elements ||
|
!children ||
|
||||||
!elements.length ||
|
!children.length ||
|
||||||
elements.every((element) => {
|
children.every((element) => {
|
||||||
const box = element.getBoundingClientRect();
|
const box = element.getBoundingClientRect();
|
||||||
return !box.width || !box.height;
|
return !box.width || !box.height;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<OptionsType>
|
|
||||||
|
|
||||||
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 = <AutoMediaOptionsType | undefined>undefined;
|
|
||||||
+57
-152
@@ -1,6 +1,4 @@
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
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 { HassEntity } from 'home-assistant-js-websocket';
|
||||||
import {
|
import {
|
||||||
CSSResultGroup,
|
CSSResultGroup,
|
||||||
@@ -13,15 +11,16 @@ import {
|
|||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import { guard } from 'lit/directives/guard.js';
|
import { guard } from 'lit/directives/guard.js';
|
||||||
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
import { keyed } from 'lit/directives/keyed.js';
|
import { keyed } from 'lit/directives/keyed.js';
|
||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
import { CameraManager } from '../../camera-manager/manager.js';
|
import { CameraManager } from '../../camera-manager/manager.js';
|
||||||
import { CameraConfigs, CameraEndpoints } from '../../camera-manager/types.js';
|
import { CameraConfigs, CameraEndpoints } from '../../camera-manager/types.js';
|
||||||
import { ConditionControllerEpoch, getOverriddenConfig } from '../../conditions.js';
|
import { ConditionControllerEpoch, getOverriddenConfig } from '../../conditions.js';
|
||||||
import { localize } from '../../localize/localize.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
|
import basicBlockStyle from '../../scss/basic-block.scss';
|
||||||
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
||||||
import liveProviderStyle from '../../scss/live-provider.scss';
|
import liveProviderStyle from '../../scss/live-provider.scss';
|
||||||
import basicBlockStyle from '../../scss/basic-block.scss';
|
|
||||||
import {
|
import {
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
CardWideConfig,
|
CardWideConfig,
|
||||||
@@ -37,28 +36,30 @@ import {
|
|||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||||
import { contentsChanged } from '../../utils/basic.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 {
|
import {
|
||||||
dispatchExistingMediaLoadedInfoAsEvent,
|
dispatchExistingMediaLoadedInfoAsEvent,
|
||||||
dispatchMediaUnloadedEvent,
|
dispatchMediaUnloadedEvent,
|
||||||
} from '../../utils/media-info.js';
|
} from '../../utils/media-info.js';
|
||||||
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
|
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
|
||||||
import { playMediaMutingIfNecessary } from '../../utils/media.js';
|
import { playMediaMutingIfNecessary } from '../../utils/media.js';
|
||||||
|
import { Timer } from '../../utils/timer.js';
|
||||||
import { dispatchViewContextChangeEvent, View } from '../../view/view.js';
|
import { dispatchViewContextChangeEvent, View } from '../../view/view.js';
|
||||||
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
|
import { EmblaCarouselPlugins } from '../carousel.js';
|
||||||
import {
|
|
||||||
FrigateCardMediaCarousel,
|
|
||||||
wrapMediaLoadedEventForCarousel,
|
|
||||||
wrapMediaUnloadedEventForCarousel,
|
|
||||||
} from '../media-carousel.js';
|
|
||||||
import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../message.js';
|
import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../message.js';
|
||||||
import '../next-prev-control.js';
|
import '../next-prev-control.js';
|
||||||
import '../surround.js';
|
import '../surround.js';
|
||||||
import '../title-control.js';
|
import '../title-control.js';
|
||||||
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
|
import {
|
||||||
import { Lazyload } from './../embla-plugins/lazyload.js';
|
FrigateCardTitleControl,
|
||||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
getDefaultTitleConfigForView,
|
||||||
import { MediaGridSelected } from '../../utils/media-grid-controller.js';
|
showTitleControlAfterDelay,
|
||||||
import { getDefaultTitleConfigForView } from '../title-control.js';
|
} from '../title-control.js';
|
||||||
|
|
||||||
interface LiveViewContext {
|
interface LiveViewContext {
|
||||||
// A cameraID override (used for dependencies/substreams to force a different
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
protected shouldUpdate(_changedProps: PropertyValues): boolean {
|
protected shouldUpdate(_changedProps: PropertyValues): boolean {
|
||||||
// Don't process updates if it's in the background and a message was
|
// 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;
|
return !this._inBackground || !this._messageReceivedPostRender;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Component connected callback.
|
|
||||||
*/
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
this._intersectionObserver.observe(this);
|
this._intersectionObserver.observe(this);
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Component disconnected callback.
|
|
||||||
*/
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this._intersectionObserver.disconnect();
|
this._intersectionObserver.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Master render method.
|
|
||||||
* @returns A rendered template.
|
|
||||||
*/
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (
|
if (
|
||||||
!this.hass ||
|
!this.hass ||
|
||||||
@@ -318,9 +304,6 @@ export class FrigateCardLiveGrid extends LitElement {
|
|||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public liveOverrides?: LiveOverrides;
|
public liveOverrides?: LiveOverrides;
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public inBackground?: boolean;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public conditionControllerEpoch?: ConditionControllerEpoch;
|
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||||
|
|
||||||
@@ -342,7 +325,6 @@ export class FrigateCardLiveGrid extends LitElement {
|
|||||||
.viewFilterCameraID=${cameraID}
|
.viewFilterCameraID=${cameraID}
|
||||||
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
|
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
|
||||||
.overriddenLiveConfig=${this.overriddenLiveConfig}
|
.overriddenLiveConfig=${this.overriddenLiveConfig}
|
||||||
.inBackground=${this.inBackground}
|
|
||||||
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||||
.liveOverrides=${this.liveOverrides}
|
.liveOverrides=${this.liveOverrides}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
@@ -423,9 +405,6 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public liveOverrides?: LiveOverrides;
|
public liveOverrides?: LiveOverrides;
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public inBackground?: boolean;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public conditionControllerEpoch?: ConditionControllerEpoch;
|
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||||
|
|
||||||
@@ -443,38 +422,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
|
|
||||||
// Index between camera name and slide number.
|
// Index between camera name and slide number.
|
||||||
protected _cameraToSlide: Record<string, number> = {};
|
protected _cameraToSlide: Record<string, number> = {};
|
||||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
protected _titleTimer = new Timer();
|
||||||
|
protected _refTitleControl: Ref<FrigateCardTitleControl> = 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 {
|
protected _getTransitionEffect(): TransitionEffect {
|
||||||
return (
|
return (
|
||||||
this.overriddenLiveConfig?.transition_effect ??
|
this.overriddenLiveConfig?.transition_effect ??
|
||||||
@@ -490,49 +440,19 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
return Math.max(0, Array.from(cameraIDs).indexOf(this.view.camera));
|
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 {
|
protected _getPlugins(): EmblaCarouselPlugins {
|
||||||
const cameraCount = this.viewFilterCameraID
|
|
||||||
? 1
|
|
||||||
: this.cameraManager?.getStore().getVisibleCameraCount() ?? 0;
|
|
||||||
return [
|
return [
|
||||||
// Only enable wheel plugin if there is more than one camera.
|
AutoLazyLoad({
|
||||||
...(cameraCount > 1
|
|
||||||
? [
|
|
||||||
WheelGesturesPlugin({
|
|
||||||
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
|
||||||
// gestures as scrolling for the carousel.
|
|
||||||
forceWheelAxis: 'y',
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
Lazyload({
|
|
||||||
...(this.overriddenLiveConfig?.lazy_load && {
|
...(this.overriddenLiveConfig?.lazy_load && {
|
||||||
lazyLoadCallback: (index, slide) =>
|
lazyLoadCallback: (index, slide) =>
|
||||||
this._lazyloadOrUnloadSlide('load', index, slide),
|
this._lazyloadOrUnloadSlide('load', index, slide),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
lazyUnloadCondition: this.overriddenLiveConfig?.lazy_unload,
|
lazyUnloadCondition: this.overriddenLiveConfig?.lazy_unload,
|
||||||
lazyUnloadCallback: (index, slide) =>
|
lazyUnloadCallback: (index, slide) =>
|
||||||
this._lazyloadOrUnloadSlide('unload', index, slide),
|
this._lazyloadOrUnloadSlide('unload', index, slide),
|
||||||
}),
|
}),
|
||||||
AutoMediaPlugin({
|
AutoMediaLoadedInfo(),
|
||||||
|
AutoMediaActions({
|
||||||
playerSelector: FRIGATE_CARD_LIVE_PROVIDER,
|
playerSelector: FRIGATE_CARD_LIVE_PROVIDER,
|
||||||
...(this.overriddenLiveConfig?.auto_play && {
|
...(this.overriddenLiveConfig?.auto_play && {
|
||||||
autoPlayCondition: this.overriddenLiveConfig.auto_play,
|
autoPlayCondition: this.overriddenLiveConfig.auto_play,
|
||||||
@@ -547,6 +467,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
autoUnmuteCondition: this.overriddenLiveConfig.auto_unmute,
|
autoUnmuteCondition: this.overriddenLiveConfig.auto_unmute,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
AutoSize(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,11 +483,6 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
return this.overriddenLiveConfig?.lazy_load === false ? null : 0;
|
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<string, number>] {
|
protected _getSlides(): [TemplateResult[], Record<string, number>] {
|
||||||
let cameras: CameraConfigs | null = null;
|
let cameras: CameraConfigs | null = null;
|
||||||
if (this.viewFilterCameraID) {
|
if (this.viewFilterCameraID) {
|
||||||
@@ -595,7 +511,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
: this.cameraManager?.getStore().getCameraConfig(liveCameraID);
|
: this.cameraManager?.getStore().getCameraConfig(liveCameraID);
|
||||||
|
|
||||||
const slide = liveCameraConfig
|
const slide = liveCameraConfig
|
||||||
? this._renderLive(liveCameraID, liveCameraConfig, slides.length)
|
? this._renderLive(liveCameraID, liveCameraConfig)
|
||||||
: null;
|
: null;
|
||||||
if (slide) {
|
if (slide) {
|
||||||
cameraToSlide[cameraID] = slides.length;
|
cameraToSlide[cameraID] = slides.length;
|
||||||
@@ -605,10 +521,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
return [slides, cameraToSlide];
|
return [slides, cameraToSlide];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected _setViewHandler(ev: CustomEvent<CarouselSelected>): void {
|
||||||
* Handle the user selecting a new slide in the carousel.
|
|
||||||
*/
|
|
||||||
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
|
|
||||||
const cameras = this.cameraManager?.getStore().getVisibleCameras();
|
const cameras = this.cameraManager?.getStore().getVisibleCameras();
|
||||||
if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) {
|
if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) {
|
||||||
this._setViewCameraID(Array.from(cameras.keys())[ev.detail.index]);
|
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(
|
protected _lazyloadOrUnloadSlide(
|
||||||
action: 'load' | 'unload',
|
action: 'load' | 'unload',
|
||||||
_index: number,
|
_index: number,
|
||||||
@@ -649,14 +557,13 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
FRIGATE_CARD_LIVE_PROVIDER,
|
FRIGATE_CARD_LIVE_PROVIDER,
|
||||||
) as FrigateCardLiveProvider | null;
|
) as FrigateCardLiveProvider | null;
|
||||||
if (liveProvider) {
|
if (liveProvider) {
|
||||||
liveProvider.disabled = action !== 'load';
|
liveProvider.load = action === 'load';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _renderLive(
|
protected _renderLive(
|
||||||
cameraID: string,
|
cameraID: string,
|
||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
slideIndex: number,
|
|
||||||
): TemplateResult | void {
|
): TemplateResult | void {
|
||||||
if (
|
if (
|
||||||
!this.overriddenLiveConfig ||
|
!this.overriddenLiveConfig ||
|
||||||
@@ -683,7 +590,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<div class="embla__slide">
|
<div class="embla__slide">
|
||||||
<frigate-card-live-provider
|
<frigate-card-live-provider
|
||||||
?disabled=${config.lazy_load}
|
?load=${!config.lazy_load}
|
||||||
.microphoneStream=${this.view?.camera === cameraID
|
.microphoneStream=${this.view?.camera === cameraID
|
||||||
? this.microphoneStream
|
? this.microphoneStream
|
||||||
: undefined}
|
: undefined}
|
||||||
@@ -696,12 +603,6 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
.liveConfig=${config}
|
.liveConfig=${config}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
|
|
||||||
wrapMediaLoadedEventForCarousel(slideIndex, ev);
|
|
||||||
}}
|
|
||||||
@frigate-card:media:unloaded=${(ev: CustomEvent<void>) => {
|
|
||||||
wrapMediaUnloadedEventForCarousel(slideIndex, ev);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
</frigate-card-live-provider>
|
</frigate-card-live-provider>
|
||||||
</div>
|
</div>
|
||||||
@@ -728,10 +629,6 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the element.
|
|
||||||
* @returns A template to display to the user.
|
|
||||||
*/
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.overriddenLiveConfig || !this.view || !this.hass || !this.cameraManager) {
|
if (!this.overriddenLiveConfig || !this.view || !this.hass || !this.cameraManager) {
|
||||||
return;
|
return;
|
||||||
@@ -743,6 +640,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasMultipleCameras = slides.length > 1;
|
||||||
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
|
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
|
||||||
|
|
||||||
const overrideCameraID = (cameraID: string): string => {
|
const overrideCameraID = (cameraID: string): string => {
|
||||||
@@ -776,28 +674,25 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
// little later).
|
// little later).
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<frigate-card-media-carousel
|
<frigate-card-carousel
|
||||||
${ref(this._refMediaCarousel)}
|
.loop=${hasMultipleCameras}
|
||||||
.carouselOptions=${guard(
|
.dragEnabled=${hasMultipleCameras && this.overriddenLiveConfig?.draggable}
|
||||||
[this.cameraManager, this.overriddenLiveConfig],
|
.plugins=${guard(
|
||||||
this._getOptions.bind(this),
|
|
||||||
)}
|
|
||||||
.carouselPlugins=${guard(
|
|
||||||
[this.cameraManager, this.overriddenLiveConfig],
|
[this.cameraManager, this.overriddenLiveConfig],
|
||||||
this._getPlugins.bind(this),
|
this._getPlugins.bind(this),
|
||||||
) as EmblaCarouselPlugins}
|
)}
|
||||||
.label="${cameraMetadataCurrent
|
|
||||||
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
|
|
||||||
: ''}"
|
|
||||||
.logo="${cameraMetadataCurrent?.engineLogo}"
|
|
||||||
.titlePopupConfig=${titleConfig ?? undefined}
|
|
||||||
.selected=${this._getSelectedCameraIndex()}
|
.selected=${this._getSelectedCameraIndex()}
|
||||||
transitionEffect=${this._getTransitionEffect()}
|
transitionEffect=${this._getTransitionEffect()}
|
||||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
@frigate-card:carousel:select=${this._setViewHandler.bind(this)}
|
||||||
@frigate-card:carousel:settle=${() => {
|
@frigate-card:carousel:settle=${() => {
|
||||||
// Fetch the thumbnails after the carousel has settled.
|
// Fetch the thumbnails after the carousel has settled.
|
||||||
dispatchViewContextChangeEvent(this, { thumbnails: { fetch: true } });
|
dispatchViewContextChangeEvent(this, { thumbnails: { fetch: true } });
|
||||||
}}
|
}}
|
||||||
|
@frigate-card:media:loaded=${() => {
|
||||||
|
if (this._refTitleControl.value) {
|
||||||
|
showTitleControlAfterDelay(this._refTitleControl.value, this._titleTimer);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<frigate-card-next-previous-control
|
<frigate-card-next-previous-control
|
||||||
slot="previous"
|
slot="previous"
|
||||||
@@ -828,13 +723,22 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
</frigate-card-next-previous-control>
|
</frigate-card-next-previous-control>
|
||||||
</frigate-card-media-carousel>
|
</frigate-card-carousel>
|
||||||
|
${cameraMetadataCurrent && titleConfig
|
||||||
|
? html`<frigate-card-title-control
|
||||||
|
${ref(this._refTitleControl)}
|
||||||
|
.config=${titleConfig}
|
||||||
|
.text="${cameraMetadataCurrent
|
||||||
|
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
|
||||||
|
: ''}"
|
||||||
|
.logo="${cameraMetadataCurrent?.engineLogo}"
|
||||||
|
.fitInto=${this as HTMLElement}
|
||||||
|
>
|
||||||
|
</frigate-card-title-control> `
|
||||||
|
: ``}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(liveCarouselStyle);
|
return unsafeCSS(liveCarouselStyle);
|
||||||
}
|
}
|
||||||
@@ -857,10 +761,11 @@ export class FrigateCardLiveProvider
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public liveConfig?: LiveConfig;
|
public liveConfig?: LiveConfig;
|
||||||
|
|
||||||
// Whether or not to disable this entity. If `true`, no contents are rendered
|
// Whether or not to load the video for this camera. If `false`, no contents
|
||||||
// until this attribute is set to `false` (this is useful for lazy loading).
|
// are rendered until this attribute is set to `true` (this is useful for lazy
|
||||||
|
// loading).
|
||||||
@property({ attribute: true, type: Boolean })
|
@property({ attribute: true, type: Boolean })
|
||||||
public disabled = false;
|
public load = false;
|
||||||
|
|
||||||
// Label that is used for ARIA support and as tooltip.
|
// Label that is used for ARIA support and as tooltip.
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
@@ -995,8 +900,8 @@ export class FrigateCardLiveProvider
|
|||||||
* Called before each update.
|
* Called before each update.
|
||||||
*/
|
*/
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('disabled')) {
|
if (changedProps.has('load')) {
|
||||||
if (this.disabled) {
|
if (!this.load) {
|
||||||
this._isVideoMediaLoaded = false;
|
this._isVideoMediaLoaded = false;
|
||||||
dispatchMediaUnloadedEvent(this);
|
dispatchMediaUnloadedEvent(this);
|
||||||
}
|
}
|
||||||
@@ -1051,7 +956,7 @@ export class FrigateCardLiveProvider
|
|||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
*/
|
*/
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (this.disabled || !this.hass || !this.liveConfig || !this.cameraConfig) {
|
if (!this.load || !this.hass || !this.liveConfig || !this.cameraConfig) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<CarouselMediaLoadedInfo>(
|
|
||||||
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<CarouselMediaUnloadedInfo>(
|
|
||||||
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<MediaLoadedInfo>,
|
|
||||||
) => {
|
|
||||||
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<void>,
|
|
||||||
) => {
|
|
||||||
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<number, MediaLoadedInfo> = {};
|
|
||||||
protected _nextControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
|
|
||||||
protected _previousControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
|
|
||||||
protected _titleControlRef: Ref<FrigateCardTitleControl> = 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<FrigateCardCarousel> = 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<CarouselMediaLoadedInfo>): 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<CarouselMediaUnloadedInfo>): 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<CarouselSelect>): 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<CarouselSelect>(
|
|
||||||
this,
|
|
||||||
'media-carousel:select',
|
|
||||||
selected,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Dispatch media info.
|
|
||||||
this._dispatchMediaLoadedInfo(selected);
|
|
||||||
}
|
|
||||||
|
|
||||||
return html` <frigate-card-carousel
|
|
||||||
${ref(this._refCarousel)}
|
|
||||||
.selected=${this.selected ?? 0}
|
|
||||||
.carouselOptions=${this.carouselOptions}
|
|
||||||
.carouselPlugins=${this.carouselPlugins}
|
|
||||||
transitionEffect=${ifDefined(this.transitionEffect)}
|
|
||||||
@frigate-card:carousel:select=${(ev: CustomEvent<CarouselSelect>) => {
|
|
||||||
selectSlide(ev);
|
|
||||||
}}
|
|
||||||
@frigate-card:carousel:media:loaded=${this._storeMediaLoadedInfo.bind(this)}
|
|
||||||
@frigate-card:carousel:media:unloaded=${this._removeMediaLoadedInfo.bind(this)}
|
|
||||||
>
|
|
||||||
<slot slot="previous" name="previous"></slot>
|
|
||||||
<slot></slot>
|
|
||||||
<slot slot="next" name="next"></slot>
|
|
||||||
</frigate-card-carousel>
|
|
||||||
${this.label && this.titlePopupConfig
|
|
||||||
? html`<frigate-card-title-control
|
|
||||||
${ref(this._titleControlRef)}
|
|
||||||
.config=${this.titlePopupConfig}
|
|
||||||
.text="${this.label}"
|
|
||||||
.logo="${this.logo}"
|
|
||||||
.fitInto=${this as HTMLElement}
|
|
||||||
>
|
|
||||||
</frigate-card-title-control> `
|
|
||||||
: ``}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
|
||||||
return unsafeCSS(mediaCarouselStyle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface HTMLElementTagNameMap {
|
|
||||||
'frigate-card-media-carousel': FrigateCardMediaCarousel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
|
|
||||||
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
|
||||||
import {
|
import {
|
||||||
CSSResultGroup,
|
CSSResultGroup,
|
||||||
html,
|
html,
|
||||||
@@ -10,18 +8,16 @@ import {
|
|||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { classMap } from 'lit/directives/class-map.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 thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
|
||||||
import { ExtendedHomeAssistant, ThumbnailsControlConfig } from '../types.js';
|
import { ExtendedHomeAssistant, ThumbnailsControlConfig } from '../types.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||||
import { dispatchFrigateCardEvent } from '../utils/basic.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 { MediaQueriesResults } from '../view/media-queries-results';
|
||||||
import { FrigateCardCarousel } from './carousel.js';
|
import { View } from '../view/view.js';
|
||||||
import './thumbnail.js';
|
|
||||||
import './carousel.js';
|
import './carousel.js';
|
||||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
import './thumbnail.js';
|
||||||
import { CameraManager } from '../camera-manager/manager.js';
|
|
||||||
|
|
||||||
export interface ThumbnailCarouselTap {
|
export interface ThumbnailCarouselTap {
|
||||||
queryResults: MediaQueriesResults;
|
queryResults: MediaQueriesResults;
|
||||||
@@ -38,83 +34,11 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
protected _refCarousel: Ref<FrigateCardCarousel> = 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 })
|
@property({ attribute: false })
|
||||||
public config?: ThumbnailsControlConfig;
|
public config?: ThumbnailsControlConfig;
|
||||||
|
|
||||||
@property({ attribute: false })
|
protected _thumbnailSlides: TemplateResult[] = [];
|
||||||
public selected? = 0;
|
|
||||||
|
|
||||||
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 {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('config')) {
|
if (changedProps.has('config')) {
|
||||||
if (this.config?.size) {
|
if (this.config?.size) {
|
||||||
@@ -128,32 +52,41 @@ 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(
|
this.style.setProperty(
|
||||||
'--frigate-card-carousel-thumbnail-opacity',
|
'--frigate-card-carousel-thumbnail-opacity',
|
||||||
this.selected === undefined ? '1.0' : '0.4',
|
this._getSelectedSlide() === null ? '1.0' : '0.4',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected _getSelectedSlide(view?: View): number | null {
|
||||||
* Render a given thumbnail.
|
return (view ?? this.view)?.queryResults?.getSelectedIndex() ?? null;
|
||||||
* @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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {
|
const classes = {
|
||||||
embla__slide: true,
|
embla__slide: true,
|
||||||
'slide-selected': this.selected === index,
|
'slide-selected': selectedIndex === index,
|
||||||
};
|
};
|
||||||
|
|
||||||
const seekTarget = this.view?.context?.mediaViewer?.seek;
|
slides.push(html` <frigate-card-thumbnail
|
||||||
return html` <frigate-card-thumbnail
|
|
||||||
class="${classMap(classes)}"
|
class="${classMap(classes)}"
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
@@ -177,46 +110,35 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
</frigate-card-thumbnail>`;
|
</frigate-card-thumbnail>`);
|
||||||
|
}
|
||||||
|
return slides;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected _getDirection(): CarouselDirection | null {
|
||||||
* Get the direction of the thumbnail carousel.
|
|
||||||
* @returns `vertical`, `horizontal` or undefined.
|
|
||||||
*/
|
|
||||||
protected _getDirection(): 'horizontal' | 'vertical' | undefined {
|
|
||||||
if (this.config?.mode === 'left' || this.config?.mode === 'right') {
|
if (this.config?.mode === 'left' || this.config?.mode === 'right') {
|
||||||
return 'vertical';
|
return 'vertical';
|
||||||
} else if (this.config?.mode === 'above' || this.config?.mode === 'below') {
|
} else if (this.config?.mode === 'above' || this.config?.mode === 'below') {
|
||||||
return 'horizontal';
|
return 'horizontal';
|
||||||
}
|
}
|
||||||
return undefined;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the element.
|
|
||||||
* @returns A template to display to the user.
|
|
||||||
*/
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
const slides = this._getSlides();
|
const direction = this._getDirection();
|
||||||
if (!slides.length || !this.config || this.config.mode === 'none') {
|
if (!this._thumbnailSlides.length || !this.config || !direction) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`<frigate-card-carousel
|
return html`<frigate-card-carousel
|
||||||
${ref(this._refCarousel)}
|
direction=${direction}
|
||||||
direction=${ifDefined(this._getDirection())}
|
.selected=${this._getSelectedSlide() ?? 0}
|
||||||
.selected=${this.selected ?? 0}
|
.dragFree=${true}
|
||||||
.carouselOptions=${this._carouselOptions}
|
|
||||||
.carouselPlugins=${this._carouselPlugins}
|
|
||||||
>
|
>
|
||||||
${slides}
|
${this._thumbnailSlides}
|
||||||
</frigate-card-carousel>`;
|
</frigate-card-carousel>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get element styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(thumbnailCarouselStyle);
|
return unsafeCSS(thumbnailCarouselStyle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,36 @@ import { customElement, property } from 'lit/decorators.js';
|
|||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
import titleStyle from '../scss/title-control.scss';
|
import titleStyle from '../scss/title-control.scss';
|
||||||
import { TitleControlConfig } from '../types.js';
|
import { TitleControlConfig } from '../types.js';
|
||||||
|
import { Timer } from '../utils/timer';
|
||||||
import { View } from '../view/view.js';
|
import { View } from '../view/view.js';
|
||||||
|
|
||||||
type PaperToast = HTMLElement & {
|
type PaperToast = HTMLElement & {
|
||||||
opened: boolean;
|
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 = (
|
export const getDefaultTitleConfigForView = (
|
||||||
view?: Readonly<View>,
|
view?: Readonly<View>,
|
||||||
baseConfig?: TitleControlConfig,
|
baseConfig?: TitleControlConfig,
|
||||||
@@ -39,10 +63,6 @@ export class FrigateCardTitleControl extends LitElement {
|
|||||||
|
|
||||||
protected _toastRef: Ref<PaperToast> = createRef();
|
protected _toastRef: Ref<PaperToast> = createRef();
|
||||||
|
|
||||||
/**
|
|
||||||
* Master render method.
|
|
||||||
* @returns A rendered template.
|
|
||||||
*/
|
|
||||||
protected render(): TemplateResult {
|
protected render(): TemplateResult {
|
||||||
if (!this.text || !this.config || this.config.mode == 'none' || !this.fitInto) {
|
if (!this.text || !this.config || this.config.mode == 'none' || !this.fitInto) {
|
||||||
return html``;
|
return html``;
|
||||||
@@ -64,17 +84,10 @@ export class FrigateCardTitleControl extends LitElement {
|
|||||||
</paper-toast>`;
|
</paper-toast>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if the toast is visible.
|
|
||||||
* @returns `true` if the toast is visible, `false` otherwise.
|
|
||||||
*/
|
|
||||||
public isVisible(): boolean {
|
public isVisible(): boolean {
|
||||||
return this._toastRef.value?.opened ?? false;
|
return this._toastRef.value?.opened ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the toast.
|
|
||||||
*/
|
|
||||||
public hide(): void {
|
public hide(): void {
|
||||||
if (this._toastRef.value) {
|
if (this._toastRef.value) {
|
||||||
// Set it to false first, to ensure the timer resets.
|
// 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 {
|
public show(): void {
|
||||||
if (this._toastRef.value) {
|
if (this._toastRef.value) {
|
||||||
// Set it to false first, to ensure the timer resets.
|
// 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 {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(titleStyle);
|
return unsafeCSS(titleStyle);
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-98
@@ -1,5 +1,3 @@
|
|||||||
import { EmblaPluginType } from 'embla-carousel';
|
|
||||||
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
|
||||||
import {
|
import {
|
||||||
CSSResultGroup,
|
CSSResultGroup,
|
||||||
html,
|
html,
|
||||||
@@ -12,11 +10,15 @@ import { customElement, property } from 'lit/decorators.js';
|
|||||||
import { guard } from 'lit/directives/guard.js';
|
import { guard } from 'lit/directives/guard.js';
|
||||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
import { createRef, Ref, ref } from 'lit/directives/ref.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 { 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 { localize } from '../localize/localize.js';
|
||||||
import '../patches/ha-hls-player';
|
import '../patches/ha-hls-player';
|
||||||
|
import basicBlockStyle from '../scss/basic-block.scss';
|
||||||
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
|
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
|
||||||
import viewerProviderStyle from '../scss/viewer-provider.scss';
|
import viewerProviderStyle from '../scss/viewer-provider.scss';
|
||||||
import viewerStyle from '../scss/viewer.scss';
|
import viewerStyle from '../scss/viewer.scss';
|
||||||
@@ -36,6 +38,11 @@ import {
|
|||||||
errorToConsole,
|
errorToConsole,
|
||||||
setOrRemoveAttribute,
|
setOrRemoveAttribute,
|
||||||
} from '../utils/basic.js';
|
} 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 { canonicalizeHAURL } from '../utils/ha/index.js';
|
||||||
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
||||||
import { MediaGridSelected } from '../utils/media-grid-controller.js';
|
import { MediaGridSelected } from '../utils/media-grid-controller.js';
|
||||||
@@ -57,22 +64,21 @@ import {
|
|||||||
setControlsOnVideo,
|
setControlsOnVideo,
|
||||||
} from '../utils/media.js';
|
} from '../utils/media.js';
|
||||||
import { screenshotMedia } from '../utils/screenshot.js';
|
import { screenshotMedia } from '../utils/screenshot.js';
|
||||||
|
import { Timer } from '../utils/timer';
|
||||||
import { ViewMediaClassifier } from '../view/media-classifier';
|
import { ViewMediaClassifier } from '../view/media-classifier';
|
||||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||||
import { MediaQueriesResults } from '../view/media-queries-results.js';
|
import { MediaQueriesResults } from '../view/media-queries-results.js';
|
||||||
import { VideoContentType, ViewMedia } from '../view/media.js';
|
import { VideoContentType, ViewMedia } from '../view/media.js';
|
||||||
import { View } from '../view/view.js';
|
import { View } from '../view/view.js';
|
||||||
import type { CarouselSelect } from './carousel.js';
|
import type { EmblaCarouselPlugins } 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 './next-prev-control.js';
|
import './next-prev-control.js';
|
||||||
import './surround.js';
|
import './surround.js';
|
||||||
import './title-control.js';
|
import './title-control.js';
|
||||||
import { getDefaultTitleConfigForView } from './title-control.js';
|
import {
|
||||||
|
FrigateCardTitleControl,
|
||||||
|
getDefaultTitleConfigForView,
|
||||||
|
showTitleControlAfterDelay,
|
||||||
|
} from './title-control.js';
|
||||||
|
|
||||||
export interface MediaViewerViewContext {
|
export interface MediaViewerViewContext {
|
||||||
seek?: Date;
|
seek?: Date;
|
||||||
@@ -114,10 +120,6 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
/**
|
|
||||||
* Master render method.
|
|
||||||
* @returns A rendered template.
|
|
||||||
*/
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (
|
if (
|
||||||
!this.hass ||
|
!this.hass ||
|
||||||
@@ -186,9 +188,6 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
</frigate-card-viewer-grid>`;
|
</frigate-card-viewer-grid>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get element styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(viewerStyle);
|
return unsafeCSS(viewerStyle);
|
||||||
}
|
}
|
||||||
@@ -227,8 +226,10 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public selected = 0;
|
public selected = 0;
|
||||||
|
|
||||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
|
||||||
protected _media: ViewMedia[] | null = null;
|
protected _media: ViewMedia[] | null = null;
|
||||||
|
protected _titleTimer = new Timer();
|
||||||
|
protected _refTitleControl: Ref<FrigateCardTitleControl> = createRef();
|
||||||
|
protected _player: FrigateCardMediaPlayer | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The updated lifecycle callback for this element.
|
* 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.
|
* Get the Embla plugins to use.
|
||||||
* @returns A list of EmblaOptionsTypes.
|
* @returns A list of EmblaOptionsTypes.
|
||||||
*/
|
*/
|
||||||
protected _getPlugins(): EmblaPluginType[] {
|
protected _getPlugins(): EmblaCarouselPlugins {
|
||||||
return [
|
return [
|
||||||
// Only enable wheel plugin if there is more than one media item.
|
AutoLazyLoad({
|
||||||
...(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({
|
|
||||||
...(this.viewerConfig?.lazy_load && {
|
...(this.viewerConfig?.lazy_load && {
|
||||||
lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide),
|
lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
AutoMediaPlugin({
|
AutoMediaLoadedInfo(),
|
||||||
|
AutoMediaActions({
|
||||||
playerSelector: FRIGATE_CARD_VIEWER_PROVIDER,
|
playerSelector: FRIGATE_CARD_VIEWER_PROVIDER,
|
||||||
...(this.viewerConfig?.auto_play && {
|
...(this.viewerConfig?.auto_play && {
|
||||||
autoPlayCondition: this.viewerConfig.auto_play,
|
autoPlayCondition: this.viewerConfig.auto_play,
|
||||||
@@ -314,6 +287,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
autoUnmuteCondition: this.viewerConfig.auto_unmute,
|
autoUnmuteCondition: this.viewerConfig.auto_unmute,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
AutoSize(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,10 +320,6 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
|
|
||||||
this._setViewSelectedIndex(ev.detail.index);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _setViewSelectedIndex(index: number): void {
|
protected _setViewSelectedIndex(index: number): void {
|
||||||
if (!this._media) {
|
if (!this._media) {
|
||||||
return;
|
return;
|
||||||
@@ -396,7 +366,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
'frigate-card-viewer-provider',
|
'frigate-card-viewer-provider',
|
||||||
) as FrigateCardViewerProvider | null;
|
) as FrigateCardViewerProvider | null;
|
||||||
if (viewerProvider) {
|
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) {
|
for (let i = 0; i < this._media.length; ++i) {
|
||||||
const media = this._media[i];
|
const media = this._media[i];
|
||||||
if (media) {
|
if (media) {
|
||||||
const slide = this._renderMediaItem(media, i);
|
const slide = this._renderMediaItem(media);
|
||||||
if (slide) {
|
if (slide) {
|
||||||
slides[i] = slide;
|
slides[i] = slide;
|
||||||
}
|
}
|
||||||
@@ -487,22 +457,24 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<frigate-card-media-carousel
|
<frigate-card-carousel
|
||||||
${ref(this._refMediaCarousel)}
|
.dragEnabled=${this.viewerConfig?.draggable ?? true}
|
||||||
.carouselOptions=${guard([this.viewerConfig], () => ({
|
.plugins=${guard([this.viewerConfig, this._media], this._getPlugins.bind(this))}
|
||||||
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}
|
|
||||||
.selected=${this.selected ?? 0}
|
.selected=${this.selected ?? 0}
|
||||||
transitionEffect=${this._getTransitionEffect()}
|
transitionEffect=${this._getTransitionEffect()}
|
||||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
@frigate-card:carousel:select=${(ev: CustomEvent<CarouselSelected>) => {
|
||||||
@frigate-card:media:loaded=${this._seekHandler.bind(this)}
|
this._setViewSelectedIndex(ev.detail.index);
|
||||||
|
}}
|
||||||
|
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
|
||||||
|
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;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<frigate-card-next-previous-control
|
<frigate-card-next-previous-control
|
||||||
slot="previous"
|
slot="previous"
|
||||||
@@ -531,11 +503,21 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
}}
|
}}
|
||||||
></frigate-card-next-previous-control>
|
></frigate-card-next-previous-control>
|
||||||
</frigate-card-media-carousel>
|
</frigate-card-carousel>
|
||||||
<div class="seek-warning">
|
<div class="seek-warning">
|
||||||
<ha-icon title="${localize('media_viewer.unseekable')}" icon="mdi:clock-remove">
|
<ha-icon title="${localize('media_viewer.unseekable')}" icon="mdi:clock-remove">
|
||||||
</ha-icon>
|
</ha-icon>
|
||||||
</div>
|
</div>
|
||||||
|
${cameraMetadata && titleConfig
|
||||||
|
? html`<frigate-card-title-control
|
||||||
|
${ref(this._refTitleControl)}
|
||||||
|
.config=${titleConfig}
|
||||||
|
.text="${selectedMedia.getTitle() ?? undefined}"
|
||||||
|
.logo="${cameraMetadata?.engineLogo}"
|
||||||
|
.fitInto=${this as HTMLElement}
|
||||||
|
>
|
||||||
|
</frigate-card-title-control> `
|
||||||
|
: ``}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,21 +526,20 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
*/
|
*/
|
||||||
protected async _seekHandler(): Promise<void> {
|
protected async _seekHandler(): Promise<void> {
|
||||||
const seek = this.view?.context?.mediaViewer?.seek;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const selectedMedia = this._media[this.selected];
|
const selectedMedia = this._media[this.selected];
|
||||||
const player = this._getPlayer();
|
if (!selectedMedia) {
|
||||||
if (!selectedMedia || !player) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seekTimeInMedia = selectedMedia.includesTime(seek);
|
const seekTimeInMedia = selectedMedia.includesTime(seek);
|
||||||
setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable');
|
setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable');
|
||||||
if (!seekTimeInMedia && !player.isPaused()) {
|
if (!seekTimeInMedia && !this._player.isPaused()) {
|
||||||
player.pause();
|
this._player.pause();
|
||||||
} else if (seekTimeInMedia && player.isPaused()) {
|
} else if (seekTimeInMedia && this._player.isPaused()) {
|
||||||
player.play();
|
this._player.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
const seekTime =
|
const seekTime =
|
||||||
@@ -566,17 +547,11 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
null;
|
null;
|
||||||
|
|
||||||
if (seekTime !== null) {
|
if (seekTime !== null) {
|
||||||
player.seek(seekTime);
|
this._player.seek(seekTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected _renderMediaItem(media: ViewMedia): TemplateResult | null {
|
||||||
* 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 {
|
|
||||||
if (!this.hass || !this.view || !this.viewerConfig) {
|
if (!this.hass || !this.view || !this.viewerConfig) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -589,11 +564,8 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
.viewerConfig=${this.viewerConfig}
|
.viewerConfig=${this.viewerConfig}
|
||||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.disabled=${this.viewerConfig.lazy_load}
|
.load=${!this.viewerConfig.lazy_load}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
@frigate-card:media:loaded=${(e: CustomEvent<MediaLoadedInfo>) => {
|
|
||||||
wrapMediaLoadedEventForCarousel(index, e);
|
|
||||||
}}
|
|
||||||
></frigate-card-viewer-provider>
|
></frigate-card-viewer-provider>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -715,10 +687,11 @@ export class FrigateCardViewerProvider
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public resolvedMediaCache?: ResolvedMediaCache;
|
public resolvedMediaCache?: ResolvedMediaCache;
|
||||||
|
|
||||||
// Whether or not to disable this entity. If `true`, no contents are rendered
|
// Whether or not to load the viewer media. If `false`, no contents are
|
||||||
// until this attribute is set to `false` (this is useful for lazy loading).
|
// rendered until this attribute is set to `true` (this is useful for lazy
|
||||||
|
// loading).
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public disabled = false;
|
public load = false;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
@@ -865,7 +838,7 @@ export class FrigateCardViewerProvider
|
|||||||
const mediaContentID = this.media ? this.media.getContentID() : null;
|
const mediaContentID = this.media ? this.media.getContentID() : null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(changedProps.has('disabled') ||
|
(changedProps.has('load') ||
|
||||||
changedProps.has('media') ||
|
changedProps.has('media') ||
|
||||||
changedProps.has('viewerConfig') ||
|
changedProps.has('viewerConfig') ||
|
||||||
changedProps.has('resolvedMediaCache') ||
|
changedProps.has('resolvedMediaCache') ||
|
||||||
@@ -873,7 +846,7 @@ export class FrigateCardViewerProvider
|
|||||||
this.hass &&
|
this.hass &&
|
||||||
mediaContentID &&
|
mediaContentID &&
|
||||||
!this.resolvedMediaCache?.has(mediaContentID) &&
|
!this.resolvedMediaCache?.has(mediaContentID) &&
|
||||||
(!this.viewerConfig?.lazy_load || !this.disabled)
|
(!this.viewerConfig?.lazy_load || this.load)
|
||||||
) {
|
) {
|
||||||
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => {
|
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => {
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
@@ -897,7 +870,7 @@ export class FrigateCardViewerProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
// Keep carousel controls relative to the media carousel itself.
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.embla {
|
.embla {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
:host {
|
||||||
|
--video-max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
// If the carousel has an unselected attribute set on it, do not let the
|
// 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
|
// 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
|
// 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.
|
// will enlarge on hover, and the wheel-gestures plugin may block scrolling.
|
||||||
// See matching in viewer-carousel.scss .
|
// See matching in viewer-carousel.scss .
|
||||||
:host([unselected]) frigate-card-media-carousel {
|
:host([unselected]) frigate-card-carousel {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,4 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
--video-max-height: none;
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
:host {
|
:host {
|
||||||
// Center unseekable icon.
|
// Center unseekable icon.
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
--video-max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the carousel has an unselected attribute set on it, do not let the
|
// 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
|
// 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.
|
// will enlarge on hover, and the wheel-gestures plugin may block scrolling.
|
||||||
// See matching in live-carousel.scss .
|
// See matching in live-carousel.scss .
|
||||||
:host([unselected]) frigate-card-media-carousel,
|
:host([unselected]) frigate-card-carousel,
|
||||||
:host([unselected]) .seek-warning
|
:host([unselected]) .seek-warning {
|
||||||
{
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:host([unseekable]) frigate-card-media-carousel {
|
:host([unseekable]) frigate-card-carousel {
|
||||||
filter: brightness(50%);
|
filter: brightness(50%);
|
||||||
}
|
}
|
||||||
:host([unseekable]) .seek-warning {
|
:host([unseekable]) .seek-warning {
|
||||||
display: block
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seek-warning {
|
.seek-warning {
|
||||||
|
|||||||
@@ -232,3 +232,11 @@ export const isTruthy = <T>(x: T | false | undefined | null | '' | 0): x is T =>
|
|||||||
*/
|
*/
|
||||||
export const isHTMLElement = (element: unknown): element is HTMLElement =>
|
export const isHTMLElement = (element: unknown): element is HTMLElement =>
|
||||||
element instanceof 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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ export function getAllDependentCameras(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (cameraID) {
|
|
||||||
getDependentCameras(cameraID);
|
getDependentCameras(cameraID);
|
||||||
}
|
|
||||||
return cameraIDs;
|
return cameraIDs;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<LoosePluginType, Record<string, unknown>>[];
|
||||||
|
|
||||||
|
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<CarouselSelected>(
|
||||||
|
this._parent,
|
||||||
|
'carousel:select',
|
||||||
|
carouselSelected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
carousel.on('init', () => selectSlide());
|
||||||
|
carousel.on('select', () => selectSlide());
|
||||||
|
carousel.on('settle', () => {
|
||||||
|
const carouselSelected = getCarouselSelectedObject();
|
||||||
|
if (carouselSelected) {
|
||||||
|
dispatchFrigateCardEvent<CarouselSelected>(
|
||||||
|
this._parent,
|
||||||
|
'carousel:settle',
|
||||||
|
carouselSelected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return carousel;
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-67
@@ -1,17 +1,26 @@
|
|||||||
|
import { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
|
||||||
import { CreateOptionsType } from 'embla-carousel/components/Options';
|
import { CreateOptionsType } from 'embla-carousel/components/Options';
|
||||||
import { CreatePluginType } from 'embla-carousel/components/Plugins';
|
import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler';
|
||||||
import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
|
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
|
||||||
import { LazyUnloadCondition } from '../../types';
|
import { LazyUnloadCondition } from '../../../../types';
|
||||||
|
|
||||||
|
declare module 'embla-carousel/components/Plugins' {
|
||||||
|
interface EmblaPluginsType {
|
||||||
|
lazyload?: AutoLazyLoadType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type OptionsType = CreateOptionsType<{
|
type OptionsType = CreateOptionsType<{
|
||||||
// Number of slides to lazyload left/right of selected (0 == only selected
|
// Number of slides to lazyload left/right of selected (0 == only selected
|
||||||
// slide).
|
// slide).
|
||||||
lazyLoadCount?: number;
|
lazyLoadCount: number;
|
||||||
lazyUnloadCondition?: LazyUnloadCondition;
|
lazyUnloadCondition?: LazyUnloadCondition;
|
||||||
|
|
||||||
lazyLoadCallback?: (index: number, slide: HTMLElement) => void;
|
lazyLoadCallback?: (index: number, slide: HTMLElement) => void;
|
||||||
lazyUnloadCallback?: (index: number, slide: HTMLElement) => void;
|
lazyUnloadCallback?: (index: number, slide: HTMLElement) => void;
|
||||||
}>;
|
}>;
|
||||||
|
type AutoLazyLoadOptionsType = Partial<OptionsType>;
|
||||||
|
type AutoLazyLoadType = CreatePluginType<LoosePluginType, AutoLazyLoadOptionsType>;
|
||||||
|
|
||||||
const defaultOptions: OptionsType = {
|
const defaultOptions: OptionsType = {
|
||||||
active: true,
|
active: true,
|
||||||
@@ -19,74 +28,54 @@ const defaultOptions: OptionsType = {
|
|||||||
lazyLoadCount: 0,
|
lazyLoadCount: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
type LazyloadOptionsType = Partial<OptionsType>;
|
export function AutoLazyLoad(
|
||||||
|
userOptions: AutoLazyLoadOptionsType = {},
|
||||||
type LazyloadType = CreatePluginType<
|
): AutoLazyLoadType {
|
||||||
{
|
let options: OptionsType;
|
||||||
hasLazyloaded(index: number): boolean;
|
let emblaApi: EmblaCarouselType;
|
||||||
},
|
|
||||||
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;
|
|
||||||
let slides: HTMLElement[];
|
let slides: HTMLElement[];
|
||||||
const lazyLoadedSlides: Set<number> = new Set();
|
const lazyLoadedSlides: Set<number> = new Set();
|
||||||
|
|
||||||
const loadEvents: EmblaEventType[] = ['init', 'select', 'resize'];
|
const loadEvents: EmblaEventType[] = ['init', 'select'];
|
||||||
const unloadEvents: EmblaEventType[] = ['select'];
|
const unloadEvents: EmblaEventType[] = ['select'];
|
||||||
|
|
||||||
/**
|
function init(
|
||||||
* Initialize the plugin.
|
emblaApiInstance: EmblaCarouselType,
|
||||||
*/
|
optionsHandler: OptionsHandlerType,
|
||||||
function init(embla: EmblaCarouselType): void {
|
): void {
|
||||||
carousel = embla;
|
const { mergeOptions, optionsAtMedia } = optionsHandler;
|
||||||
options = optionsHandler.atMedia(self.options);
|
const allOptions = mergeOptions(defaultOptions, userOptions);
|
||||||
slides = carousel.slideNodes();
|
options = optionsAtMedia(allOptions);
|
||||||
|
|
||||||
|
emblaApi = emblaApiInstance;
|
||||||
|
slides = emblaApi.slideNodes();
|
||||||
|
|
||||||
if (options.lazyLoadCallback) {
|
if (options.lazyLoadCallback) {
|
||||||
loadEvents.forEach((evt) => carousel.on(evt, lazyLoadHandler));
|
loadEvents.forEach((evt) => emblaApi.on(evt, lazyLoadHandler));
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
options.lazyUnloadCallback &&
|
options.lazyUnloadCallback &&
|
||||||
options.lazyUnloadCondition &&
|
options.lazyUnloadCondition &&
|
||||||
['all', 'unselected'].includes(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);
|
document.addEventListener('visibilitychange', visibilityHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy the plugin.
|
|
||||||
*/
|
|
||||||
function destroy(): void {
|
function destroy(): void {
|
||||||
if (options.lazyLoadCallback) {
|
if (options.lazyLoadCallback) {
|
||||||
loadEvents.forEach((evt) => carousel.off(evt, lazyLoadHandler));
|
loadEvents.forEach((evt) => emblaApi.off(evt, lazyLoadHandler));
|
||||||
}
|
}
|
||||||
if (options.lazyUnloadCallback) {
|
if (options.lazyUnloadCallback) {
|
||||||
unloadEvents.forEach((evt) => carousel.off(evt, lazyUnloadPreviousHandler));
|
unloadEvents.forEach((evt) => emblaApi.off(evt, lazyUnloadPreviousHandler));
|
||||||
}
|
}
|
||||||
document.removeEventListener('visibilitychange', visibilityHandler);
|
document.removeEventListener('visibilitychange', visibilityHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle document visibility changes.
|
|
||||||
*/
|
|
||||||
function visibilityHandler(): void {
|
function visibilityHandler(): void {
|
||||||
if (
|
if (
|
||||||
document.visibilityState === 'hidden' &&
|
document.visibilityState === 'hidden' &&
|
||||||
options.lazyUnloadCallback &&
|
|
||||||
options.lazyUnloadCondition &&
|
options.lazyUnloadCondition &&
|
||||||
['all', 'hidden'].includes(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 {
|
function hasLazyloaded(index: number): boolean {
|
||||||
return lazyLoadedSlides.has(index);
|
return lazyLoadedSlides.has(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily load media in the carousel.
|
|
||||||
*/
|
|
||||||
function lazyLoadHandler(): void {
|
function lazyLoadHandler(): void {
|
||||||
const lazyLoadCount = options.lazyLoadCount ?? 0;
|
const lazyLoadCount = options.lazyLoadCount;
|
||||||
const currentIndex = carousel.selectedScrollSnap();
|
const currentIndex = emblaApi.selectedScrollSnap();
|
||||||
const slidesToLoad = new Set<number>();
|
const slidesToLoad = new Set<number>();
|
||||||
|
|
||||||
// Lazily load 'count' slides on either side of the slides in view.
|
// 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 {
|
function lazyUnloadAllHandler(): void {
|
||||||
lazyLoadedSlides.forEach((index) => {
|
lazyLoadedSlides.forEach((index) => {
|
||||||
if (options.lazyUnloadCallback) {
|
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 {
|
function lazyUnloadPreviousHandler(): void {
|
||||||
const index = carousel.previousScrollSnap();
|
const index = emblaApi.previousScrollSnap();
|
||||||
|
|
||||||
if (hasLazyloaded(index) && options.lazyUnloadCallback) {
|
if (hasLazyloaded(index) && options.lazyUnloadCallback) {
|
||||||
options.lazyUnloadCallback(index, slides[index]);
|
options.lazyUnloadCallback(index, slides[index]);
|
||||||
@@ -154,14 +129,11 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const self: LazyloadType = {
|
const self: AutoLazyLoadType = {
|
||||||
name: 'lazyload',
|
name: 'autoLazyLoad',
|
||||||
options: optionsHandler.merge(optionsBase, userOptions),
|
options: userOptions,
|
||||||
init,
|
init,
|
||||||
destroy,
|
destroy,
|
||||||
hasLazyloaded,
|
|
||||||
};
|
};
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
Lazyload.globalOptions = <LazyloadOptionsType | undefined>undefined;
|
|
||||||
@@ -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<OptionsType>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<LoosePluginType, LooseOptionsType>;
|
||||||
|
|
||||||
|
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<MediaLoadedInfo>): 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;
|
||||||
@@ -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<LoosePluginType, LooseOptionsType>;
|
||||||
|
interface SlideDimensions {
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This plugin offers the following functionality:
|
||||||
|
* - Auto-height: Automatically resize the container to fit the largest slide on
|
||||||
|
* view. Unlike the stock `auto-height` plugin, this version will use active
|
||||||
|
* DOM sizing vs the internal engine sizes to account for pre-reinit resize
|
||||||
|
* detection.
|
||||||
|
* - Resize and intersection re-initializing: Re-initialize the carousel on
|
||||||
|
* slide or container resizes, or container intersection changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function AutoSize(): AutoSizeType {
|
||||||
|
let emblaApi: EmblaCarouselType;
|
||||||
|
let reInitController: EmblaReInitController | null = null;
|
||||||
|
|
||||||
|
let previousContainerIntersecting: boolean | null = null;
|
||||||
|
const previousDimensions: Map<Element, SlideDimensions> = new Map();
|
||||||
|
|
||||||
|
const resizeObserver: ResizeObserver = new ResizeObserver(resizeHandler);
|
||||||
|
const intersectionObserver: IntersectionObserver = new IntersectionObserver(
|
||||||
|
intersectionHandler,
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import isEqual from 'lodash-es/isEqual';
|
|||||||
import throttle from 'lodash-es/throttle';
|
import throttle from 'lodash-es/throttle';
|
||||||
import Masonry from 'masonry-layout';
|
import Masonry from 'masonry-layout';
|
||||||
import { MediaLoadedInfo, ViewDisplayConfig } from '../types';
|
import { MediaLoadedInfo, ViewDisplayConfig } from '../types';
|
||||||
import { dispatchFrigateCardEvent, setOrRemoveAttribute } from './basic';
|
import { dispatchFrigateCardEvent, getChildrenFromElement, setOrRemoveAttribute } from './basic';
|
||||||
import {
|
import {
|
||||||
FrigateMediaLoadedEventTarget,
|
FrigateMediaLoadedEventTarget,
|
||||||
dispatchExistingMediaLoadedInfoAsEvent,
|
dispatchExistingMediaLoadedInfoAsEvent,
|
||||||
@@ -144,21 +144,12 @@ export class MediaGridController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected _calculateGridContentsFromHost = (): void => {
|
protected _calculateGridContentsFromHost = (): void => {
|
||||||
let childrenElements: Element[];
|
const children = getChildrenFromElement(this._host);
|
||||||
|
|
||||||
if (this._host instanceof HTMLSlotElement) {
|
|
||||||
childrenElements = this._host.assignedElements();
|
|
||||||
} else {
|
|
||||||
childrenElements = [...this._host.children];
|
|
||||||
}
|
|
||||||
|
|
||||||
const gridContents: MediaGridContents = new Map();
|
const gridContents: MediaGridContents = new Map();
|
||||||
for (const child of childrenElements) {
|
for (const child of children) {
|
||||||
if (child instanceof HTMLElement) {
|
|
||||||
const id = child.getAttribute(this._idAttribute) || String(gridContents.size);
|
const id = child.getAttribute(this._idAttribute) || String(gridContents.size);
|
||||||
gridContents.set(id, child);
|
gridContents.set(id, child);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this._setGridContents(gridContents);
|
this._setGridContents(gridContents);
|
||||||
};
|
};
|
||||||
@@ -200,6 +191,7 @@ export class MediaGridController {
|
|||||||
const eventPath = ev.composedPath();
|
const eventPath = ev.composedPath();
|
||||||
|
|
||||||
for (const [id, element] of this._gridContents.entries()) {
|
for (const [id, element] of this._gridContents.entries()) {
|
||||||
|
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||||
if (eventPath.includes(element)) {
|
if (eventPath.includes(element)) {
|
||||||
this._mediaLoadedInfoMap.set(id, ev.detail);
|
this._mediaLoadedInfoMap.set(id, ev.detail);
|
||||||
if (id !== this._selected) {
|
if (id !== this._selected) {
|
||||||
@@ -271,6 +263,7 @@ export class MediaGridController {
|
|||||||
const eventPath = ev.composedPath();
|
const eventPath = ev.composedPath();
|
||||||
|
|
||||||
for (const [id, element] of this._gridContents.entries()) {
|
for (const [id, element] of this._gridContents.entries()) {
|
||||||
|
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||||
if (eventPath.includes(element)) {
|
if (eventPath.includes(element)) {
|
||||||
if (this._selected !== id) {
|
if (this._selected !== id) {
|
||||||
this.selectCell(id);
|
this.selectCell(id);
|
||||||
|
|||||||
+11
-1
@@ -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 {
|
export interface FrigateMediaLoadedEventTarget extends EventTarget {
|
||||||
addEventListener(
|
addEventListener(
|
||||||
event: 'frigate-card:media:loaded',
|
event: 'frigate-card:media:loaded',
|
||||||
@@ -122,6 +122,11 @@ export interface FrigateMediaLoadedEventTarget extends EventTarget {
|
|||||||
) => void,
|
) => void,
|
||||||
options?: AddEventListenerOptions | boolean,
|
options?: AddEventListenerOptions | boolean,
|
||||||
): void;
|
): void;
|
||||||
|
addEventListener(
|
||||||
|
event: 'frigate-card:media:unloaded',
|
||||||
|
listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void,
|
||||||
|
options?: AddEventListenerOptions | boolean,
|
||||||
|
): void;
|
||||||
addEventListener(
|
addEventListener(
|
||||||
type: string,
|
type: string,
|
||||||
callback: EventListenerOrEventListenerObject,
|
callback: EventListenerOrEventListenerObject,
|
||||||
@@ -135,6 +140,11 @@ export interface FrigateMediaLoadedEventTarget extends EventTarget {
|
|||||||
) => void,
|
) => void,
|
||||||
options?: boolean | EventListenerOptions,
|
options?: boolean | EventListenerOptions,
|
||||||
): void;
|
): void;
|
||||||
|
removeEventListener(
|
||||||
|
event: 'frigate-card:media:unloaded',
|
||||||
|
listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void,
|
||||||
|
options?: boolean | EventListenerOptions,
|
||||||
|
): void;
|
||||||
removeEventListener(
|
removeEventListener(
|
||||||
type: string,
|
type: string,
|
||||||
callback: EventListenerOrEventListenerObject,
|
callback: EventListenerOrEventListenerObject,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export class MenuButtonController {
|
|||||||
) as FrigateCardCustomAction,
|
) as FrigateCardCustomAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (visibleCameras) {
|
if (visibleCameras.size) {
|
||||||
const menuItems = Array.from(visibleCameras, ([cameraID, config]) => {
|
const menuItems = Array.from(visibleCameras, ([cameraID, config]) => {
|
||||||
const action = createFrigateCardCustomAction('camera_select', {
|
const action = createFrigateCardCustomAction('camera_select', {
|
||||||
camera: cameraID,
|
camera: cameraID,
|
||||||
@@ -399,6 +399,7 @@ export class MenuButtonController {
|
|||||||
const action = createFrigateCardCustomAction('display_mode_select', {
|
const action = createFrigateCardCustomAction('display_mode_select', {
|
||||||
display_mode: isGrid ? 'single' : 'grid',
|
display_mode: isGrid ? 'single' : 'grid',
|
||||||
});
|
});
|
||||||
|
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||||
if (action) {
|
if (action) {
|
||||||
buttons.push({
|
buttons.push({
|
||||||
icon: isGrid ? 'mdi:grid-off' : 'mdi:grid',
|
icon: isGrid ? 'mdi:grid-off' : 'mdi:grid',
|
||||||
|
|||||||
+8
-9
@@ -74,23 +74,22 @@ export const getParseErrorPaths = <T>(error: z.ZodError<T>): Set<string> | null
|
|||||||
* available unions). This usually suggests the user specified an incorrect
|
* available unions). This usually suggests the user specified an incorrect
|
||||||
* type name entirely. */
|
* type name entirely. */
|
||||||
const contenders = new Set<string>();
|
const contenders = new Set<string>();
|
||||||
if (error && error.issues) {
|
if (error.issues.length) {
|
||||||
for (let i = 0; i < error.issues.length; i++) {
|
for (const issue of error.issues) {
|
||||||
const issue = error.issues[i];
|
if (issue.code === 'invalid_union') {
|
||||||
if (issue.code == 'invalid_union') {
|
|
||||||
const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors;
|
const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors;
|
||||||
for (let j = 0; j < unionErrors.length; j++) {
|
for (const unionError of unionErrors) {
|
||||||
const nestedErrors = getParseErrorPaths(unionErrors[j]);
|
const nestedErrors = getParseErrorPaths(unionError);
|
||||||
if (nestedErrors && nestedErrors.size) {
|
if (nestedErrors && nestedErrors.size) {
|
||||||
nestedErrors.forEach(contenders.add, contenders);
|
nestedErrors.forEach(contenders.add, contenders);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (issue.code == 'invalid_type') {
|
} else if (issue.code === 'invalid_type') {
|
||||||
if (issue.path[issue.path.length - 1] == 'type') {
|
if (issue.path[issue.path.length - 1] === 'type') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
contenders.add(getParseErrorPathString(issue.path));
|
contenders.add(getParseErrorPathString(issue.path));
|
||||||
} else if (issue.code != 'custom') {
|
} else {
|
||||||
contenders.add(getParseErrorPathString(issue.path));
|
contenders.add(getParseErrorPathString(issue.path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class ResultSlice {
|
|||||||
if (options?.results && options.results.length) {
|
if (options?.results && options.results.length) {
|
||||||
if (!options?.selectApproach || options?.selectApproach === 'last') {
|
if (!options?.selectApproach || options?.selectApproach === 'last') {
|
||||||
return options.results.length - 1;
|
return options.results.length - 1;
|
||||||
} else if (options.selectApproach === 'first') {
|
} else {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-5
@@ -111,13 +111,9 @@ export class View {
|
|||||||
const switchingToMediaFromMedia = curr?.is('media') && next.is('media');
|
const switchingToMediaFromMedia = curr?.is('media') && next.is('media');
|
||||||
|
|
||||||
if (hasNoQueryOrResults) {
|
if (hasNoQueryOrResults) {
|
||||||
if (switchingToGalleryFromViewer) {
|
if (switchingToGalleryFromViewer && curr.query && curr.queryResults) {
|
||||||
if (curr.query) {
|
|
||||||
next.query = curr.query;
|
next.query = curr.query;
|
||||||
}
|
|
||||||
if (curr.queryResults) {
|
|
||||||
next.queryResults = curr.queryResults;
|
next.queryResults = curr.queryResults;
|
||||||
}
|
|
||||||
} else if (switchingToMediaFromMedia && currentQueriesView) {
|
} else if (switchingToMediaFromMedia && currentQueriesView) {
|
||||||
next.view =
|
next.view =
|
||||||
currentQueriesView === 'clips'
|
currentQueriesView === 'clips'
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ describe('RecordingSegmentsCache', () => {
|
|||||||
start: now,
|
start: now,
|
||||||
end: add(now, { hours: 1 }),
|
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 => {
|
const createSegment = (date: Date, id: string): RecordingSegment => {
|
||||||
return {
|
return {
|
||||||
start_time: date.getTime() / 1000,
|
start_time: date.getTime() / 1000,
|
||||||
@@ -89,7 +89,7 @@ describe('RecordingSegmentsCache', () => {
|
|||||||
});
|
});
|
||||||
it('should not get for other range', () => {
|
it('should not get for other range', () => {
|
||||||
cache.add('camera-1', range, segments);
|
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', () => {
|
it('should have coverage when added', () => {
|
||||||
@@ -102,7 +102,7 @@ describe('RecordingSegmentsCache', () => {
|
|||||||
});
|
});
|
||||||
it('should not have coverage for other range', () => {
|
it('should not have coverage for other range', () => {
|
||||||
cache.add('camera-1', range, segments);
|
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', () => {
|
it('should be empty when cleared', () => {
|
||||||
@@ -114,11 +114,11 @@ describe('RecordingSegmentsCache', () => {
|
|||||||
|
|
||||||
it('should get size', () => {
|
it('should get size', () => {
|
||||||
cache.add('camera-1', range, segments);
|
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', () => {
|
it('should not size for other camera', () => {
|
||||||
cache.add('camera-1', range, segments);
|
cache.add('camera-1', range, segments);
|
||||||
expect(cache.getSize("camera-2")).toBeNull();
|
expect(cache.getSize('camera-2')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return cameraIDs', () => {
|
it('should return cameraIDs', () => {
|
||||||
@@ -127,6 +127,24 @@ describe('RecordingSegmentsCache', () => {
|
|||||||
expect(sortBy(cache.getCameraIDs())).toEqual(sortBy(['camera-1', 'camera-2']));
|
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', () => {
|
it('should remove expired matches', () => {
|
||||||
cache.add('camera-1', range, segments);
|
cache.add('camera-1', range, segments);
|
||||||
cache.expireMatches('camera-1', (segment) => segment === segments[0]);
|
cache.expireMatches('camera-1', (segment) => segment === segments[0]);
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
|
|||||||
Engine.Frigate,
|
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 () => {
|
it('should throw error on invalid entity', async () => {
|
||||||
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
|
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
|
||||||
const entityRegistryManager = new EntityRegistryManager(new EntityCache());
|
const entityRegistryManager = new EntityRegistryManager(new EntityCache());
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ describe('compressRanges', () => {
|
|||||||
compressRanges([
|
compressRanges([
|
||||||
{ start: now, end: nowPlusOne },
|
{ start: now, end: nowPlusOne },
|
||||||
{ start: nowPlusOne, end: nowPlusTwo },
|
{ start: nowPlusOne, end: nowPlusTwo },
|
||||||
|
{ start: now, end: nowPlusOne },
|
||||||
]),
|
]),
|
||||||
).toEqual([{ start: now, end: nowPlusTwo }]);
|
).toEqual([{ start: now, end: nowPlusTwo }]);
|
||||||
});
|
});
|
||||||
@@ -162,4 +163,8 @@ describe('compressRanges', () => {
|
|||||||
];
|
];
|
||||||
expect(compressRanges(input)).toEqual([{ start: 1, end: 3 }]);
|
expect(compressRanges(input)).toEqual([{ start: 1, end: 3 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return nothing with no input', () => {
|
||||||
|
expect(compressRanges([])).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', () => {
|
describe('getOverridesByKey', () => {
|
||||||
|
|||||||
+44
-8
@@ -239,17 +239,53 @@ export class TestViewMedia extends ViewMedia {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createResizeObserverImplementation = (): (() => void) => {
|
export const ResizeObserverMock = vi.fn(() => ({
|
||||||
return () => ({
|
disconnect: vi.fn(),
|
||||||
observe: vi.fn(),
|
observe: vi.fn(),
|
||||||
unobserve: vi.fn(),
|
unobserve: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const IntersectionObserverMock = vi.fn(() => ({
|
||||||
disconnect: 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) => {
|
export const createSlotHost = (options?: {
|
||||||
return () => ({
|
slot?: HTMLSlotElement;
|
||||||
observe: vi.fn(),
|
children?: HTMLElement[];
|
||||||
disconnect: vi.fn(),
|
}): 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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { FrigateCardError } from '../../src/types';
|
||||||
import {
|
import {
|
||||||
allPromises,
|
allPromises,
|
||||||
arrayify,
|
|
||||||
arrayMove,
|
arrayMove,
|
||||||
|
arrayify,
|
||||||
contentsChanged,
|
contentsChanged,
|
||||||
dayToDate,
|
dayToDate,
|
||||||
dispatchFrigateCardEvent,
|
dispatchFrigateCardEvent,
|
||||||
errorToConsole,
|
errorToConsole,
|
||||||
isTruthy,
|
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDateAndTime,
|
formatDateAndTime,
|
||||||
|
getChildrenFromElement,
|
||||||
getDurationString,
|
getDurationString,
|
||||||
|
isHTMLElement,
|
||||||
isHoverableDevice,
|
isHoverableDevice,
|
||||||
isSuperset,
|
isSuperset,
|
||||||
|
isTruthy,
|
||||||
isValidDate,
|
isValidDate,
|
||||||
prettifyTitle,
|
prettifyTitle,
|
||||||
runWhenIdleIfSupported,
|
runWhenIdleIfSupported,
|
||||||
setify,
|
|
||||||
setOrRemoveAttribute,
|
setOrRemoveAttribute,
|
||||||
|
setify,
|
||||||
sleep,
|
sleep,
|
||||||
isHTMLElement,
|
|
||||||
} from '../../src/utils/basic';
|
} from '../../src/utils/basic';
|
||||||
|
import { createSlot, createSlotHost } from '../test-utils';
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
describe('dispatchFrigateCardEvent', () => {
|
describe('dispatchFrigateCardEvent', () => {
|
||||||
@@ -183,6 +185,11 @@ describe('getDurationString', () => {
|
|||||||
const end = new Date(2023, 3, 14, 15, 37, 20);
|
const end = new Date(2023, 3, 14, 15, 37, 20);
|
||||||
expect(getDurationString(start, end)).toBe('2h 2m 20s');
|
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', () => {
|
describe('allPromises', () => {
|
||||||
@@ -268,3 +275,19 @@ describe('isHTMLElement', () => {
|
|||||||
expect(isHTMLElement(svgElement)).toBeFalsy();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,13 +12,66 @@ vi.mock('../../src/utils/ha');
|
|||||||
const media = new ViewMedia('clip', 'camera-1');
|
const media = new ViewMedia('clip', 'camera-1');
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
describe('downloadURL', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
global.window.location = mock<Location>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should download same origin via link', () => {
|
||||||
|
const location: Location & { origin: string } = mock<Location>();
|
||||||
|
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>();
|
||||||
|
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', () => {
|
describe('downloadMedia', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
global.window.location = mock<Location>();
|
global.window.location = mock<Location>();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error when no media', async () => {
|
it('should throw error when no media', () => {
|
||||||
const cameraManager = createCameraManager();
|
const cameraManager = createCameraManager();
|
||||||
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue(null);
|
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue(null);
|
||||||
|
|
||||||
@@ -55,44 +108,16 @@ describe('downloadMedia', () => {
|
|||||||
await downloadMedia(createHASS(), cameraManager, media);
|
await downloadMedia(createHASS(), cameraManager, media);
|
||||||
expect(windowSpy).toBeCalledWith('http://foo/signed-url', '_blank');
|
expect(windowSpy).toBeCalledWith('http://foo/signed-url', '_blank');
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('downloadURL', () => {
|
it('should download media without signing', async () => {
|
||||||
afterEach(() => {
|
const cameraManager = createCameraManager();
|
||||||
vi.resetAllMocks();
|
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue({
|
||||||
global.window.location = mock<Location>();
|
sign: false,
|
||||||
|
endpoint: 'https://foo/',
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should download same origin via link', async () => {
|
|
||||||
const location: Location & { origin: string } = mock<Location>();
|
|
||||||
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>();
|
|
||||||
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);
|
const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||||
|
|
||||||
downloadURL('http://foo/url.mp4');
|
await downloadMedia(createHASS(), cameraManager, media);
|
||||||
expect(windowSpy).toBeCalledWith('http://foo/url.mp4', '_blank');
|
expect(windowSpy).toBeCalledWith('https://foo/', '_blank');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<EmblaCarouselType> | 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: <TypeA extends LooseOptionsType, TypeB extends LooseOptionsType>(
|
||||||
|
optionsA: TypeA,
|
||||||
|
optionsB?: TypeB,
|
||||||
|
): TypeA => {
|
||||||
|
return merge({}, optionsA, optionsB);
|
||||||
|
},
|
||||||
|
optionsAtMedia: <Type extends LooseOptionsType>(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<EmblaCarouselType>();
|
||||||
|
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'),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
} from '../../src/utils/media-grid-controller';
|
} from '../../src/utils/media-grid-controller';
|
||||||
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info';
|
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info';
|
||||||
import {
|
import {
|
||||||
createMutationObserverImplementation,
|
MutationObserverMock,
|
||||||
createResizeObserverImplementation,
|
ResizeObserverMock,
|
||||||
|
createSlot,
|
||||||
|
createSlotHost,
|
||||||
} from '../test-utils';
|
} from '../test-utils';
|
||||||
|
|
||||||
vi.mock('lodash-es/throttle', () => ({
|
vi.mock('lodash-es/throttle', () => ({
|
||||||
@@ -41,7 +43,7 @@ const setElementWidth = (element: HTMLElement, width: number): void => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const createHost = (options?: {
|
const createParent = (options?: {
|
||||||
children?: HTMLElement[];
|
children?: HTMLElement[];
|
||||||
width?: number;
|
width?: number;
|
||||||
}): HTMLElement => {
|
}): HTMLElement => {
|
||||||
@@ -54,28 +56,6 @@ const createHost = (options?: {
|
|||||||
return host;
|
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) => {
|
const createController = (host: HTMLElement, options?: MediaGridConstructorOptions) => {
|
||||||
return new MediaGridController(host, options);
|
return new MediaGridController(host, options);
|
||||||
};
|
};
|
||||||
@@ -101,30 +81,20 @@ describe('MediaGridController', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.stubGlobal('MutationObserver', MutationObserverMock);
|
||||||
global.ResizeObserver = vi
|
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
||||||
.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<MutationObserver>();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be constructable', () => {
|
it('should be constructable', () => {
|
||||||
const controller = createController(createHost());
|
const controller = createController(createParent());
|
||||||
expect(controller).toBeTruthy();
|
expect(controller).toBeTruthy();
|
||||||
expect(masonry.layout).toBeCalled();
|
expect(masonry.layout).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set grid contents correctly from regular elements', () => {
|
it('should set grid contents correctly from regular elements', () => {
|
||||||
const children = createChildren();
|
const children = createChildren();
|
||||||
const host = createHost({ children: children });
|
const parent = createParent({ children: children });
|
||||||
const controller = createController(host);
|
const controller = createController(parent);
|
||||||
expect(controller.getGridContents()).toEqual(
|
expect(controller.getGridContents()).toEqual(
|
||||||
new Map([
|
new Map([
|
||||||
['0', children[0]],
|
['0', children[0]],
|
||||||
@@ -138,7 +108,8 @@ describe('MediaGridController', () => {
|
|||||||
|
|
||||||
it('should set grid contents correctly from slotted elements', () => {
|
it('should set grid contents correctly from slotted elements', () => {
|
||||||
const children = createChildren();
|
const children = createChildren();
|
||||||
const host = createSlotHost({ children: children });
|
const slot = createSlot();
|
||||||
|
const host = createSlotHost({ slot: slot, children: children });
|
||||||
const controller = createController(host);
|
const controller = createController(host);
|
||||||
expect(controller.getGridContents()).toEqual(
|
expect(controller.getGridContents()).toEqual(
|
||||||
new Map([
|
new Map([
|
||||||
@@ -152,7 +123,10 @@ describe('MediaGridController', () => {
|
|||||||
|
|
||||||
it('should select element', () => {
|
it('should select element', () => {
|
||||||
const children = createChildren();
|
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.
|
// All children should be unselected.
|
||||||
expect(controller.getSelected()).toBeNull();
|
expect(controller.getSelected()).toBeNull();
|
||||||
@@ -176,7 +150,10 @@ describe('MediaGridController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should re-select element', () => {
|
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.
|
// All children should be unselected.
|
||||||
expect(controller.getSelected()).toBeNull();
|
expect(controller.getSelected()).toBeNull();
|
||||||
@@ -190,16 +167,14 @@ describe('MediaGridController', () => {
|
|||||||
|
|
||||||
it('should dispatch media loaded info on selection', () => {
|
it('should dispatch media loaded info on selection', () => {
|
||||||
const children = createChildren();
|
const children = createChildren();
|
||||||
const host = createSlotHost({ children: children });
|
const slot = createSlot();
|
||||||
const controller = createController(host);
|
const host = createSlotHost({ slot: slot, children: children });
|
||||||
|
const controller = createController(slot);
|
||||||
|
|
||||||
const mediaLoadedInfoHandler = vi.fn();
|
const mediaLoadedInfoHandler = vi.fn();
|
||||||
host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
|
host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
|
||||||
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||||
|
|
||||||
// Nothing is selected, so the event should not have propagated.
|
|
||||||
expect(mediaLoadedInfoHandler).not.toBeCalled();
|
|
||||||
|
|
||||||
controller.selectCell('0');
|
controller.selectCell('0');
|
||||||
expect(mediaLoadedInfoHandler).toBeCalledWith(
|
expect(mediaLoadedInfoHandler).toBeCalledWith(
|
||||||
expect.objectContaining({
|
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', () => {
|
it('should unselect', () => {
|
||||||
const children = createChildren();
|
const children = createChildren();
|
||||||
const host = createSlotHost({ children: children });
|
const slot = createSlot();
|
||||||
|
const host = createSlotHost({ slot: slot, children: children });
|
||||||
const controller = createController(host);
|
const controller = createController(host);
|
||||||
|
|
||||||
const unselectedHandler = vi.fn();
|
const unselectedHandler = vi.fn();
|
||||||
@@ -234,20 +245,29 @@ describe('MediaGridController', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expect handlers to have been called.
|
// Expect handlers to have been called.
|
||||||
expect(unselectedHandler).toBeCalled();
|
expect(unselectedHandler).toBeCalledTimes(1);
|
||||||
expect(unloadMediaHandler).toBeCalled();
|
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', () => {
|
it('should select in constructor', () => {
|
||||||
const children = createChildren();
|
const children = createChildren();
|
||||||
const host = createSlotHost({ children: children });
|
const slot = createSlot();
|
||||||
|
const host = createSlotHost({ slot: slot, children: children });
|
||||||
const controller = createController(host, { selected: '2' });
|
const controller = createController(host, { selected: '2' });
|
||||||
|
|
||||||
expect(controller.getSelected()).toBe('2');
|
expect(controller.getSelected()).toBe('2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should respect grid attribute option', () => {
|
it('should respect grid attribute option', () => {
|
||||||
const children = createChildren(['one', 'two', 'three'], 'test-id');
|
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' });
|
const controller = createController(host, { idAttribute: 'test-id' });
|
||||||
expect(controller.getGridContents()).toEqual(
|
expect(controller.getGridContents()).toEqual(
|
||||||
new Map([
|
new Map([
|
||||||
@@ -258,10 +278,21 @@ describe('MediaGridController', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should destroy', () => {
|
it('should destroy with regular elements', () => {
|
||||||
const children = createChildren();
|
const children = createChildren();
|
||||||
const host = createSlotHost({ children: children });
|
const parent = createParent({ children: children });
|
||||||
const controller = createController(host);
|
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);
|
expect(controller.getGridSize()).toBe(3);
|
||||||
controller.destroy();
|
controller.destroy();
|
||||||
expect(controller.getGridSize()).toBe(0);
|
expect(controller.getGridSize()).toBe(0);
|
||||||
@@ -269,16 +300,16 @@ describe('MediaGridController', () => {
|
|||||||
|
|
||||||
it('should replace children when they change', () => {
|
it('should replace children when they change', () => {
|
||||||
const children = createChildren();
|
const children = createChildren();
|
||||||
const host = createHost({ children: children });
|
const parent = createParent({ children: children });
|
||||||
const controller = createController(host, { selected: '1' });
|
const controller = createController(parent, { selected: '1' });
|
||||||
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||||
|
|
||||||
expect(controller.getSelected()).toBe('1');
|
expect(controller.getSelected()).toBe('1');
|
||||||
expect(controller.getGridSize()).toBe(3);
|
expect(controller.getGridSize()).toBe(3);
|
||||||
|
|
||||||
children.forEach((child) => host.removeChild(child));
|
children.forEach((child) => parent.removeChild(child));
|
||||||
const newChildren = createChildren(['one', 'two', 'three']);
|
const newChildren = createChildren(['one', 'two', 'three']);
|
||||||
newChildren.forEach((child) => host.appendChild(child));
|
newChildren.forEach((child) => parent.appendChild(child));
|
||||||
|
|
||||||
triggerMutationObserver();
|
triggerMutationObserver();
|
||||||
|
|
||||||
@@ -294,19 +325,19 @@ describe('MediaGridController', () => {
|
|||||||
|
|
||||||
it('should replace children of a slot when they change', () => {
|
it('should replace children of a slot when they change', () => {
|
||||||
const children = createChildren();
|
const children = createChildren();
|
||||||
const slotParent = createSlotParent();
|
const slot = createSlot();
|
||||||
const host = createSlotHost({ children: children, parent: slotParent });
|
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.getSelected()).toBe('1');
|
||||||
expect(controller.getGridSize()).toBe(3);
|
expect(controller.getGridSize()).toBe(3);
|
||||||
|
|
||||||
children.forEach((child) => slotParent.removeChild(child));
|
children.forEach((child) => host.removeChild(child));
|
||||||
const newChildren = createChildren(['one', 'two', 'three']);
|
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(
|
expect(controller.getGridContents()).toEqual(
|
||||||
new Map([
|
new Map([
|
||||||
@@ -320,10 +351,10 @@ describe('MediaGridController', () => {
|
|||||||
|
|
||||||
it('should construct masonry correctly', () => {
|
it('should construct masonry correctly', () => {
|
||||||
const children = createChildren();
|
const children = createChildren();
|
||||||
const host = createHost({ children: children });
|
const parent = createParent({ children: children });
|
||||||
createController(host);
|
createController(parent);
|
||||||
expect(Masonry).toBeCalledWith(
|
expect(Masonry).toBeCalledWith(
|
||||||
host,
|
parent,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
initLayout: false,
|
initLayout: false,
|
||||||
percentPosition: true,
|
percentPosition: true,
|
||||||
@@ -333,57 +364,85 @@ describe('MediaGridController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set default column size correctly', () => {
|
it('should set default column size correctly', () => {
|
||||||
const host = createHost({ children: createChildren() });
|
const parent = createParent({ children: createChildren() });
|
||||||
createController(host);
|
createController(parent);
|
||||||
expect(Masonry).toBeCalledWith(
|
expect(Masonry).toBeCalledWith(
|
||||||
host,
|
parent,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
columnWidth: 246,
|
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', () => {
|
it('should respect exact columns', () => {
|
||||||
const host = createHost({ children: createChildren(), width: 2000 });
|
const parent = createParent({ children: createChildren(), width: 2000 });
|
||||||
const controller = createController(host);
|
const controller = createController(parent);
|
||||||
controller.setDisplayConfig({ mode: 'grid', grid_columns: 2 });
|
controller.setDisplayConfig({ mode: 'grid', grid_columns: 2 });
|
||||||
|
|
||||||
// Will have been called once on construction, and then again when the
|
// Will have been called once on construction, and then again when the
|
||||||
// number of columns changes.
|
// number of columns changes.
|
||||||
expect(Masonry).toBeCalledTimes(2);
|
expect(Masonry).toBeCalledTimes(2);
|
||||||
expect(Masonry).toBeCalledWith(
|
expect(Masonry).toBeCalledWith(
|
||||||
host,
|
parent,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
columnWidth: 1000,
|
columnWidth: 1000,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe(
|
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe(
|
||||||
'1000px',
|
'1000px',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should respect selected width factor', () => {
|
it('should respect selected width factor', () => {
|
||||||
const host = createHost({ children: createChildren(), width: 2000 });
|
const parent = createParent({ children: createChildren(), width: 2000 });
|
||||||
const controller = createController(host);
|
const controller = createController(parent);
|
||||||
controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 });
|
controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 });
|
||||||
expect(
|
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');
|
).toBe('3');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should select cell with interacted with', () => {
|
it('should select cell when interacted with', () => {
|
||||||
const children = createChildren();
|
const children = createChildren();
|
||||||
const host = createHost({ children: children, width: 2000 });
|
const parent = createParent({ children: children, width: 2000 });
|
||||||
const controller = createController(host);
|
const controller = createController(parent);
|
||||||
|
|
||||||
expect(controller.getSelected()).toBeNull();
|
expect(controller.getSelected()).toBeNull();
|
||||||
|
|
||||||
|
const clickHandler = vi.fn();
|
||||||
|
parent.addEventListener('click', clickHandler);
|
||||||
|
|
||||||
children[1].click();
|
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');
|
expect(controller.getSelected()).toBe('1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should re-layout when child size changes', () => {
|
it('should re-layout when child size changes', () => {
|
||||||
createController(createHost({ children: createChildren() }));
|
createController(createParent({ children: createChildren() }));
|
||||||
|
|
||||||
vi.mocked(masonry.layout)?.mockClear();
|
vi.mocked(masonry.layout)?.mockClear();
|
||||||
triggerResizeObserver('cell');
|
triggerResizeObserver('cell');
|
||||||
@@ -392,32 +451,41 @@ describe('MediaGridController', () => {
|
|||||||
|
|
||||||
it('should re-create masonry when host size changes', () => {
|
it('should re-create masonry when host size changes', () => {
|
||||||
const children = createChildren();
|
const children = createChildren();
|
||||||
const host = createHost({ children: children });
|
const parent = createParent({ children: children });
|
||||||
const controller = createController(host);
|
createController(parent);
|
||||||
expect(Masonry).toBeCalledWith(
|
expect(Masonry).toBeCalledWith(
|
||||||
host,
|
parent,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
columnWidth: 246,
|
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.
|
// Clear mock state.
|
||||||
vi.mocked(Masonry).mockClear();
|
vi.mocked(Masonry).mockClear();
|
||||||
vi.mocked(masonry.layout)?.mockClear();
|
vi.mocked(masonry.layout)?.mockClear();
|
||||||
|
|
||||||
// Resize the host.
|
// Resize the host.
|
||||||
setElementWidth(host, 2000);
|
setElementWidth(parent, 2000);
|
||||||
triggerResizeObserver('host');
|
triggerResizeObserver('host');
|
||||||
|
|
||||||
// Masonry should be reconstructed, styles set and layout called.
|
// Masonry should be reconstructed, styles set and layout called.
|
||||||
expect(Masonry).toBeCalledWith(
|
expect(Masonry).toBeCalledWith(
|
||||||
host,
|
parent,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
columnWidth: 667,
|
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();
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,6 +108,16 @@ describe('playMediaMutingIfNecessary', () => {
|
|||||||
expect(player.isMuted).toBeCalled();
|
expect(player.isMuted).toBeCalled();
|
||||||
expect(player.mute).toBeCalled();
|
expect(player.mute).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should ignore calls without a video', async () => {
|
||||||
|
const player = mock<FrigateCardMediaPlayer>();
|
||||||
|
player.isMuted.mockReturnValue(false);
|
||||||
|
|
||||||
|
await playMediaMutingIfNecessary(player);
|
||||||
|
|
||||||
|
expect(player.isMuted).not.toBeCalled();
|
||||||
|
expect(player.mute).not.toBeCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('constants', () => {
|
describe('constants', () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import isEqual from 'lodash-es/isEqual';
|
||||||
import screenfull from 'screenfull';
|
import screenfull from 'screenfull';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
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', () => {
|
it('should have substream button with single dependency', () => {
|
||||||
const cameraManager = createCameraManager({
|
const cameraManager = createCameraManager({
|
||||||
configs: new Map([
|
configs: new Map([
|
||||||
@@ -1122,7 +1143,17 @@ describe('MenuButtonController', () => {
|
|||||||
style: {},
|
style: {},
|
||||||
};
|
};
|
||||||
controller.addDynamicMenuButton(button);
|
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);
|
controller.removeDynamicMenuButton(button);
|
||||||
expect(calculateButtons(controller)).not.toContainEqual(button);
|
expect(calculateButtons(controller)).not.toContainEqual(button);
|
||||||
|
|||||||
@@ -52,6 +52,20 @@ describe('createViewWithoutSubstream', () => {
|
|||||||
const newView = createViewWithoutSubstream(view);
|
const newView = createViewWithoutSubstream(view);
|
||||||
expect(newView?.context?.live?.overrides).toEqual(new Map());
|
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', () => {
|
describe('hasSubstream', () => {
|
||||||
@@ -95,7 +109,7 @@ describe('createViewWithNextStream', () => {
|
|||||||
camera: 'camera',
|
camera: 'camera',
|
||||||
});
|
});
|
||||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera']));
|
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera']));
|
||||||
const cameraManager = createCameraManager()
|
const cameraManager = createCameraManager();
|
||||||
const newView = createViewWithNextStream(cameraManager, view);
|
const newView = createViewWithNextStream(cameraManager, view);
|
||||||
expect(newView.camera).toBe(view.camera);
|
expect(newView.camera).toBe(view.camera);
|
||||||
expect(newView.view).toBe(view.view);
|
expect(newView.view).toBe(view.view);
|
||||||
@@ -107,7 +121,7 @@ describe('createViewWithNextStream', () => {
|
|||||||
camera: 'camera',
|
camera: 'camera',
|
||||||
});
|
});
|
||||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
||||||
const cameraManager = createCameraManager()
|
const cameraManager = createCameraManager();
|
||||||
const newView = createViewWithNextStream(cameraManager, view);
|
const newView = createViewWithNextStream(cameraManager, view);
|
||||||
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']]));
|
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']]));
|
||||||
});
|
});
|
||||||
@@ -122,7 +136,7 @@ describe('createViewWithNextStream', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
||||||
const cameraManager = createCameraManager()
|
const cameraManager = createCameraManager();
|
||||||
const newView = createViewWithNextStream(cameraManager, view);
|
const newView = createViewWithNextStream(cameraManager, view);
|
||||||
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
|
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
|
||||||
});
|
});
|
||||||
@@ -137,7 +151,7 @@ describe('createViewWithNextStream', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
||||||
const cameraManager = createCameraManager()
|
const cameraManager = createCameraManager();
|
||||||
const newView = createViewWithNextStream(cameraManager, view);
|
const newView = createViewWithNextStream(cameraManager, view);
|
||||||
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
|
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { z } from 'zod';
|
import { z, ZodError } from 'zod';
|
||||||
import {
|
import {
|
||||||
deepRemoveDefaults,
|
deepRemoveDefaults,
|
||||||
getParseErrorKeys,
|
getParseErrorKeys,
|
||||||
@@ -88,4 +88,7 @@ describe('getParseErrorPaths', () => {
|
|||||||
new Set(['array[0] -> type', 'array[0] -> data']),
|
new Set(['array[0] -> type', 'array[0] -> data']),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
it('should get no paths for empty error', () => {
|
||||||
|
expect(getParseErrorPaths(new ZodError([]))).toEqual(new Set());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,6 +92,27 @@ describe('Zoom', () => {
|
|||||||
expect(panzoom.handleUp).toBeCalledWith(ev_5);
|
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', () => {
|
it('should respond with touch', () => {
|
||||||
mediaMediSpy.mockReturnValue(<MediaQueryList>{ matches: false });
|
mediaMediSpy.mockReturnValue(<MediaQueryList>{ matches: false });
|
||||||
|
|
||||||
@@ -252,4 +273,50 @@ describe('Zoom', () => {
|
|||||||
element.dispatchEvent(ev_2);
|
element.dispatchEvent(ev_2);
|
||||||
expect(element.style.touchAction).toBeFalsy();
|
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<PanzoomEventDetail>('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<PanzoomEventDetail>('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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -243,4 +243,40 @@ describe('dispatchViewContextChangeEvent', () => {
|
|||||||
.map((media) => media.getID()),
|
.map((media) => media.getID()),
|
||||||
).toEqual(['id-office-99', 'id-kitchen-99', 'id-office-99']);
|
).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+93
-4
@@ -4,7 +4,7 @@ import { ViewMedia } from '../../src/view/media';
|
|||||||
import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries';
|
import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries';
|
||||||
import { MediaQueriesResults } from '../../src/view/media-queries-results';
|
import { MediaQueriesResults } from '../../src/view/media-queries-results';
|
||||||
import { View, dispatchViewContextChangeEvent } from '../../src/view/view';
|
import { View, dispatchViewContextChangeEvent } from '../../src/view/view';
|
||||||
import { createView, generateViewMediaArray } from '../test-utils';
|
import { createView } from '../test-utils';
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
describe('View Basics', () => {
|
describe('View Basics', () => {
|
||||||
@@ -122,6 +122,14 @@ describe('View Basics', () => {
|
|||||||
expect(view.context).toEqual({});
|
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', () => {
|
it('should remove context property', () => {
|
||||||
const view = createView({ context: { live: { overrides: new Map() } } });
|
const view = createView({ context: { live: { overrides: new Map() } } });
|
||||||
|
|
||||||
@@ -129,6 +137,13 @@ describe('View Basics', () => {
|
|||||||
expect(view.context).toEqual({ live: {} });
|
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', () => {
|
it('should detect gallery views', () => {
|
||||||
expect(createView({ view: 'clips' }).isGalleryView()).toBeTruthy();
|
expect(createView({ view: 'clips' }).isGalleryView()).toBeTruthy();
|
||||||
expect(createView({ view: 'snapshots' }).isGalleryView()).toBeTruthy();
|
expect(createView({ view: 'snapshots' }).isGalleryView()).toBeTruthy();
|
||||||
@@ -292,6 +307,28 @@ describe('View.adoptFromViewIfAppropriate', () => {
|
|||||||
expect(next.queryResults).toBe(queryResults);
|
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([
|
it.each([
|
||||||
[
|
[
|
||||||
new EventMediaQueries([
|
new EventMediaQueries([
|
||||||
@@ -311,7 +348,7 @@ describe('View.adoptFromViewIfAppropriate', () => {
|
|||||||
]),
|
]),
|
||||||
'recording',
|
'recording',
|
||||||
],
|
],
|
||||||
])('should adopt for media case', (mediaQueries, expectedView) => {
|
])('should adopt in media case', (mediaQueries, expectedView) => {
|
||||||
const current = createView({
|
const current = createView({
|
||||||
view: 'media',
|
view: 'media',
|
||||||
query: mediaQueries,
|
query: mediaQueries,
|
||||||
@@ -324,6 +361,54 @@ describe('View.adoptFromViewIfAppropriate', () => {
|
|||||||
expect(next.queryResults).toBeFalsy();
|
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', () => {
|
it('should not adopt for other case', () => {
|
||||||
const query = new EventMediaQueries([
|
const query = new EventMediaQueries([
|
||||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
|
{ 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: 'media' }).supportsMultipleDisplayModes()).toBeTruthy();
|
||||||
expect(createView({ view: 'clip' }).supportsMultipleDisplayModes()).toBeTruthy();
|
expect(createView({ view: 'clip' }).supportsMultipleDisplayModes()).toBeTruthy();
|
||||||
expect(createView({ view: 'snapshot' }).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: 'clips' }).supportsMultipleDisplayModes()).toBeFalsy();
|
||||||
expect(createView({ view: 'snapshots' }).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: 'image' }).supportsMultipleDisplayModes()).toBeFalsy();
|
||||||
expect(createView({ view: 'timeline' }).supportsMultipleDisplayModes()).toBeFalsy();
|
expect(createView({ view: 'timeline' }).supportsMultipleDisplayModes()).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user