Complete carousel refactor.

Reduces one layer of DOM nesting for simplication, uses the latest Embla
version, unittests for everything.
This commit is contained in:
Dermot Duffy
2023-09-04 16:25:32 -07:00
parent 48ece1e154
commit e830db1a77
56 changed files with 3561 additions and 1950 deletions
+54 -238
View File
@@ -1,5 +1,3 @@
import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel';
import { EmblaNodesType } from 'embla-carousel/components';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import {
CSSResultGroup,
@@ -11,16 +9,12 @@ import {
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import isEqual from 'lodash-es/isEqual';
import throttle from 'lodash-es/throttle';
import carouselStyle from '../scss/carousel.scss';
import { TransitionEffect } from '../types';
import { dispatchFrigateCardEvent, isHTMLElement } from '../utils/basic.js';
export interface CarouselSelect {
index: number;
element: HTMLElement;
}
import {
CarouselController,
CarouselDirection,
} from '../utils/embla/carousel-controller';
export type EmblaCarouselPlugins = CreatePluginType<
LoosePluginType,
@@ -30,269 +24,91 @@ export type EmblaCarouselPlugins = CreatePluginType<
@customElement('frigate-card-carousel')
export class FrigateCardCarousel extends LitElement {
@property({ attribute: true, reflect: true })
public direction: 'vertical' | 'horizontal' = 'horizontal';
@property({ attribute: false })
public carouselOptions?: EmblaOptionsType;
@property({ attribute: false })
public carouselPlugins?: EmblaCarouselPlugins;
@property({ attribute: false })
public selected = 0;
public direction: CarouselDirection = 'horizontal';
@property({ attribute: true })
public transitionEffect?: TransitionEffect;
protected _refSlot: Ref<HTMLSlotElement> = createRef();
@property({ attribute: false })
public loop?: boolean;
protected _carousel?: EmblaCarouselType;
@property({ attribute: false })
public dragFree?: boolean;
// Whether the carousel is actively scrolling.
protected _scrolling = false;
@property({ attribute: false })
public dragEnabled = true;
// Whether to reinit the carousel when it settles.
protected _reInitOnSettle = false;
@property({ attribute: false })
public plugins?: EmblaCarouselPlugins;
protected _carouselReInitInPlace = throttle(
this._carouselReInitInPlaceInternal.bind(this),
500,
{ trailing: true },
);
@property({ attribute: false })
public selected = 0;
protected _refParent: Ref<HTMLSlotElement> = createRef();
protected _refRoot: Ref<HTMLElement> = createRef();
protected _carousel: CarouselController | null = null;
connectedCallback(): void {
super.connectedCallback();
// Guarantee a re-render if the component is reconnected. See note in
// disconnectedCallback().
// Guarantee recreation of carousel if the component is reconnected.
this.requestUpdate();
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
// Destroy the carousel when the component is disconnected, which forces the
// plugins (which may have registered event handlers) to also be destroyed.
// The carousel will automatically reconstruct if the component is re-rendered.
this._destroyCarousel();
this._carousel?.destroy();
super.disconnectedCallback();
}
/**
* Destroy the carousel if certain properties change.
* @param changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void {
const destroyProperties = [
'direction',
'carouselOptions',
'carouselPlugins',
] as const;
if (changedProps.has('direction')) {
this.setAttribute('direction', this.direction);
}
const destroyProperties = ['direction', 'dragFree', 'transitionEffect'] as const;
if (destroyProperties.some((prop) => changedProps.has(prop))) {
this._destroyCarousel();
}
}
/**
* Get the selected slide.
* @returns A CarouselSelect object (index & element).
*/
public getCarouselSelected(slide?: number): CarouselSelect | null {
const index = slide ?? this._carousel?.selectedScrollSnap();
const element =
index !== undefined ? this._carousel?.slideNodes()[index] ?? null : null;
if (index !== undefined && element) {
return {
index: index,
element: element,
};
}
return null;
}
/**
* Get the carousel.
*/
public carousel(): EmblaCarouselType | null {
return this._carousel ?? null;
}
/**
* ReInit the carousel but stay on the current slide.
*/
protected _carouselReInitInPlaceInternal(): void {
const carouselReInit = (options?: EmblaOptionsType): void => {
// Allow the browser a moment to paint components that are inflight, to
// ensure accurate measurements are taken during the carousel
// reinitialization.
window.requestAnimationFrame(() => {
this._carousel?.reInit({ ...options });
});
};
carouselReInit({
startIndex: this.selected,
});
}
/**
* ReInit the carousel when it is safe to do so without disturbing the
* appearance (i.e. cutting off a scroll in progress).
*/
public carouselReInitWhenSafe(): void {
if (this._scrolling) {
this._reInitOnSettle = true;
} else {
this._carouselReInitInPlace();
}
}
/**
* The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render.
*/
updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (!this._carousel) {
this._initCarousel();
}
if (changedProperties.has('selected')) {
this._carousel?.scrollTo(this.selected, this.transitionEffect === 'none');
}
}
/**
* Destroy the carousel.
* @param options If `savePosition` is set the existing carousel position
* will be saved so it can be restored if the carousel is recreated.
*/
protected _destroyCarousel(): void {
if (this._carousel) {
this._carousel.destroy();
}
this._carousel = undefined;
}
protected _getSlideElements(): HTMLElement[] {
return (
this._refSlot.value?.assignedElements({ flatten: true }).filter(isHTMLElement) ??
[]
);
}
/**
* Initialize the carousel.
*/
protected _initCarousel(): void {
const carouselNode = this.renderRoot.querySelector(
'.embla__viewport',
) as HTMLElement;
const nodes: EmblaNodesType = {
root: carouselNode,
// As the slides are slotted, need to explicitly pull them out and pass
// them to Embla.
slides: this._getSlideElements(),
};
if (carouselNode && nodes.slides) {
this._carousel = EmblaCarousel(
nodes,
{
axis: this.direction == 'horizontal' ? 'x' : 'y',
speed: 30,
startIndex: this.selected,
...this.carouselOptions,
},
this.carouselPlugins,
);
const selectSlide = (slide?: number): void => {
const selected = this.getCarouselSelected(slide);
if (selected) {
dispatchFrigateCardEvent<CarouselSelect>(this, 'carousel:select', selected);
}
// Make sure every select causes a refresh to allow for re-paint of the
// next/previous controls.
this.requestUpdate();
};
this._carousel.on(
'init',
// On initialization selectedScrollSnap() will return 0, even if the
// startIndex during initialization is different, as such we override
// the selected slide as returned by the carousel. This need should be
// verified in future versions of Embla (tested as necessary on v7.0.9).
// Test case:
//
// - Start in `live` view in grid mode.
// - Select any camera that is not the first one.
// - Go to non-grid mode.
// - Go back to grid mode.
// - If successful, thumbnails will load correctly (and the query and
// queryResults in the view will be set vs having been reset in
// `_setViewCameraID` in `live.ts`).
() => selectSlide(this.selected),
);
this._carousel.on('select', () => selectSlide());
this._carousel.on('scroll', () => {
this._scrolling = true;
});
this._carousel.on('settle', () => {
// Reinitialize the carousel if a request to reinitialize was made
// during scrolling (instead the request is handled after the scrolling
// has settled).
this._scrolling = false;
if (this._reInitOnSettle) {
this._reInitOnSettle = false;
this._carouselReInitInPlace();
}
});
this._carousel.on('settle', () => {
const selected = this.getCarouselSelected();
if (selected) {
dispatchFrigateCardEvent<CarouselSelect>(this, 'carousel:settle', selected);
}
});
}
}
/**
* Called when the slotted children in the carousel change.
*/
protected _slotChanged(): void {
// Check whether the slotted elements have changed (without this check the
// carousel initializations are duplicated).
if (!isEqual(this._getSlideElements(), this._carousel?.slideNodes())) {
// Cannot just re-init, because the slide elements themselves may have
// changed, and only a carousel init can pass in new (slotted) children.
this._destroyCarousel();
this.requestUpdate();
this._carousel?.destroy();
this._carousel = null;
}
}
protected render(): TemplateResult | void {
const slides = this._refSlot.value?.assignedElements({ flatten: true }) || [];
const showPrevious = this.carouselOptions?.loop || this.selected > 0;
const showNext = this.carouselOptions?.loop || this.selected + 1 < slides.length;
return html` <div class="embla">
${showPrevious ? html`<slot name="previous"></slot>` : ``}
<div class="embla__viewport">
<slot name="previous"></slot>
<div ${ref(this._refRoot)} class="embla__viewport">
<div class="embla__container">
<slot ${ref(this._refSlot)} @slotchange=${this._slotChanged.bind(this)}></slot>
<slot ${ref(this._refParent)}></slot>
</div>
</div>
${showNext ? html`<slot name="next"></slot>` : ``}
<slot name="next"></slot>
</div>`;
}
/**
* Get element styles.
*/
protected updated(changedProps: PropertyValues): void {
if (!this._carousel && this._refRoot.value && this._refParent.value) {
this._carousel = new CarouselController(
this._refRoot.value,
this._refParent.value,
{
direction: this.direction,
dragEnabled: this.dragEnabled,
dragFree: this.dragFree,
startIndex: this.selected,
transitionEffect: this.transitionEffect,
loop: this.loop,
plugins: this.plugins,
},
);
}
if (changedProps.has('selected')) {
this._carousel?.selectSlide(this.selected);
}
}
static get styles(): CSSResultGroup {
return unsafeCSS(carouselStyle);
}
+12 -8
View File
@@ -13,7 +13,7 @@ import { SideDrawer } from 'side-drawer';
import drawerInjectStyle from '../scss/drawer-inject.scss';
import drawerStyle from '../scss/drawer.scss';
import { stopEventFromActivatingCardWideActions } from '../utils/action';
import { isHoverableDevice } from '../utils/basic';
import { getChildrenFromElement, isHoverableDevice } from '../utils/basic';
export interface DrawerIcons {
open?: string;
@@ -67,12 +67,14 @@ export class FrigateCardDrawer extends LitElement {
* Called when the slotted children in the drawer change.
*/
protected _slotChanged(): void {
const elements = this._refSlot.value?.assignedElements({ flatten: true });
const children = this._refSlot.value
? getChildrenFromElement(this._refSlot.value)
: [];
// Watch all slot children for size changes.
this._resizeObserver.disconnect();
for (const element of elements ?? []) {
this._resizeObserver.observe(element);
for (const child of children) {
this._resizeObserver.observe(child);
}
this._hideDrawerIfNecessary();
}
@@ -86,11 +88,13 @@ export class FrigateCardDrawer extends LitElement {
return;
}
const elements = this._refSlot.value?.assignedElements({ flatten: true });
const children = this._refSlot.value
? getChildrenFromElement(this._refSlot.value)
: null;
this.empty =
!elements ||
!elements.length ||
elements.every((element) => {
!children ||
!children.length ||
children.every((element) => {
const box = element.getBoundingClientRect();
return !box.width || !box.height;
});
-234
View File
@@ -1,234 +0,0 @@
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
import { CreateOptionsType } from 'embla-carousel/components/Options.js';
import { CreatePluginType } from 'embla-carousel/components/Plugins.js';
import {
AutoMuteCondition,
AutoPauseCondition,
AutoPlayCondition,
AutoUnmuteCondition,
FrigateCardMediaPlayer,
} from '../../types.js';
type OptionsType = CreateOptionsType<{
playerSelector?: string;
// Note: Neither play nor unmute will activate on selection. The caller is
// expected to call the `play()` or `unmute()` methods manually when the media
// is actually loaded (and not just when the slide is visible -- the browser
// cannot play media that is not actually loaded yet, e.g. lazy loading).
autoPlayCondition?: AutoPlayCondition;
autoUnmuteCondition?: AutoUnmuteCondition;
autoPauseCondition?: AutoPauseCondition;
autoMuteCondition?: AutoMuteCondition;
}>;
const defaultOptions: OptionsType = {
active: true,
breakpoints: {},
};
type AutoMediaOptionsType = Partial<OptionsType>
export type AutoMediaType = CreatePluginType<
{
play: () => void;
pause: () => void;
mute: () => void;
unmute: () => void;
},
AutoMediaOptionsType
>;
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
autoMedia?: AutoMediaType
}
}
/**
* An Embla plugin to take automated actions on media (e.g. pause, unmute, etc).
* @param userOptions
* @returns
*/
export function AutoMediaPlugin(
userOptions?: AutoMediaOptionsType,
): AutoMediaType {
const optionsHandler = EmblaCarousel.optionsHandler();
const optionsBase = optionsHandler.merge(
defaultOptions,
AutoMediaPlugin.globalOptions,
);
let options: AutoMediaType['options'];
let carousel: EmblaCarouselType;
let slides: HTMLElement[];
/**
* Initialize the plugin.
*/
function init(embla: EmblaCarouselType): void {
carousel = embla;
options = optionsHandler.atMedia(self.options);
slides = carousel.slideNodes();
// Frigate card media autoplays when the media loads not necessarily when the
// slide is selected, so only pause (and not play/unmute) based on carousel
// events.
carousel.on('destroy', pause);
if (
options.autoPauseCondition &&
['all', 'unselected'].includes(options.autoPauseCondition)
) {
carousel.on('select', pausePrevious);
}
carousel.on('destroy', mute);
if (
options.autoMuteCondition &&
['all', 'unselected'].includes(options.autoMuteCondition)
) {
carousel.on('select', mutePrevious);
}
document.addEventListener('visibilitychange', visibilityHandler);
}
/**
* Destroy the plugin.
*/
function destroy(): void {
carousel.off('destroy', pause);
if (
options.autoPauseCondition &&
['all', 'unselected'].includes(options.autoPauseCondition)
) {
carousel.off('select', pausePrevious);
}
carousel.off('destroy', mute);
if (
options.autoMuteCondition &&
['all', 'unselected'].includes(options.autoMuteCondition)
) {
carousel.off('select', mutePrevious);
}
document.removeEventListener('visibilitychange', visibilityHandler);
}
/**
* Handle document visibility changes.
*/
function visibilityHandler(): void {
if (document.visibilityState === 'hidden') {
if (
options.autoPauseCondition &&
['all', 'hidden'].includes(options.autoPauseCondition)
) {
pauseAll();
}
if (
options.autoMuteCondition &&
['all', 'hidden'].includes(options.autoMuteCondition)
) {
muteAll();
}
} else if (document.visibilityState === 'visible') {
if (
options.autoPlayCondition &&
['all', 'visible'].includes(options.autoPlayCondition)
) {
play();
}
if (
options.autoUnmuteCondition &&
['all', 'visible'].includes(options.autoUnmuteCondition)
) {
unmute();
}
}
}
/**
* Get the media player from a slide.
* @param slide
* @returns A FrigateCardMediaPlayer object or `null`.
*/
function getPlayer(slide: HTMLElement | undefined): FrigateCardMediaPlayer | null {
return options.playerSelector
? (slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null)
: null;
}
/**
* Play the current slide.
*/
function play(): void {
getPlayer(slides[carousel.selectedScrollSnap()])?.play();
}
/**
* Pause the current slide.
*/
function pause(): void {
getPlayer(slides[carousel.selectedScrollSnap()])?.pause();
}
/**
* Pause the previous slide.
*/
function pausePrevious(): void {
getPlayer(slides[carousel.previousScrollSnap()])?.pause();
}
/**
* Pause all slides.
*/
function pauseAll(): void {
for (const slide of slides) {
getPlayer(slide)?.pause();
}
}
/**
* Unmute the current slide.
*/
function unmute(): void {
getPlayer(slides[carousel.selectedScrollSnap()])?.unmute();
}
/**
* Mute the current slide.
*/
function mute(): void {
getPlayer(slides[carousel.selectedScrollSnap()])?.mute();
}
/**
* Mute the previous slide.
*/
function mutePrevious(): void {
getPlayer(slides[carousel.previousScrollSnap()])?.mute();
}
/**
* Mute all slides.
*/
function muteAll(): void {
for (const slide of slides) {
getPlayer(slide)?.mute();
}
}
const self: AutoMediaType = {
name: 'autoMedia',
options: optionsHandler.merge(optionsBase, userOptions),
init,
destroy,
play,
pause,
mute,
unmute,
};
return self;
}
AutoMediaPlugin.globalOptions = <AutoMediaOptionsType | undefined>undefined;
-167
View File
@@ -1,167 +0,0 @@
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';
type OptionsType = CreateOptionsType<{
// Number of slides to lazyload left/right of selected (0 == only selected
// slide).
lazyLoadCount?: number;
lazyUnloadCondition?: LazyUnloadCondition;
lazyLoadCallback?: (index: number, slide: HTMLElement) => void;
lazyUnloadCallback?: (index: number, slide: HTMLElement) => void;
}>;
const defaultOptions: OptionsType = {
active: true,
breakpoints: {},
lazyLoadCount: 0,
};
type LazyloadOptionsType = Partial<OptionsType>;
type LazyloadType = CreatePluginType<
{
hasLazyloaded(index: number): boolean;
},
LazyloadOptionsType
>;
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
lazyload?: LazyloadType;
}
}
export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
const optionsHandler = EmblaCarousel.optionsHandler();
const optionsBase = optionsHandler.merge(defaultOptions, Lazyload.globalOptions);
let options: LazyloadType['options'];
let carousel: EmblaCarouselType;
let slides: HTMLElement[];
const lazyLoadedSlides: Set<number> = new Set();
const loadEvents: EmblaEventType[] = ['init', 'select', 'resize'];
const unloadEvents: EmblaEventType[] = ['select'];
/**
* Initialize the plugin.
*/
function init(embla: EmblaCarouselType): void {
carousel = embla;
options = optionsHandler.atMedia(self.options);
slides = carousel.slideNodes();
if (options.lazyLoadCallback) {
loadEvents.forEach((evt) => carousel.on(evt, lazyLoadHandler));
}
if (
options.lazyUnloadCallback &&
options.lazyUnloadCondition &&
['all', 'unselected'].includes(options.lazyUnloadCondition)
) {
unloadEvents.forEach((evt) => carousel.on(evt, lazyUnloadPreviousHandler));
}
document.addEventListener('visibilitychange', visibilityHandler);
}
/**
* Destroy the plugin.
*/
function destroy(): void {
if (options.lazyLoadCallback) {
loadEvents.forEach((evt) => carousel.off(evt, lazyLoadHandler));
}
if (options.lazyUnloadCallback) {
unloadEvents.forEach((evt) => carousel.off(evt, lazyUnloadPreviousHandler));
}
document.removeEventListener('visibilitychange', visibilityHandler);
}
/**
* Handle document visibility changes.
*/
function visibilityHandler(): void {
if (
document.visibilityState === 'hidden' &&
options.lazyUnloadCallback &&
options.lazyUnloadCondition &&
['all', 'hidden'].includes(options.lazyUnloadCondition)
) {
lazyUnloadAllHandler();
} else if (document.visibilityState === 'visible' && options.lazyLoadCallback) {
lazyLoadHandler();
}
}
/**
* Determine if a slide index has been lazily loaded.
* @param index Slide index.
* @returns `true` if the slide has been lazily loaded.
*/
function hasLazyloaded(index: number): boolean {
return lazyLoadedSlides.has(index);
}
/**
* Lazily load media in the carousel.
*/
function lazyLoadHandler(): void {
const lazyLoadCount = options.lazyLoadCount ?? 0;
const currentIndex = carousel.selectedScrollSnap();
const slidesToLoad = new Set<number>();
// Lazily load 'count' slides on either side of the slides in view.
for (let i = 1; i <= lazyLoadCount && currentIndex - i >= 0; i++) {
slidesToLoad.add(currentIndex - i);
}
slidesToLoad.add(currentIndex);
for (let i = 1; i <= lazyLoadCount && currentIndex + i < slides.length; i++) {
slidesToLoad.add(currentIndex + i);
}
slidesToLoad.forEach((index) => {
if (!hasLazyloaded(index) && options.lazyLoadCallback) {
lazyLoadedSlides.add(index);
options.lazyLoadCallback(index, slides[index]);
}
});
}
/**
* Lazily unload all media in the carousel.
*/
function lazyUnloadAllHandler(): void {
lazyLoadedSlides.forEach((index) => {
if (options.lazyUnloadCallback) {
options.lazyUnloadCallback(index, slides[index]);
lazyLoadedSlides.delete(index);
}
});
}
/**
* Lazily unload the previously selected media in the carousel.
*/
function lazyUnloadPreviousHandler(): void {
const index = carousel.previousScrollSnap();
if (hasLazyloaded(index) && options.lazyUnloadCallback) {
options.lazyUnloadCallback(index, slides[index]);
lazyLoadedSlides.delete(index);
}
}
const self: LazyloadType = {
name: 'lazyload',
options: optionsHandler.merge(optionsBase, userOptions),
init,
destroy,
hasLazyloaded,
};
return self;
}
Lazyload.globalOptions = <LazyloadOptionsType | undefined>undefined;
+57 -152
View File
@@ -1,6 +1,4 @@
import { HomeAssistant } from 'custom-card-helpers';
import { EmblaOptionsType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import { HassEntity } from 'home-assistant-js-websocket';
import {
CSSResultGroup,
@@ -13,15 +11,16 @@ import {
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { guard } from 'lit/directives/guard.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { keyed } from 'lit/directives/keyed.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { CameraConfigs, CameraEndpoints } from '../../camera-manager/types.js';
import { ConditionControllerEpoch, getOverriddenConfig } from '../../conditions.js';
import { localize } from '../../localize/localize.js';
import basicBlockStyle from '../../scss/basic-block.scss';
import liveCarouselStyle from '../../scss/live-carousel.scss';
import liveProviderStyle from '../../scss/live-provider.scss';
import basicBlockStyle from '../../scss/basic-block.scss';
import {
CameraConfig,
CardWideConfig,
@@ -37,28 +36,30 @@ import {
} from '../../types.js';
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
import { contentsChanged } from '../../utils/basic.js';
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
import { AutoMediaActions } from '../../utils/embla/plugins/auto-media-actions/auto-media-actions.js';
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
import { MediaGridSelected } from '../../utils/media-grid-controller.js';
import {
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaUnloadedEvent,
} from '../../utils/media-info.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
import { playMediaMutingIfNecessary } from '../../utils/media.js';
import { Timer } from '../../utils/timer.js';
import { dispatchViewContextChangeEvent, View } from '../../view/view.js';
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
import {
FrigateCardMediaCarousel,
wrapMediaLoadedEventForCarousel,
wrapMediaUnloadedEventForCarousel,
} from '../media-carousel.js';
import { EmblaCarouselPlugins } from '../carousel.js';
import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../message.js';
import '../next-prev-control.js';
import '../surround.js';
import '../title-control.js';
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
import { Lazyload } from './../embla-plugins/lazyload.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { MediaGridSelected } from '../../utils/media-grid-controller.js';
import { getDefaultTitleConfigForView } from '../title-control.js';
import {
FrigateCardTitleControl,
getDefaultTitleConfigForView,
showTitleControlAfterDelay,
} from '../title-control.js';
interface LiveViewContext {
// A cameraID override (used for dependencies/substreams to force a different
@@ -200,11 +201,6 @@ export class FrigateCardLive extends LitElement {
}
}
/**
* Determine whether the element should be updated.
* @param _changedProps The changed properties if any.
* @returns `true` if the element should be updated.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected shouldUpdate(_changedProps: PropertyValues): boolean {
// Don't process updates if it's in the background and a message was
@@ -213,26 +209,16 @@ export class FrigateCardLive extends LitElement {
return !this._inBackground || !this._messageReceivedPostRender;
}
/**
* Component connected callback.
*/
connectedCallback(): void {
this._intersectionObserver.observe(this);
super.connectedCallback();
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
super.disconnectedCallback();
this._intersectionObserver.disconnect();
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (
!this.hass ||
@@ -318,9 +304,6 @@ export class FrigateCardLiveGrid extends LitElement {
@property({ attribute: false, hasChanged: contentsChanged })
public liveOverrides?: LiveOverrides;
@property({ attribute: false })
public inBackground?: boolean;
@property({ attribute: false })
public conditionControllerEpoch?: ConditionControllerEpoch;
@@ -342,7 +325,6 @@ export class FrigateCardLiveGrid extends LitElement {
.viewFilterCameraID=${cameraID}
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig}
.inBackground=${this.inBackground}
.conditionControllerEpoch=${this.conditionControllerEpoch}
.liveOverrides=${this.liveOverrides}
.cardWideConfig=${this.cardWideConfig}
@@ -423,9 +405,6 @@ export class FrigateCardLiveCarousel extends LitElement {
@property({ attribute: false, hasChanged: contentsChanged })
public liveOverrides?: LiveOverrides;
@property({ attribute: false })
public inBackground?: boolean;
@property({ attribute: false })
public conditionControllerEpoch?: ConditionControllerEpoch;
@@ -443,38 +422,9 @@ export class FrigateCardLiveCarousel extends LitElement {
// Index between camera name and slide number.
protected _cameraToSlide: Record<string, number> = {};
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
protected _titleTimer = new Timer();
protected _refTitleControl: Ref<FrigateCardTitleControl> = createRef();
/**
* The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render.
*/
updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has('inBackground')) {
this.updateComplete.then(async () => {
const frigateCardMediaCarousel = this._refMediaCarousel.value;
if (frigateCardMediaCarousel) {
await frigateCardMediaCarousel.updateComplete;
// If this has changed to be in the background (i.e. preloaded but not
// visible) take the appropriate play/pause/mute/unmute actions.
if (this.inBackground) {
frigateCardMediaCarousel.autoPause();
frigateCardMediaCarousel.autoMute();
} else {
frigateCardMediaCarousel.autoPlay();
frigateCardMediaCarousel.autoUnmute();
}
}
});
}
}
/**
* Get the transition effect to use.
* @returns An TransitionEffect object.
*/
protected _getTransitionEffect(): TransitionEffect {
return (
this.overriddenLiveConfig?.transition_effect ??
@@ -490,49 +440,19 @@ export class FrigateCardLiveCarousel extends LitElement {
return Math.max(0, Array.from(cameraIDs).indexOf(this.view.camera));
}
/**
* Get the Embla options to use.
* @returns An EmblaOptionsType object or undefined for no options.
*/
protected _getOptions(): EmblaOptionsType {
return {
// If the carousel is being filtered to a single cameraID, it is never
// draggable.
draggable: !this.viewFilterCameraID && this.overriddenLiveConfig?.draggable,
loop: true,
};
}
/**
* Get the Embla plugins to use.
* @returns A list of EmblaOptionsTypes.
*/
protected _getPlugins(): EmblaCarouselPlugins {
const cameraCount = this.viewFilterCameraID
? 1
: this.cameraManager?.getStore().getVisibleCameraCount() ?? 0;
return [
// Only enable wheel plugin if there is more than one camera.
...(cameraCount > 1
? [
WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
// gestures as scrolling for the carousel.
forceWheelAxis: 'y',
}),
]
: []),
Lazyload({
AutoLazyLoad({
...(this.overriddenLiveConfig?.lazy_load && {
lazyLoadCallback: (index, slide) =>
this._lazyloadOrUnloadSlide('load', index, slide),
}),
lazyUnloadCondition: this.overriddenLiveConfig?.lazy_unload,
lazyUnloadCallback: (index, slide) =>
this._lazyloadOrUnloadSlide('unload', index, slide),
}),
AutoMediaPlugin({
AutoMediaLoadedInfo(),
AutoMediaActions({
playerSelector: FRIGATE_CARD_LIVE_PROVIDER,
...(this.overriddenLiveConfig?.auto_play && {
autoPlayCondition: this.overriddenLiveConfig.auto_play,
@@ -547,6 +467,7 @@ export class FrigateCardLiveCarousel extends LitElement {
autoUnmuteCondition: this.overriddenLiveConfig.auto_unmute,
}),
}),
AutoSize(),
];
}
@@ -562,11 +483,6 @@ export class FrigateCardLiveCarousel extends LitElement {
return this.overriddenLiveConfig?.lazy_load === false ? null : 0;
}
/**
* Get slides to include in the render.
* @returns The slides to include in the render and an index keyed by camera
* name to slide number.
*/
protected _getSlides(): [TemplateResult[], Record<string, number>] {
let cameras: CameraConfigs | null = null;
if (this.viewFilterCameraID) {
@@ -595,7 +511,7 @@ export class FrigateCardLiveCarousel extends LitElement {
: this.cameraManager?.getStore().getCameraConfig(liveCameraID);
const slide = liveCameraConfig
? this._renderLive(liveCameraID, liveCameraConfig, slides.length)
? this._renderLive(liveCameraID, liveCameraConfig)
: null;
if (slide) {
cameraToSlide[cameraID] = slides.length;
@@ -605,10 +521,7 @@ export class FrigateCardLiveCarousel extends LitElement {
return [slides, cameraToSlide];
}
/**
* Handle the user selecting a new slide in the carousel.
*/
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
protected _setViewHandler(ev: CustomEvent<CarouselSelected>): void {
const cameras = this.cameraManager?.getStore().getVisibleCameras();
if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) {
this._setViewCameraID(Array.from(cameras.keys())[ev.detail.index]);
@@ -631,11 +544,6 @@ export class FrigateCardLiveCarousel extends LitElement {
}
}
/**
* Lazy load a slide.
* @param _index The slide number to lazy load.
* @param slide The slide to lazy load.
*/
protected _lazyloadOrUnloadSlide(
action: 'load' | 'unload',
_index: number,
@@ -649,14 +557,13 @@ export class FrigateCardLiveCarousel extends LitElement {
FRIGATE_CARD_LIVE_PROVIDER,
) as FrigateCardLiveProvider | null;
if (liveProvider) {
liveProvider.disabled = action !== 'load';
liveProvider.load = action === 'load';
}
}
protected _renderLive(
cameraID: string,
cameraConfig: CameraConfig,
slideIndex: number,
): TemplateResult | void {
if (
!this.overriddenLiveConfig ||
@@ -683,7 +590,7 @@ export class FrigateCardLiveCarousel extends LitElement {
return html`
<div class="embla__slide">
<frigate-card-live-provider
?disabled=${config.lazy_load}
?load=${!config.lazy_load}
.microphoneStream=${this.view?.camera === cameraID
? this.microphoneStream
: undefined}
@@ -696,12 +603,6 @@ export class FrigateCardLiveCarousel extends LitElement {
.liveConfig=${config}
.hass=${this.hass}
.cardWideConfig=${this.cardWideConfig}
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
wrapMediaLoadedEventForCarousel(slideIndex, ev);
}}
@frigate-card:media:unloaded=${(ev: CustomEvent<void>) => {
wrapMediaUnloadedEventForCarousel(slideIndex, ev);
}}
>
</frigate-card-live-provider>
</div>
@@ -728,10 +629,6 @@ export class FrigateCardLiveCarousel extends LitElement {
];
}
/**
* Render the element.
* @returns A template to display to the user.
*/
protected render(): TemplateResult | void {
if (!this.overriddenLiveConfig || !this.view || !this.hass || !this.cameraManager) {
return;
@@ -743,6 +640,7 @@ export class FrigateCardLiveCarousel extends LitElement {
return;
}
const hasMultipleCameras = slides.length > 1;
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
const overrideCameraID = (cameraID: string): string => {
@@ -776,28 +674,25 @@ export class FrigateCardLiveCarousel extends LitElement {
// little later).
return html`
<frigate-card-media-carousel
${ref(this._refMediaCarousel)}
.carouselOptions=${guard(
[this.cameraManager, this.overriddenLiveConfig],
this._getOptions.bind(this),
)}
.carouselPlugins=${guard(
<frigate-card-carousel
.loop=${hasMultipleCameras}
.dragEnabled=${hasMultipleCameras && this.overriddenLiveConfig?.draggable}
.plugins=${guard(
[this.cameraManager, this.overriddenLiveConfig],
this._getPlugins.bind(this),
) as EmblaCarouselPlugins}
.label="${cameraMetadataCurrent
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
: ''}"
.logo="${cameraMetadataCurrent?.engineLogo}"
.titlePopupConfig=${titleConfig ?? undefined}
)}
.selected=${this._getSelectedCameraIndex()}
transitionEffect=${this._getTransitionEffect()}
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
@frigate-card:carousel:select=${this._setViewHandler.bind(this)}
@frigate-card:carousel:settle=${() => {
// Fetch the thumbnails after the carousel has settled.
dispatchViewContextChangeEvent(this, { thumbnails: { fetch: true } });
}}
@frigate-card:media:loaded=${() => {
if (this._refTitleControl.value) {
showTitleControlAfterDelay(this._refTitleControl.value, this._titleTimer);
}
}}
>
<frigate-card-next-previous-control
slot="previous"
@@ -828,13 +723,22 @@ export class FrigateCardLiveCarousel extends LitElement {
}}
>
</frigate-card-next-previous-control>
</frigate-card-media-carousel>
</frigate-card-carousel>
${cameraMetadataCurrent && titleConfig
? html`<frigate-card-title-control
${ref(this._refTitleControl)}
.config=${titleConfig}
.text="${cameraMetadataCurrent
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
: ''}"
.logo="${cameraMetadataCurrent?.engineLogo}"
.fitInto=${this as HTMLElement}
>
</frigate-card-title-control> `
: ``}
`;
}
/**
* Get styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(liveCarouselStyle);
}
@@ -857,10 +761,11 @@ export class FrigateCardLiveProvider
@property({ attribute: false })
public liveConfig?: LiveConfig;
// Whether or not to disable this entity. If `true`, no contents are rendered
// until this attribute is set to `false` (this is useful for lazy loading).
// Whether or not to load the video for this camera. If `false`, no contents
// are rendered until this attribute is set to `true` (this is useful for lazy
// loading).
@property({ attribute: true, type: Boolean })
public disabled = false;
public load = false;
// Label that is used for ARIA support and as tooltip.
@property({ attribute: false })
@@ -995,8 +900,8 @@ export class FrigateCardLiveProvider
* Called before each update.
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('disabled')) {
if (this.disabled) {
if (changedProps.has('load')) {
if (!this.load) {
this._isVideoMediaLoaded = false;
dispatchMediaUnloadedEvent(this);
}
@@ -1051,7 +956,7 @@ export class FrigateCardLiveProvider
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (this.disabled || !this.hass || !this.liveConfig || !this.cameraConfig) {
if (!this.load || !this.hass || !this.liveConfig || !this.cameraConfig) {
return;
}
-440
View File
@@ -1,440 +0,0 @@
import { EmblaOptionsType } from 'embla-carousel';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import debounce from 'lodash-es/debounce';
import mediaCarouselStyle from '../scss/media-carousel.scss';
import type {
MediaLoadedInfo,
NextPreviousControlConfig,
TitleControlConfig,
TransitionEffect,
} from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic';
import {
dispatchExistingMediaLoadedInfoAsEvent,
isValidMediaLoadedInfo,
} from '../utils/media-info.js';
import { Timer } from '../utils/timer';
import { CarouselSelect, EmblaCarouselPlugins, FrigateCardCarousel } from './carousel';
import './carousel.js';
import { AutoMediaType } from './embla-plugins/automedia.js';
import './next-prev-control.js';
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
import { FrigateCardTitleControl } from './title-control.js';
interface CarouselMediaLoadedInfo {
slide: number;
mediaLoadedInfo: MediaLoadedInfo;
}
interface CarouselMediaUnloadedInfo {
slide: number;
}
/**
* Dispatch a carousel media loaded event.
* @param target The target to send it from.
* @param carouselMediaLoadedInfo The CarouselMediaLoadedInfo.
*/
const dispatchFrigateCardCarouselMediaLoaded = (
target: EventTarget,
carouselMediaLoadedInfo: CarouselMediaLoadedInfo,
): void => {
dispatchFrigateCardEvent<CarouselMediaLoadedInfo>(
target,
'carousel:media:loaded',
carouselMediaLoadedInfo,
);
};
/**
* Dispatch a carousel media UNloaded event.
* @param target The target to send it from.
* @param carouselMediaUnloadedInfo The CarouselMediaUnloadedInfo.
*/
const dispatchFrigateCardCarouselMediaUnloaded = (
target: EventTarget,
carouselMediaUnloadedInfo: CarouselMediaUnloadedInfo,
): void => {
dispatchFrigateCardEvent<CarouselMediaUnloadedInfo>(
target,
'carousel:media:unloaded',
carouselMediaUnloadedInfo,
);
};
/**
* Turn a MediaLoadedInfo into a CarouselMediaLoadedInfo.
* @param slide The slide number.
* @param event The MediaShowEvent.
*/
export const wrapMediaLoadedEventForCarousel = (
slide: number,
event: CustomEvent<MediaLoadedInfo>,
) => {
event.stopPropagation();
dispatchFrigateCardCarouselMediaLoaded(event.composedPath()[0], {
slide: slide,
mediaLoadedInfo: event.detail,
});
};
/**
* Turn a MediaUnloadedInfo into a CarouselMediaUnloadedInfo.
* @param slide The slide number.
* @param event The MediaUnloadedEvent.
*/
export const wrapMediaUnloadedEventForCarousel = (
slide: number,
event: CustomEvent<void>,
) => {
event.stopPropagation();
dispatchFrigateCardCarouselMediaUnloaded(event.composedPath()[0], {
slide: slide,
});
};
@customElement('frigate-card-media-carousel')
export class FrigateCardMediaCarousel extends LitElement {
@property({ attribute: false })
public nextPreviousConfig?: NextPreviousControlConfig;
@property({ attribute: false })
public carouselOptions?: EmblaOptionsType;
@property({ attribute: false })
public carouselPlugins?: EmblaCarouselPlugins;
@property({ attribute: false, type: Number })
public selected = 0;
@property({ attribute: true })
public transitionEffect?: TransitionEffect;
@property({ attribute: false })
public label?: string;
@property({ attribute: false })
public logo?: string;
@property({ attribute: false })
public titlePopupConfig?: TitleControlConfig;
// A "map" from slide number to MediaLoadedInfo object.
protected _mediaLoadedInfo: Record<number, MediaLoadedInfo> = {};
protected _nextControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
protected _previousControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
protected _titleControlRef: Ref<FrigateCardTitleControl> = createRef();
protected _titleTimer = new Timer();
protected _boundAutoPlayHandler = this.autoPlay.bind(this);
protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this);
protected _boundTitleHandler = this._titleHandler.bind(this);
// Debounce multiple calls to adapt the container height.
protected _debouncedAdaptContainerHeightToSlide = debounce(
this._adaptContainerHeightToSlide.bind(this),
1 * 250,
{trailing: true});
// This carousel may be resized by Lovelace resizes, window resizes,
// fullscreen, etc. Always call the adaptive height handler when the size
// changes.
protected _slideResizeObserver: ResizeObserver;
protected _intersectionObserver: IntersectionObserver;
protected _refCarousel: Ref<FrigateCardCarousel> = createRef();
constructor() {
super();
// Need to watch both changes in this element (e.g. caused by a window
// resize or fullscreen change) and changes in the selected slide itself
// (e.g. changing from a progress indicator to a loaded media).
this._slideResizeObserver = new ResizeObserver(
this._reInitAndAdjustHeight.bind(this),
);
this._intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this),
);
}
/**
* Get the underlying carousel.
*/
public frigateCardCarousel(): FrigateCardCarousel | null {
return this._refCarousel.value ?? null;
}
/**
* Get the AutoMedia plugin (if any).
* @returns The plugin or `null`.
*/
protected _getAutoMediaPlugin(): AutoMediaType | null {
return this.frigateCardCarousel()?.carousel()?.plugins().autoMedia ?? null;
}
/**
* Play the media on the selected slide.
*/
public autoPlay(): void {
const automediaOptions = this._getAutoMediaPlugin()?.options;
if (
automediaOptions?.autoPlayCondition &&
['all', 'selected'].includes(automediaOptions?.autoPlayCondition)
) {
this._getAutoMediaPlugin()?.play();
}
}
/**
* Pause the media on the selected slide.
*/
public autoPause(): void {
const automediaOptions = this._getAutoMediaPlugin()?.options;
if (
automediaOptions?.autoPauseCondition &&
['all', 'selected'].includes(automediaOptions.autoPauseCondition)
) {
this._getAutoMediaPlugin()?.pause();
}
}
/**
* Unmute the media on the selected slide.
*/
public autoUnmute(): void {
const automediaOptions = this._getAutoMediaPlugin()?.options;
if (
automediaOptions?.autoUnmuteCondition &&
['all', 'selected'].includes(automediaOptions?.autoUnmuteCondition)
) {
this._getAutoMediaPlugin()?.unmute();
}
}
/**
* Mute the media on the selected slide.
*/
public autoMute(): void {
const automediaOptions = this._getAutoMediaPlugin()?.options;
if (
automediaOptions?.autoMuteCondition &&
['all', 'selected'].includes(automediaOptions?.autoMuteCondition)
) {
this._getAutoMediaPlugin()?.mute();
}
}
/**
* Show the media title after the media loads.
*/
protected _titleHandler(): void {
const show = () => {
this._titleTimer.stop();
this._titleControlRef.value?.show();
};
if (this._titleControlRef.value?.isVisible()) {
// If it's already visible, update it immediately (but also update it
// after the timer expires to ensure it re-positions if necessary, see
// comment below).
show();
}
// Allow a brief pause after the media loads, but before the title is
// displayed. This allows for a pleasant appearance/disappear of the title,
// and allows for the browser to finish rendering the carousel.
this._titleTimer.start(0.5, show);
}
/**
* Component connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('frigate-card:media:loaded', this._boundAutoPlayHandler);
this.addEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler);
this.addEventListener(
'frigate-card:media:loaded',
this._debouncedAdaptContainerHeightToSlide,
);
this.addEventListener('frigate-card:media:loaded', this._boundTitleHandler);
this._intersectionObserver.observe(this);
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
this.removeEventListener('frigate-card:media:loaded', this._boundAutoPlayHandler);
this.removeEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler);
this.removeEventListener(
'frigate-card:media:loaded',
this._debouncedAdaptContainerHeightToSlide,
);
this.removeEventListener('frigate-card:media:loaded', this._boundTitleHandler);
this._intersectionObserver.disconnect();
this._mediaLoadedInfo = {};
super.disconnectedCallback();
}
/**
* ReInit the carousel and adapt the container height.
*/
protected _reInitAndAdjustHeight(): void {
this.frigateCardCarousel()?.carouselReInitWhenSafe();
this._debouncedAdaptContainerHeightToSlide();
}
/**
* Called when the carousel intersects with the viewport.
* @param entries The IntersectionObserverEntry entries (should be only 1).
*/
protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
/**
* - If the DOM that contains this carousel changes such that it causes
* slides to entirely appear/disappear (e.g. `display: none` or hidden),
* then the displayed slide sizes will significantly change and the
* carousel will need to be reinitialized. Without this, odd bugs may
* occur for some users in some circumstances causing the carousel to
* appear 'stuck'.
* - Example bug when this reinitialization is not performed:
* https://github.com/dermotduffy/frigate-hass-card/issues/651
*/
if (entries.some((entry) => entry.isIntersecting)) {
this._reInitAndAdjustHeight();
}
}
/**
* Set the the height of the component on media load in case the dimensions
* have changed. This handler is not triggered from carousel events, as it's
* actually the media load/show that will change the dimensions, and that is
* async from carousel actions (e.g. lazy-loaded media).
*
* This component does not use the stock Embla auto-height plugin as that
* resizes the container only on selection rather than media load.
*/
protected _adaptContainerHeightToSlide(): void {
const selected = this.frigateCardCarousel()?.getCarouselSelected();
if (selected) {
this.style.removeProperty('max-height');
const height = selected.element.getBoundingClientRect().height;
if (height !== undefined && height > 0) {
this.style.maxHeight = `${height}px`;
}
}
}
/**
* Fire a media show event when a slide is selected.
*/
protected _dispatchMediaLoadedInfo(selected: CarouselSelect): void {
const slideIndex = selected.index;
if (slideIndex !== undefined && slideIndex in this._mediaLoadedInfo) {
dispatchExistingMediaLoadedInfoAsEvent(this, this._mediaLoadedInfo[slideIndex]);
}
}
/**
* Handle a media:loaded event that is generated by a child component, saving the
* contents for future use when the relevant slide is actually shown.
* @param slideIndex The relevant slide index.
* @param event The media:loaded event from the child component.
*/
protected _storeMediaLoadedInfo(event: CustomEvent<CarouselMediaLoadedInfo>): void {
// Don't allow the inbound event to propagate upwards, that will be
// automatically done at the appropriate time as the slide is shown.
event.stopPropagation();
const mediaLoadedInfo = event.detail.mediaLoadedInfo;
const slideIndex = event.detail.slide;
// isValidMediaLoadedInfo is used to prevent saving media info that will be
// rejected upstream (empty 1x1 images will be rejected here).
if (mediaLoadedInfo && isValidMediaLoadedInfo(mediaLoadedInfo)) {
this._mediaLoadedInfo[slideIndex] = mediaLoadedInfo;
if (this.frigateCardCarousel()?.getCarouselSelected()?.index === slideIndex) {
dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo);
}
}
}
/**
* Remove a media loaded info (i.e. a media item has unloaded).
* @param event The CarouselMediaUnloadedInfo event.
*/
protected _removeMediaLoadedInfo(event: CustomEvent<CarouselMediaUnloadedInfo>): void {
const slideIndex = event.detail.slide;
delete this._mediaLoadedInfo[slideIndex];
// If the slide that unloaded is not visible, don't propagate the event upwards.
if (this.frigateCardCarousel()?.getCarouselSelected()?.index !== slideIndex) {
event.stopPropagation();
}
}
protected render(): TemplateResult | void {
const selectSlide = (ev: CustomEvent<CarouselSelect>): void => {
this._slideResizeObserver.disconnect();
const parent = this.getRootNode();
if (parent && parent instanceof ShadowRoot) {
this._slideResizeObserver.observe(parent.host);
}
const selected = ev.detail;
this._slideResizeObserver.observe(selected.element);
// Pass up the media-carousel select event first to allow parents to
// initialize/reset before the media info is dispatched.
dispatchFrigateCardEvent<CarouselSelect>(
this,
'media-carousel:select',
selected,
);
// Dispatch media info.
this._dispatchMediaLoadedInfo(selected);
}
return html` <frigate-card-carousel
${ref(this._refCarousel)}
.selected=${this.selected ?? 0}
.carouselOptions=${this.carouselOptions}
.carouselPlugins=${this.carouselPlugins}
transitionEffect=${ifDefined(this.transitionEffect)}
@frigate-card:carousel:select=${(ev: CustomEvent<CarouselSelect>) => {
selectSlide(ev);
}}
@frigate-card:carousel:media:loaded=${this._storeMediaLoadedInfo.bind(this)}
@frigate-card:carousel:media:unloaded=${this._removeMediaLoadedInfo.bind(this)}
>
<slot slot="previous" name="previous"></slot>
<slot></slot>
<slot slot="next" name="next"></slot>
</frigate-card-carousel>
${this.label && this.titlePopupConfig
? html`<frigate-card-title-control
${ref(this._titleControlRef)}
.config=${this.titlePopupConfig}
.text="${this.label}"
.logo="${this.logo}"
.fitInto=${this as HTMLElement}
>
</frigate-card-title-control> `
: ``}`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(mediaCarouselStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-media-carousel': FrigateCardMediaCarousel;
}
}
+68 -146
View File
@@ -1,5 +1,3 @@
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import {
CSSResultGroup,
html,
@@ -10,18 +8,16 @@ import {
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { CameraManager } from '../camera-manager/manager.js';
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
import { ExtendedHomeAssistant, ThumbnailsControlConfig } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { View } from '../view/view.js';
import { CarouselDirection } from '../utils/embla/carousel-controller.js';
import { MediaQueriesResults } from '../view/media-queries-results';
import { FrigateCardCarousel } from './carousel.js';
import './thumbnail.js';
import { View } from '../view/view.js';
import './carousel.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { CameraManager } from '../camera-manager/manager.js';
import './thumbnail.js';
export interface ThumbnailCarouselTap {
queryResults: MediaQueriesResults;
@@ -38,83 +34,11 @@ export class FrigateCardThumbnailCarousel extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
protected _refCarousel: Ref<FrigateCardCarousel> = createRef();
// Thumbnail carousels can expand (e.g. drawer-based carousels after the main
// media loads). The carousel must be re-initialized in these cases, or the
// dynamic sizing fails (and users can scroll past the end of the carousel).
protected _resizeObserver: ResizeObserver;
@property({ attribute: false })
public config?: ThumbnailsControlConfig;
@property({ attribute: false })
public selected? = 0;
protected _thumbnailSlides: TemplateResult[] = [];
protected _carouselOptions?: EmblaOptionsType = {
containScroll: 'keepSnaps',
dragFree: true,
};
protected _carouselPlugins: EmblaPluginType[] = [
WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
// gestures as scrolling for the carousel.
forceWheelAxis: 'y',
}),
];
constructor() {
super();
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
}
/**
* Handle gallery resize.
*/
protected _resizeHandler(): void {
this._refCarousel.value?.carouselReInitWhenSafe();
}
/**
* Component connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
this._resizeObserver.observe(this);
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
this._resizeObserver.disconnect();
super.disconnectedCallback();
}
/**
* Get slides to include in the render.
* @returns The slides to include in the render.
*/
protected _getSlides(): TemplateResult[] {
if (!this.view?.query || !this.view.queryResults?.hasResults()) {
return [];
}
const slides: TemplateResult[] = [];
for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) {
const thumbnail = this._renderThumbnail(i);
if (thumbnail) {
slides[i] = thumbnail;
}
}
return slides;
}
/**
* Called when an update will occur.
* @param changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('config')) {
if (this.config?.size) {
@@ -128,95 +52,93 @@ export class FrigateCardThumbnailCarousel extends LitElement {
}
}
if (changedProps.has('selected')) {
const renderProperties = [
'cameraManager',
'config',
'transitionEffect',
'view',
] as const;
if (renderProperties.some((prop) => changedProps.has(prop))) {
this._thumbnailSlides = this._renderSlides();
}
if (changedProps.has('view')) {
this.style.setProperty(
'--frigate-card-carousel-thumbnail-opacity',
this.selected === undefined ? '1.0' : '0.4',
this._getSelectedSlide() === null ? '1.0' : '0.4',
);
}
}
/**
* Render a given thumbnail.
* @param mediaToRender The media item to render.
* @returns A template or void if the item could not be rendered.
*/
protected _renderThumbnail(index: number): TemplateResult | void {
const media = this.view?.queryResults?.getResult(index) ?? null;
if (!media || !this.view) {
return;
}
const classes = {
embla__slide: true,
'slide-selected': this.selected === index,
};
const seekTarget = this.view?.context?.mediaViewer?.seek;
return html` <frigate-card-thumbnail
class="${classMap(classes)}"
.cameraManager=${this.cameraManager}
.hass=${this.hass}
.media=${media}
.view=${this.view}
.seek=${seekTarget && media.includesTime(seekTarget) ? seekTarget : undefined}
?details=${!!this.config?.show_details}
?show_favorite_control=${this.config?.show_favorite_control}
?show_timeline_control=${this.config?.show_timeline_control}
?show_download_control=${this.config?.show_download_control}
@click=${(ev: Event) => {
if (this.view && this.view.queryResults) {
dispatchFrigateCardEvent<ThumbnailCarouselTap>(
this,
'thumbnail-carousel:tap',
{
queryResults: this.view.queryResults.clone().selectIndex(index),
},
);
}
stopEventFromActivatingCardWideActions(ev);
}}
>
</frigate-card-thumbnail>`;
protected _getSelectedSlide(view?: View): number | null {
return (view ?? this.view)?.queryResults?.getSelectedIndex() ?? null;
}
/**
* Get the direction of the thumbnail carousel.
* @returns `vertical`, `horizontal` or undefined.
*/
protected _getDirection(): 'horizontal' | 'vertical' | undefined {
protected _renderSlides(): TemplateResult[] {
const slides: TemplateResult[] = [];
const seekTarget = this.view?.context?.mediaViewer?.seek;
const selectedIndex = this._getSelectedSlide();
for (const media of this.view?.queryResults?.getResults() ?? []) {
const index = slides.length;
const classes = {
embla__slide: true,
'slide-selected': selectedIndex === index,
};
slides.push(html` <frigate-card-thumbnail
class="${classMap(classes)}"
.cameraManager=${this.cameraManager}
.hass=${this.hass}
.media=${media}
.view=${this.view}
.seek=${seekTarget && media.includesTime(seekTarget) ? seekTarget : undefined}
?details=${!!this.config?.show_details}
?show_favorite_control=${this.config?.show_favorite_control}
?show_timeline_control=${this.config?.show_timeline_control}
?show_download_control=${this.config?.show_download_control}
@click=${(ev: Event) => {
if (this.view && this.view.queryResults) {
dispatchFrigateCardEvent<ThumbnailCarouselTap>(
this,
'thumbnail-carousel:tap',
{
queryResults: this.view.queryResults.clone().selectIndex(index),
},
);
}
stopEventFromActivatingCardWideActions(ev);
}}
>
</frigate-card-thumbnail>`);
}
return slides;
}
protected _getDirection(): CarouselDirection | null {
if (this.config?.mode === 'left' || this.config?.mode === 'right') {
return 'vertical';
} else if (this.config?.mode === 'above' || this.config?.mode === 'below') {
return 'horizontal';
}
return undefined;
return null;
}
/**
* Render the element.
* @returns A template to display to the user.
*/
protected render(): TemplateResult | void {
const slides = this._getSlides();
if (!slides.length || !this.config || this.config.mode === 'none') {
const direction = this._getDirection();
if (!this._thumbnailSlides.length || !this.config || !direction) {
return;
}
return html`<frigate-card-carousel
${ref(this._refCarousel)}
direction=${ifDefined(this._getDirection())}
.selected=${this.selected ?? 0}
.carouselOptions=${this._carouselOptions}
.carouselPlugins=${this._carouselPlugins}
direction=${direction}
.selected=${this._getSelectedSlide() ?? 0}
.dragFree=${true}
>
${slides}
${this._thumbnailSlides}
</frigate-card-carousel>`;
}
/**
* Get element styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(thumbnailCarouselStyle);
}
+24 -17
View File
@@ -3,12 +3,36 @@ import { customElement, property } from 'lit/decorators.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import titleStyle from '../scss/title-control.scss';
import { TitleControlConfig } from '../types.js';
import { Timer } from '../utils/timer';
import { View } from '../view/view.js';
type PaperToast = HTMLElement & {
opened: boolean;
};
export const showTitleControlAfterDelay = (
control: FrigateCardTitleControl,
timer: Timer,
delay = 0.5,
): void => {
const show = () => {
timer.stop();
control.show();
};
if (control.isVisible()) {
// If it's already visible, update it immediately (but also update it
// after the timer expires to ensure it re-positions if necessary, see
// comment below).
show();
}
// Allow a brief pause after the media loads, but before the title is
// displayed. This allows for a pleasant appearance/disappear of the title,
// and allows for the browser to finish rendering the carousel.
timer.start(delay, show);
};
export const getDefaultTitleConfigForView = (
view?: Readonly<View>,
baseConfig?: TitleControlConfig,
@@ -39,10 +63,6 @@ export class FrigateCardTitleControl extends LitElement {
protected _toastRef: Ref<PaperToast> = createRef();
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult {
if (!this.text || !this.config || this.config.mode == 'none' || !this.fitInto) {
return html``;
@@ -64,17 +84,10 @@ export class FrigateCardTitleControl extends LitElement {
</paper-toast>`;
}
/**
* Determine if the toast is visible.
* @returns `true` if the toast is visible, `false` otherwise.
*/
public isVisible(): boolean {
return this._toastRef.value?.opened ?? false;
}
/**
* Show the toast.
*/
public hide(): void {
if (this._toastRef.value) {
// Set it to false first, to ensure the timer resets.
@@ -82,9 +95,6 @@ export class FrigateCardTitleControl extends LitElement {
}
}
/**
* Show the toast.
*/
public show(): void {
if (this._toastRef.value) {
// Set it to false first, to ensure the timer resets.
@@ -93,9 +103,6 @@ export class FrigateCardTitleControl extends LitElement {
}
}
/**
* Get element styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(titleStyle);
}
+71 -98
View File
@@ -1,5 +1,3 @@
import { EmblaPluginType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import {
CSSResultGroup,
html,
@@ -12,11 +10,15 @@ import { customElement, property } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import basicBlockStyle from '../scss/basic-block.scss';
import { CameraManager } from '../camera-manager/manager.js';
import { dispatchMessageEvent, renderMessage, renderProgressIndicator } from '../components/message.js';
import {
dispatchMessageEvent,
renderMessage,
renderProgressIndicator,
} from '../components/message.js';
import { localize } from '../localize/localize.js';
import '../patches/ha-hls-player';
import basicBlockStyle from '../scss/basic-block.scss';
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
import viewerProviderStyle from '../scss/viewer-provider.scss';
import viewerStyle from '../scss/viewer.scss';
@@ -36,6 +38,11 @@ import {
errorToConsole,
setOrRemoveAttribute,
} from '../utils/basic.js';
import { CarouselSelected } from '../utils/embla/carousel-controller.js';
import { AutoLazyLoad } from '../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
import { AutoMediaActions } from '../utils/embla/plugins/auto-media-actions/auto-media-actions.js';
import AutoMediaLoadedInfo from '../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
import AutoSize from '../utils/embla/plugins/auto-size/auto-size.js';
import { canonicalizeHAURL } from '../utils/ha/index.js';
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
import { MediaGridSelected } from '../utils/media-grid-controller.js';
@@ -57,22 +64,21 @@ import {
setControlsOnVideo,
} from '../utils/media.js';
import { screenshotMedia } from '../utils/screenshot.js';
import { Timer } from '../utils/timer';
import { ViewMediaClassifier } from '../view/media-classifier';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { MediaQueriesResults } from '../view/media-queries-results.js';
import { VideoContentType, ViewMedia } from '../view/media.js';
import { View } from '../view/view.js';
import type { CarouselSelect } from './carousel.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import { Lazyload } from './embla-plugins/lazyload.js';
import {
FrigateCardMediaCarousel,
wrapMediaLoadedEventForCarousel,
} from './media-carousel.js';
import type { EmblaCarouselPlugins } from './carousel.js';
import './next-prev-control.js';
import './surround.js';
import './title-control.js';
import { getDefaultTitleConfigForView } from './title-control.js';
import {
FrigateCardTitleControl,
getDefaultTitleConfigForView,
showTitleControlAfterDelay,
} from './title-control.js';
export interface MediaViewerViewContext {
seek?: Date;
@@ -114,10 +120,6 @@ export class FrigateCardViewer extends LitElement {
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (
!this.hass ||
@@ -186,9 +188,6 @@ export class FrigateCardViewer extends LitElement {
</frigate-card-viewer-grid>`;
}
/**
* Get element styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(viewerStyle);
}
@@ -227,8 +226,10 @@ export class FrigateCardViewerCarousel extends LitElement {
@property({ attribute: false })
public selected = 0;
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
protected _media: ViewMedia[] | null = null;
protected _titleTimer = new Timer();
protected _refTitleControl: Ref<FrigateCardTitleControl> = createRef();
protected _player: FrigateCardMediaPlayer | null = null;
/**
* The updated lifecycle callback for this element.
@@ -259,47 +260,19 @@ export class FrigateCardViewerCarousel extends LitElement {
);
}
/**
* Get the media player on a slide (or current slide if not provided).
* @param slide An optional slide.
* @returns The FrigateCardMediaPlayer or null if not found.
*/
protected _getPlayer(slide?: HTMLElement | null): FrigateCardMediaPlayer | null {
if (!slide) {
slide = this._refMediaCarousel.value
?.frigateCardCarousel()
?.getCarouselSelected()?.element;
}
return (
(slide?.querySelector(
FRIGATE_CARD_VIEWER_PROVIDER,
) as unknown as FrigateCardMediaPlayer) ?? null
);
}
/**
* Get the Embla plugins to use.
* @returns A list of EmblaOptionsTypes.
*/
protected _getPlugins(): EmblaPluginType[] {
protected _getPlugins(): EmblaCarouselPlugins {
return [
// Only enable wheel plugin if there is more than one media item.
...(this._media && this._media.length > 1
? [
WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
// gestures as scrolling for the carousel.
forceWheelAxis: 'y',
}),
]
: []),
Lazyload({
AutoLazyLoad({
...(this.viewerConfig?.lazy_load && {
lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide),
}),
}),
AutoMediaPlugin({
AutoMediaLoadedInfo(),
AutoMediaActions({
playerSelector: FRIGATE_CARD_VIEWER_PROVIDER,
...(this.viewerConfig?.auto_play && {
autoPlayCondition: this.viewerConfig.auto_play,
@@ -314,6 +287,7 @@ export class FrigateCardViewerCarousel extends LitElement {
autoUnmuteCondition: this.viewerConfig.auto_unmute,
}),
}),
AutoSize(),
];
}
@@ -346,10 +320,6 @@ export class FrigateCardViewerCarousel extends LitElement {
};
}
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
this._setViewSelectedIndex(ev.detail.index);
}
protected _setViewSelectedIndex(index: number): void {
if (!this._media) {
return;
@@ -396,7 +366,7 @@ export class FrigateCardViewerCarousel extends LitElement {
'frigate-card-viewer-provider',
) as FrigateCardViewerProvider | null;
if (viewerProvider) {
viewerProvider.disabled = false;
viewerProvider.load = true;
}
}
@@ -413,7 +383,7 @@ export class FrigateCardViewerCarousel extends LitElement {
for (let i = 0; i < this._media.length; ++i) {
const media = this._media[i];
if (media) {
const slide = this._renderMediaItem(media, i);
const slide = this._renderMediaItem(media);
if (slide) {
slides[i] = slide;
}
@@ -487,22 +457,24 @@ export class FrigateCardViewerCarousel extends LitElement {
);
return html`
<frigate-card-media-carousel
${ref(this._refMediaCarousel)}
.carouselOptions=${guard([this.viewerConfig], () => ({
draggable: this.viewerConfig?.draggable ?? true,
}))}
.carouselPlugins=${guard(
[this.viewerConfig, this._media],
this._getPlugins.bind(this),
)}
.label=${selectedMedia.getTitle() ?? undefined}
.logo=${cameraMetadata?.engineLogo}
.titlePopupConfig=${titleConfig ?? undefined}
<frigate-card-carousel
.dragEnabled=${this.viewerConfig?.draggable ?? true}
.plugins=${guard([this.viewerConfig, this._media], this._getPlugins.bind(this))}
.selected=${this.selected ?? 0}
transitionEffect=${this._getTransitionEffect()}
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
@frigate-card:media:loaded=${this._seekHandler.bind(this)}
@frigate-card:carousel:select=${(ev: CustomEvent<CarouselSelected>) => {
this._setViewSelectedIndex(ev.detail.index);
}}
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
if (this._refTitleControl.value) {
showTitleControlAfterDelay(this._refTitleControl.value, this._titleTimer);
}
this._player = ev.detail.player ?? null;
this._seekHandler();
}}
@frigate-card:media:unloaded=${() => {
this._player = null;
}}
>
<frigate-card-next-previous-control
slot="previous"
@@ -531,11 +503,21 @@ export class FrigateCardViewerCarousel extends LitElement {
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>
</frigate-card-media-carousel>
</frigate-card-carousel>
<div class="seek-warning">
<ha-icon title="${localize('media_viewer.unseekable')}" icon="mdi:clock-remove">
</ha-icon>
</div>
${cameraMetadata && titleConfig
? html`<frigate-card-title-control
${ref(this._refTitleControl)}
.config=${titleConfig}
.text="${selectedMedia.getTitle() ?? undefined}"
.logo="${cameraMetadata?.engineLogo}"
.fitInto=${this as HTMLElement}
>
</frigate-card-title-control> `
: ``}
`;
}
@@ -544,21 +526,20 @@ export class FrigateCardViewerCarousel extends LitElement {
*/
protected async _seekHandler(): Promise<void> {
const seek = this.view?.context?.mediaViewer?.seek;
if (!this.hass || !seek || !this._media || this.selected === null) {
if (!this.hass || !seek || !this._media || this.selected === null || !this._player) {
return;
}
const selectedMedia = this._media[this.selected];
const player = this._getPlayer();
if (!selectedMedia || !player) {
if (!selectedMedia) {
return;
}
const seekTimeInMedia = selectedMedia.includesTime(seek);
setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable');
if (!seekTimeInMedia && !player.isPaused()) {
player.pause();
} else if (seekTimeInMedia && player.isPaused()) {
player.play();
if (!seekTimeInMedia && !this._player.isPaused()) {
this._player.pause();
} else if (seekTimeInMedia && this._player.isPaused()) {
this._player.play();
}
const seekTime =
@@ -566,17 +547,11 @@ export class FrigateCardViewerCarousel extends LitElement {
null;
if (seekTime !== null) {
player.seek(seekTime);
this._player.seek(seekTime);
}
}
/**
* Render a single media item in the viewer carousel.
* @param media The ViewMedia to render.
* @param index The (slide|queryResult) index of the item to render.
* @returns A rendered template.
*/
protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null {
protected _renderMediaItem(media: ViewMedia): TemplateResult | null {
if (!this.hass || !this.view || !this.viewerConfig) {
return null;
}
@@ -589,11 +564,8 @@ export class FrigateCardViewerCarousel extends LitElement {
.viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.disabled=${this.viewerConfig.lazy_load}
.load=${!this.viewerConfig.lazy_load}
.cardWideConfig=${this.cardWideConfig}
@frigate-card:media:loaded=${(e: CustomEvent<MediaLoadedInfo>) => {
wrapMediaLoadedEventForCarousel(index, e);
}}
></frigate-card-viewer-provider>
</div>`;
}
@@ -715,10 +687,11 @@ export class FrigateCardViewerProvider
@property({ attribute: false })
public resolvedMediaCache?: ResolvedMediaCache;
// Whether or not to disable this entity. If `true`, no contents are rendered
// until this attribute is set to `false` (this is useful for lazy loading).
// Whether or not to load the viewer media. If `false`, no contents are
// rendered until this attribute is set to `true` (this is useful for lazy
// loading).
@property({ attribute: false })
public disabled = false;
public load = false;
@property({ attribute: false })
public cameraManager?: CameraManager;
@@ -865,7 +838,7 @@ export class FrigateCardViewerProvider
const mediaContentID = this.media ? this.media.getContentID() : null;
if (
(changedProps.has('disabled') ||
(changedProps.has('load') ||
changedProps.has('media') ||
changedProps.has('viewerConfig') ||
changedProps.has('resolvedMediaCache') ||
@@ -873,7 +846,7 @@ export class FrigateCardViewerProvider
this.hass &&
mediaContentID &&
!this.resolvedMediaCache?.has(mediaContentID) &&
(!this.viewerConfig?.lazy_load || !this.disabled)
(!this.viewerConfig?.lazy_load || this.load)
) {
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => {
this.requestUpdate();
@@ -897,7 +870,7 @@ export class FrigateCardViewerProvider
}
protected render(): TemplateResult | void {
if (this.disabled || !this.media || !this.hass || !this.view || !this.viewerConfig) {
if (!this.load || !this.media || !this.hass || !this.view || !this.viewerConfig) {
return;
}