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