feat: Add support for Frigate reviews / detections [initial PR] (#2315)

- Add support for Frigate reviews / detections.
 - Add support for GenAI metadata.
- Significant internal refactor to more flexible "UnifiedQuery" to allow
mixing cameras with simple metadata and review metadata (e.g. a timeline
view of a Frigate camera with reviews, and a Reolink camera with simple
metadata).
 - Add support for folder media as camera media.

There are a few more PRs to commit prior to this going live, but
commiting this for now due to the scale of the change.

BREAKING CHANGE: `media_type` and `events_type` are retired under
`live`, `viewer` and `timeline` configuration sections, instead media
type is associated (once) with the camera under `media`.
This commit is contained in:
Dermot Duffy
2026-01-19 13:32:50 -08:00
committed by GitHub
parent 6eb0a87ca8
commit fc32727860
215 changed files with 14491 additions and 6160 deletions
+19 -2
View File
@@ -52,6 +52,9 @@ export class AdvancedCameraCardCarousel extends LitElement {
protected _refRoot: Ref<HTMLElement> = createRef();
protected _carousel: CarouselController | null = null;
// Track slide count to distinguish user-navigation from content changes.
protected _previousSlideCount: number | null = null;
connectedCallback(): void {
super.connectedCallback();
@@ -100,6 +103,8 @@ export class AdvancedCameraCardCarousel extends LitElement {
}
protected updated(changedProps: PropertyValues): void {
const currentSlideCount = this._refParent.value?.assignedElements().length ?? 0;
if (
!this._carousel &&
this._refRoot.value &&
@@ -127,9 +132,21 @@ export class AdvancedCameraCardCarousel extends LitElement {
wheelScrolling: this.wheelScrolling,
},
);
} else if (changedProps.has('selected')) {
this._carousel?.selectSlide(this.selected);
this._previousSlideCount = currentSlideCount;
} else if (changedProps.has('selected') && this._carousel) {
// Only scroll to selection if the slide count hasn't changed.
// This distinguishes between:
// - Navigation (next/prev): count same → scroll to selection
// - Content change (item removed): count changed → preserve position
const contentChanged =
this._previousSlideCount !== null &&
this._previousSlideCount !== currentSlideCount;
if (!contentChanged && this.selected !== this._carousel.getSelectedIndex()) {
this._carousel.selectSlide(this.selected);
}
}
this._previousSlideCount = currentSlideCount;
}
static get styles(): CSSResultGroup {
-151
View File
@@ -1,151 +0,0 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { FoldersManager } from '../../card-controller/folders/manager.js';
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import {
getUpFolderMediaItem,
upFolderClickHandler,
} from '../../components-lib/folder/up-folder.js';
import { FolderGalleryController } from '../../components-lib/gallery/folder-gallery-controller.js';
import { MediaGalleryConfig } from '../../config/schema/media-gallery.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize';
import folderGalleryStyle from '../../scss/folder-gallery.scss';
import { ViewItem } from '../../view/item.js';
import '../media-filter';
import '../message.js';
import { renderMessage } from '../message.js';
import '../surround-basic';
import '../thumbnail/thumbnail.js';
import './gallery-core.js';
@customElement('advanced-camera-card-folder-gallery')
export class AdvancedCameraCardFolderGallery extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: false })
public galleryConfig?: MediaGalleryConfig;
@property({ attribute: false })
public foldersManager?: FoldersManager;
protected _controller = new FolderGalleryController(this);
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('galleryConfig')) {
this._controller.setThumbnailSize(this.galleryConfig?.controls.thumbnails.size);
}
}
protected _renderThumbnail(
item: ViewItem,
selected: boolean,
clickCallback: (item: ViewItem, ev: Event) => void,
): TemplateResult | void {
return html`<advanced-camera-card-thumbnail
class=${classMap({
selected,
})}
.hass=${this.hass}
.item=${item}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewItemManager=${this.viewItemManager}
?selected=${selected}
?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}
?show_download_control=${!!this.galleryConfig?.controls.thumbnails
.show_download_control}
@click=${(ev: Event) => clickCallback(item, ev)}
>
</advanced-camera-card-thumbnail>`;
}
protected _renderThumbnails(): TemplateResult | void {
const selected = this.viewManagerEpoch?.manager
.getView()
?.queryResults?.getSelectedResult();
return html`
${this.viewManagerEpoch?.manager
.getView()
?.queryResults?.getResults()
?.map((item) =>
this._renderThumbnail(item, item === selected, (item: ViewItem, ev: Event) => {
const manager = this.viewManagerEpoch?.manager;
if (manager) {
this._controller.itemClickHandler(manager, item, ev, this.foldersManager);
}
}),
)}
`;
}
protected render(): TemplateResult | void {
const folderIsLoading =
!!this.viewManagerEpoch?.manager.getView()?.context?.loading?.query;
const upThumbnail = getUpFolderMediaItem(this.viewManagerEpoch?.manager.getView());
return html`
<advanced-camera-card-surround-basic>
${!this.viewManagerEpoch?.manager.getView()?.queryResults?.hasResults() &&
(folderIsLoading || !upThumbnail)
? renderMessage({
type: 'info',
message: folderIsLoading
? localize('error.awaiting_folder')
: localize('common.no_folder'),
icon: 'mdi:folder-play',
dotdotdot: folderIsLoading,
})
: html`<advanced-camera-card-gallery-core
.hass=${this.hass}
.columnWidth=${this._controller.getColumnWidth(
this.galleryConfig?.controls.thumbnails,
)}
.columnCountRoundMethod=${this._controller.getColumnCountRoundMethod(
this.galleryConfig?.controls.thumbnails,
)}
>
${upThumbnail
? this._renderThumbnail(
upThumbnail,
false,
(item: ViewItem, ev: Event) =>
upFolderClickHandler(item, ev, this.viewManagerEpoch),
)
: ''}
${this._renderThumbnails()}
</advanced-camera-card-gallery-core>`}
</advanced-camera-card-surround-basic>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(folderGalleryStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-folder-gallery': AdvancedCameraCardFolderGallery;
}
}
+294
View File
@@ -0,0 +1,294 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { FoldersManager } from '../../card-controller/folders/manager.js';
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
import { MergeContextViewModifier } from '../../card-controller/view/modifiers/merge-context.js';
import { RemoveContextViewModifier } from '../../card-controller/view/modifiers/remove-context.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { GalleryController } from '../../components-lib/gallery/controller.js';
import {
FolderNavigationParamaters,
getUpFolderItem,
navigateToFolder,
navigateToMedia,
navigateUp,
} from '../../components-lib/navigation.js';
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types.js';
import { MediaGalleryConfig } from '../../config/schema/media-gallery.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import galleryStyle from '../../scss/media-gallery.scss';
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { ViewFolder, ViewItem } from '../../view/item.js';
import { UnifiedQueryBuilder } from '../../view/unified-query-builder.js';
import { UnifiedQueryRunner } from '../../view/unified-query-runner.js';
import '../media-filter.js';
import '../message.js';
import { renderMessage } from '../message.js';
import '../surround-basic.js';
import '../thumbnail/thumbnail.js';
import './gallery-core.js';
import { GalleryExtendEvent } from './types.js';
const GALLERY_FILTER_MENU_ICONS = {
closed: 'mdi:filter-cog-outline',
open: 'mdi:filter-cog',
};
@customElement('advanced-camera-card-gallery')
export class AdvancedCameraCardGallery extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public galleryConfig?: MediaGalleryConfig;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public foldersManager?: FoldersManager;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public conditionStateManager?: ConditionStateManagerReadonlyInterface;
protected _controller = new GalleryController(this);
protected _upFolderItem: ViewFolder | null = null;
protected _builder: UnifiedQueryBuilder | null = null;
protected willUpdate(changedProps: PropertyValues): void {
if (
(changedProps.has('cameraManager') || changedProps.has('foldersManager')) &&
this.cameraManager &&
this.foldersManager
) {
this._builder = new UnifiedQueryBuilder(this.cameraManager, this.foldersManager);
}
if (changedProps.has('viewManagerEpoch')) {
const view = this.viewManagerEpoch?.manager.getView();
this._controller.setItemsFromView(view, this.viewManagerEpoch?.oldView);
this._upFolderItem = getUpFolderItem(view?.query);
}
if (changedProps.has('galleryConfig')) {
this._controller.setThumbnailSize(this.galleryConfig?.controls.thumbnails.size);
}
}
protected _getLimit(): number {
return (
this.cardWideConfig?.performance?.features?.media_chunk_size ??
MEDIA_CHUNK_SIZE_DEFAULT
);
}
protected _getFolderNavigationParameters(): FolderNavigationParamaters | null {
return this._builder && this.viewManagerEpoch
? {
builder: this._builder,
viewManagerEpoch: this.viewManagerEpoch,
limit: this._getLimit(),
}
: null;
}
protected _renderUpFolder(): TemplateResult | void {
if (!this._upFolderItem) {
return;
}
return html`<advanced-camera-card-thumbnail
.hass=${this.hass}
.item=${this._upFolderItem}
.viewManagerEpoch=${this.viewManagerEpoch}
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
navigateUp(this._getFolderNavigationParameters());
}}
>
</advanced-camera-card-thumbnail>`;
}
protected _renderThumbnails(): TemplateResult | void {
const selected = this.viewManagerEpoch?.manager
.getView()
?.queryResults?.getSelectedResult();
return html`
${this._controller.getItems()?.map(
(item) =>
html`<advanced-camera-card-thumbnail
class=${classMap({
selected: item === selected,
})}
.hass=${this.hass}
.cameraManager=${this.cameraManager}
.viewItemManager=${this.viewItemManager}
.item=${item}
.viewManagerEpoch=${this.viewManagerEpoch}
?selected=${item === selected}
?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}
?show_download_control=${!!this.galleryConfig?.controls.thumbnails
.show_download_control}
?show_review_control=${!!this.galleryConfig?.controls.thumbnails
.show_review_control}
?show_info_control=${!!this.galleryConfig?.controls.thumbnails
.show_info_control}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (ViewItemClassifier.isMedia(item) && this.viewManagerEpoch) {
navigateToMedia(item, {
viewManagerEpoch: this.viewManagerEpoch,
modifiers: [
new RemoveContextViewModifier(['timeline', 'mediaViewer']),
new MergeContextViewModifier({
gallery: {
originView: this.viewManagerEpoch.manager.getView()?.view,
},
}),
],
});
} else if (ViewItemClassifier.isFolder(item)) {
navigateToFolder(item, this._getFolderNavigationParameters());
}
}}
>
</advanced-camera-card-thumbnail>`,
)}
`;
}
protected render(): TemplateResult | void {
const isLoading =
!!this.viewManagerEpoch?.manager.getView()?.context?.loading?.query;
const hasItems =
(this._controller.getItems()?.length ?? 0) > 0 || !!this._upFolderItem;
const showFilter =
this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none';
return html`
<advanced-camera-card-surround-basic
.drawerIcons=${{
...(showFilter && {
[this.galleryConfig!.controls.filter.mode]: GALLERY_FILTER_MENU_ICONS,
}),
}}
>
${showFilter && this.galleryConfig
? html` <advanced-camera-card-media-filter
.hass=${this.hass}
.cameraManager=${this.cameraManager}
.foldersManager=${this.foldersManager}
.viewManagerEpoch=${this.viewManagerEpoch}
.cardWideConfig=${this.cardWideConfig}
slot=${this.galleryConfig.controls.filter.mode}
>
</advanced-camera-card-media-filter>`
: ''}
${!hasItems
? renderMessage({
type: 'info',
message: isLoading
? localize('error.awaiting_media')
: localize('common.no_media'),
icon: 'mdi:multimedia',
dotdotdot: isLoading,
})
: html`<advanced-camera-card-gallery-core
.hass=${this.hass}
.columnWidth=${this._controller.getColumnWidth(
this.galleryConfig?.controls.thumbnails,
)}
.columnCountRoundMethod=${this._controller.getColumnCountRoundMethod(
this.galleryConfig?.controls.thumbnails,
)}
.cardWideConfig=${this.cardWideConfig}
.extendUp=${true}
.extendDown=${true}
@advanced-camera-card:gallery:extend:up=${(
ev: CustomEvent<GalleryExtendEvent>,
) =>
this._extendGallery(
ev,
'later',
// Avoid use of cache since the user is explicitly looking for
// the freshest possible data.
false,
)}
@advanced-camera-card:gallery:extend:down=${(
ev: CustomEvent<GalleryExtendEvent>,
) => this._extendGallery(ev, 'earlier')}
>
${this._renderUpFolder()} ${this._renderThumbnails()}
</advanced-camera-card-gallery-core>`}
</advanced-camera-card-surround-basic>
`;
}
protected async _extendGallery(
ev: CustomEvent<GalleryExtendEvent>,
direction: 'earlier' | 'later',
useCache = true,
): Promise<void> {
if (
!this.cameraManager ||
!this.foldersManager ||
!this.viewManagerEpoch ||
!this.conditionStateManager
) {
ev.detail.resolve();
return;
}
const runner = new UnifiedQueryRunner(
this.cameraManager,
this.foldersManager,
this.conditionStateManager,
);
await this._controller.extend(runner, this.viewManagerEpoch, direction, useCache);
ev.detail.resolve();
}
static get styles(): CSSResultGroup {
return unsafeCSS(galleryStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-gallery': AdvancedCameraCardGallery;
}
interface HTMLElementEventMap {
'advanced-camera-card:media:reviewed': CustomEvent<ViewItem>;
}
}
-194
View File
@@ -1,194 +0,0 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { MediaGalleryController } from '../../components-lib/gallery/media-gallery-controller.js';
import { MediaGalleryConfig } from '../../config/schema/media-gallery.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize';
import mediaGalleryStyle from '../../scss/media-gallery.scss';
import '../media-filter';
import '../message.js';
import { renderMessage } from '../message.js';
import '../surround-basic';
import '../thumbnail/thumbnail.js';
import './gallery-core.js';
import { GalleryExtendEvent } from './types.js';
const MEDIA_GALLERY_FILTER_MENU_ICONS = {
closed: 'mdi:filter-cog-outline',
open: 'mdi:filter-cog',
};
@customElement('advanced-camera-card-media-gallery')
export class AdvancedCameraCardMediaGallery extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public galleryConfig?: MediaGalleryConfig;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
protected _controller = new MediaGalleryController(this);
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('viewManagerEpoch')) {
this._controller.setMediaFromView(
this.viewManagerEpoch?.manager.getView(),
this.viewManagerEpoch?.oldView,
);
}
if (changedProps.has('galleryConfig')) {
this._controller.setThumbnailSize(this.galleryConfig?.controls.thumbnails.size);
}
}
protected _renderThumbnails(): TemplateResult | void {
const selected = this.viewManagerEpoch?.manager
.getView()
?.queryResults?.getSelectedResult();
return html`
${this._controller.getMedia()?.map(
(media, index) =>
html`<advanced-camera-card-thumbnail
class=${classMap({
selected: media === selected,
})}
.hass=${this.hass}
.cameraManager=${this.cameraManager}
.viewItemManager=${this.viewItemManager}
.item=${media}
.viewManagerEpoch=${this.viewManagerEpoch}
?selected=${media === selected}
?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}
?show_download_control=${!!this.galleryConfig?.controls.thumbnails
.show_download_control}
@click=${(ev: Event) => {
const manager = this.viewManagerEpoch?.manager;
if (manager) {
this._controller.itemClickHandler(manager, index, ev);
}
}}
>
</advanced-camera-card-thumbnail>`,
)}
`;
}
protected render(): TemplateResult | void {
const mediaIsLoading =
!!this.viewManagerEpoch?.manager.getView()?.context?.loading?.query;
return html`
<advanced-camera-card-surround-basic
.drawerIcons=${{
...(this.galleryConfig &&
this.galleryConfig.controls.filter.mode !== 'none' && {
[this.galleryConfig.controls.filter.mode]: MEDIA_GALLERY_FILTER_MENU_ICONS,
}),
}}
>
${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none'
? html` <advanced-camera-card-media-filter
.hass=${this.hass}
.cameraManager=${this.cameraManager}
.viewManagerEpoch=${this.viewManagerEpoch}
.cardWideConfig=${this.cardWideConfig}
slot=${this.galleryConfig.controls.filter.mode}
>
</advanced-camera-card-media-filter>`
: ''}
${!this._controller.getMedia()?.length
? renderMessage({
type: 'info',
message: mediaIsLoading
? localize('error.awaiting_media')
: localize('common.no_media'),
icon: 'mdi:multimedia',
dotdotdot: mediaIsLoading,
})
: html`<advanced-camera-card-gallery-core
.hass=${this.hass}
.columnWidth=${this._controller.getColumnWidth(
this.galleryConfig?.controls.thumbnails,
)}
.columnCountRoundMethod=${this._controller.getColumnCountRoundMethod(
this.galleryConfig?.controls.thumbnails,
)}
.cardWideConfig=${this.cardWideConfig}
.extendUp=${true}
.extendDown=${true}
@advanced-camera-card:gallery:extend:up=${(
ev: CustomEvent<GalleryExtendEvent>,
) =>
this._extendGallery(
ev,
'later',
// Avoid use of cache since the user is explicitly looking for
// the freshest possible data.
false,
)}
@advanced-camera-card:gallery:extend:down=${(
ev: CustomEvent<GalleryExtendEvent>,
) => this._extendGallery(ev, 'earlier')}
>
${this._renderThumbnails()}
</advanced-camera-card-gallery-core>`}
</advanced-camera-card-surround-basic>
`;
}
protected async _extendGallery(
ev: CustomEvent<GalleryExtendEvent>,
direction: 'earlier' | 'later',
useCache = true,
): Promise<void> {
if (!this.cameraManager || !this.viewManagerEpoch) {
return;
}
await this._controller.extendMediaGallery(
this.cameraManager,
this.viewManagerEpoch,
direction,
useCache,
);
ev.detail.resolve();
}
static get styles(): CSSResultGroup {
return unsafeCSS(mediaGalleryStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-media-gallery': AdvancedCameraCardMediaGallery;
}
}
+57 -33
View File
@@ -11,12 +11,11 @@ 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';
import { FoldersManager } from '../card-controller/folders/manager';
import { ViewManagerEpoch } from '../card-controller/view/types';
import {
MediaFilterController,
MediaFilterCoreFavoriteSelection,
MediaFilterCoreWhen,
MediaFilterMediaType,
} from '../components-lib/media-filter-controller';
import { CardWideConfig } from '../config/schema/types';
import { HomeAssistant } from '../ha/types';
@@ -38,6 +37,9 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public foldersManager?: FoldersManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@@ -56,6 +58,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
protected _refWhat: Ref<AdvancedCameraCardSelect> = createRef();
protected _refWhere: Ref<AdvancedCameraCardSelect> = createRef();
protected _refFavorite: Ref<AdvancedCameraCardSelect> = createRef();
protected _refReviewed: Ref<AdvancedCameraCardSelect> = createRef();
protected _refTags: Ref<AdvancedCameraCardSelect> = createRef();
protected willUpdate(changedProps: PropertyValues): void {
@@ -63,42 +66,56 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
this._mediaFilterController.setViewManager(this.viewManagerEpoch?.manager ?? null);
}
if (changedProps.has('cameraManager') && this.cameraManager) {
this._mediaFilterController.computeCameraOptions(this.cameraManager);
if (
(changedProps.has('cameraManager') || changedProps.has('foldersManager')) &&
this.cameraManager &&
this.foldersManager
) {
this._mediaFilterController.computeCameraOptions(
this.cameraManager,
this.foldersManager,
);
this._mediaFilterController.computeMetadataOptions(this.cameraManager);
}
// The first time the viewManager is set, compute the initial default selections.
if (
!changedProps.get('viewManager') &&
(!changedProps.get('viewManager') || changedProps.has('foldersManager')) &&
this.viewManagerEpoch &&
this.cameraManager
this.cameraManager &&
this.foldersManager
) {
this._mediaFilterController.computeInitialDefaultsFromView(this.cameraManager);
this._mediaFilterController.computeInitialDefaultsFromView(
this.cameraManager,
this.foldersManager,
);
}
}
protected render(): TemplateResult | void {
const valueChange = async () => {
if (!this.cameraManager || !this.viewManagerEpoch || !this.cardWideConfig) {
if (
!this.cameraManager ||
!this.foldersManager ||
!this.viewManagerEpoch ||
!this.cardWideConfig
) {
return;
}
await this._mediaFilterController.valueChangeHandler(
this.cameraManager,
this.foldersManager,
this.cardWideConfig,
{
camera: this._refCamera.value?.value ?? undefined,
mediaType: (this._refMediaType.value?.value ?? undefined) as
| MediaFilterMediaType
| undefined,
mediaTypes: this._refMediaType.value?.value ?? undefined,
when: {
selected: this._refWhen.value?.value ?? undefined,
from: this._refWhenFrom.value?.value,
to: this._refWhenTo.value?.value,
},
favorite: (this._refFavorite.value?.value ?? undefined) as
| MediaFilterCoreFavoriteSelection
| undefined,
favorite: this._refFavorite.value?.value ?? undefined,
reviewed: this._refReviewed.value?.value ?? undefined,
where: this._refWhere.value?.value ?? undefined,
what: this._refWhat.value?.value ?? undefined,
tags: this._refTags.value?.value ?? undefined,
@@ -126,7 +143,6 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
return;
}
const controls = this._mediaFilterController.getControlsToShow(this.cameraManager);
const defaults = this._mediaFilterController.getDefaults();
const whatOptions = this._mediaFilterController.getWhatOptions();
const tagsOptions = this._mediaFilterController.getTagsOptions();
@@ -137,7 +153,9 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
label=${localize('media_filter.media_type')}
placeholder=${localize('media_filter.select_media_type')}
.options=${this._mediaFilterController.getMediaTypeOptions()}
.initialValue=${defaults?.mediaType}
.initialValue=${defaults?.mediaTypes}
multiple
clearable
@advanced-camera-card:select:change=${() => valueChange()}
>
</advanced-camera-card-select>
@@ -184,7 +202,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
@advanced-camera-card:select:change=${() => valueChange()}
>
</advanced-camera-card-select>
${controls.events && whatOptions.length
${whatOptions.length
? html` <advanced-camera-card-select
${ref(this._refWhat)}
label=${localize('media_filter.what')}
@@ -197,7 +215,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
>
</advanced-camera-card-select>`
: ''}
${controls.events && tagsOptions.length
${tagsOptions.length
? html` <advanced-camera-card-select
${ref(this._refTags)}
label=${localize('media_filter.tag')}
@@ -210,7 +228,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
>
</advanced-camera-card-select>`
: ''}
${controls.events && whereOptions.length
${whereOptions.length
? html` <advanced-camera-card-select
${ref(this._refWhere)}
label=${localize('media_filter.where')}
@@ -223,20 +241,26 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
>
</advanced-camera-card-select>`
: ''}
${controls.favorites
? html`
<advanced-camera-card-select
${ref(this._refFavorite)}
label=${localize('media_filter.favorite')}
placeholder=${localize('media_filter.select_favorite')}
.options=${this._mediaFilterController.getFavoriteOptions()}
.initialValue=${defaults?.favorite}
clearable
@advanced-camera-card:select:change=${() => valueChange()}
>
</advanced-camera-card-select>
`
: ''}`;
<advanced-camera-card-select
${ref(this._refFavorite)}
label=${localize('media_filter.favorite')}
placeholder=${localize('media_filter.select_favorite')}
.options=${this._mediaFilterController.getFavoriteOptions()}
.initialValue=${defaults?.favorite}
clearable
@advanced-camera-card:select:change=${() => valueChange()}
>
</advanced-camera-card-select>
<advanced-camera-card-select
${ref(this._refReviewed)}
label=${localize('media_filter.reviewed')}
placeholder=${localize('media_filter.select_reviewed')}
.options=${this._mediaFilterController.getReviewedOptions()}
.initialValue=${defaults?.reviewed}
clearable
@advanced-camera-card:select:change=${() => valueChange()}
>
</advanced-camera-card-select>`;
}
static get styles(): CSSResultGroup {
+131
View File
@@ -0,0 +1,131 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import overlayMessageStyle from '../scss/overlay-message.scss';
import { MetadataField, OverlayMessage, OverlayMessageControl } from '../types.js';
import { dispatchDismissOverlayMessageEvent } from '../utils/overlay-message.js';
import './icon.js';
@customElement('advanced-camera-card-overlay-message')
export class AdvancedCameraCardOverlayMessage extends LitElement {
@property({ attribute: false })
public message: OverlayMessage | null = null;
protected _refMessage: Ref<HTMLElement> = createRef();
public connectedCallback(): void {
super.connectedCallback();
window.addEventListener('click', this._handleOutsideInteraction);
window.addEventListener('focusin', this._handleOutsideInteraction);
}
public disconnectedCallback(): void {
window.removeEventListener('click', this._handleOutsideInteraction);
window.removeEventListener('focusin', this._handleOutsideInteraction);
super.disconnectedCallback();
}
protected render(): TemplateResult | void {
if (!this.message) {
return;
}
const heading = this.message.heading;
const details = this.message.details ?? [];
const text = this.message.text;
const controls = this.message.controls ?? [];
return html`
<div class="backdrop" @click=${this._dismiss}></div>
<div
class="message"
${ref(this._refMessage)}
@animationend=${this._handleAnimationEnd}
>
<div class="details">
${heading ? this._renderDetail(heading, true) : ''}
${details.map((detail) => this._renderDetail(detail))}
${text ? html`<div class="description">${text}</div>` : ''}
</div>
${controls.length
? html`<div class="controls">
${controls.map((control) => this._renderControl(control))}
</div>`
: ''}
<div class="close" @click=${this._dismiss}>
<advanced-camera-card-icon
.icon=${{ icon: 'mdi:close' }}
></advanced-camera-card-icon>
</div>
</div>
`;
}
protected _renderControl(control: OverlayMessageControl): TemplateResult {
const emphasisClass = control.emphasis ? `emphasis-${control.emphasis}` : '';
return html`
<div
class="control ${emphasisClass}"
title=${control.title}
@click=${async () => this._handleControlClick(control)}
>
${control.icon
? html`<advanced-camera-card-icon
.icon=${control.icon}
></advanced-camera-card-icon>`
: ''}
</div>
`;
}
protected async _handleControlClick(control: OverlayMessageControl): Promise<void> {
const result = await control.callback();
if (result === null) {
// null = close the message
this._dismiss();
} else {
// Updated message = keep open and refresh
this.message = result;
}
}
protected _renderDetail(detail: MetadataField, isHeading = false): TemplateResult {
return html`
<div class="detail ${isHeading ? 'heading' : ''}">
${detail.icon
? html`<advanced-camera-card-icon
title=${detail.hint ?? ''}
.icon=${detail.icon}
></advanced-camera-card-icon>`
: ''}
<span title=${detail.title}>${detail.title}</span>
</div>
`;
}
protected _dismiss = (): void => {
this._refMessage.value?.classList.add('exiting');
};
protected _handleAnimationEnd = (ev: AnimationEvent): void => {
if (ev.animationName === 'slideDown') {
dispatchDismissOverlayMessageEvent(this);
}
};
protected _handleOutsideInteraction = (ev: Event): void => {
if (!ev.composedPath().includes(this)) {
this._dismiss();
}
};
static get styles(): CSSResultGroup {
return unsafeCSS(overlayMessageStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-overlay-message': AdvancedCameraCardOverlayMessage;
}
}
+4 -3
View File
@@ -70,10 +70,11 @@ export class AdvancedCameraCardSelect extends ScopedRegistryHost(LitElement) {
const initialValueSet = this.value === null;
this.value = value;
// The underlying gr-select element will call on the first first value set
// The underlying gr-select element will call on the first value set
// (even when the user has not interacted with the control). Do not
// dispatch events for this.
if (!initialValueSet) {
// dispatch events for this UNLESS the value is actually non-empty
// (indicating a user selection).
if (!initialValueSet || (Array.isArray(value) ? value.length : !!value)) {
fireAdvancedCameraCardEvent(this, 'select:change', value);
}
}
+3
View File
@@ -100,6 +100,7 @@ export class AdvancedCameraCardStatusBar extends LitElement {
return html`<div
.actionHandler=${handler}
class="${classes}"
data-severity=${item.severity ?? ''}
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
>
${item.string}
@@ -109,6 +110,7 @@ export class AdvancedCameraCardStatusBar extends LitElement {
.actionHandler=${handler}
.icon=${{ icon: item.icon }}
class="${classes}"
data-severity=${item.severity ?? ''}
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
></advanced-camera-card-icon>`;
} else if (item.type === 'custom:advanced-camera-card-status-bar-image') {
@@ -116,6 +118,7 @@ export class AdvancedCameraCardStatusBar extends LitElement {
.actionHandler=${handler}
class="${classes}"
src="${item.image}"
data-severity=${item.severity ?? ''}
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
/>`;
}
+4 -79
View File
@@ -7,22 +7,18 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { isEqual } from 'lodash-es';
import { CameraManager } from '../camera-manager/manager.js';
import { FoldersManager } from '../card-controller/folders/manager.js';
import { ViewItemManager } from '../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import { TimelineKeys } from '../components-lib/timeline/types.js';
import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js';
import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js';
import { MiniTimelineControlConfig } from '../config/schema/common/controls/timeline.js';
import { FolderConfig } from '../config/schema/folders.js';
import { CardWideConfig } from '../config/schema/types.js';
import { HomeAssistant } from '../ha/types.js';
import basicBlockStyle from '../scss/basic-block.scss';
import { contentsChanged } from '../utils/basic.js';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
import { QueryClassifier } from '../view/query-classifier.js';
import './surround-basic.js';
import './thumbnail-carousel';
@@ -55,11 +51,8 @@ export class AdvancedCameraCardSurround extends LitElement {
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
protected _timelineKeys?: TimelineKeys | null = null;
/**
* Determine if a drawer is being used.
* @returns `true` if a drawer is used, `false` otherwise.
*/
protected _hasDrawer(): boolean {
return (
@@ -67,80 +60,11 @@ export class AdvancedCameraCardSurround extends LitElement {
);
}
protected willUpdate(changedProperties: PropertyValues): void {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected willUpdate(_changedProperties: PropertyValues): void {
if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') {
import('./timeline-core.js');
}
// Only reset the timeline cameraIDs when the media or display mode
// materially changes (and not on every view change, since the view will
// change frequently when the user is scrubbing video).
const view = this.viewManagerEpoch?.manager.getView();
if (
changedProperties.has('viewManagerEpoch') &&
(this.viewManagerEpoch?.manager.hasMajorMediaChange(
this.viewManagerEpoch?.oldView,
) ||
this.viewManagerEpoch?.oldView?.displayMode !== view?.displayMode)
) {
const newKeys = this._getTimelineKeys();
// Update only if changed, to avoid unnecessary timeline destructions.
if (!isEqual(newKeys, this._timelineKeys)) {
this._timelineKeys = newKeys ?? undefined;
}
}
}
protected _getTimelineKeys(): TimelineKeys | null {
const cameraIDsToKeys = (cameraIDs: Set<string> | null): TimelineKeys | null => {
return cameraIDs?.size
? {
type: 'camera',
cameraIDs,
}
: null;
};
const folderToKeys = (folderConfig: FolderConfig): TimelineKeys => {
return {
type: 'folder',
folder: folderConfig,
};
};
const view = this.viewManagerEpoch?.manager.getView();
if (!view || !this.cameraManager) {
return null;
}
if (view.is('live')) {
const capabilitySearch = {
anyCapabilities: ['clips' as const, 'snapshots' as const, 'recordings' as const],
};
if (view.supportsMultipleDisplayModes() && view.isGrid()) {
return cameraIDsToKeys(
this.cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch),
);
} else {
return cameraIDsToKeys(
this.cameraManager
.getStore()
.getAllDependentCameras(view.camera, capabilitySearch),
);
}
}
const queries = view.query;
if (view.isViewerView()) {
if (QueryClassifier.isMediaQuery(queries)) {
return cameraIDsToKeys(queries.getQueryCameraIDs());
} else if (QueryClassifier.isFolderQuery(queries)) {
const folderConfig = queries.getQuery()?.folder;
return folderConfig ? folderToKeys(folderConfig) : null;
}
}
return null;
}
protected render(): TemplateResult | void {
@@ -175,7 +99,9 @@ export class AdvancedCameraCardSurround extends LitElement {
.viewItemManager=${this.viewItemManager}
.fadeThumbnails=${view.isViewerView()}
.viewManagerEpoch=${this.viewManagerEpoch}
.foldersManager=${this.foldersManager}
.selected=${view.queryResults?.getSelectedIndex() ?? undefined}
.cardWideConfig=${this.cardWideConfig}
>
</advanced-camera-card-thumbnail-carousel>`
: ''}
@@ -189,7 +115,6 @@ export class AdvancedCameraCardSurround extends LitElement {
this.thumbnailConfig?.mode === 'none'
? 'play'
: 'select'}
.keys=${this._timelineKeys}
.mini=${true}
.timelineConfig=${this.timelineConfig}
.thumbnailConfig=${this.thumbnailConfig}
+78 -65
View File
@@ -9,14 +9,20 @@ import {
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
import { FoldersManager } from '../card-controller/folders/manager.js';
import { ViewItemManager } from '../card-controller/view/item-manager.js';
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import {
getUpFolderMediaItem,
upFolderClickHandler,
} from '../components-lib/folder/up-folder.js';
FolderNavigationParamaters,
getUpFolderItem,
navigateToFolder,
navigateToMedia,
navigateUp,
} from '../components-lib/navigation.js';
import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js';
import { CardWideConfig } from '../config/schema/types.js';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
import { HomeAssistant } from '../ha/types.js';
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
@@ -24,7 +30,7 @@ import { CarouselDirection } from '../utils/embla/carousel-controller.js';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
import { ViewItemClassifier } from '../view/item-classifier.js';
import { ViewItem, ViewMedia } from '../view/item.js';
import { QueryClassifier } from '../view/query-classifier.js';
import { UnifiedQueryBuilder } from '../view/unified-query-builder.js';
import './carousel.js';
import './thumbnail/thumbnail.js';
@@ -43,18 +49,50 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public foldersManager?: FoldersManager;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: false })
public config?: ThumbnailsControlConfig;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public fadeThumbnails = false;
protected _thumbnails: TemplateResult[] = [];
protected _builder: UnifiedQueryBuilder | null = null;
protected _getLimit(): number {
return (
this.cardWideConfig?.performance?.features?.media_chunk_size ??
MEDIA_CHUNK_SIZE_DEFAULT
);
}
protected _getFolderNavOptions(): FolderNavigationParamaters | undefined {
return this._builder && this.viewManagerEpoch
? {
builder: this._builder,
viewManagerEpoch: this.viewManagerEpoch,
limit: this._getLimit(),
}
: undefined;
}
protected willUpdate(changedProps: PropertyValues): void {
if (
(changedProps.has('cameraManager') || changedProps.has('foldersManager')) &&
this.cameraManager &&
this.foldersManager
) {
this._builder = new UnifiedQueryBuilder(this.cameraManager, this.foldersManager);
}
if (changedProps.has('config')) {
if (this.config?.size) {
this.style.setProperty(
@@ -94,53 +132,17 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
);
}
protected _itemClickCallback(item: ViewItem, ev: Event): void {
stopEventFromActivatingCardWideActions(ev);
const view = this.viewManagerEpoch?.manager.getView();
const query = view?.query;
const results = view?.queryResults;
if (!view || !query || !results) {
return;
}
if (ViewItemClassifier.isMedia(item)) {
const newResults = results
.clone()
.selectResultIfFound((result) => result === item);
const cameraID = item.getCameraID();
fireAdvancedCameraCardEvent<ThumbnailMediaSelect>(
this,
'thumbnails-carousel:media-select',
{ media: item },
);
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
view: 'media',
queryResults: newResults,
...(cameraID && { camera: cameraID }),
},
protected _handleMediaClick(item: ViewMedia): void {
fireAdvancedCameraCardEvent<ThumbnailMediaSelect>(
this,
'thumbnails-carousel:media-select',
{ media: item },
);
if (this.viewManagerEpoch) {
navigateToMedia(item, {
viewManagerEpoch: this.viewManagerEpoch,
modifiers: [new RemoveContextViewModifier(['timeline', 'mediaViewer'])],
});
} else if (
QueryClassifier.isFolderQuery(query) &&
ViewItemClassifier.isFolder(item)
) {
const rawQuery = query.getQuery();
if (!rawQuery) {
return;
}
this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: {
query: query.clone().setQuery({
folder: rawQuery.folder,
path: [...(rawQuery.path ?? []), { folder: item }],
}),
},
});
}
}
@@ -171,31 +173,42 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
?show_favorite_control=${this.config?.show_favorite_control}
?show_timeline_control=${this.config?.show_timeline_control}
?show_download_control=${this.config?.show_download_control}
?show_review_control=${this.config?.show_review_control}
?show_info_control=${this.config?.show_info_control}
@click=${(ev: Event) => clickCallback(item, ev)}
>
</advanced-camera-card-thumbnail>`;
}
protected _renderThumbnails(): TemplateResult[] {
const upThumbnail = getUpFolderMediaItem(this.viewManagerEpoch?.manager.getView());
const thumbnails: TemplateResult[] = [
...(upThumbnail
? [
this._renderThumbnail(upThumbnail, false, (item: ViewItem, ev: Event) =>
upFolderClickHandler(item, ev, this.viewManagerEpoch),
),
]
: []),
];
const upFolderItem = getUpFolderItem(
this.viewManagerEpoch?.manager.getView()?.query,
);
const thumbnails: TemplateResult[] = upFolderItem
? [
this._renderThumbnail(upFolderItem, false, (_item: ViewItem, ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
navigateUp(this._getFolderNavOptions());
}),
]
: [];
const view = this.viewManagerEpoch?.manager.getView();
const selectedIndex = this._getSelectedSlide();
for (const item of view?.queryResults?.getResults() ?? []) {
const clickHandler = (item: ViewItem, ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (ViewItemClassifier.isMedia(item)) {
this._handleMediaClick(item);
} else if (ViewItemClassifier.isFolder(item)) {
navigateToFolder(item, this._getFolderNavOptions());
}
};
thumbnails.push(
this._renderThumbnail(
item,
selectedIndex === thumbnails.length,
(item: ViewItem, ev: Event) => this._itemClickCallback(item, ev),
clickHandler,
view?.context?.mediaViewer?.seek,
),
);
@@ -214,18 +227,18 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
}
protected render(): TemplateResult | void {
const direction = this._getDirection();
if (!this._thumbnails.length || !this.config || !direction) {
if (!this._thumbnails.length || !this.config?.mode || this.config.mode === 'none') {
return;
}
return html`<advanced-camera-card-carousel
direction=${direction}
.selected=${this._getSelectedSlide() ?? 0}
class="${classMap({ fade: this.fadeThumbnails })}"
direction=${this._getDirection() ?? 'horizontal'}
.selected=${this._getSelectedSlide()}
.dragFree=${true}
>
${this._thumbnails}
</advanced-camera-card-carousel>`;
</advanced-camera-card-carousel> `;
}
static get styles(): CSSResultGroup {
+23 -23
View File
@@ -7,10 +7,12 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../../camera-manager/manager';
import { ThumbnailDetailsController } from '../../components-lib/thumbnail/details-controller';
import { MediaDetailsController } from '../../components-lib/media/details-controller';
import { HomeAssistant } from '../../ha/types';
import thumbnailDetailsStyle from '../../scss/thumbnail-details.scss';
import { MetadataField } from '../../types';
import { ViewItem } from '../../view/item';
import '../icon';
@@ -28,7 +30,7 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
@property({ attribute: false })
public seek?: Date;
private _controller = new ThumbnailDetailsController();
private _controller = new MediaDetailsController();
protected willUpdate(changedProperties: PropertyValues): void {
if (['item', 'seek', 'cameraManager'].some((prop) => changedProperties.has(prop))) {
@@ -40,28 +42,26 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
const heading = this._controller.getHeading();
const details = this._controller.getDetails();
const renderDetail = (detail: MetadataField, heading = false): TemplateResult => {
return html`<div
class=${classMap({
heading,
})}
>
${detail.icon
? html` <advanced-camera-card-icon
severity=${detail.emphasis}
title=${detail.hint ?? ''}
.icon=${detail.icon}
></advanced-camera-card-icon>`
: ''}
<span title=${detail.title}>${detail.title}</span>
</div>`;
};
return html`
${heading
? html` <div class="heading">
<span title=${heading}>${heading}</span>
</div>`
: ``}
${details
? html` <div class="details">
${details.map(
(detail) =>
html`<div>
${detail.icon
? html` <advanced-camera-card-icon
title=${detail.hint ?? ''}
.icon=${detail.icon}
></advanced-camera-card-icon>`
: ''}
<span>${detail.title}</span>
</div>`,
)}
</div>`
: ''}
${heading ? renderDetail(heading, true) : ``}
${details.length ? details.map((detail) => renderDetail(detail)) : ``}
`;
}
+178 -17
View File
@@ -9,10 +9,26 @@ import {
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../../../camera-manager/manager';
import { ViewItemManager } from '../../../card-controller/view/item-manager';
import { ViewManagerEpoch } from '../../../card-controller/view/types';
import {
MediaDetailsController,
OverlayControlsContext,
} from '../../../components-lib/media/details-controller';
import { ThumbnailFeatureController } from '../../../components-lib/thumbnail/feature/controller';
import { HomeAssistant } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import thumbnailFeatureStyle from '../../../scss/thumbnail-feature.scss';
import { stopEventFromActivatingCardWideActions } from '../../../utils/action';
import {
downloadMedia,
navigateToTimeline,
toggleFavorite,
toggleReviewed,
} from '../../../utils/media-actions';
import { dispatchShowOverlayMessageEvent } from '../../../utils/overlay-message';
import { ViewItem } from '../../../view/item';
import { ViewItemClassifier } from '../../../view/item-classifier';
import '../../icon.js';
import './thumbnail.js';
@@ -24,12 +40,33 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public item?: ViewItem;
@property({ attribute: false })
public hasDetails?: boolean;
@property({ attribute: true, type: Boolean })
public show_favorite_control = false;
@property({ attribute: true, type: Boolean })
public show_timeline_control = false;
@property({ attribute: true, type: Boolean })
public show_download_control = false;
@property({ attribute: true, type: Boolean })
public show_review_control = false;
@property({ attribute: true, type: Boolean })
public show_info_control = false;
private _controller = new ThumbnailFeatureController();
protected willUpdate(changedProperties: PropertyValues): void {
@@ -40,34 +77,158 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
}
}
private _getControlContext(): OverlayControlsContext {
return {
hass: this.hass,
viewItemManager: this.viewItemManager,
viewManagerEpoch: this.viewManagerEpoch,
capabilities: this.item ? this.viewItemManager?.getCapabilities(this.item) : null,
};
}
protected render(): TemplateResult | void {
if (!this.item) {
return;
}
const starClasses = {
star: true,
starred: ViewItemClassifier.isMedia(this.item) && !!this.item?.isFavorite(),
};
const shouldShowTimelineControl =
this.show_timeline_control && ViewItemClassifier.supportsTimeline(this.item);
const mediaCapabilities = this.viewItemManager?.getCapabilities(this.item) ?? null;
const shouldShowFavoriteControl =
this.show_favorite_control &&
this.item &&
this.hass &&
mediaCapabilities?.canFavorite;
const shouldShowDownloadControl =
this.show_download_control &&
this.hass &&
this.item.getID() &&
mediaCapabilities?.canDownload;
const isReviewed = ViewItemClassifier.isReview(this.item)
? this.item.isReviewed()
: null;
const shouldShowReviewControl = this.show_review_control && isReviewed !== null;
const shouldShowInfoControl =
this.show_info_control && ViewItemClassifier.isMedia(this.item);
const title = this._controller.getTitle();
const subtitles = this._controller.getSubtitles();
const iconClasses = classMap({
background: title || subtitles.length,
});
const hasText = !!title || !!subtitles.length;
const mainIconClasses = {
placeholder: true,
};
const thumbnailClass = this._controller.getThumbnailClass();
const thumbnailClasses = classMap({
...(thumbnailClass && { [thumbnailClass]: true }),
'has-text': hasText,
});
return html`
${this._controller.getThumbnail()
? html` <advanced-camera-card-thumbnail-feature-thumbnail
class="${thumbnailClasses}"
.hass=${this.hass}
.thumbnail=${this._controller.getThumbnail()}
aria-label=${this.item?.getTitle() ?? ''}
title=${this.item?.getTitle() ?? ''}
></advanced-camera-card-thumbnail-feature-thumbnail>`
: this._controller.getIcon()
? html`<advanced-camera-card-icon
class="${iconClasses}"
.icon=${{ icon: this._controller.getIcon() }}
<div class=${classMap({ media: true, 'has-text': hasText })}>
${this._controller.getThumbnail()
? html` <advanced-camera-card-thumbnail-feature-thumbnail
class="${thumbnailClasses}"
.hass=${this.hass}
.thumbnail=${this._controller.getThumbnail()}
aria-label=${this.item?.getTitle() ?? ''}
title=${this.item?.getTitle() ?? ''}
></advanced-camera-card-thumbnail-feature-thumbnail>`
: this._controller.getIcon()
? html`<advanced-camera-card-icon
class=${classMap(mainIconClasses)}
.icon=${{ icon: this._controller.getIcon() }}
></advanced-camera-card-icon>`
: ''}
</div>
${shouldShowReviewControl
? html`<advanced-camera-card-icon
class="review"
title=${isReviewed
? localize('common.set_reviews.unreviewed')
: localize('common.set_reviews.reviewed')}
.icon=${{
icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline',
}}
@click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (this.item) {
await toggleReviewed(this.item, this._getControlContext());
}
}}
></advanced-camera-card-icon>`
: shouldShowFavoriteControl
? html` <advanced-camera-card-icon
class="${classMap(starClasses)}"
title=${localize('thumbnail.retain_indefinitely')}
.icon=${{
icon: this.item.isFavorite() ? 'mdi:star' : 'mdi:star-outline',
}}
@click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (
this.item &&
(await toggleFavorite(this.item, this._getControlContext()))
) {
this.requestUpdate();
}
}}
></advanced-camera-card-icon>`
: ''}
${title || subtitles.length
: ``}
${shouldShowInfoControl
? html`<advanced-camera-card-icon
class="info"
.icon=${{ icon: 'mdi:information-outline' }}
title=${this.item?.getDescription() ?? ''}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
const detailsController = new MediaDetailsController();
detailsController.calculate(this.cameraManager, this.item);
dispatchShowOverlayMessageEvent(
this,
detailsController.getMessage(this._getControlContext()),
);
}}
></advanced-camera-card-icon>`
: ''}
${shouldShowTimelineControl
? html`<advanced-camera-card-icon
class="timeline"
.icon=${{ icon: 'mdi:target' }}
title=${localize('thumbnail.timeline')}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (this.item) {
navigateToTimeline(this.item, this._getControlContext());
}
}}
></advanced-camera-card-icon>`
: ''}
${shouldShowDownloadControl
? html` <advanced-camera-card-icon
class="download"
.icon=${{ icon: 'mdi:download' }}
title=${localize('thumbnail.download')}
@click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (this.item) {
await downloadMedia(this.item, this._getControlContext());
}
}}
></advanced-camera-card-icon>`
: ``}
${hasText
? html`
${title ? html`<div class="title">${title}</div>` : ''}
${subtitles.length
+13 -95
View File
@@ -1,18 +1,11 @@
import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { FoldersManager } from '../../card-controller/folders/manager.js';
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
import { RemoveContextViewModifier } from '../../card-controller/view/modifiers/remove-context.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { dispatchAdvancedCameraCardErrorEvent } from '../../components-lib/message/dispatch.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import thumbnailStyle from '../../scss/thumbnail.scss';
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
import { errorToConsole } from '../../utils/basic.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { ViewItem } from '../../view/item.js';
import './details.js';
import './feature/feature.js';
@@ -56,6 +49,12 @@ export class AdvancedCameraCardThumbnail extends LitElement {
@property({ attribute: true, type: Boolean })
public show_download_control = false;
@property({ attribute: true, type: Boolean })
public show_review_control = false;
@property({ attribute: true, type: Boolean })
public show_info_control = false;
@property({ attribute: false })
public seek?: Date;
@@ -68,62 +67,21 @@ export class AdvancedCameraCardThumbnail extends LitElement {
return;
}
const starClasses = {
star: true,
starred: ViewItemClassifier.isMedia(this.item) && !!this.item?.isFavorite(),
};
const shouldShowTimelineControl =
this.show_timeline_control &&
((ViewItemClassifier.isEvent(this.item) && this.item.getStartTime()) ||
(ViewItemClassifier.isRecording(this.item) &&
this.item.getStartTime() &&
this.item.getEndTime()));
const mediaCapabilities = this.viewItemManager?.getCapabilities(this.item) ?? null;
const shouldShowFavoriteControl =
this.show_favorite_control &&
this.item &&
this.hass &&
mediaCapabilities?.canFavorite;
const shouldShowDownloadControl =
this.show_download_control &&
this.hass &&
this.item.getID() &&
mediaCapabilities?.canDownload;
return html`
<advanced-camera-card-thumbnail-feature
.cameraManager=${this.cameraManager}
.hasDetails=${this.details}
.hass=${this.hass}
.item=${this.item}
.viewItemManager=${this.viewItemManager}
.viewManagerEpoch=${this.viewManagerEpoch}
.show_favorite_control=${this.show_favorite_control}
.show_timeline_control=${this.show_timeline_control}
.show_download_control=${this.show_download_control}
.show_review_control=${this.show_review_control}
.show_info_control=${this.show_info_control}
>
</advanced-camera-card-thumbnail-feature>
${shouldShowFavoriteControl
? html` <advanced-camera-card-icon
class="${classMap(starClasses)}"
title=${localize('thumbnail.retain_indefinitely')}
.icon=${{ icon: this.item.isFavorite() ? 'mdi:star' : 'mdi:star-outline' }}
@click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (this.hass && this.item) {
try {
await this.viewItemManager?.favorite(
this.item,
!this.item.isFavorite(),
);
} catch (e) {
errorToConsole(e as Error);
return;
}
this.requestUpdate();
}
}}
/></advanced-camera-card-icon>`
: ``}
${this.details
? html`<advanced-camera-card-thumbnail-details
.hass=${this.hass}
@@ -132,46 +90,6 @@ export class AdvancedCameraCardThumbnail extends LitElement {
.seek=${this.seek}
></advanced-camera-card-thumbnail-details>`
: ''}
${shouldShowTimelineControl
? html`<advanced-camera-card-icon
class="timeline"
.icon=${{ icon: 'mdi:target' }}
title=${localize('thumbnail.timeline')}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (!this.viewManagerEpoch || !this.item) {
return;
}
this.viewManagerEpoch.manager.setViewByParameters({
params: {
view: 'timeline',
queryResults: this.viewManagerEpoch?.manager
.getView()
?.queryResults?.clone()
.selectResultIfFound((media) => media === this.item),
},
modifiers: [new RemoveContextViewModifier(['timeline'])],
});
}}
></advanced-camera-card-icon>`
: ''}
${shouldShowDownloadControl
? html` <advanced-camera-card-icon
class="download"
.icon=${{ icon: 'mdi:download' }}
title=${localize('thumbnail.download')}
@click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (this.hass && this.item) {
try {
this.viewItemManager?.download(this.item);
} catch (error: unknown) {
dispatchAdvancedCameraCardErrorEvent(this, error);
}
}
}}
></advanced-camera-card-icon>`
: ``}
`;
}
+19 -9
View File
@@ -18,7 +18,6 @@ import {
ThumbnailDataRequest,
ThumbnailDataRequestEvent,
TimelineItemClickAction,
TimelineKeys,
} from '../components-lib/timeline/types';
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
import { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails';
@@ -120,11 +119,6 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
@property({ attribute: true, type: Boolean, reflect: true })
public mini = false;
// Which cameraIDs to include in the timeline. If not specified, all cameraIDs
// are shown.
@property({ attribute: false, hasChanged: contentsChanged })
public keys?: TimelineKeys;
@property({ attribute: false })
public cameraManager?: CameraManager;
@@ -152,7 +146,22 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
return;
}
if (!this.keys) {
const view = this.viewManagerEpoch?.manager.getView();
const isLoading = !!view?.context?.loading?.query;
if (isLoading) {
if (!this.mini) {
return renderMessage({
message: localize('error.awaiting_media'),
icon: 'mdi:chart-gantt',
type: 'info',
dotdotdot: true,
});
}
return;
}
if (!view?.query || !view.query.hasNodes()) {
if (!this.mini) {
return renderMessage({
message: localize('error.no_camera_or_media_for_timeline'),
@@ -234,13 +243,14 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
if (
[
'viewManagerEpoch',
'cameraManager',
'viewItemManager',
'timelineConfig',
'mini',
'thumbnailConfig',
'keys',
'conditionStateManager',
'foldersManager',
].some((prop) => changedProps.has(prop))
) {
this._controller.setOptions({
@@ -251,7 +261,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
timelineConfig: this.timelineConfig,
mini: this.mini,
thumbnailConfig: this.thumbnailConfig,
keys: this.keys,
query: this.viewManagerEpoch?.manager.getView()?.query ?? undefined,
});
}
}
-44
View File
@@ -4,13 +4,11 @@ import { CameraManager } from '../camera-manager/manager';
import { FoldersManager } from '../card-controller/folders/manager';
import { ViewItemManager } from '../card-controller/view/item-manager';
import { ViewManagerEpoch } from '../card-controller/view/types';
import { TimelineKeys } from '../components-lib/timeline/types';
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
import { TimelineConfig } from '../config/schema/timeline';
import { CardWideConfig } from '../config/schema/types';
import { HomeAssistant } from '../ha/types';
import basicBlockStyle from '../scss/basic-block.scss';
import { QueryClassifier } from '../view/query-classifier';
import './surround.js';
import './timeline-core.js';
@@ -40,47 +38,6 @@ export class AdvancedCameraCardTimeline extends LitElement {
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
protected _getKeys(): TimelineKeys | undefined {
const query = this.viewManagerEpoch?.manager.getView()?.query;
// If there's a query, try to extract camera IDs or folder info from it.
if (QueryClassifier.isMediaQuery(query)) {
const cameraIDs = query.getQueryCameraIDs();
if (cameraIDs && cameraIDs.size) {
return {
type: 'camera',
cameraIDs,
};
}
} else if (QueryClassifier.isFolderQuery(query)) {
const folderConfig = query.getQuery()?.folder;
if (folderConfig) {
return {
type: 'folder',
folder: folderConfig,
};
}
}
// Otherwise fall back to all cameras that support media queries.
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability({
anyCapabilities: ['clips', 'snapshots', 'recordings'],
});
const folder = this.foldersManager?.getFolder() ?? null;
return cameraIDs?.size
? {
type: 'camera',
cameraIDs,
}
: folder
? {
type: 'folder',
folder,
}
: undefined;
}
protected render(): TemplateResult | void {
if (!this.timelineConfig) {
return html``;
@@ -96,7 +53,6 @@ export class AdvancedCameraCardTimeline extends LitElement {
.foldersManager=${this.foldersManager}
.conditionStateManager=${this.conditionStateManager}
.viewItemManager=${this.viewItemManager}
.keys=${this._getKeys()}
.cardWideConfig=${this.cardWideConfig}
.itemClickAction=${this.timelineConfig.controls.thumbnails.mode === 'none'
? 'play'
+2 -2
View File
@@ -53,7 +53,7 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
);
}
}}
@loadeddata=${(ev: Event) => {
@loadeddata="${(ev: Event) => {
dispatchMediaLoadedEvent(this, ev, {
...(this._mediaPlayerController && {
mediaPlayerController: this._mediaPlayerController,
@@ -64,7 +64,7 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
},
technology: ['mp4'],
});
}}
}}"
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
@play=${() => dispatchMediaPlayEvent(this)}
@pause=${() => dispatchMediaPauseEvent(this)}
+5
View File
@@ -9,6 +9,7 @@ import {
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
import { CardWideConfig } from '../../config/schema/types.js';
@@ -39,6 +40,9 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
protected _renderCarousel(filterCamera?: string): TemplateResult {
const selectedCameraID = this.viewManagerEpoch?.manager.getView()?.camera;
@@ -60,6 +64,7 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.showControls=${!filterCamera || selectedCameraID === filterCamera}
.viewItemManager=${this.viewItemManager}
>
</advanced-camera-card-viewer-carousel>
`;
+5
View File
@@ -8,6 +8,7 @@ import {
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { ViewerConfig } from '../../config/schema/viewer.js';
@@ -50,6 +51,9 @@ export class AdvancedCameraCardViewer extends LitElement {
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: 'empty', reflect: true, type: Boolean })
public isEmpty = false;
@@ -96,6 +100,7 @@ export class AdvancedCameraCardViewer extends LitElement {
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.viewItemManager=${this.viewItemManager}
>
</advanced-camera-card-viewer-grid>`;
}
+4 -9
View File
@@ -10,6 +10,7 @@ import { customElement, property, state } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { QueryType } from '../../camera-manager/types.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
@@ -29,7 +30,7 @@ import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../ty
import { errorToConsole } from '../../utils/basic.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { VideoContentType, ViewMedia } from '../../view/item.js';
import { QueryClassifier } from '../../view/query-classifier.js';
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js';
import '../image-player.js';
import { renderProgressIndicator } from '../progress-indicator.js';
import '../video-player.js';
@@ -85,19 +86,13 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
// If this specific media item has no clip, then do nothing (even if all
// the other media items do).
!ViewItemClassifier.isEvent(this.media) ||
!QueryClassifier.isEventQuery(view.query)
!view.query?.hasMediaQueriesOfType(QueryType.Event)
) {
return;
}
// Convert the query to a clips equivalent.
const clipQuery = view.query.clone();
clipQuery.convertToClipsQueries();
const queries = clipQuery.getQuery();
if (!queries) {
return;
}
const clipQuery = UnifiedQueryTransformer.convertToClips(view.query);
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: {
+8 -16
View File
@@ -76,16 +76,14 @@ export class AdvancedCameraCardViews extends LitElement {
if (view?.is('live') || this._shouldLivePreload()) {
import('./live/index.js');
}
if (view?.isMediaGalleryView() && !view.is('folders')) {
import('./gallery/media-gallery.js');
if (view?.isGalleryView()) {
import('./gallery/gallery.js');
} else if (view?.isViewerView()) {
import('./viewer/index.js');
} else if (view?.is('image')) {
import('./image.js');
} else if (view?.is('timeline')) {
import('./timeline.js');
} else if (view?.is('folders')) {
import('./gallery/folder-gallery.js');
}
}
@@ -167,16 +165,18 @@ export class AdvancedCameraCardViews extends LitElement {
>
</advanced-camera-card-image>`
: ``}
${!this.hide && view?.isMediaGalleryView() && !view.is('folders')
? html` <advanced-camera-card-media-gallery
${!this.hide && view?.isGalleryView()
? html` <advanced-camera-card-gallery
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.galleryConfig=${this.config.media_gallery}
.cameraManager=${this.cameraManager}
.foldersManager=${this.foldersManager}
.viewItemManager=${this.viewItemManager}
.cardWideConfig=${this.cardWideConfig}
.conditionStateManager=${this.conditionStateManager}
>
</advanced-camera-card-media-gallery>`
</advanced-camera-card-gallery>`
: ``}
${!this.hide && view?.isViewerView()
? html`
@@ -187,6 +187,7 @@ export class AdvancedCameraCardViews extends LitElement {
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.viewItemManager=${this.viewItemManager}
>
</advanced-camera-card-viewer>
`
@@ -212,15 +213,6 @@ export class AdvancedCameraCardViews extends LitElement {
>
</advanced-camera-card-diagnostics>`
: ``}
${!this.hide && view?.is('folders')
? html` <advanced-camera-card-folder-gallery
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewItemManager=${this.viewItemManager}
.galleryConfig=${this.config.media_gallery}
.foldersManager=${this.foldersManager}
></advanced-camera-card-folder-gallery>`
: ``}
${
// Note: Subtle difference in condition below vs the other views in
// order to always render the live view for live.preload mode.