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
+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);
}