From 06364eb68aeb2a407004b6e048a58e185a0624fd Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 1 Jul 2022 20:50:26 -0700 Subject: [PATCH 01/14] Initial adaptation to embla 7. --- package.json | 2 +- src/components/embla-plugins/automedia.ts | 46 ++++++++++++++++------- src/components/embla-plugins/lazyload.ts | 30 ++++++++++----- yarn.lock | 8 ++-- 4 files changed, 58 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 67ac1304..662e65a9 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "crypto": "^1.0.1", "custom-card-helpers": "^1.9.0", "date-fns": "^2.28.0", - "embla-carousel": "^6.2.0", + "embla-carousel": "^7.0.0-rc01", "embla-carousel-wheel-gestures": "^2.1.1", "home-assistant-js-websocket": "^7.1.0", "keycharm": "^0.4.0", diff --git a/src/components/embla-plugins/automedia.ts b/src/components/embla-plugins/automedia.ts index 5a19b6be..e77cebcd 100644 --- a/src/components/embla-plugins/automedia.ts +++ b/src/components/embla-plugins/automedia.ts @@ -1,4 +1,6 @@ -import { EmblaCarouselType, EmblaPluginType } from 'embla-carousel'; +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, @@ -7,8 +9,8 @@ import { FrigateCardMediaPlayer, } from '../../types.js'; -export type AutoMediaPluginOptionsType = { - playerSelector: string; +export type AutoMediaPluginOptionsType = 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 @@ -18,16 +20,22 @@ export type AutoMediaPluginOptionsType = { autoUnmuteCondition?: AutoUnmuteCondition; autoPauseCondition?: AutoPauseCondition; autoMuteCondition?: AutoMuteCondition; +}>; + +export const defaultOptions: AutoMediaPluginOptionsType = { + active: true, + breakpoints: {}, }; -export const defaultOptions: Partial = {}; - -export type AutoMediaPluginType = EmblaPluginType & { - play: () => void; - pause: () => void; - mute: () => void; - unmute: () => void; -}; +export type AutoMediaPluginType = CreatePluginType< + { + play: () => void; + pause: () => void; + mute: () => void; + unmute: () => void; + }, + AutoMediaPluginOptionsType +>; /** * An Embla plugin to take automated actions on media (e.g. pause, unmute, etc). @@ -37,8 +45,13 @@ export type AutoMediaPluginType = EmblaPluginType & export function AutoMediaPlugin( userOptions?: AutoMediaPluginOptionsType, ): AutoMediaPluginType { - const options = Object.assign({}, defaultOptions, userOptions); + const optionsHandler = EmblaCarousel.optionsHandler(); + const optionsBase = optionsHandler.merge( + defaultOptions, + AutoMediaPlugin.globalOptions, + ); + let options: AutoMediaPluginType['options']; let carousel: EmblaCarouselType; let slides: HTMLElement[]; @@ -47,6 +60,7 @@ export function AutoMediaPlugin( */ 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 @@ -131,7 +145,9 @@ export function AutoMediaPlugin( * @returns A FrigateCardMediaPlayer object or `null`. */ function getPlayer(slide: HTMLElement | undefined): FrigateCardMediaPlayer | null { - return slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null; + return options.playerSelector + ? (slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null) + : null; } /** @@ -196,7 +212,7 @@ export function AutoMediaPlugin( const self: AutoMediaPluginType = { name: 'AutoMediaPlugin', - options, + options: optionsHandler.merge(optionsBase, userOptions), init, destroy, play, @@ -206,3 +222,5 @@ export function AutoMediaPlugin( }; return self; } + +AutoMediaPlugin.globalOptions = undefined; diff --git a/src/components/embla-plugins/lazyload.ts b/src/components/embla-plugins/lazyload.ts index 772ab3ff..98f15876 100644 --- a/src/components/embla-plugins/lazyload.ts +++ b/src/components/embla-plugins/lazyload.ts @@ -1,7 +1,9 @@ -import { EmblaCarouselType, EmblaEventType, EmblaPluginType } 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'; -export type LazyloadOptionsType = { +export type LazyloadOptionsType = CreateOptionsType<{ // Number of slides to lazyload left/right of selected (0 == only selected // slide). lazyLoadCount?: number; @@ -9,18 +11,25 @@ export type LazyloadOptionsType = { lazyLoadCallback?: (index: number, slide: HTMLElement) => void; lazyUnloadCallback?: (index: number, slide: HTMLElement) => void; -}; +}>; -export const defaultOptions: Partial = { +export const defaultOptions: LazyloadOptionsType = { + active: true, + breakpoints: {}, lazyLoadCount: 0, }; -export type LazyloadType = EmblaPluginType & { - hasLazyloaded: (index: number) => boolean; -}; +export type LazyloadType = CreatePluginType< + { + hasLazyloaded(index: number): boolean; + }, + LazyloadOptionsType +>; export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { - const options = Object.assign({}, defaultOptions, userOptions); + const optionsHandler = EmblaCarousel.optionsHandler(); + const optionsBase = optionsHandler.merge(defaultOptions, Lazyload.globalOptions); + let options: LazyloadType['options']; let carousel: EmblaCarouselType; let slides: HTMLElement[]; @@ -34,6 +43,7 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { */ function init(embla: EmblaCarouselType): void { carousel = embla; + options = optionsHandler.atMedia(self.options); slides = carousel.slideNodes(); if (options.lazyLoadCallback) { @@ -138,10 +148,12 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { const self: LazyloadType = { name: 'Lazyload', - options, + options: optionsHandler.merge(optionsBase, userOptions), init, destroy, hasLazyloaded, }; return self; } + +Lazyload.globalOptions = undefined; diff --git a/yarn.lock b/yarn.lock index 5004f5f7..8da90b3b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1162,10 +1162,10 @@ embla-carousel-wheel-gestures@^2.1.1: dependencies: wheel-gestures "^2.2.5" -embla-carousel@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-6.2.0.tgz#c16b18abe50e05ccd03d0b8d0b738f6a87aea1e0" - integrity sha512-dSNsiQ7nmSQJZgbYfZCLdzrnznHwpaAcdJFcMRKgm/pjH1doOgxmfsvlMy4VfO4J11hLz8jm/W8WxSSDqfuu4w== +embla-carousel@^7.0.0-rc01: + version "7.0.0-rc01" + resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-7.0.0-rc01.tgz#7c9adfd7302b85c2de9354b7ef6343f16a696eb7" + integrity sha512-IBTSKcPw7u9K0zoLvsnWYsijKsI0msFqzNp6ASIthTXiMZNJNGQt8k0ax7KqUVinDZeNM07WPWqYsjrvUM/Epw== emojis-list@^3.0.0: version "3.0.0" From 30fec98e18754abedfdd83db762b676bd9baa35e Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 2 Jul 2022 19:20:54 -0700 Subject: [PATCH 02/14] Use the new plugin access API from embla7. --- src/components/carousel.ts | 10 +-------- src/components/embla-plugins/automedia.ts | 22 ++++++++++--------- src/components/embla-plugins/lazyload.ts | 8 ++++--- src/components/live.ts | 6 ++---- src/components/media-carousel.ts | 23 +++++++++++++++++--- src/components/viewer.ts | 26 +++++++++++------------ 6 files changed, 52 insertions(+), 43 deletions(-) diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 57bcad5d..3077d144 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -18,7 +18,6 @@ export class FrigateCardCarousel extends LitElement { public direction: 'vertical' | 'horizontal' = 'horizontal'; protected _carousel?: EmblaCarouselType; - protected _plugins: Record = {}; /** * Scroll to a particular slide. @@ -81,7 +80,6 @@ export class FrigateCardCarousel extends LitElement { if (this._carousel) { this._carousel.destroy(); } - this._plugins = {}; this._carousel = undefined; } @@ -94,19 +92,13 @@ export class FrigateCardCarousel extends LitElement { ) as HTMLElement; if (carouselNode) { - const plugins = this._getPlugins() ?? []; - this._plugins = plugins.reduce((acc, cur) => { - acc[cur.name] = cur; - return acc; - }, {}); - this._carousel = EmblaCarousel( carouselNode, { axis: this.direction == 'horizontal' ? 'x' : 'y', ...this._getOptions(), }, - plugins, + this._getPlugins() ?? [], ); this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init')); this._carousel.on('select', () => { diff --git a/src/components/embla-plugins/automedia.ts b/src/components/embla-plugins/automedia.ts index e77cebcd..e34c5e34 100644 --- a/src/components/embla-plugins/automedia.ts +++ b/src/components/embla-plugins/automedia.ts @@ -9,7 +9,7 @@ import { FrigateCardMediaPlayer, } from '../../types.js'; -export type AutoMediaPluginOptionsType = CreateOptionsType<{ +type OptionsType = CreateOptionsType<{ playerSelector?: string; // Note: Neither play nor unmute will activate on selection. The caller is @@ -22,19 +22,21 @@ export type AutoMediaPluginOptionsType = CreateOptionsType<{ autoMuteCondition?: AutoMuteCondition; }>; -export const defaultOptions: AutoMediaPluginOptionsType = { +const defaultOptions: OptionsType = { active: true, breakpoints: {}, }; -export type AutoMediaPluginType = CreatePluginType< +export type AutoMediaOptionsType = Partial + +export type AutoMediaType = CreatePluginType< { play: () => void; pause: () => void; mute: () => void; unmute: () => void; }, - AutoMediaPluginOptionsType + AutoMediaOptionsType >; /** @@ -43,15 +45,15 @@ export type AutoMediaPluginType = CreatePluginType< * @returns */ export function AutoMediaPlugin( - userOptions?: AutoMediaPluginOptionsType, -): AutoMediaPluginType { + userOptions?: AutoMediaOptionsType, +): AutoMediaType { const optionsHandler = EmblaCarousel.optionsHandler(); const optionsBase = optionsHandler.merge( defaultOptions, AutoMediaPlugin.globalOptions, ); - let options: AutoMediaPluginType['options']; + let options: AutoMediaType['options']; let carousel: EmblaCarouselType; let slides: HTMLElement[]; @@ -210,8 +212,8 @@ export function AutoMediaPlugin( } } - const self: AutoMediaPluginType = { - name: 'AutoMediaPlugin', + const self: AutoMediaType = { + name: 'autoMedia', options: optionsHandler.merge(optionsBase, userOptions), init, destroy, @@ -223,4 +225,4 @@ export function AutoMediaPlugin( return self; } -AutoMediaPlugin.globalOptions = undefined; +AutoMediaPlugin.globalOptions = undefined; diff --git a/src/components/embla-plugins/lazyload.ts b/src/components/embla-plugins/lazyload.ts index 98f15876..47b82f1b 100644 --- a/src/components/embla-plugins/lazyload.ts +++ b/src/components/embla-plugins/lazyload.ts @@ -3,7 +3,7 @@ import { CreatePluginType } from 'embla-carousel/components/Plugins'; import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel'; import { LazyUnloadCondition } from '../../types'; -export type LazyloadOptionsType = CreateOptionsType<{ +export type OptionsType = CreateOptionsType<{ // Number of slides to lazyload left/right of selected (0 == only selected // slide). lazyLoadCount?: number; @@ -13,12 +13,14 @@ export type LazyloadOptionsType = CreateOptionsType<{ lazyUnloadCallback?: (index: number, slide: HTMLElement) => void; }>; -export const defaultOptions: LazyloadOptionsType = { +export const defaultOptions: OptionsType = { active: true, breakpoints: {}, lazyLoadCount: 0, }; +export type LazyloadOptionsType = Partial + export type LazyloadType = CreatePluginType< { hasLazyloaded(index: number): boolean; @@ -147,7 +149,7 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { } const self: LazyloadType = { - name: 'Lazyload', + name: 'lazyload', options: optionsHandler.merge(optionsBase, userOptions), init, destroy, diff --git a/src/components/live.ts b/src/components/live.ts index 35006e6e..9af99e33 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -45,7 +45,7 @@ import { dispatchMediaShowEvent, } from '../utils/media-info.js'; import { View } from '../view.js'; -import { AutoMediaPlugin, AutoMediaPluginType } from './embla-plugins/automedia.js'; +import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload } from './embla-plugins/lazyload.js'; import { FrigateCardMediaCarousel } from './media-carousel.js'; import { dispatchErrorMessageEvent } from './message.js'; @@ -239,9 +239,7 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { } if (changedProperties.has('preloaded')) { - const automedia = this._plugins['AutoMediaPlugin'] as - | AutoMediaPluginType - | undefined; + const automedia = this._getAutoMediaPlugin(); if (automedia) { // If this has changed to preloaded (i.e. is now loaded but in the // background) take the appropriate play/pause/mute/unmute actions. diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index 2b654071..fb81018e 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -9,7 +9,8 @@ import { isValidMediaShowInfo } from '../utils/media-info.js'; import { FrigateCardCarousel } from './carousel.js'; -import { AutoMediaPluginType } from './embla-plugins/automedia.js'; +import { AutoMediaType } from './embla-plugins/automedia.js'; +import { LazyloadType } from './embla-plugins/lazyload'; import './next-prev-control.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import { FrigateCardTitleControl } from './title-control.js'; @@ -41,12 +42,28 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { ); } + /** + * Get the AutoMedia plugin (if any). + * @returns The plugin or `null`. + */ + protected _getAutoMediaPlugin(): AutoMediaType | null { + return this._carousel?.plugins()['autoMedia'] ?? null; + } + + /** + * Get the LazyLoad plugin (if any). + * @returns The plugin or `null`. + */ + protected _getLazyLoadPlugin(): LazyloadType | null { + return this._carousel?.plugins()['lazyload'] ?? null; + } + /** * Play the media on the selected slide. May be overridden to control when * autoplay should happen. */ protected _autoPlayHandler(): void { - (this._plugins['AutoMediaPlugin'] as AutoMediaPluginType | undefined)?.play(); + this._getAutoMediaPlugin()?.play(); } /** @@ -54,7 +71,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { * autoplay should happen. */ protected _autoUnmuteHandler(): void { - (this._plugins['AutoMediaPlugin'] as AutoMediaPluginType | undefined)?.unmute(); + this._getAutoMediaPlugin()?.unmute(); } /** diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 46512f96..9a560995 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -7,14 +7,14 @@ import { LitElement, PropertyValues, TemplateResult, - unsafeCSS + unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { ref } from 'lit/directives/ref.js'; import { dispatchFrigateCardErrorEvent, - renderProgressIndicator + renderProgressIndicator, } from '../components/message.js'; import viewerStyle from '../scss/viewer.scss'; import type { @@ -26,7 +26,7 @@ import type { FrigateCardMediaPlayer, MediaShowInfo, TransitionEffect, - ViewerConfig + ViewerConfig, } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { contentsChanged } from '../utils/basic.js'; @@ -36,19 +36,19 @@ import { getFullDependentBrowseMediaQueryParametersOrDispatchError, isTrueMedia, multipleBrowseMediaQueryMerged, - overrideMultiBrowseMediaQueryParameters + overrideMultiBrowseMediaQueryParameters, } from '../utils/ha/browse-media.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; import { createMediaShowInfo } from '../utils/media-info.js'; import { View } from '../view.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; -import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js'; +import { Lazyload } from './embla-plugins/lazyload.js'; import { FrigateCardMediaCarousel, IMG_EMPTY } from './media-carousel.js'; import './next-prev-control.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import './title-control.js'; -import "../patches/ha-hls-player"; -import "./surround-thumbnails"; +import '../patches/ha-hls-player'; +import './surround-thumbnails'; @customElement('frigate-card-viewer') export class FrigateCardViewer extends LitElement { @@ -778,9 +778,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { // images in media-carousel.ts). Here we need to only call the // media load handler on a 'real' load. !lazyLoad || - (this._plugins['Lazyload'] as LazyloadType | undefined)?.hasLazyloaded( - slideIndex, - ) + this._getLazyLoadPlugin()?.hasLazyloaded(slideIndex) ) { this._mediaLoadedHandler(slideIndex, createMediaShowInfo(e)); } @@ -792,8 +790,8 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { } declare global { - interface HTMLElementTagNameMap { - "frigate-card-viewer-carousel": FrigateCardViewerCarousel - "frigate-card-viewer": FrigateCardViewer - } + interface HTMLElementTagNameMap { + 'frigate-card-viewer-carousel': FrigateCardViewerCarousel; + 'frigate-card-viewer': FrigateCardViewer; + } } From dedecc24c62b283b55588a615fda2ed248a9f4ba Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 9 Jul 2022 22:42:48 -0700 Subject: [PATCH 03/14] Refactor carousel into separate components. --- README.md | 4 + package.json | 4 +- src/components/carousel.ts | 194 ++++++++++--- src/components/embla-plugins/automedia.ts | 10 +- src/components/embla-plugins/lazyload.ts | 8 +- src/components/live.ts | 213 ++++++-------- src/components/media-carousel.ts | 328 +++++++++++++--------- src/components/surround-thumbnails.ts | 2 +- src/components/thumbnail-carousel.ts | 126 +++++---- src/components/viewer.ts | 244 +++++++--------- src/scss/carousel.scss | 25 +- src/scss/live-carousel.scss | 4 + src/scss/media-carousel.scss | 13 +- src/scss/next-previous-control.scss | 2 +- src/scss/thumbnail-carousel.scss | 4 + src/scss/thumbnail-feature-event.scss | 4 + src/scss/viewer-carousel.scss | 14 + yarn.lock | 16 +- 18 files changed, 698 insertions(+), 517 deletions(-) create mode 100644 src/scss/live-carousel.scss create mode 100644 src/scss/viewer-carousel.scss diff --git a/README.md b/README.md index 599dee2e..f0f40285 100644 --- a/README.md +++ b/README.md @@ -2676,6 +2676,10 @@ See [screenshot above](#screenshots-card-casting). You must be using a version of the [Frigate integration](https://github.com/blakeblackshear/frigate-hass-integration) >= 3.0.0-rc.2 to see recordings. Using an older version of the integration may also show blank thumbnails in the events viewer. Please upgrade your integration accordingly. +### Chrome autoplays when a tab becomes visible again + +Even if `live.auto_play` or `media_viewer.auto_play` is set to `never`, Chrome itself will still auto play a video that was previously playing prior to the tab being hidden, once that tab is visible again. This behavior cannot be influenced by the card. Other browsers (e.g. Firefox, Safari) do not exhibit this behavior. + ### JSMPEG Live Camera Only Shows A 'spinner' diff --git a/package.json b/package.json index 662e65a9..bd914ac3 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,8 @@ "crypto": "^1.0.1", "custom-card-helpers": "^1.9.0", "date-fns": "^2.28.0", - "embla-carousel": "^7.0.0-rc01", - "embla-carousel-wheel-gestures": "^2.1.1", + "embla-carousel": "^7.0.0-rc04", + "embla-carousel-wheel-gestures": "^3.0.0-rc01", "home-assistant-js-websocket": "^7.1.0", "keycharm": "^0.4.0", "lit": "^2.2.5", diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 3077d144..cebc209c 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -1,10 +1,20 @@ -import EmblaCarousel, { - EmblaCarouselType, - EmblaOptionsType, - EmblaPluginType -} from 'embla-carousel'; -import { CSSResultGroup, LitElement, PropertyValues, unsafeCSS } from 'lit'; -import { property } from 'lit/decorators.js'; +import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel'; +import { EmblaNodesType } from 'embla-carousel/components'; +import { + CreatePluginType, + EmblaPluginsType, + LoosePluginType, +} from 'embla-carousel/components/Plugins'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; import carouselStyle from '../scss/carousel.scss'; import { TransitionEffect } from '../types'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; @@ -13,28 +23,120 @@ export interface CarouselSelect { index: number; } +export type EmblaCarouselPlugins = CreatePluginType< + LoosePluginType, + Record +>[]; + +@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: true }) + public transitionEffect?: TransitionEffect; + + protected _refSlot: Ref = createRef(); + protected _carousel?: EmblaCarouselType; + connectedCallback(): void { + super.connectedCallback(); + + // Guarantee a re-render if the component is reconnected. See note in + // disconnectedCallback(). + 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(); + super.disconnectedCallback(); + } + /** * Scroll to a particular slide. * @param index Slide number. */ - carouselScrollTo(index: number): void { - this._carousel?.scrollTo(index, this._getTransitionEffect() === 'none'); + public carouselScrollTo(index: number): void { + this._carousel?.scrollTo(index, this.transitionEffect === 'none'); + } + + /** + * Scroll to the previous slide. + */ + public carouselScrollPrevious(): void { + this._carousel?.scrollPrev(this.transitionEffect === 'none'); + } + + /** + * Scroll to the next slide. + */ + public carouselScrollNext(): void { + this._carousel?.scrollNext(this.transitionEffect === 'none'); } /** * Get the selected slide. * @returns The slide index or undefined if the carousel is not loaded. */ - carouselSelected(): number | undefined { + public carouselSelected(): number | undefined { return this._carousel?.selectedScrollSnap(); } + /** + * Get the selected node. + * @returns The slide index or undefined if the carousel is not loaded. + */ + public carouselSelectedElement(): HTMLElement | null { + const selected = this._carousel?.selectedScrollSnap(); + if (selected !== undefined) { + return this._carousel?.slideNodes()[selected] ?? null; + } + return null; + } + + /** + * Get the carousel. + */ + public carouselClickAllowed(): boolean { + return this._carousel?.clickAllowed() ?? true; + } + + /** + * Get the carousel. + */ + public carousel(): EmblaCarouselType | null { + return this._carousel ?? null; + } + + /** + * ReInit the carousel. + */ + public carouselReInit(): void { + // Safari appears to not loop the carousel unless the options are passed + // back in during re-initialization. + return this._carousel?.reInit(this.carouselOptions); + } + + /** + * Get the live carousel plugins. + */ + public getCarouselPlugins(): EmblaPluginsType | null { + return this._carousel?.plugins() ?? null; + } + /** * The updated lifecycle callback for this element. * @param changedProperties The properties that were changed in this render. @@ -52,30 +154,6 @@ export class FrigateCardCarousel extends LitElement { } } - /** - * Get the transition effect to use. - * @returns An TransitionEffect object. - */ - protected _getTransitionEffect(): TransitionEffect | undefined { - return 'slide'; - } - - /** - * Get the Embla options to use. - * @returns An EmblaOptionsType object or undefined for no options. - */ - protected _getOptions(): EmblaOptionsType | undefined { - return undefined; - } - - /** - * Get the Embla plugins to use. - * @returns A list of EmblaOptionsTypes. - */ - protected _getPlugins(): EmblaPluginType[] { - return []; - } - protected _destroyCarousel(): void { if (this._carousel) { this._carousel.destroy(); @@ -91,14 +169,21 @@ export class FrigateCardCarousel extends LitElement { '.embla__viewport', ) as HTMLElement; - if (carouselNode) { + const nodes: EmblaNodesType = { + root: carouselNode, + // As the slides are slotted, need to explicitly pull them out and pass + // them to Embla. + slides: this._refSlot.value?.assignedElements({ flatten: true }) as HTMLElement[], + }; + + if (carouselNode && nodes.slides) { this._carousel = EmblaCarousel( - carouselNode, + nodes, { axis: this.direction == 'horizontal' ? 'x' : 'y', - ...this._getOptions(), + ...this.carouselOptions, }, - this._getPlugins() ?? [], + this.carouselPlugins, ); this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init')); this._carousel.on('select', () => { @@ -112,6 +197,33 @@ export class FrigateCardCarousel extends LitElement { } } + /** + * Called when the slotted children in the carousel change. + */ + protected _slotChanged(): void { + // Cannot just re-init, because the slide elements themselves may have + // changed, and only a carousel init can pass in new (slotted) children. + this._destroyCarousel(); + this.requestUpdate(); + } + + protected render(): TemplateResult | void { + const slides = this._refSlot.value?.assignedElements({ flatten: true }) || []; + const currentSlide = this._carousel?.selectedScrollSnap() ?? 0; + const showPrevious = this.carouselOptions?.loop || currentSlide > 0; + const showNext = this.carouselOptions?.loop || currentSlide + 1 < slides.length; + + return html`
+ ${showPrevious ? html`` : ``} +
+
+ +
+
+ ${showNext ? html`` : ``} +
`; + } + /** * Get element styles. */ @@ -119,3 +231,9 @@ export class FrigateCardCarousel extends LitElement { return unsafeCSS(carouselStyle); } } + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-carousel': FrigateCardCarousel; + } +} diff --git a/src/components/embla-plugins/automedia.ts b/src/components/embla-plugins/automedia.ts index e34c5e34..078eac90 100644 --- a/src/components/embla-plugins/automedia.ts +++ b/src/components/embla-plugins/automedia.ts @@ -39,6 +39,12 @@ export type AutoMediaType = CreatePluginType< 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 @@ -112,7 +118,7 @@ export function AutoMediaPlugin( * Handle document visibility changes. */ function visibilityHandler(): void { - if (document.visibilityState == 'hidden') { + if (document.visibilityState === 'hidden') { if ( options.autoPauseCondition && ['all', 'hidden'].includes(options.autoPauseCondition) @@ -125,7 +131,7 @@ export function AutoMediaPlugin( ) { muteAll(); } - } else if (document.visibilityState == 'visible') { + } else if (document.visibilityState === 'visible') { if ( options.autoPlayCondition && ['all', 'visible'].includes(options.autoPlayCondition) diff --git a/src/components/embla-plugins/lazyload.ts b/src/components/embla-plugins/lazyload.ts index 47b82f1b..3fbdb9f4 100644 --- a/src/components/embla-plugins/lazyload.ts +++ b/src/components/embla-plugins/lazyload.ts @@ -19,7 +19,7 @@ export const defaultOptions: OptionsType = { lazyLoadCount: 0, }; -export type LazyloadOptionsType = Partial +export type LazyloadOptionsType = Partial; export type LazyloadType = CreatePluginType< { @@ -28,6 +28,12 @@ export type LazyloadType = CreatePluginType< 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); diff --git a/src/components/live.ts b/src/components/live.ts index 9af99e33..23ec5fb1 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -15,12 +15,16 @@ import { customElement, property, state } from 'lit/decorators.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { until } from 'lit/directives/until.js'; import { ConditionState, getOverriddenConfig } from '../card-condition.js'; -import { dispatchFrigateCardErrorEvent, renderProgressIndicator } from '../components/message.js'; +import { + dispatchFrigateCardErrorEvent, + renderProgressIndicator, +} from '../components/message.js'; import { localize } from '../localize/localize.js'; import liveFrigateStyle from '../scss/live-frigate.scss'; import liveJSMPEGStyle from '../scss/live-jsmpeg.scss'; import liveWebRTCStyle from '../scss/live-webrtc.scss'; import liveStyle from '../scss/live.scss'; +import liveCarouselStyle from '../scss/live-carousel.scss'; import { CameraConfig, ExtendedHomeAssistant, @@ -47,10 +51,9 @@ import { import { View } from '../view.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload } from './embla-plugins/lazyload.js'; -import { FrigateCardMediaCarousel } from './media-carousel.js'; +import { FrigateCardMediaCarousel, wrapMediaShowEventForCarousel } from './media-carousel.js'; import { dispatchErrorMessageEvent } from './message.js'; import './next-prev-control.js'; -import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import './title-control.js'; import './surround-thumbnails'; import '../patches/ha-camera-stream'; @@ -182,7 +185,7 @@ export class FrigateCardLive extends LitElement { } @customElement('frigate-card-live-carousel') -export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { +export class FrigateCardLiveCarousel extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @@ -206,40 +209,39 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { // Index between camera name and slide number. protected _cameraToSlide: Record = {}; + protected _refMediaCarousel: Ref = createRef(); /** * The updated lifecycle callback for this element. * @param changedProperties The properties that were changed in this render. */ updated(changedProperties: PropertyValues): void { - if ( - this._carousel && - (changedProperties.has('cameras') || changedProperties.has('liveConfig')) - ) { - // All of these properties may fundamentally change the contents/size of - // the DOM, and the carousel should be reset when they change. - this._destroyCarousel(); - } - super.updated(changedProperties); + const frigateCardMediaCarousel = this._refMediaCarousel.value; + const frigateCardCarousel = frigateCardMediaCarousel?.frigateCardCarousel(); + if (changedProperties.has('view')) { const oldView = changedProperties.get('view') as View | undefined; if ( - this._carousel && + frigateCardCarousel && oldView && this.view?.camera && this.view?.camera != oldView.camera ) { const slide: number | undefined = this._cameraToSlide[this.view.camera]; - if (slide !== undefined && slide !== this.carouselSelected()) { - this.carouselScrollTo(slide); + if (slide !== undefined && slide !== frigateCardCarousel.carouselSelected()) { + frigateCardCarousel.carouselScrollTo(slide); } } } - if (changedProperties.has('preloaded')) { - const automedia = this._getAutoMediaPlugin(); + if ( + frigateCardMediaCarousel && + frigateCardCarousel && + changedProperties.has('preloaded') + ) { + const automedia = frigateCardCarousel.getCarouselPlugins()?.autoMedia; if (automedia) { // If this has changed to preloaded (i.e. is now loaded but in the // background) take the appropriate play/pause/mute/unmute actions. @@ -257,8 +259,8 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { automedia.mute(); } } else { - this._autoPlayHandler(); - this._autoUnmuteHandler(); + frigateCardMediaCarousel.autoPlay(); + frigateCardMediaCarousel.autoUnmute(); } } } @@ -268,8 +270,11 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { * Get the transition effect to use. * @returns An TransitionEffect object. */ - protected _getTransitionEffect(): TransitionEffect | undefined { - return this.liveConfig?.transition_effect; + protected _getTransitionEffect(): TransitionEffect { + return ( + this.liveConfig?.transition_effect ?? + frigateCardConfigDefaults.live.transition_effect + ); } /** @@ -293,7 +298,6 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { */ protected _getPlugins(): EmblaPluginType[] { return [ - ...super._getPlugins(), // Only enable wheel plugin if there is more than one camera. ...(this.cameras && this.cameras.size > 1 ? [ @@ -314,6 +318,8 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { lazyUnloadCallback: (index, slide) => this._lazyloadOrUnloadSlide('unload', index, slide), }), + + // TODO: AutoMediaPlugin could be moved to MediaCarousel. AutoMediaPlugin({ playerSelector: 'frigate-card-live-provider', ...(this.liveConfig?.auto_play && { @@ -332,30 +338,6 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { ]; } - /** - * Play the media on the loaded slide. - */ - protected _autoPlayHandler(): void { - if ( - this.liveConfig?.auto_play && - ['all', 'selected'].includes(this.liveConfig.auto_play) - ) { - super._autoPlayHandler(); - } - } - - /** - * Unmute the media on the loaded slide. - */ - protected _autoUnmuteHandler(): void { - if ( - this.liveConfig?.auto_unmute && - ['all', 'selected'].includes(this.liveConfig.auto_unmute) - ) { - super._autoUnmuteHandler(); - } - } - /** * Returns the number of slides to lazily load. 0 means all slides are lazy * loaded, 1 means that 1 slide on each side of the currently selected slide @@ -394,15 +376,17 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { /** * Handle the user selecting a new slide in the carousel. */ - protected _selectSlideSetViewHandler(): void { - if (!this._carousel || !this.view || !this.cameras) { + protected _setViewHandler(): void { + const selectedCameraIndex = this._refMediaCarousel.value + ?.frigateCardCarousel() + ?.carouselSelected(); + if (selectedCameraIndex === undefined || !this.view || !this.cameras) { return; } - const selectedSnap = this._carousel.selectedScrollSnap(); this.view .evolve({ - camera: Array.from(this.cameras.keys())[selectedSnap], + camera: Array.from(this.cameras.keys())[selectedCameraIndex], // Reset the target so thumbnails will be re-fetched. target: null, @@ -419,9 +403,13 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { protected _lazyloadOrUnloadSlide( action: 'load' | 'unload', _index: number, - slide: HTMLElement, + slide: Element, ): void { - const liveProvider = slide.querySelector( + if (slide instanceof HTMLSlotElement) { + slide = slide.assignedElements({flatten: true})[0]; + } + + const liveProvider = slide?.querySelector( 'frigate-card-live-provider', ) as FrigateCardLiveProvider; if (liveProvider) { @@ -451,18 +439,21 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { conditionState, ) as LiveConfig; - return html`
- ) => - this._mediaShowEventHandler(slideIndex, e)} - > - -
`; + return html` +
+ ) => { + wrapMediaShowEventForCarousel(slideIndex, e) + }} + > + +
+ `; } protected _getCameraNeighbors(): [CameraConfig | null, CameraConfig | null] { @@ -487,30 +478,6 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { return [prev, next]; } - /** - * Handle updating of the next/previous controls when the carousel is moved. - */ - protected _selectSlideNextPreviousHandler(): void { - const updateNextPreviousControl = ( - control: FrigateCardNextPreviousControl, - direction: 'previous' | 'next', - ): void => { - const [prev, next] = this._getCameraNeighbors(); - const target = direction == 'previous' ? prev : next; - - control.disabled = target == null; - control.title = getCameraTitle(this.hass, target); - control.icon = getCameraIcon(this.hass, target); - }; - - if (this._previousControlRef.value) { - updateNextPreviousControl(this._previousControlRef.value, 'previous'); - } - if (this._nextControlRef.value) { - updateNextPreviousControl(this._nextControlRef.value, 'next'); - } - } - /** * Render the element. * @returns A template to display to the user. @@ -532,46 +499,58 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { const title = getCameraTitle(this.hass, this.cameras.get(this.view.camera)); return html` -
+ { - this._nextPreviousHandler('previous'); + this._refMediaCarousel.value + ?.frigateCardCarousel() + ?.carouselScrollPrevious(); stopEventFromActivatingCardWideActions(ev); }} > -
-
${slides}
-
+ ${slides} { - this._nextPreviousHandler('next'); + this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext(); stopEventFromActivatingCardWideActions(ev); }} > -
- - + `; } + + /** + * Get styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(liveCarouselStyle); + } } @customElement('frigate-card-live-provider') @@ -747,20 +726,16 @@ export class FrigateCardLiveFrigate extends LitElement { } if (!this.cameraConfig?.camera_entity) { - return dispatchErrorMessageEvent( - this, - localize('error.no_live_camera'), - { context: this.cameraConfig }, - ); + return dispatchErrorMessageEvent(this, localize('error.no_live_camera'), { + context: this.cameraConfig, + }); } const stateObj = this.hass.states[this.cameraConfig.camera_entity]; if (!stateObj || stateObj.state === 'unavailable') { - return dispatchErrorMessageEvent( - this, - localize('error.live_camera_unavailable'), - { context: this.cameraConfig }, - ); + return dispatchErrorMessageEvent(this, localize('error.live_camera_unavailable'), { + context: this.cameraConfig, + }); } return html` `data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"%3E%3C/svg%3E`; export const IMG_EMPTY = getEmptyImageSrc(16, 9); +export interface CarouselMediaShowInfo { + slide: number; + mediaShowInfo: MediaShowInfo; +} + +/** + * Dispatch a carousel media show event. + * @param target The target to send it from. + * @param carouselMediaShowInfo The CarouselMediaShowInfo. + */ +const dispatchFrigateCardCarouselMediaShow = ( + target: EventTarget, + carouselMediaShowInfo: CarouselMediaShowInfo, +): void => { + dispatchFrigateCardEvent( + target, + 'carousel:media-show', + carouselMediaShowInfo, + ); +}; + +/** + * Turn a MediaShowEvent into a CarouselMediaShowInfo. + * @param slide The slide number. + * @param event The MediaShowEvent. + */ +export const wrapMediaShowEventForCarousel = ( + slide: number, + event: CustomEvent, +) => { + event.stopPropagation(); + dispatchFrigateCardCarouselMediaShow(event.composedPath()[0], { + slide: slide, + mediaShowInfo: event.detail, + }); +}; + +/** + * Turn a (stock) media load event into a CarouselMediaShowInfo. + * @param slide The slide number. + * @param event The MediaShowEvent. + */ +export const wrapMediaLoadEventForCarousel = (slide: number, event: Event) => { + const mediaShowInfo = createMediaShowInfo(event); + if (mediaShowInfo) { + dispatchFrigateCardCarouselMediaShow(event.composedPath()[0], { + slide: slide, + mediaShowInfo: mediaShowInfo, + }); + } +}; + @customElement('frigate-card-media-carousel') -export class FrigateCardMediaCarousel extends FrigateCardCarousel { +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: true }) + public transitionEffect?: TransitionEffect; + + @property({ attribute: false }) + public label?: string; + + @property({ attribute: false }) + public titlePopupConfig?: TitleControlConfig; + + @property({ attribute: false }) + public autoPlayCondition?: AutoPlayCondition; + + @property({ attribute: false }) + public autoUnmuteCondition?: AutoUnmuteCondition; + + @property({ attribute: false }) + public autoPauseCondition?: AutoPauseCondition; + + @property({ attribute: false }) + public autoMuteCondition?: AutoMuteCondition; + // A "map" from slide number to MediaShowInfo object. protected _mediaShowInfo: Record = {}; protected _nextControlRef: Ref = createRef(); @@ -28,12 +125,19 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { protected _titleControlRef: Ref = createRef(); protected _titleTimerID: number | null = null; + protected _boundAutoPlayHandler = this.autoPlay.bind(this); + protected _boundAutoPauseHandler = this.autoPause.bind(this); + protected _boundAutoMuteHandler = this.autoMute.bind(this); + protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this); + // This carousel may be resized by Lovelace resizes, window resizes, // fullscreen, etc. Always call the adaptive height handler when the size // changes. protected _resizeObserver: ResizeObserver; protected _intersectionObserver: IntersectionObserver; + protected _refCarousel: Ref = createRef(); + constructor() { super(); this._resizeObserver = new ResizeObserver(this._adaptiveHeightHandler.bind(this)); @@ -42,36 +146,61 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { ); } + /** + * 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._carousel?.plugins()['autoMedia'] ?? null; + return this.frigateCardCarousel()?.carousel()?.plugins().autoMedia ?? null; } /** - * Get the LazyLoad plugin (if any). - * @returns The plugin or `null`. + * Play the media on the selected slide. */ - protected _getLazyLoadPlugin(): LazyloadType | null { - return this._carousel?.plugins()['lazyload'] ?? null; + public autoPlay(): void { + if (this.autoPlayCondition && ['all', 'selected'].includes(this.autoPlayCondition)) { + this._getAutoMediaPlugin()?.play(); + } } /** - * Play the media on the selected slide. May be overridden to control when - * autoplay should happen. + * Pause the media on the selected slide. */ - protected _autoPlayHandler(): void { - this._getAutoMediaPlugin()?.play(); + public autoPause(): void { + if ( + this.autoPauseCondition && + ['all', 'selected'].includes(this.autoPauseCondition) + ) { + this._getAutoMediaPlugin()?.pause(); + } } /** - * Unmute the media on the selected slide. May be overridden to control when - * autoplay should happen. + * Unmute the media on the selected slide. */ - protected _autoUnmuteHandler(): void { - this._getAutoMediaPlugin()?.unmute(); + public autoUnmute(): void { + if ( + this.autoUnmuteCondition && + ['all', 'selected'].includes(this.autoUnmuteCondition) + ) { + this._getAutoMediaPlugin()?.unmute(); + } + } + + /** + * Mute the media on the selected slide. + */ + public autoMute(): void { + if (this.autoMuteCondition && ['all', 'selected'].includes(this.autoMuteCondition)) { + this._getAutoMediaPlugin()?.mute(); + } } /** @@ -95,8 +224,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { // 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 (inc. - // adaptive height which has `0.5s ease`, see `media-carousel.scss`). + // and allows for the browser to finish rendering the carousel. this._titleTimerID = window.setTimeout(show, 0.5 * 1000); } @@ -105,8 +233,8 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { */ connectedCallback(): void { super.connectedCallback(); - this.addEventListener('frigate-card:media-show', this._autoPlayHandler); - this.addEventListener('frigate-card:media-show', this._autoUnmuteHandler); + this.addEventListener('frigate-card:media-show', this.autoPlay); + this.addEventListener('frigate-card:media-show', this.autoUnmute); this.addEventListener('frigate-card:media-show', this._adaptiveHeightHandler); this.addEventListener('frigate-card:media-show', this._titleHandler); this._resizeObserver.observe(this); @@ -118,8 +246,8 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { */ disconnectedCallback(): void { super.disconnectedCallback(); - this.removeEventListener('frigate-card:media-show', this._autoPlayHandler); - this.removeEventListener('frigate-card:media-show', this._autoUnmuteHandler); + this.removeEventListener('frigate-card:media-show', this.autoPlay); + this.removeEventListener('frigate-card:media-show', this.autoUnmute); this.removeEventListener('frigate-card:media-show', this._adaptiveHeightHandler); this.removeEventListener('frigate-card:media-show', this._titleHandler); this._resizeObserver.disconnect(); @@ -143,9 +271,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { */ const reInit = (): void => { - // Safari appears to not loop the carousel unless the options are passed - // back in during re-initialization. - this._carousel?.reInit(this._getOptions()); + this.frigateCardCarousel()?.carouselReInit(); }; if (entries.some((entry) => entry.isIntersecting)) { @@ -160,57 +286,21 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { } } - protected _destroyCarousel(): void { - super._destroyCarousel(); - - // Notes on instance variables: - // * this._mediaShowInfo: This is set when the media in the DOM loads. If a - // new View included the same media, the DOM would not change and so the - // prior contents would still be valid and would not re-appear (as the - // media would not reload) -- as such, leave this alone on carousel - // destroy. New media in that slide will replace the prior contents on - // load. - } - /** - * Initialize the carousel. - */ - protected _initCarousel(): void { - super._initCarousel(); - - // Necessary because typescript local type narrowing is not paying attention - // to the side-effect of the call to super._initCarousel(). - const carousel = this._carousel as EmblaCarouselType | undefined; - - // Update the view object as the carousel is moved. - carousel?.on('select', this._selectSlideSetViewHandler.bind(this)); - - // Update the next/previous controls as the carousel is moved. - carousel?.on('select', this._selectSlideNextPreviousHandler.bind(this)); - - // Dispatch MediaShow events as the carousel is moved. - carousel?.on('init', this._selectSlideMediaShowHandler.bind(this)); - carousel?.on('select', this._selectSlideMediaShowHandler.bind(this)); - } - - /** - * Set the the height of the container on media load in case the dimensions + * 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). */ protected _adaptiveHeightHandler(): void { const adaptCarouselHeight = (): void => { - if (!this._carousel) { - return; - } - const slide = this._carousel?.selectedScrollSnap(); + const slide = this.frigateCardCarousel()?.carouselSelected(); if (slide !== undefined) { - this._carousel.containerNode().style.removeProperty('max-height'); - const slides = this._carousel.slideNodes(); - const height = slides[slide].getBoundingClientRect().height; - if (height > 0) { - this._carousel.containerNode().style.maxHeight = `${height}px`; + this.style.removeProperty('max-height'); + const currentSlide = this.frigateCardCarousel()?.carouselSelectedElement(); + const height = currentSlide?.getBoundingClientRect().height; + if (height !== undefined && height > 0) { + this.style.maxHeight = `${height}px`; } } }; @@ -225,42 +315,12 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { window.requestAnimationFrame(adaptCarouselHeight); } - /** - * Handle the user selecting a new slide in the carousel. - */ - protected _selectSlideSetViewHandler(): void { - // To be overridden in children. - } - - /** - * Handle updating of the next/previous controls when the carousel is moved. - */ - protected _selectSlideNextPreviousHandler(): void { - // To be overridden in children. - } - - /** - * Handle a next/previous control interaction. - * @param direction The direction requested, previous or next. - */ - protected _nextPreviousHandler(direction: 'previous' | 'next'): void { - if (direction === 'previous') { - this._carousel?.scrollPrev(this._getTransitionEffect() === 'none'); - } else if (direction === 'next') { - this._carousel?.scrollNext(this._getTransitionEffect() === 'none'); - } - } - /** * Fire a media show event when a slide is selected. */ - protected _selectSlideMediaShowHandler(): void { - if (!this._carousel) { - return; - } - - const slideIndex = this._carousel.selectedScrollSnap(); - if (slideIndex in this._mediaShowInfo) { + protected _dispatchMediaShowInfo(): void { + const slideIndex = this.frigateCardCarousel()?.carouselSelected(); + if (slideIndex !== undefined && slideIndex in this._mediaShowInfo) { dispatchExistingMediaShowInfoAsEvent(this, this._mediaShowInfo[slideIndex]); } } @@ -271,46 +331,58 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { * @param slideIndex The relevant slide index. * @param event The media-show event from the child component. */ - protected _mediaShowEventHandler( - slideIndex: number, - event: CustomEvent, - ): void { + protected _storeMediaShowInfo(event: CustomEvent): void { // Don't allow the inbound event to propagate upwards, that will be // automatically done at the appropriate time as the slide is shown. event.stopPropagation(); - this._mediaLoadedHandler(slideIndex, event.detail); - } + const mediaShowInfo = event.detail.mediaShowInfo; + const slideIndex = event.detail.slide; - /** - * Handle a MediaShowInfo object that is generated on media load, by saving it - * for future, or immediate use, when the relevant slide is displayed. - * @param slideIndex The relevant slide index. - * @param mediaShowInfo The MediaShowInfo object generated by the media. - */ - protected _mediaLoadedHandler( - slideIndex: number, - mediaShowInfo?: MediaShowInfo | null, - ): void { // isValidMediaShowInfo is used to prevent saving media info that will be // rejected upstream (empty 1x1 images will be rejected here). if (mediaShowInfo && isValidMediaShowInfo(mediaShowInfo)) { this._mediaShowInfo[slideIndex] = mediaShowInfo; - if (this._carousel && this._carousel?.selectedScrollSnap() === slideIndex) { + if (this.frigateCardCarousel()?.carouselSelected() === slideIndex) { dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo); } } } + protected render(): TemplateResult | void { + return html` + + + + + ${this.label && this.titlePopupConfig + ? html` + ` + : ``}`; + } + /** * Get element styles. */ static get styles(): CSSResultGroup { - return [super.styles, unsafeCSS(mediaCarouselStyle)]; + return unsafeCSS(mediaCarouselStyle); } } declare global { - interface HTMLElementTagNameMap { - "frigate-card-media-carousel": FrigateCardMediaCarousel - } + interface HTMLElementTagNameMap { + 'frigate-card-media-carousel': FrigateCardMediaCarousel; + } } diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts index 2960fc87..4d701f07 100644 --- a/src/components/surround-thumbnails.ts +++ b/src/components/surround-thumbnails.ts @@ -148,7 +148,7 @@ export class FrigateCardSurround extends LitElement { .selected=${this.view.childIndex} .cameras=${this.cameras} @frigate-card:change-view=${(ev: CustomEvent) => changeDrawer(ev, 'close')} - @frigate-card:carousel:tap=${(ev: CustomEvent) => { + @frigate-card:thumbnail-carousel:tap=${(ev: CustomEvent) => { // Send the view change from the source of the tap event, so the // view change will be caught by the handler above (to close the drawer). this.view diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 5fa1509a..785bdab1 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -1,17 +1,31 @@ import { HomeAssistant } from 'custom-card-helpers'; import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; -import { CSSResultGroup, html, PropertyValues, TemplateResult, unsafeCSS } from 'lit'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; -import type { CameraConfig, FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; +import { + CameraConfig, + FrigateBrowseMediaSource, + ThumbnailsControlConfig, +} from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; import { isTrueMedia } from '../utils/ha/browse-media'; import { View } from '../view.js'; import { FrigateCardCarousel } from './carousel.js'; import './thumbnail.js'; +import './carousel.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; export interface ThumbnailCarouselTap { slideIndex: number; @@ -20,7 +34,7 @@ export interface ThumbnailCarouselTap { } @customElement('frigate-card-thumbnail-carousel') -export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { +export class FrigateCardThumbnailCarousel extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; @@ -35,6 +49,8 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { @property({ attribute: false }) public cameras?: Map; + protected _refCarousel: Ref = createRef(); + // Thumbnail carousels can expand (e.g. drawer-based carousels after the main // media loads). The carousel must be re-initialized in these cases, or the // dynamic sizing fails (and users can scroll past the end of the carousel). @@ -55,13 +71,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { } @property({ attribute: false }) - set config(config: ThumbnailsControlConfig) { - this.direction = ['left', 'right'].includes(config.mode) ? 'vertical' : 'horizontal'; - this._config = config; - } - - @state() - protected _config?: ThumbnailsControlConfig; + public config?: ThumbnailsControlConfig; @state() protected _selected?: number | null; @@ -70,13 +80,12 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { * Handle gallery resize. */ protected _resizeHandler(): void { - if (this._carousel) { - this._carousel.reInit(); - // Reinit will cause the scroll position to reset, so re-scroll to the - // correct location. - if (this._selected !== undefined && this._selected !== null) { - this.carouselScrollTo(this._selected); - } + this._refCarousel.value?.carouselReInit(); + + // Reinit will cause the scroll position to reset, so re-scroll to the + // correct location. + if (this._selected !== undefined && this._selected !== null) { + this._refCarousel.value?.carouselScrollTo(this._selected); } } @@ -114,7 +123,6 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { */ protected _getPlugins(): EmblaPluginType[] { return [ - ...super._getPlugins(), // Only enable wheel plugin if there is more than one camera. WheelGesturesPlugin({ // Whether the carousel is vertical or horizontal, interpret y-axis wheel @@ -148,12 +156,15 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { * @param changedProps The changed properties */ protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('_config')) { - if (this._config?.size) { - this.style.setProperty( - '--frigate-card-thumbnail-size', - `${this._config.size}px`, - ); + if (changedProps.has('config')) { + if (this.config?.size) { + this.style.setProperty('--frigate-card-thumbnail-size', `${this.config.size}px`); + } + const direction = this._getDirection(); + if (direction) { + this.setAttribute('direction', direction); + } else { + this.removeAttribute('direction'); } } } @@ -163,17 +174,12 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { * @param changedProperties The properties that were changed in this render. */ updated(changedProperties: PropertyValues): void { - if (changedProperties.has('target')) { - this._destroyCarousel(); - } super.updated(changedProperties); if (changedProperties.has('_selected')) { this.updateComplete.then(() => { - if (this._carousel) { - if (this._selected !== undefined && this._selected !== null) { - this.carouselScrollTo(this._selected); - } + if (this._selected !== undefined && this._selected !== null) { + this._refCarousel.value?.carouselScrollTo(this._selected); } }); } @@ -209,17 +215,21 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { .target=${parent} .childIndex=${childIndex} .clientID=${cameraConfig?.frigate.client_id} - ?details=${this._config?.show_details} - ?show_favorite_control=${this._config?.show_favorite_control} - ?show_timeline_control=${this._config?.show_timeline_control} + ?details=${this.config?.show_details} + ?show_favorite_control=${this.config?.show_favorite_control} + ?show_timeline_control=${this.config?.show_timeline_control} class="${classMap(classes)}" @click=${(ev) => { - if (this._carousel && this._carousel.clickAllowed()) { - dispatchFrigateCardEvent(this, 'carousel:tap', { - slideIndex: slideIndex, - target: parent, - childIndex: childIndex, - }); + if (this._refCarousel.value?.carouselClickAllowed()) { + dispatchFrigateCardEvent( + this, + 'thumbnail-carousel:tap', + { + slideIndex: slideIndex, + target: parent, + childIndex: childIndex, + }, + ); } stopEventFromActivatingCardWideActions(ev); }} @@ -227,33 +237,49 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { `; } + /** + * Get the direction of the thumbnail carousel. + * @returns `vertical`, `horizontal` or undefined. + */ + protected _getDirection(): 'horizontal' | 'vertical' | undefined { + if (this.config?.mode === 'left' || this.config?.mode === 'right') { + return 'vertical'; + } else if (this.config?.mode === 'above' || this.config?.mode === 'below') { + return 'horizontal'; + } + return undefined; + } + /** * 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') { + if (!slides.length || !this.config || this.config.mode === 'none') { return; } - return html`
-
-
${slides}
-
-
`; + return html` + ${slides} + `; } /** * Get element styles. */ static get styles(): CSSResultGroup { - return [super.styles, unsafeCSS(thumbnailCarouselStyle)]; + return unsafeCSS(thumbnailCarouselStyle); } } declare global { - interface HTMLElementTagNameMap { - "frigate-card-thumbnail-carousel": FrigateCardThumbnailCarousel - } + interface HTMLElementTagNameMap { + 'frigate-card-thumbnail-carousel': FrigateCardThumbnailCarousel; + } } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 9a560995..1f96beee 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -11,18 +11,20 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { ref } from 'lit/directives/ref.js'; +import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { dispatchFrigateCardErrorEvent, renderProgressIndicator, } from '../components/message.js'; import viewerStyle from '../scss/viewer.scss'; -import type { +import viewerCarouselStyle from '../scss/viewer-carousel.scss'; +import { BrowseMediaNeighbors, BrowseMediaQueryParameters, CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource, + frigateCardConfigDefaults, FrigateCardMediaPlayer, MediaShowInfo, TransitionEffect, @@ -39,13 +41,16 @@ import { overrideMultiBrowseMediaQueryParameters, } from '../utils/ha/browse-media.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; -import { createMediaShowInfo } from '../utils/media-info.js'; import { View } from '../view.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload } from './embla-plugins/lazyload.js'; -import { FrigateCardMediaCarousel, IMG_EMPTY } from './media-carousel.js'; +import { + FrigateCardMediaCarousel, + IMG_EMPTY, + wrapMediaLoadEventForCarousel, + wrapMediaShowEventForCarousel, +} from './media-carousel.js'; import './next-prev-control.js'; -import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import './title-control.js'; import '../patches/ha-hls-player'; import './surround-thumbnails'; @@ -133,7 +138,7 @@ export class FrigateCardViewer extends LitElement { const FRIGATE_CARD_HLS_SELECTOR = 'frigate-card-ha-hls-player'; @customElement('frigate-card-viewer-carousel') -export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { +export class FrigateCardViewerCarousel extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @@ -154,6 +159,8 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; + protected _refMediaCarousel: Ref = createRef(); + // Mapping of slide # to FrigateBrowseMediaSource child #. // (Folders are not media items that can be rendered). protected _slideToChild: Record = {}; @@ -187,22 +194,20 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { * @param changedProperties The properties that were changed in this render. */ updated(changedProperties: PropertyValues): void { - if (this._carousel && changedProperties.has('viewerConfig')) { - this._destroyCarousel(); - } + const frigateCardCarousel = this._refMediaCarousel.value?.frigateCardCarousel(); - if (this._carousel && changedProperties.has('view')) { + if (frigateCardCarousel && changedProperties.has('view')) { const oldView = changedProperties.get('view') as View | undefined; if (oldView) { - if (oldView.target !== this.view?.target) { - // If the media target is different entirely, reset the carousel. - this._destroyCarousel(); - } else if (this.view.childIndex != oldView.childIndex) { + if ( + oldView.target === this.view?.target && + this.view.childIndex != oldView.childIndex + ) { const slide = this._getSlideForChild(this.view.childIndex); - if (slide !== null && slide !== this.carouselSelected()) { + if (slide !== null && slide !== frigateCardCarousel.carouselSelected()) { // If the media target is the same as already loaded, but isn't of // the selected slide, scroll to that slide. - this.carouselScrollTo(slide); + frigateCardCarousel.carouselScrollTo(slide); } } } @@ -211,41 +216,6 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { super.updated(changedProperties); } - /** - * Play the media on the loaded slide. - */ - protected _autoPlayHandler(): void { - if ( - this.viewerConfig?.auto_play && - ['all', 'selected'].includes(this.viewerConfig.auto_play) - ) { - super._autoPlayHandler(); - } - } - - /** - * Unmute the media on the loaded slide. - */ - protected _autoUnmuteHandler(): void { - if ( - this.viewerConfig?.auto_unmute && - ['all', 'selected'].includes(this.viewerConfig.auto_unmute) - ) { - super._autoUnmuteHandler(); - } - } - - /** - * Destroy the carousel. - */ - protected _destroyCarousel(): void { - super._destroyCarousel(); - - // Notes on instance variables: - // * this._slideToChild: This is set as part of each render and does not - // need to be destroyed here. - } - /** * Get the slide number given a media child number. * @param childIndex The child index (relative to `view.target`) @@ -265,8 +235,11 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { * Get the transition effect to use. * @returns An TransitionEffect object. */ - protected _getTransitionEffect(): TransitionEffect | undefined { - return this.viewerConfig?.transition_effect; + protected _getTransitionEffect(): TransitionEffect { + return ( + this.viewerConfig?.transition_effect ?? + frigateCardConfigDefaults.media_viewer.transition_effect + ); } /** @@ -286,16 +259,16 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { * @param slide An optional slide. * @returns The FrigateCardMediaPlayer or null if not found. */ - protected _getPlayer(slide?: HTMLElement): FrigateCardMediaPlayer | null { - if (this._carousel) { - if (!slide) { - slide = this._carousel.slideNodes()[this._carousel.selectedScrollSnap()]; - } - return slide?.querySelector( - FRIGATE_CARD_HLS_SELECTOR, - ) as FrigateCardMediaPlayer | null; + protected _getPlayer(slide?: HTMLElement | null): FrigateCardMediaPlayer | null { + if (!slide) { + slide = this._refMediaCarousel.value + ?.frigateCardCarousel() + ?.carouselSelectedElement(); } - return null; + + return ( + (slide?.querySelector(FRIGATE_CARD_HLS_SELECTOR) as FrigateCardMediaPlayer) ?? null + ); } /** @@ -304,7 +277,6 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { */ protected _getPlugins(): EmblaPluginType[] { return [ - ...super._getPlugins(), // Only enable wheel plugin if there is more than one media item. ...(this.view && this.view.target && @@ -480,15 +452,17 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { /** * Handle the user selecting a new slide in the carousel. */ - protected _selectSlideSetViewHandler(): void { - if (!this._carousel || !this.view) { + protected _setViewHandler(): void { + if (!this._refMediaCarousel.value || !this.view) { return; } // Update the childIndex in the view. - const slidesInView = this._carousel.slidesInView(true); - if (slidesInView.length) { - const childIndex = this._slideToChild[slidesInView[0]]; + const selected = this._refMediaCarousel.value + .frigateCardCarousel() + ?.carouselSelected(); + if (selected !== undefined) { + const childIndex = this._slideToChild[selected]; if (childIndex !== undefined) { this.view .evolve({ @@ -556,31 +530,6 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { }); } - /** - * Handle updating of the next/previous controls when the carousel is moved. - */ - protected _selectSlideNextPreviousHandler(): void { - const updateNextPreviousControl = ( - control: FrigateCardNextPreviousControl, - direction: 'previous' | 'next', - ): void => { - const neighbors = this._getMediaNeighbors(); - const [prev, next] = [neighbors?.previous, neighbors?.next]; - const target = direction == 'previous' ? prev : next; - - control.disabled = target == null; - control.title = target && target.title ? target.title : ''; - control.thumbnail = target && target.thumbnail ? target.thumbnail : undefined; - }; - - if (this._previousControlRef.value) { - updateNextPreviousControl(this._previousControlRef.value, 'previous'); - } - if (this._nextControlRef.value) { - updateNextPreviousControl(this._nextControlRef.value, 'next'); - } - } - /** * Get slides to include in the render. * @returns The slides to include in the render and an index keyed by slide @@ -647,59 +596,59 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { protected _render(): TemplateResult | void { const [slides, slideToChild] = this._getSlides(); this._slideToChild = slideToChild; - if (!slides.length) { + if (!slides.length || !this.view?.media) { return; } const neighbors = this._getMediaNeighbors(); const [prev, next] = [neighbors?.previous, neighbors?.next]; - return html`
- { - this._nextPreviousHandler('previous'); - stopEventFromActivatingCardWideActions(ev); - }} - > -
-
${slides}
-
- { - this._nextPreviousHandler('next'); - stopEventFromActivatingCardWideActions(ev); - }} - > -
- ${this.view?.media - ? html` - ` - : ``} `; + return html` + { + this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollPrevious(); + stopEventFromActivatingCardWideActions(ev); + }} + > + ${slides} + { + this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext(); + stopEventFromActivatingCardWideActions(ev); + }} + > + `; } /** * Fire a media show event when a slide is selected. */ - protected _selectSlideMediaShowHandler(): void { - super._selectSlideMediaShowHandler(); - + protected _recordingSeekHandler(): void { // If this is a recording and play is desired to be started from a // particular point, seek to that point. Use the media off the slide itself // -- when the slide is changed, the media show event may be dispatched @@ -751,8 +700,9 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { )} .media=${mediaToRender} .hass=${this.hass} - @frigate-card:media-show=${(e: CustomEvent) => - this._mediaShowEventHandler(slideIndex, e)} + @frigate-card:media-show=${(e: CustomEvent) => { + wrapMediaShowEventForCarousel(slideIndex, e); + }} > ` : html` { - if (this._carousel?.clickAllowed()) { + if ( + this._refMediaCarousel.value + ?.frigateCardCarousel() + ?.carouselClickAllowed() + ) { this._findRelatedClipView(mediaToRender).then((view) => { if (view) { view.dispatchChangeEvent(this); @@ -771,6 +725,9 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { } }} @load="${(e: Event) => { + const lazyloadPlugin = this._refMediaCarousel.value + ?.frigateCardCarousel() + ?.getCarouselPlugins()?.lazyload; if ( // This handler will be called on the empty image (including // an updated empty image that is the same dimensions large as @@ -778,15 +735,22 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { // images in media-carousel.ts). Here we need to only call the // media load handler on a 'real' load. !lazyLoad || - this._getLazyLoadPlugin()?.hasLazyloaded(slideIndex) + lazyloadPlugin?.hasLazyloaded(slideIndex) ) { - this._mediaLoadedHandler(slideIndex, createMediaShowInfo(e)); + wrapMediaLoadEventForCarousel(slideIndex, e); } }}" />`} `; } + + /** + * Get element styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(viewerCarouselStyle); + } } declare global { diff --git a/src/scss/carousel.scss b/src/scss/carousel.scss index d72a6def..167146a8 100644 --- a/src/scss/carousel.scss +++ b/src/scss/carousel.scss @@ -4,16 +4,9 @@ width: 100%; } -img,video { - width: 100%; - height: 100%; - display: block; -} - .embla { width: 100%; height: 100%; - position: relative; margin-left: auto; margin-right: auto; } @@ -28,10 +21,10 @@ img,video { -khtml-user-select: none; -webkit-tap-highlight-color: transparent; } -:host([direction=vertical]) .embla__container { +:host([direction='vertical']) .embla__container { flex-direction: column; } -:host([direction=horizontal]) .embla__container { +:host([direction='horizontal']) .embla__container { flex-direction: row; } @@ -53,18 +46,10 @@ img,video { cursor: grabbing; } -.embla__slide { - position: relative; - overflow: visible; -} -:host([direction=vertical]) .embla__slide { +:host([direction='vertical']) ::slotted(.embla__slide) { margin-bottom: 5px; } -:host([direction=horizontal]) .embla__slide { + +:host([direction='horizontal']) ::slotted(.embla__slide) { margin-right: 5px; } -.embla__slide img,video { - // Letterbox media. has similar added directly in - // its element. - object-fit: contain; -} \ No newline at end of file diff --git a/src/scss/live-carousel.scss b/src/scss/live-carousel.scss new file mode 100644 index 00000000..aab21ed3 --- /dev/null +++ b/src/scss/live-carousel.scss @@ -0,0 +1,4 @@ +.embla__slide { + height: 100%; + flex: 0 0 100%; +} diff --git a/src/scss/media-carousel.scss b/src/scss/media-carousel.scss index d0d0142d..45964ad6 100644 --- a/src/scss/media-carousel.scss +++ b/src/scss/media-carousel.scss @@ -1,8 +1,9 @@ :host { - --video-max-height: none; -} - -.embla__slide { - flex: 0 0 100%; + display: block; + width: 100%; height: 100%; -} \ No newline at end of file + --video-max-height: none; + + // Keep the controls relative to the media carousel itself. + position: relative; +} diff --git a/src/scss/next-previous-control.scss b/src/scss/next-previous-control.scss index d72d61bd..2d9e97b0 100644 --- a/src/scss/next-previous-control.scss +++ b/src/scss/next-previous-control.scss @@ -22,7 +22,7 @@ } .controls.icons { - top: calc(50% - (40px / 2)); + top: calc(50% - (var(--frigate-card-next-prev-size) / 2)); } .controls.thumbnails { diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss index daa8c5d9..c0774cd5 100644 --- a/src/scss/thumbnail-carousel.scss +++ b/src/scss/thumbnail-carousel.scss @@ -1,6 +1,10 @@ @use 'const.scss'; :host { + display: block; + width: 100%; + height: 100%; + --frigate-card-carousel-thumbnail-opacity: 1; } diff --git a/src/scss/thumbnail-feature-event.scss b/src/scss/thumbnail-feature-event.scss index 1ca3328e..88a9bbfb 100644 --- a/src/scss/thumbnail-feature-event.scss +++ b/src/scss/thumbnail-feature-event.scss @@ -3,6 +3,10 @@ overflow: hidden; } +img { + display: block; +} + img, ha-icon { border-radius: var(--ha-card-border-radius, 4px); diff --git a/src/scss/viewer-carousel.scss b/src/scss/viewer-carousel.scss new file mode 100644 index 00000000..5e7f7390 --- /dev/null +++ b/src/scss/viewer-carousel.scss @@ -0,0 +1,14 @@ +.embla__slide { + height: 100%; + flex: 0 0 100%; +} + +.embla__slide img { + display: block; + width: 100%; + height: 100%; + + // Letterbox media. has similar added directly in + // its element. + object-fit: contain; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 8da90b3b..a633cb76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1155,17 +1155,17 @@ electron-to-chromium@^1.4.147: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.161.tgz#49cb5b35385bfee6cc439d0a04fbba7a7a7f08a1" integrity sha512-sTjBRhqh6wFodzZtc5Iu8/R95OkwaPNn7tj/TaDU5nu/5EFiQDtADGAXdR4tJcTEHlYfJpHqigzJqHvPgehP8A== -embla-carousel-wheel-gestures@^2.1.1: - version "2.2.0" - resolved "https://registry.yarnpkg.com/embla-carousel-wheel-gestures/-/embla-carousel-wheel-gestures-2.2.0.tgz#04ee1cfafe0667a5b96d16341642b3124fe8894e" - integrity sha512-IoRGblg8QWrIgZEW0NbDcIl2fO++BLFf6197k2JNair3pfbyiMYtva6rROgeRiI0sIj2kFwlSEgudTy5f8TzNQ== +embla-carousel-wheel-gestures@^3.0.0-rc01: + version "3.0.0-rc01" + resolved "https://registry.yarnpkg.com/embla-carousel-wheel-gestures/-/embla-carousel-wheel-gestures-3.0.0-rc01.tgz#70f88d6ee755817270ca26514d06b7c08c12977d" + integrity sha512-h6E1/AwGKEwro8pey6KeOnt/UMvSaCwJKxaA+sz4OERPLmVL06oejVJ2kMjL06y9WaxZ8TqKWR7m0HlbsI9F5A== dependencies: wheel-gestures "^2.2.5" -embla-carousel@^7.0.0-rc01: - version "7.0.0-rc01" - resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-7.0.0-rc01.tgz#7c9adfd7302b85c2de9354b7ef6343f16a696eb7" - integrity sha512-IBTSKcPw7u9K0zoLvsnWYsijKsI0msFqzNp6ASIthTXiMZNJNGQt8k0ax7KqUVinDZeNM07WPWqYsjrvUM/Epw== +embla-carousel@^7.0.0-rc04: + version "7.0.0-rc04" + resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-7.0.0-rc04.tgz#bd9c15da7740660b46232fba720b0c38043b667f" + integrity sha512-vhzwCEdEqwS5c6jlfPHy/X1uUkwf9AvMma8KWNbmTgB3NUNN9hoqiyTdrpraMtPU5MzlxpZipln74ZwDK4v63g== emojis-list@^3.0.0: version "3.0.0" From f66841177686c8632be553b01f4cd429acc613d1 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 9 Jul 2022 23:24:24 -0700 Subject: [PATCH 04/14] Fix opacity on the thumbnail carousel. --- src/components/thumbnail-carousel.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 785bdab1..b7795481 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -56,6 +56,12 @@ export class FrigateCardThumbnailCarousel extends LitElement { // dynamic sizing fails (and users can scroll past the end of the carousel). protected _resizeObserver: ResizeObserver; + @property({ attribute: false }) + public config?: ThumbnailsControlConfig; + + @state() + protected _selected: number | null = null; + constructor() { super(); this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); @@ -64,18 +70,12 @@ export class FrigateCardThumbnailCarousel extends LitElement { @property({ attribute: false }) set selected(selected: number | null) { this._selected = selected; - if (selected !== null) { - // If there is a selection, 'dim' all the other slides. - this.style.setProperty('--frigate-card-carousel-thumbnail-opacity', '0.4'); - } + this.style.setProperty( + '--frigate-card-carousel-thumbnail-opacity', + selected === null ? '1.0' : '0.4', + ); } - @property({ attribute: false }) - public config?: ThumbnailsControlConfig; - - @state() - protected _selected?: number | null; - /** * Handle gallery resize. */ @@ -84,7 +84,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { // Reinit will cause the scroll position to reset, so re-scroll to the // correct location. - if (this._selected !== undefined && this._selected !== null) { + if (this._selected !== null) { this._refCarousel.value?.carouselScrollTo(this._selected); } } @@ -178,7 +178,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { if (changedProperties.has('_selected')) { this.updateComplete.then(() => { - if (this._selected !== undefined && this._selected !== null) { + if (this._selected !== null) { this._refCarousel.value?.carouselScrollTo(this._selected); } }); From 0788b3fbfe810d97e61ccb648785e08dd3b2f281 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 10 Jul 2022 11:42:58 -0700 Subject: [PATCH 05/14] Fix fetch bug. --- src/components/surround-thumbnails.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts index 4d701f07..4b02b816 100644 --- a/src/components/surround-thumbnails.ts +++ b/src/components/surround-thumbnails.ts @@ -58,7 +58,7 @@ export class FrigateCardSurround extends LitElement { */ protected async _fetchMedia(): Promise { if ( - !fetch || + !this.fetch || !this.hass || !this.view || !this.config || From 898a46fda2069003003668b3781860d1861926ae Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 10 Jul 2022 14:48:58 -0700 Subject: [PATCH 06/14] Bind slotchange calls. --- src/components/carousel.ts | 2 +- src/components/drawer.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/carousel.ts b/src/components/carousel.ts index cebc209c..024a0144 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -217,7 +217,7 @@ export class FrigateCardCarousel extends LitElement { ${showPrevious ? html`` : ``}
- +
${showNext ? html`` : ``} diff --git a/src/components/drawer.ts b/src/components/drawer.ts index 8af8fd69..bfcc1f9d 100644 --- a/src/components/drawer.ts +++ b/src/components/drawer.ts @@ -117,7 +117,7 @@ export class FrigateCardDrawer extends LitElement { ` : ''} - + `; } From 214a553aa7cbc7aa41ff216bdc5c220cfa68fab4 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 10 Jul 2022 20:05:18 -0700 Subject: [PATCH 07/14] Reset the carousel of the plugins/options change. --- src/components/carousel.ts | 19 +++++++++ src/components/image.ts | 1 - src/components/live.ts | 63 +++++++++++++++--------------- src/components/media-carousel.ts | 66 ++++++++++++++++---------------- src/components/viewer.ts | 16 +++++--- 5 files changed, 91 insertions(+), 74 deletions(-) diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 024a0144..0e3a697a 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -65,6 +65,21 @@ export class FrigateCardCarousel extends LitElement { super.disconnectedCallback(); } + /** + * Destroy the carousel if certain properties change. + * @param changedProps The changed properties + */ + protected willUpdate(changedProps: PropertyValues): void { + const destroyProperties = [ + 'direction', + 'carouselOptions', + 'carouselOptions', + ] as const; + if (destroyProperties.some((prop) => changedProps.has(prop))) { + this._destroyCarousel(); + } + } + /** * Scroll to a particular slide. * @param index Slide number. @@ -193,6 +208,10 @@ export class FrigateCardCarousel extends LitElement { index: selected, }); } + + // Make sure every select causes a refresh to allow for re-paint of the + // next/previous controls. + this.requestUpdate(); }); } } diff --git a/src/components/image.ts b/src/components/image.ts index ba21b97e..a37f5ddd 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -86,7 +86,6 @@ export class FrigateCardImage extends LitElement { * Ensure there is a cached value before an update. * @param _changedProps The changed properties */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('imageConfig')) { if (this._cachedValueController) { diff --git a/src/components/live.ts b/src/components/live.ts index 23ec5fb1..2445190a 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -1,7 +1,7 @@ import JSMpeg from '@cycjimmy/jsmpeg-player'; import { Task } from '@lit-labs/task'; import { HomeAssistant } from 'custom-card-helpers'; -import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; +import { EmblaOptionsType } from 'embla-carousel'; import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { CSSResultGroup, @@ -13,6 +13,7 @@ import { } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; +import { guard } from 'lit/directives/guard.js'; import { until } from 'lit/directives/until.js'; import { ConditionState, getOverriddenConfig } from '../card-condition.js'; import { @@ -51,12 +52,16 @@ import { import { View } from '../view.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload } from './embla-plugins/lazyload.js'; -import { FrigateCardMediaCarousel, wrapMediaShowEventForCarousel } from './media-carousel.js'; +import { + FrigateCardMediaCarousel, + wrapMediaShowEventForCarousel, +} from './media-carousel.js'; import { dispatchErrorMessageEvent } from './message.js'; import './next-prev-control.js'; import './title-control.js'; import './surround-thumbnails'; import '../patches/ha-camera-stream'; +import { EmblaCarouselPlugins } from './carousel.js'; // Number of seconds a signed URL is valid for. const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; @@ -241,27 +246,14 @@ export class FrigateCardLiveCarousel extends LitElement { frigateCardCarousel && changedProperties.has('preloaded') ) { - const automedia = frigateCardCarousel.getCarouselPlugins()?.autoMedia; - if (automedia) { - // If this has changed to preloaded (i.e. is now loaded but in the - // background) take the appropriate play/pause/mute/unmute actions. - if (this.preloaded) { - if ( - this.liveConfig?.auto_pause && - ['all', 'unselected'].includes(this.liveConfig.auto_pause) - ) { - automedia.pause(); - } - if ( - this.liveConfig?.auto_mute && - ['all', 'unselected'].includes(this.liveConfig.auto_mute) - ) { - automedia.mute(); - } - } else { - frigateCardMediaCarousel.autoPlay(); - frigateCardMediaCarousel.autoUnmute(); - } + // If this has changed to preloaded (i.e. is now loaded but in the + // background) take the appropriate play/pause/mute/unmute actions. + if (this.preloaded) { + frigateCardMediaCarousel.autoPause(); + frigateCardMediaCarousel.autoMute(); + } else { + frigateCardMediaCarousel.autoPlay(); + frigateCardMediaCarousel.autoUnmute(); } } } @@ -296,7 +288,7 @@ export class FrigateCardLiveCarousel extends LitElement { * 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 camera. ...(this.cameras && this.cameras.size > 1 @@ -406,7 +398,7 @@ export class FrigateCardLiveCarousel extends LitElement { slide: Element, ): void { if (slide instanceof HTMLSlotElement) { - slide = slide.assignedElements({flatten: true})[0]; + slide = slide.assignedElements({ flatten: true })[0]; } const liveProvider = slide?.querySelector( @@ -448,7 +440,7 @@ export class FrigateCardLiveCarousel extends LitElement { .liveConfig=${config} .hass=${this.hass} @frigate-card:media-show=${(e: CustomEvent) => { - wrapMediaShowEventForCarousel(slideIndex, e) + wrapMediaShowEventForCarousel(slideIndex, e); }} > @@ -498,15 +490,20 @@ export class FrigateCardLiveCarousel extends LitElement { const [prev, next] = this._getCameraNeighbors(); const title = getCameraTitle(this.hass, this.cameras.get(this.view.camera)); + // guard() is used below to avoid reseting the carousel unless the + // options/plugins actually change. + return html` = {}; protected _nextControlRef: Ref = createRef(); @@ -126,9 +109,9 @@ export class FrigateCardMediaCarousel extends LitElement { protected _titleTimerID: number | null = null; protected _boundAutoPlayHandler = this.autoPlay.bind(this); - protected _boundAutoPauseHandler = this.autoPause.bind(this); - protected _boundAutoMuteHandler = this.autoMute.bind(this); protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this); + protected _boundAdaptiveHeightHandler = this._adaptiveHeightHandler.bind(this); + protected _boundTitleHandler = this._titleHandler.bind(this); // This carousel may be resized by Lovelace resizes, window resizes, // fullscreen, etc. Always call the adaptive height handler when the size @@ -165,7 +148,11 @@ export class FrigateCardMediaCarousel extends LitElement { * Play the media on the selected slide. */ public autoPlay(): void { - if (this.autoPlayCondition && ['all', 'selected'].includes(this.autoPlayCondition)) { + const automediaOptions = this._getAutoMediaPlugin()?.options; + if ( + automediaOptions?.autoPlayCondition && + ['all', 'selected'].includes(automediaOptions?.autoPlayCondition) + ) { this._getAutoMediaPlugin()?.play(); } } @@ -174,9 +161,10 @@ export class FrigateCardMediaCarousel extends LitElement { * Pause the media on the selected slide. */ public autoPause(): void { + const automediaOptions = this._getAutoMediaPlugin()?.options; if ( - this.autoPauseCondition && - ['all', 'selected'].includes(this.autoPauseCondition) + automediaOptions?.autoPauseCondition && + ['all', 'selected'].includes(automediaOptions.autoPauseCondition) ) { this._getAutoMediaPlugin()?.pause(); } @@ -186,9 +174,10 @@ export class FrigateCardMediaCarousel extends LitElement { * Unmute the media on the selected slide. */ public autoUnmute(): void { + const automediaOptions = this._getAutoMediaPlugin()?.options; if ( - this.autoUnmuteCondition && - ['all', 'selected'].includes(this.autoUnmuteCondition) + automediaOptions?.autoUnmuteCondition && + ['all', 'selected'].includes(automediaOptions?.autoUnmuteCondition) ) { this._getAutoMediaPlugin()?.unmute(); } @@ -198,7 +187,11 @@ export class FrigateCardMediaCarousel extends LitElement { * Mute the media on the selected slide. */ public autoMute(): void { - if (this.autoMuteCondition && ['all', 'selected'].includes(this.autoMuteCondition)) { + const automediaOptions = this._getAutoMediaPlugin()?.options; + if ( + automediaOptions?.autoMuteCondition && + ['all', 'selected'].includes(automediaOptions?.autoMuteCondition) + ) { this._getAutoMediaPlugin()?.mute(); } } @@ -233,10 +226,11 @@ export class FrigateCardMediaCarousel extends LitElement { */ connectedCallback(): void { super.connectedCallback(); - this.addEventListener('frigate-card:media-show', this.autoPlay); - this.addEventListener('frigate-card:media-show', this.autoUnmute); - this.addEventListener('frigate-card:media-show', this._adaptiveHeightHandler); - this.addEventListener('frigate-card:media-show', this._titleHandler); + + this.addEventListener('frigate-card:media-show', this._boundAutoPlayHandler); + this.addEventListener('frigate-card:media-show', this._boundAutoUnmuteHandler); + this.addEventListener('frigate-card:media-show', this._boundAdaptiveHeightHandler); + this.addEventListener('frigate-card:media-show', this._boundTitleHandler); this._resizeObserver.observe(this); this._intersectionObserver.observe(this); } @@ -245,13 +239,17 @@ export class FrigateCardMediaCarousel extends LitElement { * Component disconnected callback. */ disconnectedCallback(): void { - super.disconnectedCallback(); - this.removeEventListener('frigate-card:media-show', this.autoPlay); - this.removeEventListener('frigate-card:media-show', this.autoUnmute); - this.removeEventListener('frigate-card:media-show', this._adaptiveHeightHandler); - this.removeEventListener('frigate-card:media-show', this._titleHandler); + this.removeEventListener('frigate-card:media-show', this._boundAutoPlayHandler); + this.removeEventListener('frigate-card:media-show', this._boundAutoUnmuteHandler); + this.removeEventListener( + 'frigate-card:media-show', + this._boundAdaptiveHeightHandler, + ); + this.removeEventListener('frigate-card:media-show', this._boundTitleHandler); this._resizeObserver.disconnect(); this._intersectionObserver.disconnect(); + + super.disconnectedCallback(); } /** diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 1f96beee..8a296d84 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -9,6 +9,7 @@ import { TemplateResult, unsafeCSS, } from 'lit'; +import { guard } from 'lit/directives/guard.js'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; @@ -54,6 +55,7 @@ import './next-prev-control.js'; import './title-control.js'; import '../patches/ha-hls-player'; import './surround-thumbnails'; +import { EmblaCarouselPlugins } from './carousel.js'; @customElement('frigate-card-viewer') export class FrigateCardViewer extends LitElement { @@ -603,14 +605,16 @@ export class FrigateCardViewerCarousel extends LitElement { const neighbors = this._getMediaNeighbors(); const [prev, next] = [neighbors?.previous, neighbors?.next]; + // guard() is used below to avoid reseting the carousel unless the + // options/plugins actually change. + return html` Date: Sun, 10 Jul 2022 20:19:29 -0700 Subject: [PATCH 08/14] Resize carousel after intersection change. --- src/components/media-carousel.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index 90782b16..b5bab45c 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -269,6 +269,10 @@ export class FrigateCardMediaCarousel extends LitElement { */ const reInit = (): void => { + // In some cases the carousel may need its height adjusted after the DOM is + // newly visible (e.g. a smaller camera live in preload mode, that becomes + // visible after switching from a larger camera snapshot). + this._adaptiveHeightHandler(); this.frigateCardCarousel()?.carouselReInit(); }; From 6010d7b1e16f00971ea62255f0515bcd8d4993b5 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Jul 2022 10:54:07 -0700 Subject: [PATCH 09/14] Improve reinit/resize handling. --- src/components/carousel.ts | 88 +++++++++++++++++++++------- src/components/live.ts | 7 +-- src/components/media-carousel.ts | 59 ++++++++++--------- src/components/thumbnail-carousel.ts | 8 +-- src/components/viewer.ts | 9 ++- 5 files changed, 109 insertions(+), 62 deletions(-) diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 0e3a697a..0cb8fe37 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -15,12 +15,14 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { throttle } from 'lodash-es'; import carouselStyle from '../scss/carousel.scss'; import { TransitionEffect } from '../types'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; export interface CarouselSelect { index: number; + element: HTMLElement; } export type EmblaCarouselPlugins = CreatePluginType< @@ -46,6 +48,18 @@ export class FrigateCardCarousel extends LitElement { protected _carousel?: EmblaCarouselType; + // Whether the carousel is actively scrolling. + protected _scrolling = false; + + // Whether to reinit the carousel when it settles. + protected _reInitOnSettle = false; + + protected _carouselReInitInPlace = throttle( + this._carouselReInitInPlaceInternal.bind(this), + 500, + { trailing: true }, + ); + connectedCallback(): void { super.connectedCallback(); @@ -104,20 +118,16 @@ export class FrigateCardCarousel extends LitElement { /** * Get the selected slide. - * @returns The slide index or undefined if the carousel is not loaded. + * @returns A CarouselSelect object (index & element). */ - public carouselSelected(): number | undefined { - return this._carousel?.selectedScrollSnap(); - } - - /** - * Get the selected node. - * @returns The slide index or undefined if the carousel is not loaded. - */ - public carouselSelectedElement(): HTMLElement | null { - const selected = this._carousel?.selectedScrollSnap(); - if (selected !== undefined) { - return this._carousel?.slideNodes()[selected] ?? null; + public getCarouselSelected(): CarouselSelect | null { + const index = this._carousel?.selectedScrollSnap(); + const element = index !== undefined ? (this._carousel?.slideNodes()[index] ?? null) : null; + if (index !== undefined && element) { + return { + index: index, + element: element, + } } return null; } @@ -139,10 +149,38 @@ export class FrigateCardCarousel extends LitElement { /** * ReInit the carousel. */ - public carouselReInit(): void { + protected _carouselReInit(options?: EmblaOptionsType): void { + window.requestAnimationFrame(() => { + // Safari appears to not loop the carousel unless the options are passed + // back in during re-initialization. + this._carousel?.reInit({ ...this.carouselOptions, ...options }); + }); + } + /** + * ReInit the carousel but stay on the current slide. + */ + protected _carouselReInitInPlaceInternal(): void { + const selected = this.getCarouselSelected(); + // Safari appears to not loop the carousel unless the options are passed // back in during re-initialization. - return this._carousel?.reInit(this.carouselOptions); + const options = { + ...this.carouselOptions, + ...(selected && { startIndex: selected.index }), + }; + this._carouselReInit(options); + } + + /** + * 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(); + } } /** @@ -196,23 +234,33 @@ export class FrigateCardCarousel extends LitElement { nodes, { axis: this.direction == 'horizontal' ? 'x' : 'y', + speed: 20, ...this.carouselOptions, }, this.carouselPlugins, ); this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init')); this._carousel.on('select', () => { - const selected = this.carouselSelected(); - if (selected !== undefined) { - dispatchFrigateCardEvent(this, 'carousel:select', { - index: selected, - }); + const selected = this.getCarouselSelected(); + if (selected) { + dispatchFrigateCardEvent(this, 'carousel:select', selected); } // Make sure every select causes a refresh to allow for re-paint of the // next/previous controls. this.requestUpdate(); }); + + this._carousel.on('scroll', () => { + this._scrolling = true; + }); + this._carousel.on('settle', () => { + this._scrolling = false; + if (this._reInitOnSettle) { + this._reInitOnSettle = false; + this._carouselReInitInPlace(); + } + }); } } diff --git a/src/components/live.ts b/src/components/live.ts index 2445190a..d9a6c5ae 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -235,7 +235,7 @@ export class FrigateCardLiveCarousel extends LitElement { this.view?.camera != oldView.camera ) { const slide: number | undefined = this._cameraToSlide[this.view.camera]; - if (slide !== undefined && slide !== frigateCardCarousel.carouselSelected()) { + if (slide !== undefined && slide !== frigateCardCarousel.getCarouselSelected()?.index) { frigateCardCarousel.carouselScrollTo(slide); } } @@ -310,8 +310,6 @@ export class FrigateCardLiveCarousel extends LitElement { lazyUnloadCallback: (index, slide) => this._lazyloadOrUnloadSlide('unload', index, slide), }), - - // TODO: AutoMediaPlugin could be moved to MediaCarousel. AutoMediaPlugin({ playerSelector: 'frigate-card-live-provider', ...(this.liveConfig?.auto_play && { @@ -371,7 +369,8 @@ export class FrigateCardLiveCarousel extends LitElement { protected _setViewHandler(): void { const selectedCameraIndex = this._refMediaCarousel.value ?.frigateCardCarousel() - ?.carouselSelected(); + ?.getCarouselSelected() + ?.index; if (selectedCameraIndex === undefined || !this.view || !this.cameras) { return; } diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index b5bab45c..42f9e0bb 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -18,7 +18,7 @@ import { dispatchExistingMediaShowInfoAsEvent, isValidMediaShowInfo, } from '../utils/media-info.js'; -import { EmblaCarouselPlugins, FrigateCardCarousel } from './carousel'; +import { CarouselSelect, EmblaCarouselPlugins, FrigateCardCarousel } from './carousel'; import { AutoMediaType } from './embla-plugins/automedia.js'; import './next-prev-control.js'; import './carousel.js'; @@ -110,20 +110,25 @@ export class FrigateCardMediaCarousel extends LitElement { protected _boundAutoPlayHandler = this.autoPlay.bind(this); protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this); - protected _boundAdaptiveHeightHandler = this._adaptiveHeightHandler.bind(this); + protected _boundAdaptiveHeightHandler = this._adaptHeightToMedia.bind(this); protected _boundTitleHandler = this._titleHandler.bind(this); // This carousel may be resized by Lovelace resizes, window resizes, // fullscreen, etc. Always call the adaptive height handler when the size // changes. protected _resizeObserver: ResizeObserver; + protected _slideResizeObserver: ResizeObserver; protected _intersectionObserver: IntersectionObserver; protected _refCarousel: Ref = createRef(); constructor() { super(); - this._resizeObserver = new ResizeObserver(this._adaptiveHeightHandler.bind(this)); + // 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._resizeObserver = new ResizeObserver(this._reInitAndAdjustHeight.bind(this)); + this._slideResizeObserver = new ResizeObserver(this._reInitAndAdjustHeight.bind(this)); this._intersectionObserver = new IntersectionObserver( this._intersectionHandler.bind(this), ); @@ -252,6 +257,14 @@ export class FrigateCardMediaCarousel extends LitElement { super.disconnectedCallback(); } + /** + * ReInit the carousel and adapt the container height. + */ + protected _reInitAndAdjustHeight(): void { + this.frigateCardCarousel()?.carouselReInitWhenSafe(); + this._adaptHeightToMedia(); + } + /** * Called when the carousel intersects with the viewport. * @param entries The IntersectionObserverEntry entries (should be only 1). @@ -267,24 +280,8 @@ export class FrigateCardMediaCarousel extends LitElement { * - Example bug when this reinitialization is not performed: * https://github.com/dermotduffy/frigate-hass-card/issues/651 */ - - const reInit = (): void => { - // In some cases the carousel may need its height adjusted after the DOM is - // newly visible (e.g. a smaller camera live in preload mode, that becomes - // visible after switching from a larger camera snapshot). - this._adaptiveHeightHandler(); - this.frigateCardCarousel()?.carouselReInit(); - }; - if (entries.some((entry) => entry.isIntersecting)) { - // For performance, run the reinit in idle cycles if the browser supports - // it, but only give it 400ms before running as it may otherwise be - // noticeable to the user. - if (window.requestIdleCallback !== undefined) { - window.requestIdleCallback(reInit, { timeout: 400 }); - } else { - reInit(); - } + this._reInitAndAdjustHeight(); } } @@ -293,14 +290,16 @@ export class FrigateCardMediaCarousel extends LitElement { * 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 it + * resizes the container on selection rather than media load. */ - protected _adaptiveHeightHandler(): void { + protected _adaptHeightToMedia(): void { const adaptCarouselHeight = (): void => { - const slide = this.frigateCardCarousel()?.carouselSelected(); - if (slide !== undefined) { + const selected = this.frigateCardCarousel()?.getCarouselSelected(); + if (selected) { this.style.removeProperty('max-height'); - const currentSlide = this.frigateCardCarousel()?.carouselSelectedElement(); - const height = currentSlide?.getBoundingClientRect().height; + const height = selected.element.getBoundingClientRect().height; if (height !== undefined && height > 0) { this.style.maxHeight = `${height}px`; } @@ -321,7 +320,7 @@ export class FrigateCardMediaCarousel extends LitElement { * Fire a media show event when a slide is selected. */ protected _dispatchMediaShowInfo(): void { - const slideIndex = this.frigateCardCarousel()?.carouselSelected(); + const slideIndex = this.frigateCardCarousel()?.getCarouselSelected()?.index; if (slideIndex !== undefined && slideIndex in this._mediaShowInfo) { dispatchExistingMediaShowInfoAsEvent(this, this._mediaShowInfo[slideIndex]); } @@ -344,7 +343,7 @@ export class FrigateCardMediaCarousel extends LitElement { // rejected upstream (empty 1x1 images will be rejected here). if (mediaShowInfo && isValidMediaShowInfo(mediaShowInfo)) { this._mediaShowInfo[slideIndex] = mediaShowInfo; - if (this.frigateCardCarousel()?.carouselSelected() === slideIndex) { + if (this.frigateCardCarousel()?.getCarouselSelected()?.index === slideIndex) { dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo); } } @@ -357,7 +356,11 @@ export class FrigateCardMediaCarousel extends LitElement { .carouselPlugins=${this.carouselPlugins} transitionEffect=${ifDefined(this.transitionEffect)} @frigate-card:carousel:init=${this._dispatchMediaShowInfo.bind(this)} - @frigate-card:carousel:select=${this._dispatchMediaShowInfo.bind(this)} + @frigate-card:carousel:select=${(ev: CustomEvent) => { + this._slideResizeObserver.disconnect(); + this._slideResizeObserver.observe(ev.detail.element); + this._dispatchMediaShowInfo(); + }} @frigate-card:carousel:media-show=${this._storeMediaShowInfo.bind(this)} > diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index b7795481..c8cb2f0c 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -80,13 +80,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { * Handle gallery resize. */ protected _resizeHandler(): void { - this._refCarousel.value?.carouselReInit(); - - // Reinit will cause the scroll position to reset, so re-scroll to the - // correct location. - if (this._selected !== null) { - this._refCarousel.value?.carouselScrollTo(this._selected); - } + this._refCarousel.value?.carouselReInitWhenSafe(); } /** diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 8a296d84..806bbd29 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -206,7 +206,10 @@ export class FrigateCardViewerCarousel extends LitElement { this.view.childIndex != oldView.childIndex ) { const slide = this._getSlideForChild(this.view.childIndex); - if (slide !== null && slide !== frigateCardCarousel.carouselSelected()) { + if ( + slide !== null && + slide !== frigateCardCarousel.getCarouselSelected()?.index + ) { // If the media target is the same as already loaded, but isn't of // the selected slide, scroll to that slide. frigateCardCarousel.carouselScrollTo(slide); @@ -265,7 +268,7 @@ export class FrigateCardViewerCarousel extends LitElement { if (!slide) { slide = this._refMediaCarousel.value ?.frigateCardCarousel() - ?.carouselSelectedElement(); + ?.getCarouselSelected()?.element; } return ( @@ -462,7 +465,7 @@ export class FrigateCardViewerCarousel extends LitElement { // Update the childIndex in the view. const selected = this._refMediaCarousel.value .frigateCardCarousel() - ?.carouselSelected(); + ?.getCarouselSelected()?.index; if (selected !== undefined) { const childIndex = this._slideToChild[selected]; if (childIndex !== undefined) { From 275c9e5654245730b21ad9c0011b8ef54c075bf0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Jul 2022 11:54:54 -0700 Subject: [PATCH 10/14] Reset the thumbnail carousel less. --- src/components/surround-thumbnails.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts index 4b02b816..b218304d 100644 --- a/src/components/surround-thumbnails.ts +++ b/src/components/surround-thumbnails.ts @@ -35,7 +35,7 @@ export class FrigateCardSurround extends LitElement { @property({ attribute: false }) public view?: Readonly; - @property({ attribute: false }) + @property({ attribute: false, hasChanged: contentsChanged }) public config?: ThumbnailsControlConfig; @property({ attribute: false }) From 54079d4a08cd019c076baca6af29291f207c5687 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Jul 2022 12:28:51 -0700 Subject: [PATCH 11/14] Upgrade Embla carousel. --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index bd914ac3..b02910a4 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "crypto": "^1.0.1", "custom-card-helpers": "^1.9.0", "date-fns": "^2.28.0", - "embla-carousel": "^7.0.0-rc04", + "embla-carousel": "^7.0.0-rc05", "embla-carousel-wheel-gestures": "^3.0.0-rc01", "home-assistant-js-websocket": "^7.1.0", "keycharm": "^0.4.0", diff --git a/yarn.lock b/yarn.lock index a633cb76..81a550d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1162,10 +1162,10 @@ embla-carousel-wheel-gestures@^3.0.0-rc01: dependencies: wheel-gestures "^2.2.5" -embla-carousel@^7.0.0-rc04: - version "7.0.0-rc04" - resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-7.0.0-rc04.tgz#bd9c15da7740660b46232fba720b0c38043b667f" - integrity sha512-vhzwCEdEqwS5c6jlfPHy/X1uUkwf9AvMma8KWNbmTgB3NUNN9hoqiyTdrpraMtPU5MzlxpZipln74ZwDK4v63g== +embla-carousel@^7.0.0-rc05: + version "7.0.0-rc05" + resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-7.0.0-rc05.tgz#0c70393cb9284435c8242d9ec9e5e754ef81749d" + integrity sha512-zRPQniDxj3t3Q/okKzP8XsMRtANItAK7nhQ3Smpqfbtw3OMoZRebojUDCMD02sb5CY7ghYqL/XFEuXxS/7vJ5w== emojis-list@^3.0.0: version "3.0.0" From 773b5cf318232ec9b610c2a913ef1447a56c013f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Jul 2022 14:59:46 -0700 Subject: [PATCH 12/14] Performance improvements. --- README.md | 5 ++++ src/components/carousel.ts | 11 ++++++++- src/components/live.ts | 15 ++++++++--- src/components/thumbnail-carousel.ts | 37 ++++++++++++++-------------- src/components/viewer.ts | 5 ++-- 5 files changed, 48 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index f0f40285..ed4c207a 100644 --- a/README.md +++ b/README.md @@ -566,6 +566,11 @@ timeline: ### Dimensions Options +These options control the aspect-ratio of the entire card to make placement in +Home Assistant dashboards more stable. Aspect ratio configuration applies once +to the entire card (including the menu, thumbnails, etc), not just to displayed +media. + All configuration is under: ```yaml diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 0cb8fe37..1645d493 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -87,7 +87,7 @@ export class FrigateCardCarousel extends LitElement { const destroyProperties = [ 'direction', 'carouselOptions', - 'carouselOptions', + 'carouselPlugins', ] as const; if (destroyProperties.some((prop) => changedProps.has(prop))) { this._destroyCarousel(); @@ -255,11 +255,20 @@ export class FrigateCardCarousel extends LitElement { this._scrolling = true; }); this._carousel.on('settle', () => { + // Reinitialize the carousel if a request to reinitialize was made + // during scrolling (instead the request is handled after the scrolling + // has settled). this._scrolling = false; if (this._reInitOnSettle) { this._reInitOnSettle = false; this._carouselReInitInPlace(); } + }) + this._carousel.on('settle', () => { + const selected = this.getCarouselSelected(); + if (selected) { + dispatchFrigateCardEvent(this, 'carousel:settle', selected); + } }); } } diff --git a/src/components/live.ts b/src/components/live.ts index d9a6c5ae..ea4a81db 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -489,8 +489,15 @@ export class FrigateCardLiveCarousel extends LitElement { const [prev, next] = this._getCameraNeighbors(); const title = getCameraTitle(this.hass, this.cameras.get(this.view.camera)); - // guard() is used below to avoid reseting the carousel unless the - // options/plugins actually change. + // Notes on the below: + // - guard() is used to avoid reseting the carousel unless the + // options/plugins actually change. + // - the 'carousel:settle' event is listened for (instead of + // 'carousel:select') to only trigger the view change (which subsequently + // fetches thumbnails) after the carousel has stopped moving. This gives a + // much smoother carousel experience since network fetches are not at the + // same time as carousel movement (at a cost of fetching thumbnails a + // little later). return html` + @frigate-card:carousel:settle=${this._setViewHandler.bind(this)} + > ${slides} `; diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 806bbd29..c55ee3ad 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -608,8 +608,9 @@ export class FrigateCardViewerCarousel extends LitElement { const neighbors = this._getMediaNeighbors(); const [prev, next] = [neighbors?.previous, neighbors?.next]; - // guard() is used below to avoid reseting the carousel unless the - // options/plugins actually change. + // Notes on the below: + // - guard() is used to avoid reseting the carousel unless the + // options/plugins actually change. return html` Date: Sat, 23 Jul 2022 15:04:32 -0700 Subject: [PATCH 13/14] Minor naming clarification. --- src/components/media-carousel.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index 42f9e0bb..d99ca9de 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -1,5 +1,3 @@ -// TODO: Use the auto-height plugin instead of adaptive height - import { EmblaOptionsType } from 'embla-carousel'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; @@ -110,7 +108,8 @@ export class FrigateCardMediaCarousel extends LitElement { protected _boundAutoPlayHandler = this.autoPlay.bind(this); protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this); - protected _boundAdaptiveHeightHandler = this._adaptHeightToMedia.bind(this); + protected _boundAdaptContainerHeightToSlide = + this._adaptContainerHeightToSlide.bind(this); protected _boundTitleHandler = this._titleHandler.bind(this); // This carousel may be resized by Lovelace resizes, window resizes, @@ -128,7 +127,9 @@ export class FrigateCardMediaCarousel extends LitElement { // resize or fullscreen change) and changes in the selected slide itself // (e.g. changing from a progress indicator to a loaded media). this._resizeObserver = new ResizeObserver(this._reInitAndAdjustHeight.bind(this)); - this._slideResizeObserver = new ResizeObserver(this._reInitAndAdjustHeight.bind(this)); + this._slideResizeObserver = new ResizeObserver( + this._reInitAndAdjustHeight.bind(this), + ); this._intersectionObserver = new IntersectionObserver( this._intersectionHandler.bind(this), ); @@ -234,7 +235,10 @@ export class FrigateCardMediaCarousel extends LitElement { this.addEventListener('frigate-card:media-show', this._boundAutoPlayHandler); this.addEventListener('frigate-card:media-show', this._boundAutoUnmuteHandler); - this.addEventListener('frigate-card:media-show', this._boundAdaptiveHeightHandler); + this.addEventListener( + 'frigate-card:media-show', + this._boundAdaptContainerHeightToSlide, + ); this.addEventListener('frigate-card:media-show', this._boundTitleHandler); this._resizeObserver.observe(this); this._intersectionObserver.observe(this); @@ -248,7 +252,7 @@ export class FrigateCardMediaCarousel extends LitElement { this.removeEventListener('frigate-card:media-show', this._boundAutoUnmuteHandler); this.removeEventListener( 'frigate-card:media-show', - this._boundAdaptiveHeightHandler, + this._boundAdaptContainerHeightToSlide, ); this.removeEventListener('frigate-card:media-show', this._boundTitleHandler); this._resizeObserver.disconnect(); @@ -262,7 +266,7 @@ export class FrigateCardMediaCarousel extends LitElement { */ protected _reInitAndAdjustHeight(): void { this.frigateCardCarousel()?.carouselReInitWhenSafe(); - this._adaptHeightToMedia(); + this._adaptContainerHeightToSlide(); } /** @@ -294,7 +298,7 @@ export class FrigateCardMediaCarousel extends LitElement { * This component does not use the stock Embla auto-height plugin as it * resizes the container on selection rather than media load. */ - protected _adaptHeightToMedia(): void { + protected _adaptContainerHeightToSlide(): void { const adaptCarouselHeight = (): void => { const selected = this.frigateCardCarousel()?.getCarouselSelected(); if (selected) { From e32f0e8bb8ec68f7371d34ae7acd0af082c9d228 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Jul 2022 15:19:30 -0700 Subject: [PATCH 14/14] With Embla 7 special Safari treatment not necessary. --- src/components/carousel.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 1645d493..175f6af1 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -122,12 +122,13 @@ export class FrigateCardCarousel extends LitElement { */ public getCarouselSelected(): CarouselSelect | null { const index = this._carousel?.selectedScrollSnap(); - const element = index !== undefined ? (this._carousel?.slideNodes()[index] ?? null) : null; + const element = + index !== undefined ? this._carousel?.slideNodes()[index] ?? null : null; if (index !== undefined && element) { return { index: index, element: element, - } + }; } return null; } @@ -150,10 +151,11 @@ export class FrigateCardCarousel extends LitElement { * ReInit the carousel. */ protected _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(() => { - // Safari appears to not loop the carousel unless the options are passed - // back in during re-initialization. - this._carousel?.reInit({ ...this.carouselOptions, ...options }); + this._carousel?.reInit({ ...options }); }); } /** @@ -162,13 +164,9 @@ export class FrigateCardCarousel extends LitElement { protected _carouselReInitInPlaceInternal(): void { const selected = this.getCarouselSelected(); - // Safari appears to not loop the carousel unless the options are passed - // back in during re-initialization. - const options = { - ...this.carouselOptions, + this._carouselReInit({ ...(selected && { startIndex: selected.index }), - }; - this._carouselReInit(options); + }); } /** @@ -263,7 +261,7 @@ export class FrigateCardCarousel extends LitElement { this._reInitOnSettle = false; this._carouselReInitInPlace(); } - }) + }); this._carousel.on('settle', () => { const selected = this.getCarouselSelected(); if (selected) {