Initial code for major engine refactor.

This commit is contained in:
Dermot Duffy
2023-01-24 19:36:54 -08:00
parent 03de7b473a
commit 093925cb40
39 changed files with 3506 additions and 2459 deletions
+20 -36
View File
@@ -41,15 +41,12 @@ export class FrigateCardCarousel extends LitElement {
@property({ attribute: false })
public carouselPlugins?: EmblaCarouselPlugins;
@property({ attribute: false })
public selected = 0;
@property({ attribute: true })
public transitionEffect?: TransitionEffect;
// An override to the startIndex, used to preserve the current carousel
// position after the carousel is destroyed (so it can be restored if
// recreated).
// See: https://github.com/dermotduffy/frigate-hass-card/issues/775
protected _savedStartIndex: number | null = null;
protected _refSlot: Ref<HTMLSlotElement> = createRef();
protected _carousel?: EmblaCarouselType;
@@ -81,7 +78,7 @@ export class FrigateCardCarousel extends LitElement {
// 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({ savePosition: true });
this._destroyCarousel();
super.disconnectedCallback();
}
@@ -96,7 +93,7 @@ export class FrigateCardCarousel extends LitElement {
'carouselPlugins',
] as const;
if (destroyProperties.some((prop) => changedProps.has(prop))) {
this._destroyCarousel({ savePosition: true });
this._destroyCarousel();
}
}
@@ -105,31 +102,21 @@ export class FrigateCardCarousel extends LitElement {
* @param index Slide number.
*/
public carouselScrollTo(index: number): void {
const scroll = () =>
this._carousel?.scrollTo(index, this.transitionEffect === 'none');
// This ensures scrolling can work on initial render when the carousel may
// not yet exist.
if (this._carousel) {
scroll();
} else {
this.updateComplete.then(() => {
scroll();
});
}
this.selected = index;
}
/**
* Scroll to the previous slide.
*/
public carouselScrollPrevious(): void {
this._carousel?.scrollPrev(this.transitionEffect === 'none');
this.selected = Math.max(0, this.selected - 1);
}
/**
* Scroll to the next slide.
*/
public carouselScrollNext(): void {
this._carousel?.scrollNext(this.transitionEffect === 'none');
this.selected = this.selected + 1;
}
/**
@@ -174,11 +161,10 @@ export class FrigateCardCarousel extends LitElement {
window.requestAnimationFrame(() => {
this._carousel?.reInit({ ...options });
});
}
const selected = this.getCarouselSelected();
};
carouselReInit({
...(selected && { startIndex: selected.index }),
startIndex: this.selected,
});
}
@@ -211,6 +197,10 @@ export class FrigateCardCarousel extends LitElement {
if (!this._carousel) {
this._initCarousel();
}
if (changedProperties.has('selected')) {
this._carousel?.scrollTo(this.selected, this.transitionEffect === 'none');
}
}
/**
@@ -218,9 +208,7 @@ export class FrigateCardCarousel extends LitElement {
* @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(options?: { savePosition: boolean }): void {
this._savedStartIndex =
(options?.savePosition ? this._carousel?.selectedScrollSnap() : null) ?? null;
protected _destroyCarousel(): void {
if (this._carousel) {
this._carousel.destroy();
}
@@ -248,8 +236,8 @@ export class FrigateCardCarousel extends LitElement {
{
axis: this.direction == 'horizontal' ? 'x' : 'y',
speed: 20,
startIndex: this.selected,
...this.carouselOptions,
...(this._savedStartIndex !== null && { startIndex: this._savedStartIndex }),
},
this.carouselPlugins,
);
@@ -262,7 +250,7 @@ export class FrigateCardCarousel extends LitElement {
// Make sure every select causes a refresh to allow for re-paint of the
// next/previous controls.
this.requestUpdate();
}
};
this._carousel.on('init', selectSlide);
this._carousel.on('select', selectSlide);
@@ -294,18 +282,14 @@ export class FrigateCardCarousel extends LitElement {
protected _slotChanged(): void {
// Cannot just re-init, because the slide elements themselves may have
// changed, and only a carousel init can pass in new (slotted) children. If
// the slides themselves change, any position the user has set is assumed to
// be abandoned and so the startIndex is reset to whatever the carousel was
// originally configured with.
this._destroyCarousel({ savePosition: false });
this._destroyCarousel();
this.requestUpdate();
}
protected render(): TemplateResult | void {
const slides = this._refSlot.value?.assignedElements({ flatten: true }) || [];
const currentSlide = (this._carousel?.selectedScrollSnap() ?? this.carouselOptions?.startIndex) ?? 0;
const showPrevious = this.carouselOptions?.loop || currentSlide > 0;
const showNext = this.carouselOptions?.loop || currentSlide + 1 < slides.length;
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>` : ``}
+1 -1
View File
@@ -126,7 +126,7 @@ export class FrigateCardDrawer extends LitElement {
</div>
`
: ''}
<slot ${ref(this._refSlot)} @slotchange=${this._slotChanged.bind(this)}></slot>
<slot ${ref(this._refSlot)} @slotchange=${() => this._slotChanged()}></slot>
</side-drawer>
`;
}
+230 -238
View File
@@ -19,12 +19,10 @@ import {
} from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import {
fetchChildMediaAndDispatchViewChange,
fetchLatestMediaAndDispatchViewChange,
getFullDependentBrowseMediaQueryParametersOrDispatchError,
} from '../utils/ha/browse-media';
import { changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js';
import { DataManager } from '../utils/data-manager.js';
import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js';
import { DataManager } from '../utils/data/data-manager.js';
import { View } from '../view.js';
import { renderProgressIndicator } from './message.js';
import './thumbnail.js';
@@ -66,63 +64,54 @@ export class FrigateCardGallery extends LitElement {
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
const mediaType = this.view?.getMediaType();
if (
!this.hass ||
!this.view ||
!this.cameras ||
!this.view.isGalleryView() ||
!mediaType ||
!this.dataManager
) {
return;
}
// const mediaType = this.view?.getMediaType();
// if (
// !this.hass ||
// !this.view ||
// !this.cameras ||
// !this.view.isGalleryView() ||
// !mediaType ||
// !this.dataManager
// ) {
// return;
// }
if (!this.view.target) {
if (mediaType === 'recordings') {
changeViewToRecentRecordingForCameraAndDependents(
this,
this.hass,
this.dataManager,
this.cameras,
this.view,
{
targetView: 'recordings',
},
);
} else {
const browseMediaQueryParameters =
getFullDependentBrowseMediaQueryParametersOrDispatchError(
this,
this.hass,
this.cameras,
this.view.camera,
mediaType,
);
// if (!this.view.query) {
// if (mediaType === 'recordings') {
// changeViewToRecentRecordingForCameraAndDependents(
// this,
// this.hass,
// this.dataManager,
// this.cameras,
// this.view,
// {
// targetView: 'recordings',
// },
// );
// } else {
// changeViewToRecentEventsForCameraAndDependents(
// this,
// this.hass,
// this.dataManager,
// this.cameras,
// this.view,
// {
// targetView: mediaType,
// },
// );
// }
// return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
// }
if (!browseMediaQueryParameters) {
return;
}
fetchLatestMediaAndDispatchViewChange(
this,
this.hass,
this.view,
browseMediaQueryParameters,
);
}
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
}
return html`
<frigate-card-gallery-core
.hass=${this.hass}
.view=${this.view}
.galleryConfig=${this.galleryConfig}
.cameras=${this.cameras}
>
</frigate-card-gallery-core>
`;
// return html`
// <frigate-card-gallery-core
// .hass=${this.hass}
// .view=${this.view}
// .galleryConfig=${this.galleryConfig}
// .cameras=${this.cameras}
// >
// </frigate-card-gallery-core>
// `;
}
/**
@@ -139,203 +128,206 @@ export class FrigateCardGallery extends LitElement {
}
}
@customElement('frigate-card-gallery-core')
export class FrigateCardGalleryCore extends LitElement {
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
// @customElement('frigate-card-gallery-core')
// export class FrigateCardGalleryCore extends LitElement {
// @property({ attribute: false })
// public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
// @property({ attribute: false })
// public view?: Readonly<View>;
@property({ attribute: false })
public galleryConfig?: GalleryConfig;
// @property({ attribute: false })
// public galleryConfig?: GalleryConfig;
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
// @property({ attribute: false })
// public cameras?: Map<string, CameraConfig>;
protected _resizeObserver: ResizeObserver;
// protected _resizeObserver: ResizeObserver;
constructor() {
super();
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
}
// constructor() {
// super();
// this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
// }
/**
* Component connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
this._resizeObserver.observe(this);
}
// /**
// * Component connected callback.
// */
// connectedCallback(): void {
// super.connectedCallback();
// this._resizeObserver.observe(this);
// }
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
this._resizeObserver.disconnect();
super.disconnectedCallback();
}
// /**
// * Component disconnected callback.
// */
// disconnectedCallback(): void {
// this._resizeObserver.disconnect();
// super.disconnectedCallback();
// }
/**
* Set gallery columns.
*/
protected _setColumnCount(): void {
const thumbnailSize =
this.galleryConfig?.controls.thumbnails.size ??
frigateCardConfigDefaults.event_gallery.controls.thumbnails.size;
const columns = this.galleryConfig?.controls.thumbnails.show_details
? Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN))
: Math.max(
1,
Math.ceil(this.clientWidth / THUMBNAIL_WIDTH_MAX),
Math.ceil(this.clientWidth / thumbnailSize),
);
// /**
// * Set gallery columns.
// */
// protected _setColumnCount(): void {
// const thumbnailSize =
// this.galleryConfig?.controls.thumbnails.size ??
// frigateCardConfigDefaults.event_gallery.controls.thumbnails.size;
// const columns = this.galleryConfig?.controls.thumbnails.show_details
// ? Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN))
// : Math.max(
// 1,
// Math.ceil(this.clientWidth / THUMBNAIL_WIDTH_MAX),
// Math.ceil(this.clientWidth / thumbnailSize),
// );
this.style.setProperty('--frigate-card-gallery-columns', String(columns));
}
// this.style.setProperty('--frigate-card-gallery-columns', String(columns));
// }
/**
* Handle gallery resize.
*/
protected _resizeHandler(): void {
this._setColumnCount();
}
// /**
// * Handle gallery resize.
// */
// protected _resizeHandler(): void {
// this._setColumnCount();
// }
/**
* Determine whether the back arrow should be displayed.
* @returns `true` if the back arrow should be displayed, `false` otherwise.
*/
protected _showBackArrow(): boolean {
return (
!!this.view?.context?.gallery?.previous &&
!!this.view.context.gallery.previous.target &&
this.view.context.gallery.previous.view === this.view.view
);
}
// /**
// * Determine whether the back arrow should be displayed.
// * @returns `true` if the back arrow should be displayed, `false` otherwise.
// */
// protected _shouldShowBackArrow(): boolean {
// return (
// !!this.view?.context?.gallery?.previous &&
// !!this.view.context.gallery.previous.query &&
// this.view.context.gallery.previous.view === this.view.view
// );
// }
/**
* Called when an update will occur.
* @param changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('galleryConfig')) {
if (this.galleryConfig?.controls.thumbnails.show_details) {
this.setAttribute('details', '');
} else {
this.removeAttribute('details');
}
this._setColumnCount();
if (this.galleryConfig?.controls.thumbnails.size) {
this.style.setProperty(
'--frigate-card-thumbnail-size',
`${this.galleryConfig.controls.thumbnails.size}px`,
);
}
}
}
// /**
// * Called when an update will occur.
// * @param changedProps The changed properties
// */
// protected willUpdate(changedProps: PropertyValues): void {
// if (changedProps.has('galleryConfig')) {
// if (this.galleryConfig?.controls.thumbnails.show_details) {
// this.setAttribute('details', '');
// } else {
// this.removeAttribute('details');
// }
// this._setColumnCount();
// if (this.galleryConfig?.controls.thumbnails.size) {
// this.style.setProperty(
// '--frigate-card-thumbnail-size',
// `${this.galleryConfig.controls.thumbnails.size}px`,
// );
// }
// }
// }
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (
!this.hass ||
!this.view ||
!this.view.target ||
!this.view.target.children ||
!this.view.isGalleryView() ||
!this.cameras
) {
return html``;
}
// // TODO: This is still going to show the gallery view (akin to HA media
// // browser).
return html`
${this._showBackArrow()
? html` <ha-card
@click=${(ev) => {
if (this.view && this.view.context?.gallery?.previous) {
this.view.context.gallery.previous.dispatchChangeEvent(this);
}
stopEventFromActivatingCardWideActions(ev);
}}
outlined=""
>
<ha-icon .icon=${'mdi:arrow-left'}></ha-icon>
</ha-card>`
: ''}
${this.view.target.children.map(
(child, index) =>
html`
${child.can_expand
? html`
<ha-card
@click=${(ev) => {
if (this.hass && this.view) {
fetchChildMediaAndDispatchViewChange(
this,
this.hass,
this.view,
child,
{
gallery: {
previous: this.view,
},
},
);
}
stopEventFromActivatingCardWideActions(ev);
}}
outlined=""
>
<div>${child.title}</div>
</ha-card>
`
: html`<frigate-card-thumbnail
.view=${this.view}
.target=${this.view?.target ?? null}
.childIndex=${index}
.hass=${this.hass}
.cameraConfig=${child.frigate?.cameraID
? this.cameras?.get(child.frigate.cameraID)
: undefined}
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
.show_favorite_control}
?show_timeline_control=${!!this.galleryConfig?.controls.thumbnails
.show_timeline_control}
@click=${(ev: Event) => {
if (this.view) {
const targetView = this.view.getViewerViewForGalleryView();
if (targetView) {
this.view
.evolve({
view: targetView,
childIndex: index,
})
.dispatchChangeEvent(this);
}
}
stopEventFromActivatingCardWideActions(ev);
}}
>
</frigate-card-thumbnail>`}
`,
)}
`;
}
// /**
// * Master render method.
// * @returns A rendered template.
// */
// protected render(): TemplateResult | void {
// const results = this.view?.queryResults?.getResults();
/**
* Get styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(galleryStyle);
}
}
// if (
// !results ||
// !this.hass ||
// !this.view ||
// !this.view.isGalleryView() ||
// !this.cameras
// ) {
// return html``;
// }
// return html`
// ${this._shouldShowBackArrow()
// ? html` <ha-card
// @click=${(ev) => {
// if (this.view && this.view.context?.gallery?.previous) {
// this.view.context.gallery.previous.dispatchChangeEvent(this);
// }
// stopEventFromActivatingCardWideActions(ev);
// }}
// outlined=""
// >
// <ha-icon .icon=${'mdi:arrow-left'}></ha-icon>
// </ha-card>`
// : ''}
// ${results.map((child, index) =>
// html`
// ${child.can_expand
// ? html`
// <ha-card
// @click=${(ev) => {
// if (this.hass && this.view) {
// fetchChildMediaAndDispatchViewChange(
// this,
// this.hass,
// this.view,
// child,
// {
// gallery: {
// previous: this.view,
// },
// },
// );
// }
// stopEventFromActivatingCardWideActions(ev);
// }}
// outlined=""
// >
// <div>${child.title}</div>
// </ha-card>
// `
// : html`<frigate-card-thumbnail
// .view=${this.view}
// .target=${this.view?.target ?? null}
// .childIndex=${index}
// .hass=${this.hass}
// .cameraConfig=${child.frigate?.cameraID
// ? this.cameras?.get(child.frigate.cameraID)
// : undefined}
// ?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
// ?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
// .show_favorite_control}
// ?show_timeline_control=${!!this.galleryConfig?.controls.thumbnails
// .show_timeline_control}
// @click=${(ev: Event) => {
// if (this.view) {
// const targetView = this.view.getViewerViewForGalleryView();
// if (targetView) {
// this.view
// .evolve({
// view: targetView,
// childIndex: index,
// })
// .dispatchChangeEvent(this);
// }
// }
// stopEventFromActivatingCardWideActions(ev);
// }}
// >
// </frigate-card-thumbnail>`}
// `,
// )}
// `;
// }
// /**
// * Get styles.
// */
// static get styles(): CSSResultGroup {
// return unsafeCSS(galleryStyle);
// }
// }
declare global {
interface HTMLElementTagNameMap {
'frigate-card-gallery-core': FrigateCardGalleryCore;
//'frigate-card-gallery-core': FrigateCardGalleryCore;
'frigate-card-gallery': FrigateCardGallery;
}
}
+13 -22
View File
@@ -33,7 +33,6 @@ import {
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
import { contentsChanged } from '../../utils/basic.js';
import { getCameraIcon, getCameraTitle } from '../../utils/camera.js';
import { getFullDependentBrowseMediaQueryParameters } from '../../utils/ha/browse-media.js';
import {
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaUnloadedEvent,
@@ -52,7 +51,7 @@ import '../surround.js';
import { EmblaCarouselPlugins } from '../carousel.js';
import { classMap } from 'lit/directives/class-map.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
import { DataManager } from '../../utils/data-manager.js';
import { DataManager } from '../../utils/data/data-manager.js';
import { HomeAssistant } from 'custom-card-helpers';
import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js';
import { HassEntity } from 'home-assistant-js-websocket';
@@ -212,16 +211,6 @@ export class FrigateCardLive extends LitElement {
this.conditionState,
) as LiveConfig;
// Does not use getFullDependentBrowseMediaQueryParametersOrDispatchError to
// ensure that non-Frigate cameras will work in live view (they will not
// have a Frigate camera name).
const browseMediaParams = getFullDependentBrowseMediaQueryParameters(
this.hass,
this.cameras,
this.view.camera,
config.controls.thumbnails.media,
);
// Notes:
// - See use of liveConfig and not config below -- the carousel will
// independently override the liveConfig to reflect the camera in the
@@ -238,10 +227,9 @@ export class FrigateCardLive extends LitElement {
html`<frigate-card-surround
.hass=${this.hass}
.view=${this.view}
.fetch=${true}
.fetchMedia=${config.controls.thumbnails.media}
.thumbnailConfig=${config.controls.thumbnails}
.timelineConfig=${config.controls.timeline}
.browseMediaParams=${browseMediaParams ?? undefined}
.cameras=${this.cameras}
.dataManager=${this.dataManager}
.inBackground=${this._inBackground}
@@ -375,16 +363,19 @@ export class FrigateCardLiveCarousel extends LitElement {
);
}
protected _getSelectedCameraIndex(): number {
if (!this.cameras || !this.view) {
return 0;
}
return Math.max(0, Array.from(this.cameras.keys()).indexOf(this.view.camera));
}
/**
* Get the Embla options to use.
* @returns An EmblaOptionsType object or undefined for no options.
*/
protected _getOptions(): EmblaOptionsType {
return {
startIndex:
this.cameras && this.view
? Math.max(0, Array.from(this.cameras.keys()).indexOf(this.view.camera))
: 0,
draggable: this.liveConfig?.draggable,
loop: true,
};
@@ -483,10 +474,9 @@ export class FrigateCardLiveCarousel extends LitElement {
this.view
.evolve({
camera: Array.from(this.cameras.keys())[selectedCameraIndex],
// Reset the target.
target: null,
childIndex: null,
// Reset the query and query results.
query: null,
queryResults: null,
})
// Don't yet fetch thumbnails (they will be fetched when the carousel
// settles).
@@ -624,6 +614,7 @@ export class FrigateCardLiveCarousel extends LitElement {
) as EmblaCarouselPlugins}
.label="${title ? `${localize('common.live')}: ${title}` : ''}"
.titlePopupConfig=${config.controls.title}
.selected=${this._getSelectedCameraIndex()}
transitionEffect=${this._getTransitionEffect()}
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
@frigate-card:carousel:settle=${() => {
+4
View File
@@ -126,6 +126,9 @@ export class FrigateCardMediaCarousel extends LitElement {
@property({ attribute: false })
public carouselPlugins?: EmblaCarouselPlugins;
@property({ attribute: false, type: Number })
public selected = 0;
@property({ attribute: true })
public transitionEffect?: TransitionEffect;
@@ -418,6 +421,7 @@ export class FrigateCardMediaCarousel extends LitElement {
return html` <frigate-card-carousel
${ref(this._refCarousel)}
.selected=${this.selected ?? 0}
.carouselOptions=${this.carouselOptions}
.carouselPlugins=${this.carouselPlugins}
transitionEffect=${ifDefined(this.transitionEffect)}
+4 -2
View File
@@ -177,9 +177,11 @@ export function dispatchErrorMessageEvent(
*/
export function dispatchFrigateCardErrorEvent(
element: EventTarget,
error: FrigateCardError,
error: FrigateCardError | Error,
): void {
dispatchErrorMessageEvent(element, error.message, { context: error.context });
dispatchErrorMessageEvent(element, error.message, {
...(error instanceof FrigateCardError && { context: error.context }),
});
}
declare global {
+28 -44
View File
@@ -7,29 +7,20 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import surroundStyle from '../scss/surround.scss';
import {
BrowseMediaQueryParameters,
CameraConfig,
ClipsOrSnapshotsOrAll,
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
FrigateCardError,
MiniTimelineControlConfig,
ThumbnailsControlConfig,
} from '../types.js';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
import {
getFirstTrueMediaChildIndex,
multipleBrowseMediaQueryMerged,
} from '../utils/ha/browse-media';
import { DataManager } from '../utils/data-manager';
import { DataManager } from '../utils/data/data-manager.js';
import { View } from '../view.js';
import { dispatchFrigateCardErrorEvent } from './message.js';
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
import './surround-basic.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view';
interface ThumbnailViewContext {
// Whether or not to fetch thumbnails.
@@ -59,11 +50,9 @@ export class FrigateCardSurround extends LitElement {
@property({ attribute: false })
public inBackground?: boolean;
@property({ attribute: false })
public fetch = false;
// If fetchMedia is not specified, no fetching is done.
@property({ attribute: false, hasChanged: contentsChanged })
public browseMediaParams?: BrowseMediaQueryParameters | BrowseMediaQueryParameters[];
public fetchMedia?: ClipsOrSnapshotsOrAll;
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
@@ -79,32 +68,29 @@ export class FrigateCardSurround extends LitElement {
*/
protected async _fetchMedia(): Promise<void> {
if (
!this.fetch ||
!this.cameras ||
!this.dataManager ||
!this.fetchMedia ||
this.inBackground ||
!this.hass ||
!this.view ||
this.view.target ||
this.view.query ||
!this.thumbnailConfig ||
this.thumbnailConfig.mode === 'none' ||
!this.browseMediaParams ||
!(this.view.context?.thumbnails?.fetch ?? true)
) {
return;
}
let parent: FrigateBrowseMediaSource | null;
try {
parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams);
} catch (e) {
return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
}
if (getFirstTrueMediaChildIndex(parent) !== null) {
this.view
?.evolve({
target: parent,
childIndex: null,
})
.dispatchChangeEvent(this);
}
await changeViewToRecentEventsForCameraAndDependents(
this,
this.hass,
this.dataManager,
this.cameras,
this.view,
{
mediaType: this.fetchMedia,
},
);
}
/**
@@ -170,25 +156,21 @@ export class FrigateCardSurround extends LitElement {
slot=${this.thumbnailConfig.mode}
.hass=${this.hass}
.config=${this.thumbnailConfig}
.dataManager=${this.dataManager}
.view=${this.view}
.target=${this.view.target}
.cameras=${this.cameras}
selected=${ifDefined(this.view.childIndex ?? undefined)}
.selected=${this.view.queryResults?.getSelectedIndex() ?? undefined}
@frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
@frigate-card:thumbnail-carousel:tap=${(
ev: CustomEvent<ThumbnailCarouselTap>,
) => {
const child: FrigateBrowseMediaSource | null =
ev.detail.target?.children?.[ev.detail.childIndex] ?? null;
if (child) {
const media = ev.detail.queryResults.getSelectedResult();
if (media) {
this.view
?.evolve({
view: this.view.is('recording') ? 'recording' : 'media',
target: ev.detail.target,
childIndex: ev.detail.childIndex,
...(child.frigate?.cameraID && {
camera: child.frigate?.cameraID,
}),
queryResults: ev.detail.queryResults,
...(media.getCameraID() && { camera: media.getCameraID() }),
})
.removeContext('timeline')
// Send the view change from the source of the tap event, so
@@ -200,7 +182,9 @@ export class FrigateCardSurround extends LitElement {
>
</frigate-card-thumbnail-carousel>`
: ''}
${this.timelineConfig?.mode && this.timelineConfig.mode !== 'none' && !this.inBackground
${this.timelineConfig?.mode &&
this.timelineConfig.mode !== 'none' &&
!this.inBackground
? html` <frigate-card-timeline-core
slot=${this.timelineConfig.mode}
.hass=${this.hass}
+37 -77
View File
@@ -15,22 +15,19 @@ import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
import {
CameraConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
ThumbnailsControlConfig,
} from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
import { isTrueMedia } from '../utils/ha/browse-media';
import { View } from '../view.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { MediaQueriesResults, View } from '../view.js';
import { FrigateCardCarousel } from './carousel.js';
import './thumbnail.js';
import './carousel.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { DataManager } from '../utils/data/data-manager.js';
export interface ThumbnailCarouselTap {
slideIndex: number;
target: FrigateBrowseMediaSource;
childIndex: number;
queryResults: MediaQueriesResults;
}
@customElement('frigate-card-thumbnail-carousel')
@@ -41,14 +38,12 @@ export class FrigateCardThumbnailCarousel extends LitElement {
@property({ attribute: false })
public view?: Readonly<View>;
// Use contentsChanged here to avoid the carousel rebuilding and resetting in
// front of the user, unless the contents have actually changed.
@property({ attribute: false, hasChanged: contentsChanged })
public target?: FrigateBrowseMediaSource | null;
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
@property({ attribute: false })
public dataManager?: DataManager;
protected _refCarousel: Ref<FrigateCardCarousel> = createRef();
// Thumbnail carousels can expand (e.g. drawer-based carousels after the main
@@ -59,10 +54,14 @@ export class FrigateCardThumbnailCarousel extends LitElement {
@property({ attribute: false })
public config?: ThumbnailsControlConfig;
@property({ attribute: true, type: Number, reflect: true })
public selected?: number;
@property({ attribute: false })
public selected? = 0;
protected _carouselOptions?: EmblaOptionsType = {
containScroll: 'keepSnaps',
dragFree: true,
};
protected _carouselOptions?: EmblaOptionsType;
protected _carouselPlugins: EmblaPluginType[] = [
WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
@@ -99,31 +98,20 @@ export class FrigateCardThumbnailCarousel extends LitElement {
super.disconnectedCallback();
}
/**
* Get the Embla options to use.
* @returns An EmblaOptionsType object or undefined for no options.
*/
protected _getOptions(): EmblaOptionsType {
return {
containScroll: 'keepSnaps',
dragFree: true,
startIndex: this.selected ?? 0,
};
}
/**
* Get slides to include in the render.
* @returns The slides to include in the render.
*/
protected _getSlides(): TemplateResult[] {
if (!this.target || !this.target.children || !this.target.children.length) {
if (!this.view?.query || !this.view.queryResults?.hasResults()) {
return [];
}
const slides: TemplateResult[] = [];
for (let i = 0; i < this.target.children.length; ++i) {
const thumbnail = this._renderThumbnail(this.target, i, slides.length);
for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) {
const thumbnail = this._renderThumbnail(i);
if (thumbnail) {
slides.push(thumbnail);
slides[i] = thumbnail;
}
}
return slides;
@@ -152,30 +140,6 @@ export class FrigateCardThumbnailCarousel extends LitElement {
this.selected === undefined ? '1.0' : '0.4',
);
}
if (!this._carouselOptions) {
// Want to set the initial carousel options just before the first render
// in order to get the startIndex correct in the options. It is not safe
// to rely on carouselScrollTo() post update, since the nested carousel
// may not yet be actual rendered/created.
this._carouselOptions = this._getOptions();
}
}
/**
* 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('selected')) {
this.updateComplete.then(() => {
if (this.selected !== undefined) {
this._refCarousel.value?.carouselScrollTo(this.selected);
}
});
}
}
/**
@@ -183,45 +147,40 @@ export class FrigateCardThumbnailCarousel extends LitElement {
* @param mediaToRender The media item to render.
* @returns A template or void if the item could not be rendered.
*/
protected _renderThumbnail(
parent: FrigateBrowseMediaSource,
childIndex: number,
slideIndex: number,
): TemplateResult | void {
if (
!parent.children ||
!parent.children.length ||
!isTrueMedia(parent.children[childIndex])
) {
protected _renderThumbnail(index: number): TemplateResult | void {
const media = this.view?.queryResults?.getResult(index) ?? null;
const cameraConfig = media ? this.cameras?.get(media.getCameraID()) : null;
if (!media || !cameraConfig || !this.view) {
return;
}
const classes = {
embla__slide: true,
'slide-selected': this.selected === childIndex,
'slide-selected': this.selected === index,
};
const cameraConfig = this.view?.camera ? this.cameras?.get(this.view.camera) : null;
return html` <frigate-card-thumbnail
class="${classMap(classes)}"
.dataManager=${this.dataManager}
.hass=${this.hass}
.media=${media}
.cameraConfig=${cameraConfig}
.view=${this.view}
.target=${parent}
.childIndex=${childIndex}
.mediaSeek=${this.view?.context?.mediaViewer?.seek.get(childIndex)}
.cameraConfig=${cameraConfig ?? undefined}
?details=${this.config?.show_details}
.mediaSeek=${this.view?.context?.mediaViewer?.seek.get(index)}
?details=${!!this.config?.show_details}
?show_favorite_control=${this.config?.show_favorite_control}
?show_timeline_control=${this.config?.show_timeline_control}
class="${classMap(classes)}"
@click=${(ev) => {
if (this._refCarousel.value?.carouselClickAllowed()) {
@click=${(ev: Event) => {
if (
this.view &&
this.view.queryResults &&
this._refCarousel.value?.carouselClickAllowed()
) {
dispatchFrigateCardEvent<ThumbnailCarouselTap>(
this,
'thumbnail-carousel:tap',
{
slideIndex: slideIndex,
target: parent,
childIndex: childIndex,
queryResults: this.view.queryResults.clone().selectResult(index),
},
);
}
@@ -257,6 +216,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
return html`<frigate-card-carousel
${ref(this._refCarousel)}
direction=${ifDefined(this._getDirection())}
.selected=${this.selected ?? 0}
.carouselOptions=${this._carouselOptions}
.carouselPlugins=${this._carouselPlugins}
>
+111 -121
View File
@@ -3,30 +3,24 @@ import fromUnixTime from 'date-fns/fromUnixTime';
import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { localize } from '../localize/localize.js';
import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
import thumbnailStyle from '../scss/thumbnail.scss';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { errorToConsole, prettifyTitle } from '../utils/basic.js';
import { getDurationString, prettifyTitle } from '../utils/basic.js';
import { getCameraTitle } from '../utils/camera.js';
import { retainEvent } from '../utils/frigate.js';
import { getEventDurationString } from '../utils/frigate.js';
import { renderTask } from '../utils/task.js';
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
import { View } from '../view.js';
import type { MediaSeek } from './viewer.js';
import { TaskStatus } from '@lit-labs/task';
import type {
CameraConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
FrigateEvent,
FrigateRecording,
} from '../types.js';
import type { CameraConfig, ExtendedHomeAssistant } from '../types.js';
import { ViewMedia } from '../view-media.js';
import { DataManager } from '../utils/data/data-manager.js';
// The minimum width of a thumbnail with details enabled.
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
@@ -133,26 +127,36 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement {
@customElement('frigate-card-thumbnail-details-event')
export class FrigateCardThumbnailDetailsEvent extends LitElement {
@property({ attribute: false })
public event?: FrigateEvent;
public media?: ViewMedia;
@property({ attribute: false })
public mediaSeek?: MediaSeek;
protected render(): TemplateResult | void {
if (!this.event) {
if (!this.media || !this.media.isEvent()) {
return;
}
const score = (this.event.top_score * 100).toFixed(2) + '%';
return html`<div class="left">
<div class="larger">${prettifyTitle(this.event.label)}</div>
<div>
<span class="heading">${localize('event.start')}:</span>
<span>${format(fromUnixTime(this.event.start_time), 'HH:mm:ss')}</span>
</div>
<div>
<span class="heading">${localize('event.duration')}:</span>
<span>${getEventDurationString(this.event)}</span>
</div>
const score = this.media.getScore();
const startTime = this.media.getStartTime();
const endTime = this.media.getEndTime();
const what = this.media.getWhat();
return html` <div class="left">
${what ? html`<div class="larger">${prettifyTitle(what.join(', '))}</div>` : ``}
${startTime
? html` <div>
<span class="heading">${localize('event.start')}:</span>
<span>${format(startTime, 'HH:mm:ss')}</span>
</div>
<div>
<span class="heading">${localize('event.duration')}:</span>
<span
>${endTime
? getDurationString(startTime, endTime)
: localize('event.in_progress')}</span
>
</div>`
: ``}
${this.mediaSeek
? html` <div>
<span class="heading">${localize('event.seek')}</span>
@@ -160,9 +164,11 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
</div>`
: html``}
</div>
<div class="right">
<span class="larger">${score}</span>
</div>`;
${score
? html`<div class="right">
<span class="larger">${(score * 100).toFixed(2) + '%'}</span>
</div>`
: ``}`;
}
static get styles(): CSSResult {
@@ -173,17 +179,21 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
@customElement('frigate-card-thumbnail-details-recording')
export class FrigateCardThumbnailDetailsRecording extends LitElement {
@property({ attribute: false })
public recording?: FrigateRecording;
public media?: ViewMedia;
@property({ attribute: false })
public mediaSeek?: MediaSeek;
@property({ attribute: false })
public cameraTitle?: string;
protected render(): TemplateResult | void {
if (!this.recording) {
if (!this.media) {
return;
}
const eventCount = this.media.getEventCount();
return html`<div class="left">
<div class="larger">${prettifyTitle(this.recording.camera) || ''}</div>
<div class="larger">${this.cameraTitle ?? ''}</div>
${this.mediaSeek
? html` <div>
<span class="heading">${localize('recording.seek')}</span>
@@ -191,10 +201,12 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
</div>`
: html``}
</div>
<div class="right">
<span class="larger">${this.recording.events}</span>
<span>${localize('recording.events')}</span>
</div>`;
${eventCount !== null
? html`<div class="right">
<span class="larger">${eventCount}</span>
<span>${localize('recording.events')}</span>
</div>`
: ``}`;
}
static get styles(): CSSResult {
@@ -204,6 +216,21 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
@customElement('frigate-card-thumbnail')
export class FrigateCardThumbnail extends LitElement {
// HomeAssistant object may be required for thumbnail signing (for Frigate
// events).
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
// DataManager used for marking media as favorite.
@property({ attribute: false })
public dataManager?: DataManager;
@property({ attribute: true })
public media?: ViewMedia;
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@property({ attribute: true, type: Boolean })
public details = false;
@@ -213,160 +240,123 @@ export class FrigateCardThumbnail extends LitElement {
@property({ attribute: true, type: Boolean })
public show_timeline_control = false;
// ======================
// Target-based interface
// ======================
@property({ attribute: false })
public target?: FrigateBrowseMediaSource | null;
@property({ attribute: false })
public childIndex?: number;
@property({ attribute: false })
public mediaSeek?: MediaSeek;
// ===================================================
// Raw interface (can override target-based interface)
// ===================================================
@property({ attribute: true })
public thumbnail?: string;
@property({ attribute: true })
public label?: string;
@property({ attribute: false })
public event?: FrigateEvent;
// ================================
// Optional parameters for controls
// ================================
@property({ attribute: false })
public view?: Readonly<View>;
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public cameraConfig?: CameraConfig;
/**
* Render the element.
* @returns A template to display to the user.
*/
protected render(): TemplateResult | void {
let event: FrigateEvent | null = null;
let recording: FrigateRecording | null = null;
let thumbnail: string | null = null;
let label: string | null = null;
// Take the event / thumbnail / label from the data-bound media (if specified).
if (this.target && this.target.children && this.childIndex !== undefined) {
const media = this.target.children[this.childIndex];
event = media.frigate?.event ?? null;
recording = media.frigate?.recording ?? null;
thumbnail = media.thumbnail;
label = media.title;
}
// Always give the overrides preference (if specified).
if (this.event) {
event = this.event;
}
thumbnail = this.thumbnail ? this.thumbnail : thumbnail;
label = this.label ? this.label : label;
if (!event && !recording) {
if (!this.media || !this.cameraConfig) {
return;
}
const thumbnail = this.media.getThumbnail(this.cameraConfig);
const title = this.media.getTitle(this.cameraConfig) ?? '';
const starClasses = {
star: true,
starred: !!event?.retain_indefinitely,
starred: !!this.media?.isFavorite(),
};
const shouldShowTimelineControl =
this.show_timeline_control &&
this.view &&
(!this.media.isRecording() ||
// Only show timeline control if the recording has a start & end time.
(this.media.getStartTime() && this.media.getEndTime()));
const clientID = this.cameraConfig?.frigate.client_id;
return html` ${event
return html` ${this.media.isEvent()
? html`<frigate-card-thumbnail-feature-event
aria-label="${label ?? ''}"
title="${label ?? ''}"
aria-label="${title ?? ''}"
title=${title}
.hass=${this.hass}
.thumbnail=${thumbnail ?? undefined}
.label=${label ?? undefined}
></frigate-card-thumbnail-feature-event>`
: recording
: this.media.isRecording()
? html`<frigate-card-thumbnail-feature-recording
aria-label="${label ?? ''}"
title="${label ?? ''}"
aria-label="${title ?? ''}"
title="${title ?? ''}"
.cameraTitle=${this.details || !this.cameraConfig || !this.hass
? undefined
: getCameraTitle(this.hass, this.cameraConfig)}
.date=${recording ? fromUnixTime(recording.start_time) : undefined}
.date=${this.media.getStartTime() ?? undefined}
></frigate-card-thumbnail-feature-recording>`
: html``}
${this.show_favorite_control && event && this.hass && clientID
? html` <ha-icon
class="${classMap(starClasses)}"
icon=${event?.retain_indefinitely ? 'mdi:star' : 'mdi:star-outline'}
icon=${this.media.isFavorite() ? 'mdi:star' : 'mdi:star-outline'}
title=${localize('thumbnail.retain_indefinitely')}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (event && this.hass && clientID) {
retainEvent(this.hass, clientID, event.id, !event.retain_indefinitely)
.then(() => {
if (event) {
event.retain_indefinitely = !event.retain_indefinitely;
this.requestUpdate();
}
})
.catch((e) => {
errorToConsole(e);
});
if (this.hass && this.cameraConfig && this.media) {
this.dataManager?.favoriteMedia(
this.hass,
this.cameraConfig,
this.media,
!this.media?.isFavorite(),
);
}
}}
/></ha-icon>`
: ``}
${this.details && event
${this.details && this.media.isEvent()
? html`<frigate-card-thumbnail-details-event
.event=${event ?? undefined}
.media=${this.media ?? undefined}
.mediaSeek=${this.mediaSeek}
></frigate-card-thumbnail-details-event>`
: this.details && recording
: this.details && this.media.isRecording()
? html`<frigate-card-thumbnail-details-recording
.recording=${recording ?? undefined}
.media=${this.media ?? undefined}
.cameraTitle=${getCameraTitle(this.hass, this.cameraConfig)}
.mediaSeek=${this.mediaSeek}
></frigate-card-thumbnail-details-recording>`
: html``}
${this.show_timeline_control
${shouldShowTimelineControl
? html`<ha-icon
class="timeline"
icon="mdi:target"
title=${localize('thumbnail.timeline')}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (event) {
if (!this.view || !this.media) {
return;
}
if (this.media.isEvent()) {
this.view
?.evolve({
.evolve({
view: 'timeline',
target: this.target,
childIndex: this.childIndex ?? null,
queryResults: this.view.queryResults
?.clone()
.selectResultIfFound((media) => media === this.media),
})
.removeContext('timeline')
.dispatchChangeEvent(this);
} else if (recording) {
} else if (this.media.isRecording()) {
const startTime = this.media.getStartTime();
const endTime = this.media.getStartTime();
if (!startTime || !endTime) {
return;
}
// Specifically reset the media target/childIndex, as we cannot
// 'select' an hour in the timeline rather we set the window to
// matching values.
this.view
?.evolve({
view: 'timeline',
target: null,
childIndex: null,
query: null,
})
.mergeInContext({
timeline: {
window: {
start: fromUnixTime(recording.start_time),
end: fromUnixTime(recording.end_time),
start: startTime,
end: endTime,
},
},
})
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -2,7 +2,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js';
import timelineStyle from '../scss/timeline.scss';
import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
import { DataManager } from '../utils/data-manager';
import { DataManager } from '../utils/data/data-manager';
import { View } from '../view';
import './surround.js';
import './timeline-core.js';
@@ -43,7 +43,6 @@ export class FrigateCardTimeline extends LitElement {
.view=${this.view}
.thumbnailConfig=${this.timelineConfig.controls.thumbnails}
.cameras=${this.cameras}
.fetch=${false}
>
<frigate-card-timeline-core
.hass=${this.hass}
+187 -304
View File
@@ -16,30 +16,21 @@ import { renderProgressIndicator } from '../components/message.js';
import viewerStyle from '../scss/viewer.scss';
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
import {
BrowseMediaNeighbors,
BrowseMediaQueryParameters,
CameraConfig,
CardWideConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
frigateCardConfigDefaults,
FrigateCardMediaPlayer,
MediaLoadedInfo,
ResolvedMedia,
TransitionEffect,
ViewerConfig,
} from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { contentsChanged } from '../utils/basic.js';
import {
fetchLatestMediaAndDispatchViewChange,
getEventStartTime,
getFullDependentBrowseMediaQueryParametersOrDispatchError,
isTrueMedia,
multipleBrowseMediaQueryMerged,
overrideMultiBrowseMediaQueryParameters,
} from '../utils/ha/browse-media.js';
import { getFullDependentBrowseMediaQueryParametersOrDispatchError } from '../utils/ha/browse-media.js';
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
import { View } from '../view.js';
import { MediaQueriesResults, View } from '../view.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import { Lazyload } from './embla-plugins/lazyload.js';
import {
@@ -55,8 +46,13 @@ import '../patches/ha-hls-player';
import './surround.js';
import { renderTask } from '../utils/task.js';
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
import { DataManager } from '../utils/data-manager.js';
import { changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js';
import { DataManager } from '../utils/data/data-manager.js';
import {
changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents,
} from '../utils/media-to-view.js';
import { ViewMedia, ViewMediaClassifier } from '../view-media.js';
import { guard } from 'lit/directives/guard.js';
export interface MediaSeek {
// Specifies the point at which this recording should be played, the
@@ -123,10 +119,10 @@ export class FrigateCardViewer extends LitElement {
this.view.camera,
);
if (!this.view.target) {
// If the target is not specified, the view must tell us which mediaType
// to search for. When the target *is* specified, the view is not required
// to indicate the media type (e.g. the mixed 'events' view from the
if (!this.view.queryResults?.hasResults()) {
// If the query is not specified, the view must tell us which mediaType to
// search for. When the query *is* specified, the view is not required to
// indicate the media type (e.g. the mixed 'media' view from the
// timeline).
const mediaType = this.view.getMediaType();
if (!browseMediaQueryParameters || !mediaType) {
@@ -145,13 +141,15 @@ export class FrigateCardViewer extends LitElement {
},
);
} else {
fetchLatestMediaAndDispatchViewChange(
changeViewToRecentEventsForCameraAndDependents(
this,
this.hass,
this.dataManager,
this.cameras,
this.view,
overrideMultiBrowseMediaQueryParameters(browseMediaQueryParameters, {
mediaType: mediaType,
}),
{
targetView: mediaType === 'clips' ? 'clip' : 'snapshot',
},
);
}
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
@@ -160,7 +158,6 @@ export class FrigateCardViewer extends LitElement {
return html` <frigate-card-surround
.hass=${this.hass}
.view=${this.view}
.fetch=${false}
.thumbnailConfig=${this.viewerConfig.controls.thumbnails}
.timelineConfig=${this.viewerConfig.controls.timeline}
.dataManager=${this.dataManager}
@@ -169,8 +166,8 @@ export class FrigateCardViewer extends LitElement {
<frigate-card-viewer-carousel
.hass=${this.hass}
.view=${this.view}
.cameras=${this.cameras}
.viewerConfig=${this.viewerConfig}
.browseMediaQueryParameters=${browseMediaQueryParameters}
.resolvedMediaCache=${this.resolvedMediaCache}
.cardWideConfig=${this.cardWideConfig}
>
@@ -204,46 +201,52 @@ export class FrigateCardViewerCarousel extends LitElement {
@property({ attribute: false, hasChanged: contentsChanged })
public viewerConfig?: ViewerConfig;
@property({ attribute: false })
public browseMediaQueryParameters?: BrowseMediaQueryParameters[] | null;
@property({ attribute: false })
public resolvedMediaCache?: ResolvedMediaCache;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
// Mapping of slide # to FrigateBrowseMediaSource child #.
// (Folders are not media items that can be rendered).
protected _slideToChild: Record<number, number> = {};
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
protected _carouselOptions?: EmblaOptionsType;
protected _carouselPlugins?: EmblaPluginType[];
// A task to resolve target media if lazy loading is disabled.
protected _mediaResolutionTask = new Task<
[FrigateBrowseMediaSource | null | undefined],
[ViewerConfig | undefined, Map<string, CameraConfig> | undefined, View | undefined],
void
>(
this,
async ([target]: (FrigateBrowseMediaSource | null | undefined)[]): Promise<void> => {
for (
let i = 0;
!this.viewerConfig?.lazy_load &&
this.hass &&
target &&
target.children &&
i < (target.children || []).length;
++i
async ([viewerConfig, cameras, view]: [
ViewerConfig | undefined,
Map<string, CameraConfig> | undefined,
View | undefined,
]): Promise<void> => {
if (
!this.hass ||
!viewerConfig?.lazy_load ||
!cameras ||
!view ||
!view.queryResults?.hasResults()
) {
if (isTrueMedia(target.children[i])) {
await resolveMedia(this.hass, target.children[i], this.resolvedMediaCache);
}
return;
}
const promises: Promise<ResolvedMedia | null>[] = [];
view.queryResults?.getResults()?.forEach((media: ViewMedia) => {
const mediaContentID = media.getContentID(cameras.get(media.getCameraID()));
if (this.hass && mediaContentID) {
promises.push(
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache),
);
}
});
await Promise.all(promises);
},
() => [this.view?.target],
() => [this.viewerConfig, this.cameras, this.view],
);
/**
@@ -251,27 +254,8 @@ export class FrigateCardViewerCarousel extends LitElement {
* @param changedProperties The properties that were changed in this render.
*/
updated(changedProperties: PropertyValues): void {
const frigateCardCarousel = this._refMediaCarousel.value?.frigateCardCarousel();
if (frigateCardCarousel && changedProperties.has('view')) {
if (changedProperties.has('view')) {
const oldView = changedProperties.get('view') as View | undefined;
if (oldView) {
if (
oldView.target === this.view?.target &&
oldView.childIndex !== this.view.childIndex
) {
const slide = this._getSlideForChild(this.view.childIndex);
if (
slide !== null &&
slide !== frigateCardCarousel.getCarouselSelected()?.index
) {
// If the media target is the same as already loaded, but isn't of
// the selected slide, scroll to that slide.
frigateCardCarousel.carouselScrollTo(slide);
}
}
}
// Seek into the video if the seek time has changed (this is also called
// on media load, since the media may or may not have been loaded at
// this point).
@@ -282,21 +266,6 @@ export class FrigateCardViewerCarousel extends LitElement {
super.updated(changedProperties);
}
/**
* Get the slide number given a media child number.
* @param childIndex The child index (relative to `view.target`)
* @returns A number or null if the child is not found.
*/
protected _getSlideForChild(childIndex: number | null | undefined): number | null {
if (childIndex === undefined || childIndex === null) {
return null;
}
const slideIndex = Object.keys(this._slideToChild).find(
(key) => this._slideToChild[key] === childIndex,
);
return slideIndex !== undefined ? Number(slideIndex) : null;
}
/**
* Get the transition effect to use.
* @returns An TransitionEffect object.
@@ -308,18 +277,6 @@ export class FrigateCardViewerCarousel extends LitElement {
);
}
/**
* Get the Embla options to use.
* @returns An EmblaOptionsType object or undefined for no options.
*/
protected _getOptions(): EmblaOptionsType {
return {
// Start the carousel on the selected child number.
startIndex: this._getSlideForChild(this.view?.childIndex) ?? 0,
draggable: this.viewerConfig?.draggable ?? true,
};
}
/**
* The the HLS player on a slide (or current slide if not provided.)
* @param slide An optional slide.
@@ -344,10 +301,7 @@ export class FrigateCardViewerCarousel extends LitElement {
protected _getPlugins(): EmblaPluginType[] {
return [
// Only enable wheel plugin if there is more than one media item.
...(this.view &&
this.view.target &&
this.view.target.children &&
this.view.target.children.length > 1
...(this.view?.queryResults?.getResultsCount() ?? 0 > 1
? [
WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
@@ -384,42 +338,20 @@ export class FrigateCardViewerCarousel extends LitElement {
* @returns A BrowseMediaNeighbors with indices and objects of true media
* neighbors.
*/
protected _getMediaNeighbors(): BrowseMediaNeighbors | null {
if (
!this.view ||
!this.view.target ||
!this.view.target.children ||
this.view.childIndex === null
) {
return null;
protected _getMediaNeighbors(): [ViewMedia | null, ViewMedia | null] {
const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null;
const resultCount = this.view?.queryResults?.getResultsCount() ?? 0;
if (!this.view || !this.view.queryResults || selectedIndex === null) {
return [null, null];
}
// Work backwards from the index to get the previous real media.
let prevIndex: number | null = null;
for (let i = this.view.childIndex - 1; i >= 0; i--) {
const media = this.view.target.children[i];
if (media && isTrueMedia(media)) {
prevIndex = i;
break;
}
}
// Work forwards from the index to get the next real media.
let nextIndex: number | null = null;
for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) {
const media = this.view.target.children[i];
if (media && isTrueMedia(media)) {
nextIndex = i;
break;
}
}
return {
previousIndex: prevIndex,
previous: prevIndex != null ? this.view.target.children[prevIndex] : null,
nextIndex: nextIndex,
next: nextIndex != null ? this.view.target.children[nextIndex] : null,
};
const previous: ViewMedia | null =
selectedIndex > 0 ? this.view.queryResults.getResult(selectedIndex - 1) : null;
const next: ViewMedia | null =
selectedIndex + 1 < resultCount
? this.view.queryResults.getResult(selectedIndex + 1)
: null;
return [previous, next];
}
/**
@@ -428,91 +360,55 @@ export class FrigateCardViewerCarousel extends LitElement {
* @param snapshot The snapshot to find a matching clip for.
* @returns The view that would show the matching clip.
*/
protected async _findRelatedClipView(
snapshot: FrigateBrowseMediaSource,
): Promise<View | null> {
protected async _createRelatedClipView(targetIndex: number): Promise<View | null> {
const media = this.view?.queryResults?.getResult(targetIndex);
if (
!this.hass ||
!this.view ||
!this.view.target ||
!this.view.target.children ||
!this.view.target.children.length ||
!this.browseMediaQueryParameters
!media ||
// If this specific media item has no clip, then do nothing (even if all
// the other media items do).
!ViewMediaClassifier.isFrigateEvent(media) ||
!media.hasClip() ||
!this.view.query?.areEventQueries()
) {
return null;
}
const snapshotStartTime = getEventStartTime(snapshot);
if (!snapshotStartTime) {
return null;
}
const newResults: ViewMedia[] = [];
let newSelectedIndex: number | null = null;
// Heuristic: At this point, the user has a particular snapshot that they
// are interested in and want to see a related clip, yet the viewer code
// does not know the exact search criteria that led to that snapshot (e.g.
// it could be a 10-deep folder in the gallery). To give the user to ability
// to 'navigate' in the clips view once they change into that mode, this
// heuristic finds the earliest and latest snapshot that the user is
// currently viewing and mirrors that range into the clips view. Then,
// within the results see if there's a clip that matches the same time as
// the snapshot.
let earliest: number | null = null;
let latest: number | null = null;
for (let i = 0; i < this.view.target.children.length; i++) {
const child = this.view.target.children[i];
if (!isTrueMedia(child)) {
// Convert the query to a clips equivalent.
const newQuery = this.view.query.clone();
newQuery.convertToClipsQueries();
// Regenerate the whole results stack.
for (let i = 0; i < (this.view.queryResults?.getResultsCount() ?? 0); ++i) {
const media = this.view.queryResults?.getResult(i);
if (!media || !ViewMediaClassifier.isFrigateEvent(media)) {
continue;
}
const startTime = getEventStartTime(child);
if (startTime && (earliest === null || startTime < earliest)) {
earliest = startTime;
}
if (startTime && (latest === null || startTime > latest)) {
latest = startTime;
const clipMedia = media.getClipEquivalent();
if (clipMedia) {
newResults.push(clipMedia);
if (i === targetIndex) {
newSelectedIndex = i;
}
}
}
if (!earliest || !latest) {
if (newSelectedIndex === null) {
return null;
}
let clips: FrigateBrowseMediaSource | null;
const newQueryResults = new MediaQueriesResults(newResults);
newQueryResults.selectResult(newSelectedIndex);
const params = overrideMultiBrowseMediaQueryParameters(
this.browseMediaQueryParameters,
{
mediaType: 'clips',
before: latest,
after: earliest,
},
);
try {
clips = await multipleBrowseMediaQueryMerged(this.hass, params);
} catch (e) {
// This is best effort.
return null;
}
if (!clips || !clips.children || !clips.children.length) {
return null;
}
for (let i = 0; i < clips.children.length; i++) {
const child = clips.children[i];
if (!isTrueMedia(child)) {
continue;
}
const clipStartTime = getEventStartTime(child);
if (clipStartTime && clipStartTime === snapshotStartTime) {
return this.view.evolve({
view: 'clip',
target: clips,
childIndex: i,
});
}
}
return null;
return this.view.evolve({
view: 'clip',
query: newQuery,
queryResults: newQueryResults,
});
}
/**
@@ -523,13 +419,15 @@ export class FrigateCardViewerCarousel extends LitElement {
return;
}
// Update the childIndex in the view.
const childIndex = this._slideToChild[ev.detail.index];
if (childIndex !== undefined) {
// The slide may already be selected on load, so don't dispatch a new view
// unless necessary.
if (ev.detail.index !== this.view.queryResults?.getSelectedIndex()) {
this.view
.evolve({
childIndex: childIndex,
queryResults: this.view.queryResults?.clone().selectResult(ev.detail.index),
})
// Ensure the timeline is able to update its position.
.mergeInContext({ timeline: { noSetWindow: false } })
.dispatchChangeEvent(this);
}
}
@@ -539,11 +437,11 @@ export class FrigateCardViewerCarousel extends LitElement {
* default location will be the Chromecast receiver, not HA).
* @param url The media URL
*/
protected _canonicalizeHAURL(url?: string): string | undefined {
protected _canonicalizeHAURL(url?: string): string | null {
if (this.hass && url && url.startsWith('/')) {
return this.hass.hassUrl(url);
}
return url;
return url ?? null;
}
/**
@@ -551,44 +449,40 @@ export class FrigateCardViewerCarousel extends LitElement {
* @param index The index of the slide to lazy load.
* @param slide The slide to lazy load.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected _lazyloadSlide(index: number, slide: HTMLElement): void {
const childIndex: number | undefined = this._slideToChild[index];
if (
childIndex === undefined ||
!this.hass ||
!this.view ||
!this.view.target ||
!this.view.target.children ||
!isTrueMedia(this.view.target.children[childIndex])
) {
if (!this.hass || !this.view || !this.view.query || !this.cameras) {
return;
}
resolveMedia(
this.hass,
this.view.target.children[childIndex],
this.resolvedMediaCache,
).then((resolvedMedia) => {
if (!resolvedMedia) {
return;
}
const media = this.view.queryResults?.getResult(index);
const mediaContentID = media
? media.getContentID(this.cameras.get(media.getCameraID()))
: null;
if (!mediaContentID) {
return;
}
// Snapshots.
const img = slide.querySelector('img') as HTMLImageElement;
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(
(resolvedMedia) => {
if (!resolvedMedia) {
return;
}
// Frigate >= 0.9.0+ clips.
const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & {
url: string;
};
// Snapshots.
const img = slide.querySelector('img') as HTMLImageElement;
if (img) {
img.src = this._canonicalizeHAURL(resolvedMedia.url) || '';
} else if (hls_player) {
hls_player.url = this._canonicalizeHAURL(resolvedMedia.url) || '';
}
});
// Frigate >= 0.9.0+ clips.
const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & {
url: string;
};
if (img) {
img.src = this._canonicalizeHAURL(resolvedMedia.url) ?? '';
} else if (hls_player) {
hls_player.url = this._canonicalizeHAURL(resolvedMedia.url) ?? '';
}
},
);
}
/**
@@ -596,21 +490,18 @@ export class FrigateCardViewerCarousel extends LitElement {
* @returns The slides to include in the render.
*/
protected _getSlides(): TemplateResult[] {
if (
!this.view ||
!this.view.target ||
!this.view.target.children ||
!this.view.target.children.length
) {
if (!this.view || !this.view.queryResults) {
return [];
}
const slides: TemplateResult[] = [];
for (let i = 0; i < this.view.target.children?.length; ++i) {
const slide = this._renderMediaItem(this.view.target.children[i], slides.length);
if (slide) {
slides.push(slide);
for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) {
const media = this.view.queryResults.getResult(i);
if (media) {
const slide = this._renderMediaItem(media, i);
if (slide) {
slides[i] = slide;
}
}
}
return slides;
@@ -620,8 +511,12 @@ export class FrigateCardViewerCarousel extends LitElement {
* Determine if all the media in the carousel are resolved.
*/
protected _isMediaFullyResolved(): boolean {
for (const child of this.view?.target?.children || []) {
if (!this.resolvedMediaCache?.has(child.media_content_id)) {
if (!this.resolvedMediaCache || !this.cameras) {
return false;
}
for (const media of this.view?.queryResults?.getResults() ?? []) {
const mediaContentID = media.getContentID(this.cameras.get(media.getCameraID()));
if (mediaContentID && !this.resolvedMediaCache.has(mediaContentID)) {
return false;
}
}
@@ -633,29 +528,20 @@ export class FrigateCardViewerCarousel extends LitElement {
* @param changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void {
// Pre-populate a map between real media slides and view child indicies.
if (changedProps.has('view')) {
this._slideToChild = {};
let i = 0;
(this.view?.target?.children ?? []).forEach((child, index) => {
if (isTrueMedia(child) && ['video', 'image'].includes(child.media_content_type)) {
this._slideToChild[i++] = index;
}
})
}
if (changedProps.has('viewerConfig')) {
updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout);
}
if (!this._carouselOptions || changedProps.has('viewerConfig')) {
this._carouselOptions = this._getOptions();
this._carouselOptions = {
draggable: this.viewerConfig?.draggable ?? true,
};
}
if (
!this._carouselPlugins ||
changedProps.has('viewerConfig') ||
(changedProps.has('view') &&
this.view?.target?.children?.length !==
changedProps.get('view')?.target?.children?.length)
this.view?.queryResults?.getResultsCount() !==
changedProps.get('view')?.queryResults?.getResultsCount())
) {
this._carouselPlugins = this._getPlugins();
}
@@ -680,21 +566,20 @@ export class FrigateCardViewerCarousel extends LitElement {
* @returns A template to display to the user.
*/
protected _render(): TemplateResult | void {
const slides = this._getSlides();
if (!slides.length || !this.view?.media) {
const media = this.view?.queryResults?.getSelectedResult();
if (!media || !this.cameras) {
return;
}
const neighbors = this._getMediaNeighbors();
const [prev, next] = [neighbors?.previous, neighbors?.next];
const [prev, next] = this._getMediaNeighbors();
return html` <frigate-card-media-carousel
${ref(this._refMediaCarousel)}
.carouselOptions=${this._carouselOptions}
.carouselPlugins=${this._carouselPlugins}
.label="${this.view.media.title}"
.label=${media.getTitle() ?? undefined}
.titlePopupConfig=${this.viewerConfig?.controls.title}
.selected=${this.view?.queryResults?.getSelectedIndex() ?? 0}
transitionEffect=${this._getTransitionEffect()}
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
@frigate-card:media:loaded=${this._recordingSeekHandler.bind(this)}
@@ -704,22 +589,24 @@ export class FrigateCardViewerCarousel extends LitElement {
.hass=${this.hass}
.direction=${'previous'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${prev && prev.thumbnail ? prev.thumbnail : undefined}
.label=${prev ? prev.title : ''}
.thumbnail=${prev?.getThumbnail(this.cameras.get(prev.getCameraID())) ??
undefined}
.label=${prev?.getTitle() ?? ''}
?disabled=${!prev}
@click=${(ev) => {
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollPrevious();
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>
${slides}
${guard(this.view?.queryResults?.getResults(), () => this._getSlides())}
<frigate-card-next-previous-control
slot="next"
.hass=${this.hass}
.direction=${'next'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${next && next.thumbnail ? next.thumbnail : undefined}
.label=${next ? next.title : ''}
.thumbnail=${next?.getThumbnail(this.cameras.get(next.getCameraID())) ??
undefined}
.label=${next?.getTitle() ?? ''}
?disabled=${!next}
@click=${(ev) => {
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext();
@@ -733,10 +620,12 @@ export class FrigateCardViewerCarousel extends LitElement {
* Fire a media show event when a slide is selected.
*/
protected _recordingSeekHandler(): void {
const player = this._getPlayer();
const childIndex = this.view?.childIndex ?? null;
const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null;
const seek =
childIndex !== null ? this.view?.context?.mediaViewer?.seek.get(childIndex) : null;
selectedIndex !== null
? this.view?.context?.mediaViewer?.seek.get(selectedIndex)
: null;
const player = this._getPlayer();
if (player && seek) {
player.seek(seek.seekSeconds);
}
@@ -744,59 +633,53 @@ export class FrigateCardViewerCarousel extends LitElement {
/**
* Render a single media item in the viewer carousel.
* @param mediaToRender The FrigateBrowseMediaSource to render.
* @param slideIndex The index of the slide to render.
* @param media The ViewMedia to render.
* @param index The (slide|queryResult) index of the item to render.
* @returns A rendered template.
*/
protected _renderMediaItem(
mediaToRender: FrigateBrowseMediaSource,
slideIndex: number,
): TemplateResult | void {
protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null {
// Skip folders as they cannot be rendered by this viewer.
if (
!this.hass ||
!this.view ||
!this.viewerConfig ||
!isTrueMedia(mediaToRender) ||
!['video', 'image'].includes(mediaToRender.media_content_type)
) {
return;
if (!this.hass || !this.view || !this.viewerConfig || !this.cameras) {
return null;
}
const lazyLoad = this.viewerConfig.lazy_load;
const resolvedMedia = this.resolvedMediaCache?.get(mediaToRender.media_content_id);
const mediaContentID = media.getContentID(this.cameras.get(media.getCameraID()));
const resolvedMedia = mediaContentID
? this.resolvedMediaCache?.get(mediaContentID)
: null;
if (!resolvedMedia && !lazyLoad) {
return;
return null;
}
// The media is attached to the player as '.media' which is used in
// `_selectSlideMediaShowHandler` (and not used by the player itself).
return html`
<div class="embla__slide">
${mediaToRender.media_content_type === 'video'
${media.isVideo()
? html`<frigate-card-ha-hls-player
allow-exoplayer
aria-label="${mediaToRender.title}"
aria-label="${media.getTitle() ?? ''}"
?autoplay=${false}
controls
muted
playsinline
title="${mediaToRender.title}"
title="${media.getTitle() ?? ''}"
url=${ifDefined(
lazyLoad ? undefined : this._canonicalizeHAURL(resolvedMedia?.url),
lazyLoad ? undefined : this._canonicalizeHAURL(resolvedMedia?.url) ?? '',
)}
.hass=${this.hass}
@frigate-card:media:loaded=${(e: CustomEvent<MediaLoadedInfo>) => {
wrapMediaLoadedEventForCarousel(slideIndex, e);
wrapMediaLoadedEventForCarousel(index, e);
}}
>
</frigate-card-ha-hls-player>`
: html`<img
aria-label="${mediaToRender.title}"
aria-label="${media.getTitle() ?? ''}"
src=${ifDefined(
lazyLoad ? IMG_EMPTY : this._canonicalizeHAURL(resolvedMedia?.url),
lazyLoad ? IMG_EMPTY : this._canonicalizeHAURL(resolvedMedia?.url) ?? '',
)}
title="${mediaToRender.title}"
title="${media.getTitle() ?? ''}"
@click=${() => {
if (
this._refMediaCarousel.value
@@ -804,7 +687,7 @@ export class FrigateCardViewerCarousel extends LitElement {
?.carouselClickAllowed() &&
this.viewerConfig?.snapshot_click_plays_clip
) {
this._findRelatedClipView(mediaToRender).then((view) => {
this._createRelatedClipView(index).then((view) => {
if (view) {
view.dispatchChangeEvent(this);
}
@@ -822,9 +705,9 @@ export class FrigateCardViewerCarousel extends LitElement {
// images in media-carousel.ts). Here we need to only call the
// media load handler on a 'real' load.
!lazyLoad ||
lazyloadPlugin?.hasLazyloaded(slideIndex)
lazyloadPlugin?.hasLazyloaded(index)
) {
wrapRawMediaLoadedEventForCarousel(slideIndex, e);
wrapRawMediaLoadedEventForCarousel(index, e);
}
}}"
/>`}