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",
|
||||
"date-fns": "^2.29.2",
|
||||
"date-fns-tz": "^1.3.7",
|
||||
"embla-carousel": "^7.0.9",
|
||||
"embla-carousel-wheel-gestures": "^3.0.0",
|
||||
"embla-carousel": "8.0.0-rc12",
|
||||
"embla-carousel-wheel-gestures": "8.0.0-rc04",
|
||||
"home-assistant-js-websocket": "^8.0.0",
|
||||
"keycharm": "^0.4.0",
|
||||
"lit": "^2.3.1",
|
||||
@@ -62,7 +62,7 @@
|
||||
"@types/masonry-layout": "^4.2.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.36.2",
|
||||
"@typescript-eslint/parser": "^5.36.2",
|
||||
"@vitest/coverage-c8": "^0.29.8",
|
||||
"@vitest/coverage-istanbul": "^0.34.3",
|
||||
"eslint": "^8.23.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
@@ -80,8 +80,8 @@
|
||||
"sass": "^1.54.9",
|
||||
"ts-prune": "^0.10.3",
|
||||
"typescript": "^4.9.5",
|
||||
"vitest": "^0.29.8",
|
||||
"vitest-mock-extended": "^1.1.3"
|
||||
"vitest": "^0.34.3",
|
||||
"vitest-mock-extended": "^1.2.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "rollup -c --watch",
|
||||
|
||||
@@ -6,9 +6,9 @@ import type {
|
||||
import { noChange } from 'lit';
|
||||
import {
|
||||
AttributePart,
|
||||
directive,
|
||||
Directive,
|
||||
DirectiveParameters,
|
||||
directive,
|
||||
} from 'lit/directive.js';
|
||||
import { stopEventFromActivatingCardWideActions } from './utils/action.js';
|
||||
import { Timer } from './utils/timer.js';
|
||||
|
||||
@@ -69,13 +69,11 @@ export class CachedValueController<T> implements ReactiveController {
|
||||
public startTimer(): void {
|
||||
this.stopTimer();
|
||||
|
||||
if (this._timerSeconds > 0) {
|
||||
this._timerStartCallback?.();
|
||||
this._timer.startRepeated(this._timerSeconds, () => {
|
||||
this.updateValue();
|
||||
this._host.requestUpdate();
|
||||
});
|
||||
}
|
||||
this._timerStartCallback?.();
|
||||
this._timer.startRepeated(this._timerSeconds, () => {
|
||||
this.updateValue();
|
||||
this._host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
public hasTimer(): boolean {
|
||||
|
||||
@@ -66,7 +66,7 @@ export class CameraManagerEngineFactory {
|
||||
engine = Engine.MotionEye;
|
||||
} else if (cameraConfig.engine === 'generic') {
|
||||
engine = Engine.Generic;
|
||||
} else if (cameraConfig.engine === 'auto') {
|
||||
} else {
|
||||
const cameraEntity = getCameraEntityFromConfig(cameraConfig);
|
||||
|
||||
if (cameraEntity) {
|
||||
|
||||
@@ -58,8 +58,8 @@ export class ExpiringMemoryRangeSet
|
||||
}
|
||||
|
||||
public add(range: ExpiringRange<Date>): void {
|
||||
this._expireOldRanges();
|
||||
this._ranges.push(range);
|
||||
this._expireOldRanges();
|
||||
}
|
||||
|
||||
protected _expireOldRanges(): void {
|
||||
@@ -95,26 +95,25 @@ export const compressRanges = <T extends Date | number>(
|
||||
ranges = orderBy(ranges, (range) => range.start, 'asc');
|
||||
|
||||
let current: Range<T> | null = null;
|
||||
for (let i = 0; i < ranges.length; ++i) {
|
||||
const item = ranges[i];
|
||||
const itemStartSeconds =
|
||||
item.start instanceof Date ? item.start.getTime() : item.start;
|
||||
for (const range of ranges) {
|
||||
const rangeStartSeconds: number =
|
||||
range.start instanceof Date ? range.start.getTime() : range.start;
|
||||
|
||||
if (!current) {
|
||||
current = { ...item };
|
||||
current = { ...range };
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentEndSeconds =
|
||||
current.end instanceof Date ? current.end.getTime() : (current.end as number);
|
||||
|
||||
if (currentEndSeconds + toleranceSeconds * 1000 >= itemStartSeconds) {
|
||||
if (item.end > current.end) {
|
||||
current.end = item.end;
|
||||
if (currentEndSeconds + toleranceSeconds * 1000 >= rangeStartSeconds) {
|
||||
if (range.end > current.end) {
|
||||
current.end = range.end;
|
||||
}
|
||||
} else {
|
||||
compressedRanges.push(current);
|
||||
current = { ...item };
|
||||
current = { ...range };
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
|
||||
+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 {
|
||||
CSSResultGroup,
|
||||
@@ -11,16 +9,12 @@ import {
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { Ref, createRef, ref } from 'lit/directives/ref.js';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import carouselStyle from '../scss/carousel.scss';
|
||||
import { TransitionEffect } from '../types';
|
||||
import { dispatchFrigateCardEvent, isHTMLElement } from '../utils/basic.js';
|
||||
|
||||
export interface CarouselSelect {
|
||||
index: number;
|
||||
element: HTMLElement;
|
||||
}
|
||||
import {
|
||||
CarouselController,
|
||||
CarouselDirection,
|
||||
} from '../utils/embla/carousel-controller';
|
||||
|
||||
export type EmblaCarouselPlugins = CreatePluginType<
|
||||
LoosePluginType,
|
||||
@@ -30,269 +24,91 @@ export type EmblaCarouselPlugins = CreatePluginType<
|
||||
@customElement('frigate-card-carousel')
|
||||
export class FrigateCardCarousel extends LitElement {
|
||||
@property({ attribute: true, reflect: true })
|
||||
public direction: 'vertical' | 'horizontal' = 'horizontal';
|
||||
|
||||
@property({ attribute: false })
|
||||
public carouselOptions?: EmblaOptionsType;
|
||||
|
||||
@property({ attribute: false })
|
||||
public carouselPlugins?: EmblaCarouselPlugins;
|
||||
|
||||
@property({ attribute: false })
|
||||
public selected = 0;
|
||||
public direction: CarouselDirection = 'horizontal';
|
||||
|
||||
@property({ attribute: true })
|
||||
public transitionEffect?: TransitionEffect;
|
||||
|
||||
protected _refSlot: Ref<HTMLSlotElement> = createRef();
|
||||
@property({ attribute: false })
|
||||
public loop?: boolean;
|
||||
|
||||
protected _carousel?: EmblaCarouselType;
|
||||
@property({ attribute: false })
|
||||
public dragFree?: boolean;
|
||||
|
||||
// Whether the carousel is actively scrolling.
|
||||
protected _scrolling = false;
|
||||
@property({ attribute: false })
|
||||
public dragEnabled = true;
|
||||
|
||||
// Whether to reinit the carousel when it settles.
|
||||
protected _reInitOnSettle = false;
|
||||
@property({ attribute: false })
|
||||
public plugins?: EmblaCarouselPlugins;
|
||||
|
||||
protected _carouselReInitInPlace = throttle(
|
||||
this._carouselReInitInPlaceInternal.bind(this),
|
||||
500,
|
||||
{ trailing: true },
|
||||
);
|
||||
@property({ attribute: false })
|
||||
public selected = 0;
|
||||
|
||||
protected _refParent: Ref<HTMLSlotElement> = createRef();
|
||||
protected _refRoot: Ref<HTMLElement> = createRef();
|
||||
protected _carousel: CarouselController | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
// Guarantee a re-render if the component is reconnected. See note in
|
||||
// disconnectedCallback().
|
||||
// Guarantee recreation of carousel if the component is reconnected.
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Component disconnected callback.
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
// Destroy the carousel when the component is disconnected, which forces the
|
||||
// plugins (which may have registered event handlers) to also be destroyed.
|
||||
// The carousel will automatically reconstruct if the component is re-rendered.
|
||||
this._destroyCarousel();
|
||||
this._carousel?.destroy();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the carousel if certain properties change.
|
||||
* @param changedProps The changed properties
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
const destroyProperties = [
|
||||
'direction',
|
||||
'carouselOptions',
|
||||
'carouselPlugins',
|
||||
] as const;
|
||||
if (changedProps.has('direction')) {
|
||||
this.setAttribute('direction', this.direction);
|
||||
}
|
||||
|
||||
const destroyProperties = ['direction', 'dragFree', 'transitionEffect'] as const;
|
||||
if (destroyProperties.some((prop) => changedProps.has(prop))) {
|
||||
this._destroyCarousel();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected slide.
|
||||
* @returns A CarouselSelect object (index & element).
|
||||
*/
|
||||
public getCarouselSelected(slide?: number): CarouselSelect | null {
|
||||
const index = slide ?? this._carousel?.selectedScrollSnap();
|
||||
const element =
|
||||
index !== undefined ? this._carousel?.slideNodes()[index] ?? null : null;
|
||||
if (index !== undefined && element) {
|
||||
return {
|
||||
index: index,
|
||||
element: element,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the carousel.
|
||||
*/
|
||||
public carousel(): EmblaCarouselType | null {
|
||||
return this._carousel ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReInit the carousel but stay on the current slide.
|
||||
*/
|
||||
protected _carouselReInitInPlaceInternal(): void {
|
||||
const carouselReInit = (options?: EmblaOptionsType): void => {
|
||||
// Allow the browser a moment to paint components that are inflight, to
|
||||
// ensure accurate measurements are taken during the carousel
|
||||
// reinitialization.
|
||||
window.requestAnimationFrame(() => {
|
||||
this._carousel?.reInit({ ...options });
|
||||
});
|
||||
};
|
||||
|
||||
carouselReInit({
|
||||
startIndex: this.selected,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ReInit the carousel when it is safe to do so without disturbing the
|
||||
* appearance (i.e. cutting off a scroll in progress).
|
||||
*/
|
||||
public carouselReInitWhenSafe(): void {
|
||||
if (this._scrolling) {
|
||||
this._reInitOnSettle = true;
|
||||
} else {
|
||||
this._carouselReInitInPlace();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The updated lifecycle callback for this element.
|
||||
* @param changedProperties The properties that were changed in this render.
|
||||
*/
|
||||
updated(changedProperties: PropertyValues): void {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (!this._carousel) {
|
||||
this._initCarousel();
|
||||
}
|
||||
|
||||
if (changedProperties.has('selected')) {
|
||||
this._carousel?.scrollTo(this.selected, this.transitionEffect === 'none');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the carousel.
|
||||
* @param options If `savePosition` is set the existing carousel position
|
||||
* will be saved so it can be restored if the carousel is recreated.
|
||||
*/
|
||||
protected _destroyCarousel(): void {
|
||||
if (this._carousel) {
|
||||
this._carousel.destroy();
|
||||
}
|
||||
this._carousel = undefined;
|
||||
}
|
||||
|
||||
protected _getSlideElements(): HTMLElement[] {
|
||||
return (
|
||||
this._refSlot.value?.assignedElements({ flatten: true }).filter(isHTMLElement) ??
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the carousel.
|
||||
*/
|
||||
protected _initCarousel(): void {
|
||||
const carouselNode = this.renderRoot.querySelector(
|
||||
'.embla__viewport',
|
||||
) as HTMLElement;
|
||||
|
||||
const nodes: EmblaNodesType = {
|
||||
root: carouselNode,
|
||||
// As the slides are slotted, need to explicitly pull them out and pass
|
||||
// them to Embla.
|
||||
slides: this._getSlideElements(),
|
||||
};
|
||||
|
||||
if (carouselNode && nodes.slides) {
|
||||
this._carousel = EmblaCarousel(
|
||||
nodes,
|
||||
{
|
||||
axis: this.direction == 'horizontal' ? 'x' : 'y',
|
||||
speed: 30,
|
||||
startIndex: this.selected,
|
||||
...this.carouselOptions,
|
||||
},
|
||||
this.carouselPlugins,
|
||||
);
|
||||
const selectSlide = (slide?: number): void => {
|
||||
const selected = this.getCarouselSelected(slide);
|
||||
if (selected) {
|
||||
dispatchFrigateCardEvent<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();
|
||||
this._carousel?.destroy();
|
||||
this._carousel = null;
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const slides = this._refSlot.value?.assignedElements({ flatten: true }) || [];
|
||||
const showPrevious = this.carouselOptions?.loop || this.selected > 0;
|
||||
const showNext = this.carouselOptions?.loop || this.selected + 1 < slides.length;
|
||||
|
||||
return html` <div class="embla">
|
||||
${showPrevious ? html`<slot name="previous"></slot>` : ``}
|
||||
<div class="embla__viewport">
|
||||
<slot name="previous"></slot>
|
||||
<div ${ref(this._refRoot)} class="embla__viewport">
|
||||
<div class="embla__container">
|
||||
<slot ${ref(this._refSlot)} @slotchange=${this._slotChanged.bind(this)}></slot>
|
||||
<slot ${ref(this._refParent)}></slot>
|
||||
</div>
|
||||
</div>
|
||||
${showNext ? html`<slot name="next"></slot>` : ``}
|
||||
<slot name="next"></slot>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
if (!this._carousel && this._refRoot.value && this._refParent.value) {
|
||||
this._carousel = new CarouselController(
|
||||
this._refRoot.value,
|
||||
this._refParent.value,
|
||||
{
|
||||
direction: this.direction,
|
||||
dragEnabled: this.dragEnabled,
|
||||
dragFree: this.dragFree,
|
||||
startIndex: this.selected,
|
||||
transitionEffect: this.transitionEffect,
|
||||
loop: this.loop,
|
||||
plugins: this.plugins,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (changedProps.has('selected')) {
|
||||
this._carousel?.selectSlide(this.selected);
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(carouselStyle);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SideDrawer } from 'side-drawer';
|
||||
import drawerInjectStyle from '../scss/drawer-inject.scss';
|
||||
import drawerStyle from '../scss/drawer.scss';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action';
|
||||
import { isHoverableDevice } from '../utils/basic';
|
||||
import { getChildrenFromElement, isHoverableDevice } from '../utils/basic';
|
||||
|
||||
export interface DrawerIcons {
|
||||
open?: string;
|
||||
@@ -67,12 +67,14 @@ export class FrigateCardDrawer extends LitElement {
|
||||
* Called when the slotted children in the drawer change.
|
||||
*/
|
||||
protected _slotChanged(): void {
|
||||
const elements = this._refSlot.value?.assignedElements({ flatten: true });
|
||||
const children = this._refSlot.value
|
||||
? getChildrenFromElement(this._refSlot.value)
|
||||
: [];
|
||||
|
||||
// Watch all slot children for size changes.
|
||||
this._resizeObserver.disconnect();
|
||||
for (const element of elements ?? []) {
|
||||
this._resizeObserver.observe(element);
|
||||
for (const child of children) {
|
||||
this._resizeObserver.observe(child);
|
||||
}
|
||||
this._hideDrawerIfNecessary();
|
||||
}
|
||||
@@ -86,11 +88,13 @@ export class FrigateCardDrawer extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = this._refSlot.value?.assignedElements({ flatten: true });
|
||||
const children = this._refSlot.value
|
||||
? getChildrenFromElement(this._refSlot.value)
|
||||
: null;
|
||||
this.empty =
|
||||
!elements ||
|
||||
!elements.length ||
|
||||
elements.every((element) => {
|
||||
!children ||
|
||||
!children.length ||
|
||||
children.every((element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return !box.width || !box.height;
|
||||
});
|
||||
|
||||
@@ -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 { EmblaOptionsType } from 'embla-carousel';
|
||||
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
@@ -13,15 +11,16 @@ import {
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { CameraConfigs, CameraEndpoints } from '../../camera-manager/types.js';
|
||||
import { ConditionControllerEpoch, getOverriddenConfig } from '../../conditions.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import basicBlockStyle from '../../scss/basic-block.scss';
|
||||
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
||||
import liveProviderStyle from '../../scss/live-provider.scss';
|
||||
import basicBlockStyle from '../../scss/basic-block.scss';
|
||||
import {
|
||||
CameraConfig,
|
||||
CardWideConfig,
|
||||
@@ -37,28 +36,30 @@ import {
|
||||
} from '../../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||
import { contentsChanged } from '../../utils/basic.js';
|
||||
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
|
||||
import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
|
||||
import { AutoMediaActions } from '../../utils/embla/plugins/auto-media-actions/auto-media-actions.js';
|
||||
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
|
||||
import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
|
||||
import { MediaGridSelected } from '../../utils/media-grid-controller.js';
|
||||
import {
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
dispatchMediaUnloadedEvent,
|
||||
} from '../../utils/media-info.js';
|
||||
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
|
||||
import { playMediaMutingIfNecessary } from '../../utils/media.js';
|
||||
import { Timer } from '../../utils/timer.js';
|
||||
import { dispatchViewContextChangeEvent, View } from '../../view/view.js';
|
||||
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
|
||||
import {
|
||||
FrigateCardMediaCarousel,
|
||||
wrapMediaLoadedEventForCarousel,
|
||||
wrapMediaUnloadedEventForCarousel,
|
||||
} from '../media-carousel.js';
|
||||
import { EmblaCarouselPlugins } from '../carousel.js';
|
||||
import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../message.js';
|
||||
import '../next-prev-control.js';
|
||||
import '../surround.js';
|
||||
import '../title-control.js';
|
||||
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
|
||||
import { Lazyload } from './../embla-plugins/lazyload.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { MediaGridSelected } from '../../utils/media-grid-controller.js';
|
||||
import { getDefaultTitleConfigForView } from '../title-control.js';
|
||||
import {
|
||||
FrigateCardTitleControl,
|
||||
getDefaultTitleConfigForView,
|
||||
showTitleControlAfterDelay,
|
||||
} from '../title-control.js';
|
||||
|
||||
interface LiveViewContext {
|
||||
// A cameraID override (used for dependencies/substreams to force a different
|
||||
@@ -200,11 +201,6 @@ export class FrigateCardLive extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the element should be updated.
|
||||
* @param _changedProps The changed properties if any.
|
||||
* @returns `true` if the element should be updated.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected shouldUpdate(_changedProps: PropertyValues): boolean {
|
||||
// Don't process updates if it's in the background and a message was
|
||||
@@ -213,26 +209,16 @@ export class FrigateCardLive extends LitElement {
|
||||
return !this._inBackground || !this._messageReceivedPostRender;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component connected callback.
|
||||
*/
|
||||
connectedCallback(): void {
|
||||
this._intersectionObserver.observe(this);
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Component disconnected callback.
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._intersectionObserver.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (
|
||||
!this.hass ||
|
||||
@@ -318,9 +304,6 @@ export class FrigateCardLiveGrid extends LitElement {
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public liveOverrides?: LiveOverrides;
|
||||
|
||||
@property({ attribute: false })
|
||||
public inBackground?: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||
|
||||
@@ -342,7 +325,6 @@ export class FrigateCardLiveGrid extends LitElement {
|
||||
.viewFilterCameraID=${cameraID}
|
||||
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
|
||||
.overriddenLiveConfig=${this.overriddenLiveConfig}
|
||||
.inBackground=${this.inBackground}
|
||||
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||
.liveOverrides=${this.liveOverrides}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
@@ -423,9 +405,6 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public liveOverrides?: LiveOverrides;
|
||||
|
||||
@property({ attribute: false })
|
||||
public inBackground?: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||
|
||||
@@ -443,38 +422,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
|
||||
// Index between camera name and slide number.
|
||||
protected _cameraToSlide: Record<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 {
|
||||
return (
|
||||
this.overriddenLiveConfig?.transition_effect ??
|
||||
@@ -490,49 +440,19 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
return Math.max(0, Array.from(cameraIDs).indexOf(this.view.camera));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Embla options to use.
|
||||
* @returns An EmblaOptionsType object or undefined for no options.
|
||||
*/
|
||||
protected _getOptions(): EmblaOptionsType {
|
||||
return {
|
||||
// If the carousel is being filtered to a single cameraID, it is never
|
||||
// draggable.
|
||||
draggable: !this.viewFilterCameraID && this.overriddenLiveConfig?.draggable,
|
||||
loop: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Embla plugins to use.
|
||||
* @returns A list of EmblaOptionsTypes.
|
||||
*/
|
||||
protected _getPlugins(): EmblaCarouselPlugins {
|
||||
const cameraCount = this.viewFilterCameraID
|
||||
? 1
|
||||
: this.cameraManager?.getStore().getVisibleCameraCount() ?? 0;
|
||||
return [
|
||||
// Only enable wheel plugin if there is more than one camera.
|
||||
...(cameraCount > 1
|
||||
? [
|
||||
WheelGesturesPlugin({
|
||||
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
||||
// gestures as scrolling for the carousel.
|
||||
forceWheelAxis: 'y',
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
Lazyload({
|
||||
AutoLazyLoad({
|
||||
...(this.overriddenLiveConfig?.lazy_load && {
|
||||
lazyLoadCallback: (index, slide) =>
|
||||
this._lazyloadOrUnloadSlide('load', index, slide),
|
||||
}),
|
||||
|
||||
lazyUnloadCondition: this.overriddenLiveConfig?.lazy_unload,
|
||||
lazyUnloadCallback: (index, slide) =>
|
||||
this._lazyloadOrUnloadSlide('unload', index, slide),
|
||||
}),
|
||||
AutoMediaPlugin({
|
||||
AutoMediaLoadedInfo(),
|
||||
AutoMediaActions({
|
||||
playerSelector: FRIGATE_CARD_LIVE_PROVIDER,
|
||||
...(this.overriddenLiveConfig?.auto_play && {
|
||||
autoPlayCondition: this.overriddenLiveConfig.auto_play,
|
||||
@@ -547,6 +467,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
autoUnmuteCondition: this.overriddenLiveConfig.auto_unmute,
|
||||
}),
|
||||
}),
|
||||
AutoSize(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -562,11 +483,6 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
return this.overriddenLiveConfig?.lazy_load === false ? null : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slides to include in the render.
|
||||
* @returns The slides to include in the render and an index keyed by camera
|
||||
* name to slide number.
|
||||
*/
|
||||
protected _getSlides(): [TemplateResult[], Record<string, number>] {
|
||||
let cameras: CameraConfigs | null = null;
|
||||
if (this.viewFilterCameraID) {
|
||||
@@ -595,7 +511,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
: this.cameraManager?.getStore().getCameraConfig(liveCameraID);
|
||||
|
||||
const slide = liveCameraConfig
|
||||
? this._renderLive(liveCameraID, liveCameraConfig, slides.length)
|
||||
? this._renderLive(liveCameraID, liveCameraConfig)
|
||||
: null;
|
||||
if (slide) {
|
||||
cameraToSlide[cameraID] = slides.length;
|
||||
@@ -605,10 +521,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
return [slides, cameraToSlide];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the user selecting a new slide in the carousel.
|
||||
*/
|
||||
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
|
||||
protected _setViewHandler(ev: CustomEvent<CarouselSelected>): void {
|
||||
const cameras = this.cameraManager?.getStore().getVisibleCameras();
|
||||
if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) {
|
||||
this._setViewCameraID(Array.from(cameras.keys())[ev.detail.index]);
|
||||
@@ -631,11 +544,6 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy load a slide.
|
||||
* @param _index The slide number to lazy load.
|
||||
* @param slide The slide to lazy load.
|
||||
*/
|
||||
protected _lazyloadOrUnloadSlide(
|
||||
action: 'load' | 'unload',
|
||||
_index: number,
|
||||
@@ -649,14 +557,13 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
FRIGATE_CARD_LIVE_PROVIDER,
|
||||
) as FrigateCardLiveProvider | null;
|
||||
if (liveProvider) {
|
||||
liveProvider.disabled = action !== 'load';
|
||||
liveProvider.load = action === 'load';
|
||||
}
|
||||
}
|
||||
|
||||
protected _renderLive(
|
||||
cameraID: string,
|
||||
cameraConfig: CameraConfig,
|
||||
slideIndex: number,
|
||||
): TemplateResult | void {
|
||||
if (
|
||||
!this.overriddenLiveConfig ||
|
||||
@@ -683,7 +590,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
return html`
|
||||
<div class="embla__slide">
|
||||
<frigate-card-live-provider
|
||||
?disabled=${config.lazy_load}
|
||||
?load=${!config.lazy_load}
|
||||
.microphoneStream=${this.view?.camera === cameraID
|
||||
? this.microphoneStream
|
||||
: undefined}
|
||||
@@ -696,12 +603,6 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
.liveConfig=${config}
|
||||
.hass=${this.hass}
|
||||
.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>
|
||||
</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 {
|
||||
if (!this.overriddenLiveConfig || !this.view || !this.hass || !this.cameraManager) {
|
||||
return;
|
||||
@@ -743,6 +640,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasMultipleCameras = slides.length > 1;
|
||||
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
|
||||
|
||||
const overrideCameraID = (cameraID: string): string => {
|
||||
@@ -776,28 +674,25 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
// little later).
|
||||
|
||||
return html`
|
||||
<frigate-card-media-carousel
|
||||
${ref(this._refMediaCarousel)}
|
||||
.carouselOptions=${guard(
|
||||
[this.cameraManager, this.overriddenLiveConfig],
|
||||
this._getOptions.bind(this),
|
||||
)}
|
||||
.carouselPlugins=${guard(
|
||||
<frigate-card-carousel
|
||||
.loop=${hasMultipleCameras}
|
||||
.dragEnabled=${hasMultipleCameras && this.overriddenLiveConfig?.draggable}
|
||||
.plugins=${guard(
|
||||
[this.cameraManager, this.overriddenLiveConfig],
|
||||
this._getPlugins.bind(this),
|
||||
) as EmblaCarouselPlugins}
|
||||
.label="${cameraMetadataCurrent
|
||||
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
|
||||
: ''}"
|
||||
.logo="${cameraMetadataCurrent?.engineLogo}"
|
||||
.titlePopupConfig=${titleConfig ?? undefined}
|
||||
)}
|
||||
.selected=${this._getSelectedCameraIndex()}
|
||||
transitionEffect=${this._getTransitionEffect()}
|
||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
||||
@frigate-card:carousel:select=${this._setViewHandler.bind(this)}
|
||||
@frigate-card:carousel:settle=${() => {
|
||||
// Fetch the thumbnails after the carousel has settled.
|
||||
dispatchViewContextChangeEvent(this, { thumbnails: { fetch: true } });
|
||||
}}
|
||||
@frigate-card:media:loaded=${() => {
|
||||
if (this._refTitleControl.value) {
|
||||
showTitleControlAfterDelay(this._refTitleControl.value, this._titleTimer);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<frigate-card-next-previous-control
|
||||
slot="previous"
|
||||
@@ -828,13 +723,22 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
}}
|
||||
>
|
||||
</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 {
|
||||
return unsafeCSS(liveCarouselStyle);
|
||||
}
|
||||
@@ -857,10 +761,11 @@ export class FrigateCardLiveProvider
|
||||
@property({ attribute: false })
|
||||
public liveConfig?: LiveConfig;
|
||||
|
||||
// Whether or not to disable this entity. If `true`, no contents are rendered
|
||||
// until this attribute is set to `false` (this is useful for lazy loading).
|
||||
// Whether or not to load the video for this camera. If `false`, no contents
|
||||
// are rendered until this attribute is set to `true` (this is useful for lazy
|
||||
// loading).
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public disabled = false;
|
||||
public load = false;
|
||||
|
||||
// Label that is used for ARIA support and as tooltip.
|
||||
@property({ attribute: false })
|
||||
@@ -995,8 +900,8 @@ export class FrigateCardLiveProvider
|
||||
* Called before each update.
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('disabled')) {
|
||||
if (this.disabled) {
|
||||
if (changedProps.has('load')) {
|
||||
if (!this.load) {
|
||||
this._isVideoMediaLoaded = false;
|
||||
dispatchMediaUnloadedEvent(this);
|
||||
}
|
||||
@@ -1051,7 +956,7 @@ export class FrigateCardLiveProvider
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (this.disabled || !this.hass || !this.liveConfig || !this.cameraConfig) {
|
||||
if (!this.load || !this.hass || !this.liveConfig || !this.cameraConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
@@ -10,18 +8,16 @@ import {
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
|
||||
import { ExtendedHomeAssistant, ThumbnailsControlConfig } from '../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import { View } from '../view/view.js';
|
||||
import { CarouselDirection } from '../utils/embla/carousel-controller.js';
|
||||
import { MediaQueriesResults } from '../view/media-queries-results';
|
||||
import { FrigateCardCarousel } from './carousel.js';
|
||||
import './thumbnail.js';
|
||||
import { View } from '../view/view.js';
|
||||
import './carousel.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import './thumbnail.js';
|
||||
|
||||
export interface ThumbnailCarouselTap {
|
||||
queryResults: MediaQueriesResults;
|
||||
@@ -38,83 +34,11 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
protected _refCarousel: Ref<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 })
|
||||
public config?: ThumbnailsControlConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public selected? = 0;
|
||||
protected _thumbnailSlides: TemplateResult[] = [];
|
||||
|
||||
protected _carouselOptions?: EmblaOptionsType = {
|
||||
containScroll: 'keepSnaps',
|
||||
dragFree: true,
|
||||
};
|
||||
|
||||
protected _carouselPlugins: EmblaPluginType[] = [
|
||||
WheelGesturesPlugin({
|
||||
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
||||
// gestures as scrolling for the carousel.
|
||||
forceWheelAxis: 'y',
|
||||
}),
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle gallery resize.
|
||||
*/
|
||||
protected _resizeHandler(): void {
|
||||
this._refCarousel.value?.carouselReInitWhenSafe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Component connected callback.
|
||||
*/
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._resizeObserver.observe(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component disconnected callback.
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
this._resizeObserver.disconnect();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slides to include in the render.
|
||||
* @returns The slides to include in the render.
|
||||
*/
|
||||
protected _getSlides(): TemplateResult[] {
|
||||
if (!this.view?.query || !this.view.queryResults?.hasResults()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const slides: TemplateResult[] = [];
|
||||
for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) {
|
||||
const thumbnail = this._renderThumbnail(i);
|
||||
if (thumbnail) {
|
||||
slides[i] = thumbnail;
|
||||
}
|
||||
}
|
||||
return slides;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an update will occur.
|
||||
* @param changedProps The changed properties
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('config')) {
|
||||
if (this.config?.size) {
|
||||
@@ -128,95 +52,93 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProps.has('selected')) {
|
||||
const renderProperties = [
|
||||
'cameraManager',
|
||||
'config',
|
||||
'transitionEffect',
|
||||
'view',
|
||||
] as const;
|
||||
if (renderProperties.some((prop) => changedProps.has(prop))) {
|
||||
this._thumbnailSlides = this._renderSlides();
|
||||
}
|
||||
|
||||
if (changedProps.has('view')) {
|
||||
this.style.setProperty(
|
||||
'--frigate-card-carousel-thumbnail-opacity',
|
||||
this.selected === undefined ? '1.0' : '0.4',
|
||||
this._getSelectedSlide() === null ? '1.0' : '0.4',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a given thumbnail.
|
||||
* @param mediaToRender The media item to render.
|
||||
* @returns A template or void if the item could not be rendered.
|
||||
*/
|
||||
protected _renderThumbnail(index: number): TemplateResult | void {
|
||||
const media = this.view?.queryResults?.getResult(index) ?? null;
|
||||
if (!media || !this.view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const classes = {
|
||||
embla__slide: true,
|
||||
'slide-selected': this.selected === index,
|
||||
};
|
||||
|
||||
const seekTarget = this.view?.context?.mediaViewer?.seek;
|
||||
return html` <frigate-card-thumbnail
|
||||
class="${classMap(classes)}"
|
||||
.cameraManager=${this.cameraManager}
|
||||
.hass=${this.hass}
|
||||
.media=${media}
|
||||
.view=${this.view}
|
||||
.seek=${seekTarget && media.includesTime(seekTarget) ? seekTarget : undefined}
|
||||
?details=${!!this.config?.show_details}
|
||||
?show_favorite_control=${this.config?.show_favorite_control}
|
||||
?show_timeline_control=${this.config?.show_timeline_control}
|
||||
?show_download_control=${this.config?.show_download_control}
|
||||
@click=${(ev: Event) => {
|
||||
if (this.view && this.view.queryResults) {
|
||||
dispatchFrigateCardEvent<ThumbnailCarouselTap>(
|
||||
this,
|
||||
'thumbnail-carousel:tap',
|
||||
{
|
||||
queryResults: this.view.queryResults.clone().selectIndex(index),
|
||||
},
|
||||
);
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
>
|
||||
</frigate-card-thumbnail>`;
|
||||
protected _getSelectedSlide(view?: View): number | null {
|
||||
return (view ?? this.view)?.queryResults?.getSelectedIndex() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the direction of the thumbnail carousel.
|
||||
* @returns `vertical`, `horizontal` or undefined.
|
||||
*/
|
||||
protected _getDirection(): 'horizontal' | 'vertical' | undefined {
|
||||
protected _renderSlides(): TemplateResult[] {
|
||||
const slides: TemplateResult[] = [];
|
||||
const seekTarget = this.view?.context?.mediaViewer?.seek;
|
||||
const selectedIndex = this._getSelectedSlide();
|
||||
|
||||
for (const media of this.view?.queryResults?.getResults() ?? []) {
|
||||
const index = slides.length;
|
||||
const classes = {
|
||||
embla__slide: true,
|
||||
'slide-selected': selectedIndex === index,
|
||||
};
|
||||
|
||||
slides.push(html` <frigate-card-thumbnail
|
||||
class="${classMap(classes)}"
|
||||
.cameraManager=${this.cameraManager}
|
||||
.hass=${this.hass}
|
||||
.media=${media}
|
||||
.view=${this.view}
|
||||
.seek=${seekTarget && media.includesTime(seekTarget) ? seekTarget : undefined}
|
||||
?details=${!!this.config?.show_details}
|
||||
?show_favorite_control=${this.config?.show_favorite_control}
|
||||
?show_timeline_control=${this.config?.show_timeline_control}
|
||||
?show_download_control=${this.config?.show_download_control}
|
||||
@click=${(ev: Event) => {
|
||||
if (this.view && this.view.queryResults) {
|
||||
dispatchFrigateCardEvent<ThumbnailCarouselTap>(
|
||||
this,
|
||||
'thumbnail-carousel:tap',
|
||||
{
|
||||
queryResults: this.view.queryResults.clone().selectIndex(index),
|
||||
},
|
||||
);
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
>
|
||||
</frigate-card-thumbnail>`);
|
||||
}
|
||||
return slides;
|
||||
}
|
||||
|
||||
protected _getDirection(): CarouselDirection | null {
|
||||
if (this.config?.mode === 'left' || this.config?.mode === 'right') {
|
||||
return 'vertical';
|
||||
} else if (this.config?.mode === 'above' || this.config?.mode === 'below') {
|
||||
return 'horizontal';
|
||||
}
|
||||
return undefined;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the element.
|
||||
* @returns A template to display to the user.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
const slides = this._getSlides();
|
||||
if (!slides.length || !this.config || this.config.mode === 'none') {
|
||||
const direction = this._getDirection();
|
||||
if (!this._thumbnailSlides.length || !this.config || !direction) {
|
||||
return;
|
||||
}
|
||||
|
||||
return html`<frigate-card-carousel
|
||||
${ref(this._refCarousel)}
|
||||
direction=${ifDefined(this._getDirection())}
|
||||
.selected=${this.selected ?? 0}
|
||||
.carouselOptions=${this._carouselOptions}
|
||||
.carouselPlugins=${this._carouselPlugins}
|
||||
direction=${direction}
|
||||
.selected=${this._getSelectedSlide() ?? 0}
|
||||
.dragFree=${true}
|
||||
>
|
||||
${slides}
|
||||
${this._thumbnailSlides}
|
||||
</frigate-card-carousel>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(thumbnailCarouselStyle);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,36 @@ import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import titleStyle from '../scss/title-control.scss';
|
||||
import { TitleControlConfig } from '../types.js';
|
||||
import { Timer } from '../utils/timer';
|
||||
import { View } from '../view/view.js';
|
||||
|
||||
type PaperToast = HTMLElement & {
|
||||
opened: boolean;
|
||||
};
|
||||
|
||||
export const showTitleControlAfterDelay = (
|
||||
control: FrigateCardTitleControl,
|
||||
timer: Timer,
|
||||
delay = 0.5,
|
||||
): void => {
|
||||
const show = () => {
|
||||
timer.stop();
|
||||
control.show();
|
||||
};
|
||||
|
||||
if (control.isVisible()) {
|
||||
// If it's already visible, update it immediately (but also update it
|
||||
// after the timer expires to ensure it re-positions if necessary, see
|
||||
// comment below).
|
||||
show();
|
||||
}
|
||||
|
||||
// Allow a brief pause after the media loads, but before the title is
|
||||
// displayed. This allows for a pleasant appearance/disappear of the title,
|
||||
// and allows for the browser to finish rendering the carousel.
|
||||
timer.start(delay, show);
|
||||
};
|
||||
|
||||
export const getDefaultTitleConfigForView = (
|
||||
view?: Readonly<View>,
|
||||
baseConfig?: TitleControlConfig,
|
||||
@@ -39,10 +63,6 @@ export class FrigateCardTitleControl extends LitElement {
|
||||
|
||||
protected _toastRef: Ref<PaperToast> = createRef();
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult {
|
||||
if (!this.text || !this.config || this.config.mode == 'none' || !this.fitInto) {
|
||||
return html``;
|
||||
@@ -64,17 +84,10 @@ export class FrigateCardTitleControl extends LitElement {
|
||||
</paper-toast>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the toast is visible.
|
||||
* @returns `true` if the toast is visible, `false` otherwise.
|
||||
*/
|
||||
public isVisible(): boolean {
|
||||
return this._toastRef.value?.opened ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the toast.
|
||||
*/
|
||||
public hide(): void {
|
||||
if (this._toastRef.value) {
|
||||
// Set it to false first, to ensure the timer resets.
|
||||
@@ -82,9 +95,6 @@ export class FrigateCardTitleControl extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the toast.
|
||||
*/
|
||||
public show(): void {
|
||||
if (this._toastRef.value) {
|
||||
// Set it to false first, to ensure the timer resets.
|
||||
@@ -93,9 +103,6 @@ export class FrigateCardTitleControl extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(titleStyle);
|
||||
}
|
||||
|
||||
+71
-98
@@ -1,5 +1,3 @@
|
||||
import { EmblaPluginType } from 'embla-carousel';
|
||||
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
@@ -12,11 +10,15 @@ import { customElement, property } from 'lit/decorators.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import basicBlockStyle from '../scss/basic-block.scss';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { dispatchMessageEvent, renderMessage, renderProgressIndicator } from '../components/message.js';
|
||||
import {
|
||||
dispatchMessageEvent,
|
||||
renderMessage,
|
||||
renderProgressIndicator,
|
||||
} from '../components/message.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import '../patches/ha-hls-player';
|
||||
import basicBlockStyle from '../scss/basic-block.scss';
|
||||
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
|
||||
import viewerProviderStyle from '../scss/viewer-provider.scss';
|
||||
import viewerStyle from '../scss/viewer.scss';
|
||||
@@ -36,6 +38,11 @@ import {
|
||||
errorToConsole,
|
||||
setOrRemoveAttribute,
|
||||
} from '../utils/basic.js';
|
||||
import { CarouselSelected } from '../utils/embla/carousel-controller.js';
|
||||
import { AutoLazyLoad } from '../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
|
||||
import { AutoMediaActions } from '../utils/embla/plugins/auto-media-actions/auto-media-actions.js';
|
||||
import AutoMediaLoadedInfo from '../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
|
||||
import AutoSize from '../utils/embla/plugins/auto-size/auto-size.js';
|
||||
import { canonicalizeHAURL } from '../utils/ha/index.js';
|
||||
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
||||
import { MediaGridSelected } from '../utils/media-grid-controller.js';
|
||||
@@ -57,22 +64,21 @@ import {
|
||||
setControlsOnVideo,
|
||||
} from '../utils/media.js';
|
||||
import { screenshotMedia } from '../utils/screenshot.js';
|
||||
import { Timer } from '../utils/timer';
|
||||
import { ViewMediaClassifier } from '../view/media-classifier';
|
||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||
import { MediaQueriesResults } from '../view/media-queries-results.js';
|
||||
import { VideoContentType, ViewMedia } from '../view/media.js';
|
||||
import { View } from '../view/view.js';
|
||||
import type { CarouselSelect } from './carousel.js';
|
||||
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
||||
import { Lazyload } from './embla-plugins/lazyload.js';
|
||||
import {
|
||||
FrigateCardMediaCarousel,
|
||||
wrapMediaLoadedEventForCarousel,
|
||||
} from './media-carousel.js';
|
||||
import type { EmblaCarouselPlugins } from './carousel.js';
|
||||
import './next-prev-control.js';
|
||||
import './surround.js';
|
||||
import './title-control.js';
|
||||
import { getDefaultTitleConfigForView } from './title-control.js';
|
||||
import {
|
||||
FrigateCardTitleControl,
|
||||
getDefaultTitleConfigForView,
|
||||
showTitleControlAfterDelay,
|
||||
} from './title-control.js';
|
||||
|
||||
export interface MediaViewerViewContext {
|
||||
seek?: Date;
|
||||
@@ -114,10 +120,6 @@ export class FrigateCardViewer extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (
|
||||
!this.hass ||
|
||||
@@ -186,9 +188,6 @@ export class FrigateCardViewer extends LitElement {
|
||||
</frigate-card-viewer-grid>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(viewerStyle);
|
||||
}
|
||||
@@ -227,8 +226,10 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public selected = 0;
|
||||
|
||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
||||
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.
|
||||
@@ -259,47 +260,19 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the media player on a slide (or current slide if not provided).
|
||||
* @param slide An optional slide.
|
||||
* @returns The FrigateCardMediaPlayer or null if not found.
|
||||
*/
|
||||
protected _getPlayer(slide?: HTMLElement | null): FrigateCardMediaPlayer | null {
|
||||
if (!slide) {
|
||||
slide = this._refMediaCarousel.value
|
||||
?.frigateCardCarousel()
|
||||
?.getCarouselSelected()?.element;
|
||||
}
|
||||
|
||||
return (
|
||||
(slide?.querySelector(
|
||||
FRIGATE_CARD_VIEWER_PROVIDER,
|
||||
) as unknown as FrigateCardMediaPlayer) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Embla plugins to use.
|
||||
* @returns A list of EmblaOptionsTypes.
|
||||
*/
|
||||
protected _getPlugins(): EmblaPluginType[] {
|
||||
protected _getPlugins(): EmblaCarouselPlugins {
|
||||
return [
|
||||
// Only enable wheel plugin if there is more than one media item.
|
||||
...(this._media && this._media.length > 1
|
||||
? [
|
||||
WheelGesturesPlugin({
|
||||
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
||||
// gestures as scrolling for the carousel.
|
||||
forceWheelAxis: 'y',
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
Lazyload({
|
||||
AutoLazyLoad({
|
||||
...(this.viewerConfig?.lazy_load && {
|
||||
lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide),
|
||||
}),
|
||||
}),
|
||||
AutoMediaPlugin({
|
||||
AutoMediaLoadedInfo(),
|
||||
AutoMediaActions({
|
||||
playerSelector: FRIGATE_CARD_VIEWER_PROVIDER,
|
||||
...(this.viewerConfig?.auto_play && {
|
||||
autoPlayCondition: this.viewerConfig.auto_play,
|
||||
@@ -314,6 +287,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
autoUnmuteCondition: this.viewerConfig.auto_unmute,
|
||||
}),
|
||||
}),
|
||||
AutoSize(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -346,10 +320,6 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
|
||||
this._setViewSelectedIndex(ev.detail.index);
|
||||
}
|
||||
|
||||
protected _setViewSelectedIndex(index: number): void {
|
||||
if (!this._media) {
|
||||
return;
|
||||
@@ -396,7 +366,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
'frigate-card-viewer-provider',
|
||||
) as FrigateCardViewerProvider | null;
|
||||
if (viewerProvider) {
|
||||
viewerProvider.disabled = false;
|
||||
viewerProvider.load = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,7 +383,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
for (let i = 0; i < this._media.length; ++i) {
|
||||
const media = this._media[i];
|
||||
if (media) {
|
||||
const slide = this._renderMediaItem(media, i);
|
||||
const slide = this._renderMediaItem(media);
|
||||
if (slide) {
|
||||
slides[i] = slide;
|
||||
}
|
||||
@@ -487,22 +457,24 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
);
|
||||
|
||||
return html`
|
||||
<frigate-card-media-carousel
|
||||
${ref(this._refMediaCarousel)}
|
||||
.carouselOptions=${guard([this.viewerConfig], () => ({
|
||||
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}
|
||||
<frigate-card-carousel
|
||||
.dragEnabled=${this.viewerConfig?.draggable ?? true}
|
||||
.plugins=${guard([this.viewerConfig, this._media], this._getPlugins.bind(this))}
|
||||
.selected=${this.selected ?? 0}
|
||||
transitionEffect=${this._getTransitionEffect()}
|
||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
||||
@frigate-card:media:loaded=${this._seekHandler.bind(this)}
|
||||
@frigate-card:carousel:select=${(ev: CustomEvent<CarouselSelected>) => {
|
||||
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
|
||||
slot="previous"
|
||||
@@ -531,11 +503,21 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
</frigate-card-media-carousel>
|
||||
</frigate-card-carousel>
|
||||
<div class="seek-warning">
|
||||
<ha-icon title="${localize('media_viewer.unseekable')}" icon="mdi:clock-remove">
|
||||
</ha-icon>
|
||||
</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> {
|
||||
const seek = this.view?.context?.mediaViewer?.seek;
|
||||
if (!this.hass || !seek || !this._media || this.selected === null) {
|
||||
if (!this.hass || !seek || !this._media || this.selected === null || !this._player) {
|
||||
return;
|
||||
}
|
||||
const selectedMedia = this._media[this.selected];
|
||||
const player = this._getPlayer();
|
||||
if (!selectedMedia || !player) {
|
||||
if (!selectedMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seekTimeInMedia = selectedMedia.includesTime(seek);
|
||||
setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable');
|
||||
if (!seekTimeInMedia && !player.isPaused()) {
|
||||
player.pause();
|
||||
} else if (seekTimeInMedia && player.isPaused()) {
|
||||
player.play();
|
||||
if (!seekTimeInMedia && !this._player.isPaused()) {
|
||||
this._player.pause();
|
||||
} else if (seekTimeInMedia && this._player.isPaused()) {
|
||||
this._player.play();
|
||||
}
|
||||
|
||||
const seekTime =
|
||||
@@ -566,17 +547,11 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
null;
|
||||
|
||||
if (seekTime !== null) {
|
||||
player.seek(seekTime);
|
||||
this._player.seek(seekTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single media item in the viewer carousel.
|
||||
* @param media The ViewMedia to render.
|
||||
* @param index The (slide|queryResult) index of the item to render.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null {
|
||||
protected _renderMediaItem(media: ViewMedia): TemplateResult | null {
|
||||
if (!this.hass || !this.view || !this.viewerConfig) {
|
||||
return null;
|
||||
}
|
||||
@@ -589,11 +564,8 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.disabled=${this.viewerConfig.lazy_load}
|
||||
.load=${!this.viewerConfig.lazy_load}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
@frigate-card:media:loaded=${(e: CustomEvent<MediaLoadedInfo>) => {
|
||||
wrapMediaLoadedEventForCarousel(index, e);
|
||||
}}
|
||||
></frigate-card-viewer-provider>
|
||||
</div>`;
|
||||
}
|
||||
@@ -715,10 +687,11 @@ export class FrigateCardViewerProvider
|
||||
@property({ attribute: false })
|
||||
public resolvedMediaCache?: ResolvedMediaCache;
|
||||
|
||||
// Whether or not to disable this entity. If `true`, no contents are rendered
|
||||
// until this attribute is set to `false` (this is useful for lazy loading).
|
||||
// Whether or not to load the viewer media. If `false`, no contents are
|
||||
// rendered until this attribute is set to `true` (this is useful for lazy
|
||||
// loading).
|
||||
@property({ attribute: false })
|
||||
public disabled = false;
|
||||
public load = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
@@ -865,7 +838,7 @@ export class FrigateCardViewerProvider
|
||||
const mediaContentID = this.media ? this.media.getContentID() : null;
|
||||
|
||||
if (
|
||||
(changedProps.has('disabled') ||
|
||||
(changedProps.has('load') ||
|
||||
changedProps.has('media') ||
|
||||
changedProps.has('viewerConfig') ||
|
||||
changedProps.has('resolvedMediaCache') ||
|
||||
@@ -873,7 +846,7 @@ export class FrigateCardViewerProvider
|
||||
this.hass &&
|
||||
mediaContentID &&
|
||||
!this.resolvedMediaCache?.has(mediaContentID) &&
|
||||
(!this.viewerConfig?.lazy_load || !this.disabled)
|
||||
(!this.viewerConfig?.lazy_load || this.load)
|
||||
) {
|
||||
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => {
|
||||
this.requestUpdate();
|
||||
@@ -897,7 +870,7 @@ export class FrigateCardViewerProvider
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (this.disabled || !this.media || !this.hass || !this.view || !this.viewerConfig) {
|
||||
if (!this.load || !this.media || !this.hass || !this.view || !this.viewerConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
// Keep carousel controls relative to the media carousel itself.
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.embla {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
:host {
|
||||
--video-max-height: none;
|
||||
}
|
||||
|
||||
// If the carousel has an unselected attribute set on it, do not let the
|
||||
// pointer interact (e.g. hover, scroll) with underlying elements. This is used
|
||||
// when the carousel is part of a media-grid. Without this next/prev controls
|
||||
// will enlarge on hover, and the wheel-gestures plugin may block scrolling.
|
||||
// See matching in viewer-carousel.scss .
|
||||
:host([unselected]) frigate-card-media-carousel {
|
||||
:host([unselected]) frigate-card-carousel {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,4 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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 {
|
||||
// Center unseekable icon.
|
||||
position: relative;
|
||||
|
||||
--video-max-height: none;
|
||||
}
|
||||
|
||||
// If the carousel has an unselected attribute set on it, do not let the
|
||||
@@ -8,17 +10,16 @@
|
||||
// when the carousel is part of a media-grid. Without this next/prev controls
|
||||
// will enlarge on hover, and the wheel-gestures plugin may block scrolling.
|
||||
// See matching in live-carousel.scss .
|
||||
:host([unselected]) frigate-card-media-carousel,
|
||||
:host([unselected]) .seek-warning
|
||||
{
|
||||
:host([unselected]) frigate-card-carousel,
|
||||
:host([unselected]) .seek-warning {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:host([unseekable]) frigate-card-media-carousel {
|
||||
:host([unseekable]) frigate-card-carousel {
|
||||
filter: brightness(50%);
|
||||
}
|
||||
:host([unseekable]) .seek-warning {
|
||||
display: block
|
||||
display: block;
|
||||
}
|
||||
|
||||
.seek-warning {
|
||||
|
||||
@@ -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 =>
|
||||
element instanceof HTMLElement;
|
||||
|
||||
export const getChildrenFromElement = (parent: HTMLElement): HTMLElement[] => {
|
||||
const children =
|
||||
parent instanceof HTMLSlotElement
|
||||
? parent.assignedElements({ flatten: true })
|
||||
: [...parent.children];
|
||||
return children.filter(isHTMLElement);
|
||||
};
|
||||
|
||||
+1
-3
@@ -65,8 +65,6 @@ export function getAllDependentCameras(
|
||||
}
|
||||
}
|
||||
};
|
||||
if (cameraID) {
|
||||
getDependentCameras(cameraID);
|
||||
}
|
||||
getDependentCameras(cameraID);
|
||||
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 { CreatePluginType } from 'embla-carousel/components/Plugins';
|
||||
import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
|
||||
import { LazyUnloadCondition } from '../../types';
|
||||
import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler';
|
||||
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
|
||||
import { LazyUnloadCondition } from '../../../../types';
|
||||
|
||||
declare module 'embla-carousel/components/Plugins' {
|
||||
interface EmblaPluginsType {
|
||||
lazyload?: AutoLazyLoadType;
|
||||
}
|
||||
}
|
||||
|
||||
type OptionsType = CreateOptionsType<{
|
||||
// Number of slides to lazyload left/right of selected (0 == only selected
|
||||
// slide).
|
||||
lazyLoadCount?: number;
|
||||
lazyLoadCount: number;
|
||||
lazyUnloadCondition?: LazyUnloadCondition;
|
||||
|
||||
lazyLoadCallback?: (index: number, slide: HTMLElement) => void;
|
||||
lazyUnloadCallback?: (index: number, slide: HTMLElement) => void;
|
||||
}>;
|
||||
type AutoLazyLoadOptionsType = Partial<OptionsType>;
|
||||
type AutoLazyLoadType = CreatePluginType<LoosePluginType, AutoLazyLoadOptionsType>;
|
||||
|
||||
const defaultOptions: OptionsType = {
|
||||
active: true,
|
||||
@@ -19,74 +28,54 @@ const defaultOptions: OptionsType = {
|
||||
lazyLoadCount: 0,
|
||||
};
|
||||
|
||||
type LazyloadOptionsType = Partial<OptionsType>;
|
||||
|
||||
type LazyloadType = CreatePluginType<
|
||||
{
|
||||
hasLazyloaded(index: number): boolean;
|
||||
},
|
||||
LazyloadOptionsType
|
||||
>;
|
||||
|
||||
declare module 'embla-carousel/components/Plugins' {
|
||||
interface EmblaPluginsType {
|
||||
lazyload?: LazyloadType;
|
||||
}
|
||||
}
|
||||
|
||||
export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
|
||||
const optionsHandler = EmblaCarousel.optionsHandler();
|
||||
const optionsBase = optionsHandler.merge(defaultOptions, Lazyload.globalOptions);
|
||||
let options: LazyloadType['options'];
|
||||
|
||||
let carousel: EmblaCarouselType;
|
||||
export function AutoLazyLoad(
|
||||
userOptions: AutoLazyLoadOptionsType = {},
|
||||
): AutoLazyLoadType {
|
||||
let options: OptionsType;
|
||||
let emblaApi: EmblaCarouselType;
|
||||
let slides: HTMLElement[];
|
||||
const lazyLoadedSlides: Set<number> = new Set();
|
||||
|
||||
const loadEvents: EmblaEventType[] = ['init', 'select', 'resize'];
|
||||
const loadEvents: EmblaEventType[] = ['init', 'select'];
|
||||
const unloadEvents: EmblaEventType[] = ['select'];
|
||||
|
||||
/**
|
||||
* Initialize the plugin.
|
||||
*/
|
||||
function init(embla: EmblaCarouselType): void {
|
||||
carousel = embla;
|
||||
options = optionsHandler.atMedia(self.options);
|
||||
slides = carousel.slideNodes();
|
||||
function init(
|
||||
emblaApiInstance: EmblaCarouselType,
|
||||
optionsHandler: OptionsHandlerType,
|
||||
): void {
|
||||
const { mergeOptions, optionsAtMedia } = optionsHandler;
|
||||
const allOptions = mergeOptions(defaultOptions, userOptions);
|
||||
options = optionsAtMedia(allOptions);
|
||||
|
||||
emblaApi = emblaApiInstance;
|
||||
slides = emblaApi.slideNodes();
|
||||
|
||||
if (options.lazyLoadCallback) {
|
||||
loadEvents.forEach((evt) => carousel.on(evt, lazyLoadHandler));
|
||||
loadEvents.forEach((evt) => emblaApi.on(evt, lazyLoadHandler));
|
||||
}
|
||||
if (
|
||||
options.lazyUnloadCallback &&
|
||||
options.lazyUnloadCondition &&
|
||||
['all', 'unselected'].includes(options.lazyUnloadCondition)
|
||||
) {
|
||||
unloadEvents.forEach((evt) => carousel.on(evt, lazyUnloadPreviousHandler));
|
||||
unloadEvents.forEach((evt) => emblaApi.on(evt, lazyUnloadPreviousHandler));
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibilityHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the plugin.
|
||||
*/
|
||||
function destroy(): void {
|
||||
if (options.lazyLoadCallback) {
|
||||
loadEvents.forEach((evt) => carousel.off(evt, lazyLoadHandler));
|
||||
loadEvents.forEach((evt) => emblaApi.off(evt, lazyLoadHandler));
|
||||
}
|
||||
if (options.lazyUnloadCallback) {
|
||||
unloadEvents.forEach((evt) => carousel.off(evt, lazyUnloadPreviousHandler));
|
||||
unloadEvents.forEach((evt) => emblaApi.off(evt, lazyUnloadPreviousHandler));
|
||||
}
|
||||
document.removeEventListener('visibilitychange', visibilityHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle document visibility changes.
|
||||
*/
|
||||
function visibilityHandler(): void {
|
||||
if (
|
||||
document.visibilityState === 'hidden' &&
|
||||
options.lazyUnloadCallback &&
|
||||
options.lazyUnloadCondition &&
|
||||
['all', 'hidden'].includes(options.lazyUnloadCondition)
|
||||
) {
|
||||
@@ -96,21 +85,13 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a slide index has been lazily loaded.
|
||||
* @param index Slide index.
|
||||
* @returns `true` if the slide has been lazily loaded.
|
||||
*/
|
||||
function hasLazyloaded(index: number): boolean {
|
||||
return lazyLoadedSlides.has(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily load media in the carousel.
|
||||
*/
|
||||
function lazyLoadHandler(): void {
|
||||
const lazyLoadCount = options.lazyLoadCount ?? 0;
|
||||
const currentIndex = carousel.selectedScrollSnap();
|
||||
const lazyLoadCount = options.lazyLoadCount;
|
||||
const currentIndex = emblaApi.selectedScrollSnap();
|
||||
const slidesToLoad = new Set<number>();
|
||||
|
||||
// Lazily load 'count' slides on either side of the slides in view.
|
||||
@@ -130,9 +111,6 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily unload all media in the carousel.
|
||||
*/
|
||||
function lazyUnloadAllHandler(): void {
|
||||
lazyLoadedSlides.forEach((index) => {
|
||||
if (options.lazyUnloadCallback) {
|
||||
@@ -142,11 +120,8 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily unload the previously selected media in the carousel.
|
||||
*/
|
||||
function lazyUnloadPreviousHandler(): void {
|
||||
const index = carousel.previousScrollSnap();
|
||||
const index = emblaApi.previousScrollSnap();
|
||||
|
||||
if (hasLazyloaded(index) && options.lazyUnloadCallback) {
|
||||
options.lazyUnloadCallback(index, slides[index]);
|
||||
@@ -154,14 +129,11 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
|
||||
}
|
||||
}
|
||||
|
||||
const self: LazyloadType = {
|
||||
name: 'lazyload',
|
||||
options: optionsHandler.merge(optionsBase, userOptions),
|
||||
const self: AutoLazyLoadType = {
|
||||
name: 'autoLazyLoad',
|
||||
options: userOptions,
|
||||
init,
|
||||
destroy,
|
||||
hasLazyloaded,
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
Lazyload.globalOptions = <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 Masonry from 'masonry-layout';
|
||||
import { MediaLoadedInfo, ViewDisplayConfig } from '../types';
|
||||
import { dispatchFrigateCardEvent, setOrRemoveAttribute } from './basic';
|
||||
import { dispatchFrigateCardEvent, getChildrenFromElement, setOrRemoveAttribute } from './basic';
|
||||
import {
|
||||
FrigateMediaLoadedEventTarget,
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
@@ -144,20 +144,11 @@ export class MediaGridController {
|
||||
}
|
||||
|
||||
protected _calculateGridContentsFromHost = (): void => {
|
||||
let childrenElements: Element[];
|
||||
|
||||
if (this._host instanceof HTMLSlotElement) {
|
||||
childrenElements = this._host.assignedElements();
|
||||
} else {
|
||||
childrenElements = [...this._host.children];
|
||||
}
|
||||
|
||||
const children = getChildrenFromElement(this._host);
|
||||
const gridContents: MediaGridContents = new Map();
|
||||
for (const child of childrenElements) {
|
||||
if (child instanceof HTMLElement) {
|
||||
const id = child.getAttribute(this._idAttribute) || String(gridContents.size);
|
||||
gridContents.set(id, child);
|
||||
}
|
||||
for (const child of children) {
|
||||
const id = child.getAttribute(this._idAttribute) || String(gridContents.size);
|
||||
gridContents.set(id, child);
|
||||
}
|
||||
|
||||
this._setGridContents(gridContents);
|
||||
@@ -200,6 +191,7 @@ export class MediaGridController {
|
||||
const eventPath = ev.composedPath();
|
||||
|
||||
for (const [id, element] of this._gridContents.entries()) {
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (eventPath.includes(element)) {
|
||||
this._mediaLoadedInfoMap.set(id, ev.detail);
|
||||
if (id !== this._selected) {
|
||||
@@ -271,6 +263,7 @@ export class MediaGridController {
|
||||
const eventPath = ev.composedPath();
|
||||
|
||||
for (const [id, element] of this._gridContents.entries()) {
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (eventPath.includes(element)) {
|
||||
if (this._selected !== id) {
|
||||
this.selectCell(id);
|
||||
|
||||
+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 {
|
||||
addEventListener(
|
||||
event: 'frigate-card:media:loaded',
|
||||
@@ -122,6 +122,11 @@ export interface FrigateMediaLoadedEventTarget extends EventTarget {
|
||||
) => void,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): void;
|
||||
addEventListener(
|
||||
event: 'frigate-card:media:unloaded',
|
||||
listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): void;
|
||||
addEventListener(
|
||||
type: string,
|
||||
callback: EventListenerOrEventListenerObject,
|
||||
@@ -135,6 +140,11 @@ export interface FrigateMediaLoadedEventTarget extends EventTarget {
|
||||
) => void,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
removeEventListener(
|
||||
event: 'frigate-card:media:unloaded',
|
||||
listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: string,
|
||||
callback: EventListenerOrEventListenerObject,
|
||||
|
||||
@@ -82,7 +82,7 @@ export class MenuButtonController {
|
||||
) as FrigateCardCustomAction,
|
||||
});
|
||||
|
||||
if (visibleCameras) {
|
||||
if (visibleCameras.size) {
|
||||
const menuItems = Array.from(visibleCameras, ([cameraID, config]) => {
|
||||
const action = createFrigateCardCustomAction('camera_select', {
|
||||
camera: cameraID,
|
||||
@@ -399,6 +399,7 @@ export class MenuButtonController {
|
||||
const action = createFrigateCardCustomAction('display_mode_select', {
|
||||
display_mode: isGrid ? 'single' : 'grid',
|
||||
});
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (action) {
|
||||
buttons.push({
|
||||
icon: isGrid ? 'mdi:grid-off' : 'mdi:grid',
|
||||
|
||||
+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
|
||||
* type name entirely. */
|
||||
const contenders = new Set<string>();
|
||||
if (error && error.issues) {
|
||||
for (let i = 0; i < error.issues.length; i++) {
|
||||
const issue = error.issues[i];
|
||||
if (issue.code == 'invalid_union') {
|
||||
if (error.issues.length) {
|
||||
for (const issue of error.issues) {
|
||||
if (issue.code === 'invalid_union') {
|
||||
const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors;
|
||||
for (let j = 0; j < unionErrors.length; j++) {
|
||||
const nestedErrors = getParseErrorPaths(unionErrors[j]);
|
||||
for (const unionError of unionErrors) {
|
||||
const nestedErrors = getParseErrorPaths(unionError);
|
||||
if (nestedErrors && nestedErrors.size) {
|
||||
nestedErrors.forEach(contenders.add, contenders);
|
||||
}
|
||||
}
|
||||
} else if (issue.code == 'invalid_type') {
|
||||
if (issue.path[issue.path.length - 1] == 'type') {
|
||||
} else if (issue.code === 'invalid_type') {
|
||||
if (issue.path[issue.path.length - 1] === 'type') {
|
||||
return null;
|
||||
}
|
||||
contenders.add(getParseErrorPathString(issue.path));
|
||||
} else if (issue.code != 'custom') {
|
||||
} else {
|
||||
contenders.add(getParseErrorPathString(issue.path));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class ResultSlice {
|
||||
if (options?.results && options.results.length) {
|
||||
if (!options?.selectApproach || options?.selectApproach === 'last') {
|
||||
return options.results.length - 1;
|
||||
} else if (options.selectApproach === 'first') {
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-7
@@ -111,13 +111,9 @@ export class View {
|
||||
const switchingToMediaFromMedia = curr?.is('media') && next.is('media');
|
||||
|
||||
if (hasNoQueryOrResults) {
|
||||
if (switchingToGalleryFromViewer) {
|
||||
if (curr.query) {
|
||||
next.query = curr.query;
|
||||
}
|
||||
if (curr.queryResults) {
|
||||
next.queryResults = curr.queryResults;
|
||||
}
|
||||
if (switchingToGalleryFromViewer && curr.query && curr.queryResults) {
|
||||
next.query = curr.query;
|
||||
next.queryResults = curr.queryResults;
|
||||
} else if (switchingToMediaFromMedia && currentQueriesView) {
|
||||
next.view =
|
||||
currentQueriesView === 'clips'
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('RecordingSegmentsCache', () => {
|
||||
start: now,
|
||||
end: add(now, { hours: 1 }),
|
||||
};
|
||||
const badRange = { start: sub(now, { hours: 1 }), end: now };
|
||||
const pastRange = { start: sub(now, { hours: 1 }), end: now };
|
||||
const createSegment = (date: Date, id: string): RecordingSegment => {
|
||||
return {
|
||||
start_time: date.getTime() / 1000,
|
||||
@@ -89,7 +89,7 @@ describe('RecordingSegmentsCache', () => {
|
||||
});
|
||||
it('should not get for other range', () => {
|
||||
cache.add('camera-1', range, segments);
|
||||
expect(cache.get('camera-1', badRange)).toBeNull();
|
||||
expect(cache.get('camera-1', pastRange)).toBeNull();
|
||||
});
|
||||
|
||||
it('should have coverage when added', () => {
|
||||
@@ -102,7 +102,7 @@ describe('RecordingSegmentsCache', () => {
|
||||
});
|
||||
it('should not have coverage for other range', () => {
|
||||
cache.add('camera-1', range, segments);
|
||||
expect(cache.hasCoverage('camera-1', badRange)).toBeFalsy();
|
||||
expect(cache.hasCoverage('camera-1', pastRange)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should be empty when cleared', () => {
|
||||
@@ -114,11 +114,11 @@ describe('RecordingSegmentsCache', () => {
|
||||
|
||||
it('should get size', () => {
|
||||
cache.add('camera-1', range, segments);
|
||||
expect(cache.getSize("camera-1")).toBe(3);
|
||||
expect(cache.getSize('camera-1')).toBe(3);
|
||||
});
|
||||
it('should not size for other camera', () => {
|
||||
cache.add('camera-1', range, segments);
|
||||
expect(cache.getSize("camera-2")).toBeNull();
|
||||
expect(cache.getSize('camera-2')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return cameraIDs', () => {
|
||||
@@ -127,6 +127,24 @@ describe('RecordingSegmentsCache', () => {
|
||||
expect(sortBy(cache.getCameraIDs())).toEqual(sortBy(['camera-1', 'camera-2']));
|
||||
});
|
||||
|
||||
it('should add segments to existing range', () => {
|
||||
cache.add('camera-1', range, segments);
|
||||
cache.add('camera-1', range, [
|
||||
createSegment(add(now, { seconds: 15 }), 'segment-2.5'),
|
||||
]);
|
||||
expect(cache.get('camera-1', range)?.length).toBe(4);
|
||||
});
|
||||
it('should not get segments that are outside range', () => {
|
||||
cache.add('camera-1', range, segments);
|
||||
expect(cache.get('camera-1', range)?.length).toBe(3);
|
||||
// Add a segment before and after the desired range.
|
||||
cache.add('camera-1', range, [
|
||||
createSegment(sub(now, { seconds: 15 }), 'segment-0'),
|
||||
createSegment(add(range.end, { seconds: 10 }), 'segment-4'),
|
||||
]);
|
||||
expect(cache.get('camera-1', range)?.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should remove expired matches', () => {
|
||||
cache.add('camera-1', range, segments);
|
||||
cache.expireMatches('camera-1', (segment) => segment === segments[0]);
|
||||
|
||||
@@ -100,6 +100,11 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
|
||||
Engine.Frigate,
|
||||
);
|
||||
});
|
||||
it('should get no engine from config with insufficient details', async () => {
|
||||
const config = createCameraConfig({});
|
||||
expect(await createFactory().getEngineForCamera(createHASS(), config)).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw error on invalid entity', async () => {
|
||||
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
|
||||
const entityRegistryManager = new EntityRegistryManager(new EntityCache());
|
||||
|
||||
@@ -135,6 +135,7 @@ describe('compressRanges', () => {
|
||||
compressRanges([
|
||||
{ start: now, end: nowPlusOne },
|
||||
{ start: nowPlusOne, end: nowPlusTwo },
|
||||
{ start: now, end: nowPlusOne },
|
||||
]),
|
||||
).toEqual([{ start: now, end: nowPlusTwo }]);
|
||||
});
|
||||
@@ -162,4 +163,8 @@ describe('compressRanges', () => {
|
||||
];
|
||||
expect(compressRanges(input)).toEqual([{ start: 1, end: 3 }]);
|
||||
});
|
||||
|
||||
it('should return nothing with no input', () => {
|
||||
expect(compressRanges([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+47
-11
@@ -239,17 +239,53 @@ export class TestViewMedia extends ViewMedia {
|
||||
}
|
||||
}
|
||||
|
||||
export const createResizeObserverImplementation = (): (() => void) => {
|
||||
return () => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
});
|
||||
export const ResizeObserverMock = vi.fn(() => ({
|
||||
disconnect: vi.fn(),
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
}));
|
||||
|
||||
export const IntersectionObserverMock = vi.fn(() => ({
|
||||
disconnect: vi.fn(),
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
}));
|
||||
|
||||
export const MutationObserverMock = vi.fn(() => ({
|
||||
disconnect: vi.fn(),
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
}));
|
||||
|
||||
export const requestAnimationFrameMock = (callback: FrameRequestCallback) => {
|
||||
callback(new Date().getTime());
|
||||
return 1;
|
||||
};
|
||||
|
||||
export const createMutationObserverImplementation = (): (() => void) => {
|
||||
return () => ({
|
||||
observe: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
});
|
||||
export const createSlotHost = (options?: {
|
||||
slot?: HTMLSlotElement;
|
||||
children?: HTMLElement[];
|
||||
}): HTMLElement => {
|
||||
const parent = document.createElement('div');
|
||||
parent.attachShadow({ mode: 'open' });
|
||||
|
||||
if (options?.slot) {
|
||||
parent.shadowRoot?.append(options.slot);
|
||||
}
|
||||
if (options?.children) {
|
||||
// Children will automatically be slotted into the default slot when it is
|
||||
// created.
|
||||
parent.append(...options.children);
|
||||
}
|
||||
return parent;
|
||||
};
|
||||
|
||||
export const createSlot = (): HTMLSlotElement => {
|
||||
return document.createElement('slot');
|
||||
};
|
||||
|
||||
export const createParent = (options?: { children?: HTMLElement[] }): HTMLElement => {
|
||||
const parent = document.createElement('div');
|
||||
parent.append(...(options?.children ?? []));
|
||||
return parent;
|
||||
};
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { describe, it, expect, vi, afterAll } from 'vitest';
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import { FrigateCardError } from '../../src/types';
|
||||
import {
|
||||
allPromises,
|
||||
arrayify,
|
||||
arrayMove,
|
||||
arrayify,
|
||||
contentsChanged,
|
||||
dayToDate,
|
||||
dispatchFrigateCardEvent,
|
||||
errorToConsole,
|
||||
isTruthy,
|
||||
formatDate,
|
||||
formatDateAndTime,
|
||||
getChildrenFromElement,
|
||||
getDurationString,
|
||||
isHTMLElement,
|
||||
isHoverableDevice,
|
||||
isSuperset,
|
||||
isTruthy,
|
||||
isValidDate,
|
||||
prettifyTitle,
|
||||
runWhenIdleIfSupported,
|
||||
setify,
|
||||
setOrRemoveAttribute,
|
||||
setify,
|
||||
sleep,
|
||||
isHTMLElement,
|
||||
} from '../../src/utils/basic';
|
||||
import { createSlot, createSlotHost } from '../test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('dispatchFrigateCardEvent', () => {
|
||||
@@ -183,6 +185,11 @@ describe('getDurationString', () => {
|
||||
const end = new Date(2023, 3, 14, 15, 37, 20);
|
||||
expect(getDurationString(start, end)).toBe('2h 2m 20s');
|
||||
});
|
||||
it('should return very short duration', () => {
|
||||
const start = new Date(2023, 3, 14, 13, 35, 10);
|
||||
const end = new Date(2023, 3, 14, 13, 35, 12);
|
||||
expect(getDurationString(start, end)).toBe('2s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('allPromises', () => {
|
||||
@@ -268,3 +275,19 @@ describe('isHTMLElement', () => {
|
||||
expect(isHTMLElement(svgElement)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChildrenFromElement', () => {
|
||||
it('should return children for simple parent', () => {
|
||||
const children = [document.createElement('div'), document.createElement('div')];
|
||||
const parent = document.createElement('div');
|
||||
children.forEach((child) => parent.appendChild(child));
|
||||
expect(getChildrenFromElement(parent)).toEqual(children);
|
||||
});
|
||||
|
||||
it('should return children for slot', () => {
|
||||
const children = [document.createElement('div'), document.createElement('div')];
|
||||
const slot = createSlot();
|
||||
createSlotHost({ slot: slot, children: children });
|
||||
expect(getChildrenFromElement(slot)).toEqual(children);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,13 +12,66 @@ vi.mock('../../src/utils/ha');
|
||||
const media = new ViewMedia('clip', 'camera-1');
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('downloadURL', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
global.window.location = mock<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', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
global.window.location = mock<Location>();
|
||||
});
|
||||
|
||||
it('should throw error when no media', async () => {
|
||||
it('should throw error when no media', () => {
|
||||
const cameraManager = createCameraManager();
|
||||
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue(null);
|
||||
|
||||
@@ -55,44 +108,16 @@ describe('downloadMedia', () => {
|
||||
await downloadMedia(createHASS(), cameraManager, media);
|
||||
expect(windowSpy).toBeCalledWith('http://foo/signed-url', '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadURL', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
global.window.location = mock<Location>();
|
||||
});
|
||||
|
||||
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)',
|
||||
it('should download media without signing', async () => {
|
||||
const cameraManager = createCameraManager();
|
||||
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue({
|
||||
sign: false,
|
||||
endpoint: 'https://foo/',
|
||||
});
|
||||
|
||||
const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
|
||||
downloadURL('http://foo/url.mp4');
|
||||
expect(windowSpy).toBeCalledWith('http://foo/url.mp4', '_blank');
|
||||
await downloadMedia(createHASS(), cameraManager, media);
|
||||
expect(windowSpy).toBeCalledWith('https://foo/', '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info';
|
||||
import {
|
||||
createMutationObserverImplementation,
|
||||
createResizeObserverImplementation,
|
||||
MutationObserverMock,
|
||||
ResizeObserverMock,
|
||||
createSlot,
|
||||
createSlotHost,
|
||||
} from '../test-utils';
|
||||
|
||||
vi.mock('lodash-es/throttle', () => ({
|
||||
@@ -41,7 +43,7 @@ const setElementWidth = (element: HTMLElement, width: number): void => {
|
||||
});
|
||||
};
|
||||
|
||||
const createHost = (options?: {
|
||||
const createParent = (options?: {
|
||||
children?: HTMLElement[];
|
||||
width?: number;
|
||||
}): HTMLElement => {
|
||||
@@ -54,28 +56,6 @@ const createHost = (options?: {
|
||||
return host;
|
||||
};
|
||||
|
||||
const createSlotParent = (): HTMLElement => {
|
||||
const parent = document.createElement('div');
|
||||
parent.attachShadow({ mode: 'open' });
|
||||
return parent;
|
||||
};
|
||||
|
||||
const createSlotHost = (options?: {
|
||||
children?: HTMLElement[];
|
||||
parent?: HTMLElement;
|
||||
}): HTMLSlotElement => {
|
||||
const parent = options?.parent ?? createSlotParent();
|
||||
const slot = document.createElement('slot');
|
||||
parent.shadowRoot?.append(slot);
|
||||
|
||||
if (options?.children) {
|
||||
// Children will automatically be slotted into the default slot.
|
||||
parent.append(...options.children);
|
||||
}
|
||||
|
||||
return slot;
|
||||
};
|
||||
|
||||
const createController = (host: HTMLElement, options?: MediaGridConstructorOptions) => {
|
||||
return new MediaGridController(host, options);
|
||||
};
|
||||
@@ -101,30 +81,20 @@ describe('MediaGridController', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
global.ResizeObserver = vi
|
||||
.fn()
|
||||
// Caution: Order must match the order of initialization in
|
||||
// media-grid-controller.ts .
|
||||
.mockImplementationOnce(createResizeObserverImplementation())
|
||||
.mockImplementationOnce(createResizeObserverImplementation());
|
||||
|
||||
global.MutationObserver = vi
|
||||
.fn()
|
||||
.mockImplementation(createMutationObserverImplementation());
|
||||
//global.MutationObserver = mock<MutationObserver>();
|
||||
vi.stubGlobal('MutationObserver', MutationObserverMock);
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
||||
});
|
||||
|
||||
it('should be constructable', () => {
|
||||
const controller = createController(createHost());
|
||||
const controller = createController(createParent());
|
||||
expect(controller).toBeTruthy();
|
||||
expect(masonry.layout).toBeCalled();
|
||||
});
|
||||
|
||||
it('should set grid contents correctly from regular elements', () => {
|
||||
const children = createChildren();
|
||||
const host = createHost({ children: children });
|
||||
const controller = createController(host);
|
||||
const parent = createParent({ children: children });
|
||||
const controller = createController(parent);
|
||||
expect(controller.getGridContents()).toEqual(
|
||||
new Map([
|
||||
['0', children[0]],
|
||||
@@ -138,7 +108,8 @@ describe('MediaGridController', () => {
|
||||
|
||||
it('should set grid contents correctly from slotted elements', () => {
|
||||
const children = createChildren();
|
||||
const host = createSlotHost({ children: children });
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(host);
|
||||
expect(controller.getGridContents()).toEqual(
|
||||
new Map([
|
||||
@@ -152,7 +123,10 @@ describe('MediaGridController', () => {
|
||||
|
||||
it('should select element', () => {
|
||||
const children = createChildren();
|
||||
const controller = createController(createSlotHost({ children: children }));
|
||||
const slot = createSlot();
|
||||
createSlotHost({ slot: slot, children: children });
|
||||
|
||||
const controller = createController(slot);
|
||||
|
||||
// All children should be unselected.
|
||||
expect(controller.getSelected()).toBeNull();
|
||||
@@ -176,7 +150,10 @@ describe('MediaGridController', () => {
|
||||
});
|
||||
|
||||
it('should re-select element', () => {
|
||||
const controller = createController(createSlotHost({ children: createChildren() }));
|
||||
const children = createChildren();
|
||||
const slot = createSlot();
|
||||
createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(slot);
|
||||
|
||||
// All children should be unselected.
|
||||
expect(controller.getSelected()).toBeNull();
|
||||
@@ -190,16 +167,14 @@ describe('MediaGridController', () => {
|
||||
|
||||
it('should dispatch media loaded info on selection', () => {
|
||||
const children = createChildren();
|
||||
const host = createSlotHost({ children: children });
|
||||
const controller = createController(host);
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(slot);
|
||||
|
||||
const mediaLoadedInfoHandler = vi.fn();
|
||||
host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
|
||||
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||
|
||||
// Nothing is selected, so the event should not have propagated.
|
||||
expect(mediaLoadedInfoHandler).not.toBeCalled();
|
||||
|
||||
controller.selectCell('0');
|
||||
expect(mediaLoadedInfoHandler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -208,9 +183,45 @@ describe('MediaGridController', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch media loaded info when cell is selected', () => {
|
||||
const children = createChildren();
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(slot);
|
||||
|
||||
controller.selectCell('0');
|
||||
|
||||
const mediaLoadedInfoHandler = vi.fn();
|
||||
host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
|
||||
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||
|
||||
expect(mediaLoadedInfoHandler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: mediaLoadedInfo,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not dispatch media loaded info when cell is not selected', () => {
|
||||
const children = createChildren();
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(host);
|
||||
|
||||
controller.selectCell('1');
|
||||
|
||||
const mediaLoadedInfoHandler = vi.fn();
|
||||
host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
|
||||
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||
|
||||
// Another element is selected, so the event should not have propagated.
|
||||
expect(mediaLoadedInfoHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should unselect', () => {
|
||||
const children = createChildren();
|
||||
const host = createSlotHost({ children: children });
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(host);
|
||||
|
||||
const unselectedHandler = vi.fn();
|
||||
@@ -234,20 +245,29 @@ describe('MediaGridController', () => {
|
||||
}
|
||||
|
||||
// Expect handlers to have been called.
|
||||
expect(unselectedHandler).toBeCalled();
|
||||
expect(unloadMediaHandler).toBeCalled();
|
||||
expect(unselectedHandler).toBeCalledTimes(1);
|
||||
expect(unloadMediaHandler).toBeCalledTimes(1);
|
||||
|
||||
// Unselecting a second time should do nothing.
|
||||
controller.unselectAll();
|
||||
|
||||
expect(unselectedHandler).toBeCalledTimes(1);
|
||||
expect(unloadMediaHandler).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should select in constructor', () => {
|
||||
const children = createChildren();
|
||||
const host = createSlotHost({ children: children });
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(host, { selected: '2' });
|
||||
|
||||
expect(controller.getSelected()).toBe('2');
|
||||
});
|
||||
|
||||
it('should respect grid attribute option', () => {
|
||||
const children = createChildren(['one', 'two', 'three'], 'test-id');
|
||||
const host = createSlotHost({ children: children });
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(host, { idAttribute: 'test-id' });
|
||||
expect(controller.getGridContents()).toEqual(
|
||||
new Map([
|
||||
@@ -258,10 +278,21 @@ describe('MediaGridController', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should destroy', () => {
|
||||
it('should destroy with regular elements', () => {
|
||||
const children = createChildren();
|
||||
const host = createSlotHost({ children: children });
|
||||
const controller = createController(host);
|
||||
const parent = createParent({ children: children });
|
||||
const controller = createController(parent);
|
||||
expect(controller.getGridSize()).toBe(3);
|
||||
controller.destroy();
|
||||
expect(controller.getGridSize()).toBe(0);
|
||||
});
|
||||
|
||||
it('should destroy with slotted elements', () => {
|
||||
const children = createChildren();
|
||||
const slot = createSlot();
|
||||
createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(slot);
|
||||
|
||||
expect(controller.getGridSize()).toBe(3);
|
||||
controller.destroy();
|
||||
expect(controller.getGridSize()).toBe(0);
|
||||
@@ -269,16 +300,16 @@ describe('MediaGridController', () => {
|
||||
|
||||
it('should replace children when they change', () => {
|
||||
const children = createChildren();
|
||||
const host = createHost({ children: children });
|
||||
const controller = createController(host, { selected: '1' });
|
||||
const parent = createParent({ children: children });
|
||||
const controller = createController(parent, { selected: '1' });
|
||||
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||
|
||||
expect(controller.getSelected()).toBe('1');
|
||||
expect(controller.getGridSize()).toBe(3);
|
||||
|
||||
children.forEach((child) => host.removeChild(child));
|
||||
children.forEach((child) => parent.removeChild(child));
|
||||
const newChildren = createChildren(['one', 'two', 'three']);
|
||||
newChildren.forEach((child) => host.appendChild(child));
|
||||
newChildren.forEach((child) => parent.appendChild(child));
|
||||
|
||||
triggerMutationObserver();
|
||||
|
||||
@@ -294,19 +325,19 @@ describe('MediaGridController', () => {
|
||||
|
||||
it('should replace children of a slot when they change', () => {
|
||||
const children = createChildren();
|
||||
const slotParent = createSlotParent();
|
||||
const host = createSlotHost({ children: children, parent: slotParent });
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
|
||||
const controller = createController(host, { selected: '1' });
|
||||
const controller = createController(slot, { selected: '1' });
|
||||
|
||||
expect(controller.getSelected()).toBe('1');
|
||||
expect(controller.getGridSize()).toBe(3);
|
||||
|
||||
children.forEach((child) => slotParent.removeChild(child));
|
||||
children.forEach((child) => host.removeChild(child));
|
||||
const newChildren = createChildren(['one', 'two', 'three']);
|
||||
newChildren.forEach((child) => slotParent.append(child));
|
||||
newChildren.forEach((child) => host.append(child));
|
||||
|
||||
host.dispatchEvent(new Event('slotchange'));
|
||||
slot.dispatchEvent(new Event('slotchange'));
|
||||
|
||||
expect(controller.getGridContents()).toEqual(
|
||||
new Map([
|
||||
@@ -320,10 +351,10 @@ describe('MediaGridController', () => {
|
||||
|
||||
it('should construct masonry correctly', () => {
|
||||
const children = createChildren();
|
||||
const host = createHost({ children: children });
|
||||
createController(host);
|
||||
const parent = createParent({ children: children });
|
||||
createController(parent);
|
||||
expect(Masonry).toBeCalledWith(
|
||||
host,
|
||||
parent,
|
||||
expect.objectContaining({
|
||||
initLayout: false,
|
||||
percentPosition: true,
|
||||
@@ -333,57 +364,85 @@ describe('MediaGridController', () => {
|
||||
});
|
||||
|
||||
it('should set default column size correctly', () => {
|
||||
const host = createHost({ children: createChildren() });
|
||||
createController(host);
|
||||
const parent = createParent({ children: createChildren() });
|
||||
createController(parent);
|
||||
expect(Masonry).toBeCalledWith(
|
||||
host,
|
||||
parent,
|
||||
expect.objectContaining({
|
||||
columnWidth: 246,
|
||||
}),
|
||||
);
|
||||
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px');
|
||||
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px');
|
||||
});
|
||||
|
||||
it('should respect exact columns', () => {
|
||||
const host = createHost({ children: createChildren(), width: 2000 });
|
||||
const controller = createController(host);
|
||||
const parent = createParent({ children: createChildren(), width: 2000 });
|
||||
const controller = createController(parent);
|
||||
controller.setDisplayConfig({ mode: 'grid', grid_columns: 2 });
|
||||
|
||||
// Will have been called once on construction, and then again when the
|
||||
// number of columns changes.
|
||||
expect(Masonry).toBeCalledTimes(2);
|
||||
expect(Masonry).toBeCalledWith(
|
||||
host,
|
||||
parent,
|
||||
expect.objectContaining({
|
||||
columnWidth: 1000,
|
||||
}),
|
||||
);
|
||||
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe(
|
||||
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe(
|
||||
'1000px',
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect selected width factor', () => {
|
||||
const host = createHost({ children: createChildren(), width: 2000 });
|
||||
const controller = createController(host);
|
||||
const parent = createParent({ children: createChildren(), width: 2000 });
|
||||
const controller = createController(parent);
|
||||
controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 });
|
||||
expect(
|
||||
host.style.getPropertyValue('--frigate-card-grid-selected-width-factor'),
|
||||
parent.style.getPropertyValue('--frigate-card-grid-selected-width-factor'),
|
||||
).toBe('3');
|
||||
|
||||
// Setting the same config again should do nothing.
|
||||
controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 });
|
||||
expect(
|
||||
parent.style.getPropertyValue('--frigate-card-grid-selected-width-factor'),
|
||||
).toBe('3');
|
||||
});
|
||||
|
||||
it('should select cell with interacted with', () => {
|
||||
it('should select cell when interacted with', () => {
|
||||
const children = createChildren();
|
||||
const host = createHost({ children: children, width: 2000 });
|
||||
const controller = createController(host);
|
||||
const parent = createParent({ children: children, width: 2000 });
|
||||
const controller = createController(parent);
|
||||
|
||||
expect(controller.getSelected()).toBeNull();
|
||||
|
||||
const clickHandler = vi.fn();
|
||||
parent.addEventListener('click', clickHandler);
|
||||
|
||||
children[1].click();
|
||||
|
||||
// Click will not be allowed through.
|
||||
expect(clickHandler).not.toBeCalled();
|
||||
expect(controller.getSelected()).toBe('1');
|
||||
});
|
||||
|
||||
it('should ignore interaction events on already selected cell', () => {
|
||||
const children = createChildren();
|
||||
const parent = createParent({ children: children, width: 2000 });
|
||||
const controller = createController(parent);
|
||||
controller.selectCell('1');
|
||||
|
||||
const clickHandler = vi.fn();
|
||||
parent.addEventListener('click', clickHandler);
|
||||
children[1].click();
|
||||
|
||||
// Click will be allowed through.
|
||||
expect(clickHandler).toBeCalled();
|
||||
expect(controller.getSelected()).toBe('1');
|
||||
});
|
||||
|
||||
it('should re-layout when child size changes', () => {
|
||||
createController(createHost({ children: createChildren() }));
|
||||
createController(createParent({ children: createChildren() }));
|
||||
|
||||
vi.mocked(masonry.layout)?.mockClear();
|
||||
triggerResizeObserver('cell');
|
||||
@@ -392,32 +451,41 @@ describe('MediaGridController', () => {
|
||||
|
||||
it('should re-create masonry when host size changes', () => {
|
||||
const children = createChildren();
|
||||
const host = createHost({ children: children });
|
||||
const controller = createController(host);
|
||||
const parent = createParent({ children: children });
|
||||
createController(parent);
|
||||
expect(Masonry).toBeCalledWith(
|
||||
host,
|
||||
parent,
|
||||
expect.objectContaining({
|
||||
columnWidth: 246,
|
||||
}),
|
||||
);
|
||||
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px');
|
||||
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px');
|
||||
|
||||
// Clear mock state.
|
||||
vi.mocked(Masonry).mockClear();
|
||||
vi.mocked(masonry.layout)?.mockClear();
|
||||
|
||||
// Resize the host.
|
||||
setElementWidth(host, 2000);
|
||||
setElementWidth(parent, 2000);
|
||||
triggerResizeObserver('host');
|
||||
|
||||
// Masonry should be reconstructed, styles set and layout called.
|
||||
expect(Masonry).toBeCalledWith(
|
||||
host,
|
||||
parent,
|
||||
expect.objectContaining({
|
||||
columnWidth: 667,
|
||||
}),
|
||||
);
|
||||
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('667px');
|
||||
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('667px');
|
||||
expect(masonry.layout).toBeCalled();
|
||||
|
||||
// Clear mock state.
|
||||
vi.mocked(Masonry).mockClear();
|
||||
vi.mocked(masonry.layout)?.mockClear();
|
||||
|
||||
// Triger with the same sizes.
|
||||
triggerResizeObserver('host');
|
||||
expect(Masonry).not.toBeCalled();
|
||||
expect(masonry.layout).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,6 +108,16 @@ describe('playMediaMutingIfNecessary', () => {
|
||||
expect(player.isMuted).toBeCalled();
|
||||
expect(player.mute).toBeCalled();
|
||||
});
|
||||
|
||||
it('should ignore calls without a video', async () => {
|
||||
const player = mock<FrigateCardMediaPlayer>();
|
||||
player.isMuted.mockReturnValue(false);
|
||||
|
||||
await playMediaMutingIfNecessary(player);
|
||||
|
||||
expect(player.isMuted).not.toBeCalled();
|
||||
expect(player.mute).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('constants', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import screenfull from 'screenfull';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
@@ -160,6 +161,26 @@ describe('MenuButtonController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not have a cameras menu without a visible camera', () => {
|
||||
const cameraManager = createCameraManager({
|
||||
configs: new Map([
|
||||
['camera-1', createCameraConfig()],
|
||||
['camera-2', createCameraConfig()],
|
||||
]),
|
||||
});
|
||||
|
||||
vi.mocked(cameraManager.getStore()).getVisibleCameras.mockReturnValue(new Map());
|
||||
|
||||
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
|
||||
expect(buttons).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: 'Cameras',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should have substream button with single dependency', () => {
|
||||
const cameraManager = createCameraManager({
|
||||
configs: new Map([
|
||||
@@ -1122,7 +1143,17 @@ describe('MenuButtonController', () => {
|
||||
style: {},
|
||||
};
|
||||
controller.addDynamicMenuButton(button);
|
||||
expect(calculateButtons(controller)).toContainEqual(button);
|
||||
expect(
|
||||
calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton))
|
||||
.length,
|
||||
).toBe(1);
|
||||
|
||||
// Adding it again will have no effect.
|
||||
controller.addDynamicMenuButton(button);
|
||||
expect(
|
||||
calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton))
|
||||
.length,
|
||||
).toBe(1);
|
||||
|
||||
controller.removeDynamicMenuButton(button);
|
||||
expect(calculateButtons(controller)).not.toContainEqual(button);
|
||||
|
||||
@@ -52,6 +52,20 @@ describe('createViewWithoutSubstream', () => {
|
||||
const newView = createViewWithoutSubstream(view);
|
||||
expect(newView?.context?.live?.overrides).toEqual(new Map());
|
||||
});
|
||||
|
||||
it('should create view with overrides untouched', () => {
|
||||
const view = new View({
|
||||
view: 'live',
|
||||
camera: 'camera-1',
|
||||
context: {
|
||||
live: {
|
||||
overrides: new Map([['camera-2', 'camera-3']]),
|
||||
},
|
||||
},
|
||||
});
|
||||
const newView = createViewWithoutSubstream(view);
|
||||
expect(newView?.context?.live?.overrides).toEqual(view.context?.live?.overrides);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasSubstream', () => {
|
||||
@@ -95,7 +109,7 @@ describe('createViewWithNextStream', () => {
|
||||
camera: 'camera',
|
||||
});
|
||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera']));
|
||||
const cameraManager = createCameraManager()
|
||||
const cameraManager = createCameraManager();
|
||||
const newView = createViewWithNextStream(cameraManager, view);
|
||||
expect(newView.camera).toBe(view.camera);
|
||||
expect(newView.view).toBe(view.view);
|
||||
@@ -107,7 +121,7 @@ describe('createViewWithNextStream', () => {
|
||||
camera: 'camera',
|
||||
});
|
||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
||||
const cameraManager = createCameraManager()
|
||||
const cameraManager = createCameraManager();
|
||||
const newView = createViewWithNextStream(cameraManager, view);
|
||||
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']]));
|
||||
});
|
||||
@@ -122,7 +136,7 @@ describe('createViewWithNextStream', () => {
|
||||
},
|
||||
});
|
||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
||||
const cameraManager = createCameraManager()
|
||||
const cameraManager = createCameraManager();
|
||||
const newView = createViewWithNextStream(cameraManager, view);
|
||||
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
|
||||
});
|
||||
@@ -137,7 +151,7 @@ describe('createViewWithNextStream', () => {
|
||||
},
|
||||
});
|
||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
||||
const cameraManager = createCameraManager()
|
||||
const cameraManager = createCameraManager();
|
||||
const newView = createViewWithNextStream(cameraManager, view);
|
||||
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { z, ZodError } from 'zod';
|
||||
import {
|
||||
deepRemoveDefaults,
|
||||
getParseErrorKeys,
|
||||
getParseErrorPaths,
|
||||
deepRemoveDefaults,
|
||||
getParseErrorKeys,
|
||||
getParseErrorPaths,
|
||||
} from '../../src/utils/zod';
|
||||
|
||||
describe('deepRemoveDefaults', () => {
|
||||
@@ -88,4 +88,7 @@ describe('getParseErrorPaths', () => {
|
||||
new Set(['array[0] -> type', 'array[0] -> data']),
|
||||
);
|
||||
});
|
||||
it('should get no paths for empty error', () => {
|
||||
expect(getParseErrorPaths(new ZodError([]))).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,6 +92,27 @@ describe('Zoom', () => {
|
||||
expect(panzoom.handleUp).toBeCalledWith(ev_5);
|
||||
});
|
||||
|
||||
it('should not respond to pointer when not zoomed', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
const ev_1 = new PointerEvent('pointerdown');
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(panzoom.handleDown).not.toBeCalledWith(ev_1);
|
||||
|
||||
const ev_2 = new PointerEvent('pointermove');
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(panzoom.handleDown).not.toBeCalledWith(ev_2);
|
||||
|
||||
const ev_3 = new PointerEvent('pointerup');
|
||||
element.dispatchEvent(ev_3);
|
||||
expect(panzoom.handleDown).not.toBeCalledWith(ev_3);
|
||||
});
|
||||
|
||||
it('should respond with touch', () => {
|
||||
mediaMediSpy.mockReturnValue(<MediaQueryList>{ matches: false });
|
||||
|
||||
@@ -252,4 +273,50 @@ describe('Zoom', () => {
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(element.style.touchAction).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not fire frigate cards when state has not changed or spurious events received', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
const zoomedFunc = vi.fn();
|
||||
const unzoomedFunc = vi.fn();
|
||||
|
||||
element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc);
|
||||
element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc);
|
||||
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
const ev_1 = new CustomEvent<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()),
|
||||
).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 { MediaQueriesResults } from '../../src/view/media-queries-results';
|
||||
import { View, dispatchViewContextChangeEvent } from '../../src/view/view';
|
||||
import { createView, generateViewMediaArray } from '../test-utils';
|
||||
import { createView } from '../test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('View Basics', () => {
|
||||
@@ -122,6 +122,14 @@ describe('View Basics', () => {
|
||||
expect(view.context).toEqual({});
|
||||
});
|
||||
|
||||
it('should not remove context when no context', () => {
|
||||
const view = createView();
|
||||
expect(view.context).toBeNull();
|
||||
|
||||
view.removeContext('live');
|
||||
expect(view.context).toBeNull();
|
||||
});
|
||||
|
||||
it('should remove context property', () => {
|
||||
const view = createView({ context: { live: { overrides: new Map() } } });
|
||||
|
||||
@@ -129,6 +137,13 @@ describe('View Basics', () => {
|
||||
expect(view.context).toEqual({ live: {} });
|
||||
});
|
||||
|
||||
it('should not remove context property that does not exist', () => {
|
||||
const view = createView({ context: {} });
|
||||
|
||||
view.removeContextProperty('live', 'overrides');
|
||||
expect(view.context).toEqual({});
|
||||
});
|
||||
|
||||
it('should detect gallery views', () => {
|
||||
expect(createView({ view: 'clips' }).isGalleryView()).toBeTruthy();
|
||||
expect(createView({ view: 'snapshots' }).isGalleryView()).toBeTruthy();
|
||||
@@ -292,6 +307,28 @@ describe('View.adoptFromViewIfAppropriate', () => {
|
||||
expect(next.queryResults).toBe(queryResults);
|
||||
});
|
||||
|
||||
it('should not adopt for gallery case if neither query nor results in current view', () => {
|
||||
const current = createView({
|
||||
view: 'clip',
|
||||
query: null,
|
||||
queryResults: null,
|
||||
});
|
||||
|
||||
const nextQuery = new EventMediaQueries([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
|
||||
]);
|
||||
const nextResults = new MediaQueriesResults();
|
||||
const next = createView({
|
||||
view: 'clips',
|
||||
query: nextQuery,
|
||||
queryResults: nextResults,
|
||||
});
|
||||
View.adoptFromViewIfAppropriate(next, current);
|
||||
expect(next.view).toBe('clips');
|
||||
expect(next.query).toBe(nextQuery);
|
||||
expect(next.queryResults).toBe(nextResults);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
new EventMediaQueries([
|
||||
@@ -311,7 +348,7 @@ describe('View.adoptFromViewIfAppropriate', () => {
|
||||
]),
|
||||
'recording',
|
||||
],
|
||||
])('should adopt for media case', (mediaQueries, expectedView) => {
|
||||
])('should adopt in media case', (mediaQueries, expectedView) => {
|
||||
const current = createView({
|
||||
view: 'media',
|
||||
query: mediaQueries,
|
||||
@@ -324,6 +361,54 @@ describe('View.adoptFromViewIfAppropriate', () => {
|
||||
expect(next.queryResults).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not adopt for mixed queries in media case', () => {
|
||||
const query = new EventMediaQueries([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true },
|
||||
]);
|
||||
const results = new MediaQueriesResults();
|
||||
const current = createView({
|
||||
view: 'media',
|
||||
query: query,
|
||||
queryResults: results,
|
||||
});
|
||||
const next = createView({ view: 'media' });
|
||||
View.adoptFromViewIfAppropriate(next, current);
|
||||
|
||||
expect(next.view).toBe('media');
|
||||
expect(next.query).toBeNull();
|
||||
expect(next.queryResults).toBeNull();
|
||||
});
|
||||
|
||||
it('should not adopt when queries and results present in next view in media case', () => {
|
||||
const currentQuery = new EventMediaQueries([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera-1']), hasClip: true },
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera-1']), hasSnapshot: true },
|
||||
]);
|
||||
const currentResults = new MediaQueriesResults();
|
||||
const current = createView({
|
||||
view: 'media',
|
||||
query: currentQuery,
|
||||
queryResults: currentResults,
|
||||
});
|
||||
|
||||
const nextQuery = new EventMediaQueries([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera-2']), hasClip: true },
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera-2']), hasSnapshot: true },
|
||||
]);
|
||||
const nextResults = new MediaQueriesResults();
|
||||
const next = createView({
|
||||
view: 'media',
|
||||
query: nextQuery,
|
||||
queryResults: nextResults,
|
||||
});
|
||||
View.adoptFromViewIfAppropriate(next, current);
|
||||
|
||||
expect(next.view).toBe('media');
|
||||
expect(next.query).toBe(nextQuery);
|
||||
expect(next.queryResults).toBe(nextResults);
|
||||
});
|
||||
|
||||
it('should not adopt for other case', () => {
|
||||
const query = new EventMediaQueries([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
|
||||
@@ -431,11 +516,15 @@ describe('View.adoptFromViewIfAppropriate', () => {
|
||||
expect(createView({ view: 'media' }).supportsMultipleDisplayModes()).toBeTruthy();
|
||||
expect(createView({ view: 'clip' }).supportsMultipleDisplayModes()).toBeTruthy();
|
||||
expect(createView({ view: 'snapshot' }).supportsMultipleDisplayModes()).toBeTruthy();
|
||||
expect(createView({ view: 'recording' }).supportsMultipleDisplayModes()).toBeTruthy();
|
||||
expect(
|
||||
createView({ view: 'recording' }).supportsMultipleDisplayModes(),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(createView({ view: 'clips' }).supportsMultipleDisplayModes()).toBeFalsy();
|
||||
expect(createView({ view: 'snapshots' }).supportsMultipleDisplayModes()).toBeFalsy();
|
||||
expect(createView({ view: 'recordings' }).supportsMultipleDisplayModes()).toBeFalsy();
|
||||
expect(
|
||||
createView({ view: 'recordings' }).supportsMultipleDisplayModes(),
|
||||
).toBeFalsy();
|
||||
expect(createView({ view: 'image' }).supportsMultipleDisplayModes()).toBeFalsy();
|
||||
expect(createView({ view: 'timeline' }).supportsMultipleDisplayModes()).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -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