Complete carousel refactor.

Reduces one layer of DOM nesting for simplication, uses the latest Embla
version, unittests for everything.
This commit is contained in:
Dermot Duffy
2023-09-04 16:25:32 -07:00
parent 48ece1e154
commit e830db1a77
56 changed files with 3561 additions and 1950 deletions
+5 -5
View File
@@ -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",
+1 -1
View File
@@ -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';
+5 -7
View File
@@ -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 {
+1 -1
View File
@@ -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) {
+9 -10
View File
@@ -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
View File
@@ -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);
}
+12 -8
View File
@@ -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;
});
-234
View File
@@ -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
View File
@@ -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;
}
-440
View File
@@ -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;
}
}
+68 -146
View File
@@ -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);
}
+24 -17
View File
@@ -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
View File
@@ -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;
}
+3
View File
@@ -2,6 +2,9 @@
display: block;
height: 100%;
width: 100%;
// Keep carousel controls relative to the media carousel itself.
position: relative;
}
.embla {
+5 -1
View File
@@ -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
View File
@@ -2,6 +2,4 @@
width: 100%;
height: 100%;
display: block;
--video-max-height: none;
}
-9
View File
@@ -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;
}
+6 -5
View File
@@ -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 {
+8
View File
@@ -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
View File
@@ -65,8 +65,6 @@ export function getAllDependentCameras(
}
}
};
if (cameraID) {
getDependentCameras(cameraID);
}
getDependentCameras(cameraID);
return cameraIDs;
}
+178
View File
@@ -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;
}
}
@@ -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;
+63
View File
@@ -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 },
);
}
+7 -14
View File
@@ -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
View File
@@ -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,
+2 -1
View File
@@ -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
View File
@@ -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));
}
}
+1 -1
View File
@@ -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
View File
@@ -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'
+23 -5
View File
@@ -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());
+5
View File
@@ -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([]);
});
});
+7
View File
@@ -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
View File
@@ -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;
};
+28 -5
View File
@@ -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);
});
});
+61 -36
View File
@@ -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();
});
});
+132
View File
@@ -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'),
);
};
+159 -91
View File
@@ -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();
});
});
+10
View File
@@ -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', () => {
+32 -1
View File
@@ -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);
+18 -4
View File
@@ -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']]));
});
+7 -4
View File
@@ -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());
});
});
+67
View File
@@ -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);
});
});
+36
View File
@@ -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
View File
@@ -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();
});
+11
View File
@@ -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',
},
},
});
+583 -312
View File
File diff suppressed because it is too large Load Diff