feat: Implement basic general folder support (#2051)

- Related: #1748
This commit is contained in:
Dermot Duffy
2025-05-21 19:59:21 -07:00
committed by GitHub
parent 2eb0d9e35e
commit c6a4c8aea2
350 changed files with 12837 additions and 4509 deletions
+1 -1
View File
@@ -2,11 +2,11 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { RawAdvancedCameraCardConfig } from '../config/types';
import { DeviceRegistryManager } from '../ha/registry/device';
import { HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize';
import basicBlockStyle from '../scss/basic-block.scss';
import { getDiagnostics } from '../utils/diagnostics';
import { DeviceRegistryManager } from '../utils/ha/registry/device';
import { renderMessage } from './message';
@customElement('advanced-camera-card-diagnostics')
+1 -10
View File
@@ -64,9 +64,6 @@ export class AdvancedCameraCardDrawer extends LitElement {
this._refDrawer.value?.shadowRoot?.appendChild(style);
}
/**
* Called when the slotted children in the drawer change.
*/
protected _slotChanged(): void {
const children = this._refSlot.value
? getChildrenFromElement(this._refSlot.value)
@@ -80,10 +77,6 @@ export class AdvancedCameraCardDrawer extends LitElement {
this._hideDrawerIfNecessary();
}
/**
* Hide the drawer if there is nothing to show.
* @returns
*/
protected _hideDrawerIfNecessary(): void {
if (!this._refDrawer.value) {
return;
@@ -108,9 +101,7 @@ export class AdvancedCameraCardDrawer extends LitElement {
location="${this.location}"
?open=${this.open}
@mouseleave=${() => {
if (this.open) {
this.open = false;
}
this.open = false;
}}
>
${this.control
-506
View File
@@ -1,506 +0,0 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import throttle from 'lodash-es/throttle';
import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js';
import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import { GalleryConfig } from '../config/schema/gallery.js';
import { CardWideConfig, configDefaults } from '../config/schema/types.js';
import { HomeAssistant } from '../ha/types.js';
import { localize } from '../localize/localize';
import galleryCoreStyle from '../scss/gallery-core.scss';
import galleryStyle from '../scss/gallery.scss';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { errorToConsole, sleep } from '../utils/basic';
import { scrollIntoView } from '../utils/scroll.js';
import { ViewMedia } from '../view/media';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { MediaQueriesResults } from '../view/media-queries-results';
import './media-filter';
import './message.js';
import { renderMessage } from './message.js';
import './progress-indicator.js';
import { renderProgressIndicator } from './progress-indicator.js';
import './surround-basic';
import './thumbnail.js';
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
const GALLERY_MEDIA_FILTER_MENU_ICONS = {
closed: 'mdi:filter-cog-outline',
open: 'mdi:filter-cog',
};
const MIN_GALLERY_EXTENSION_SECONDS = 0.5;
@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?: GalleryConfig;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
const view = this.viewManagerEpoch?.manager.getView();
if (
!this.hass ||
!view?.isGalleryView() ||
!this.cameraManager ||
!this.cardWideConfig
) {
return;
}
return html`
<advanced-camera-card-surround-basic
.drawerIcons=${{
...(this.galleryConfig &&
this.galleryConfig.controls.filter.mode !== 'none' && {
[this.galleryConfig.controls.filter.mode]: GALLERY_MEDIA_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>`
: ''}
<advanced-camera-card-gallery-core
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.galleryConfig=${this.galleryConfig}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</advanced-camera-card-gallery-core>
</advanced-camera-card-surround-basic>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(galleryStyle);
}
}
@customElement('advanced-camera-card-gallery-core')
export class AdvancedCameraCardGalleryCore extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public galleryConfig?: GalleryConfig;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
protected _intersectionObserver: IntersectionObserver;
protected _resizeObserver: ResizeObserver;
protected _refLoaderBottom: Ref<HTMLElement> = createRef();
protected _refSelected: Ref<HTMLElement> = createRef();
// Bottom loader: A progress indicator shown in a "cell" (not across) at the
// bottom of the gallery. Once visible this attempts to fetch new content from
// "earlier" (less recently) than the current query. This is rendered by
// default (and once visible, the fetch is triggered after which it is
// re-hidden).
@state()
protected _showLoaderBottom = true;
// Top loader: A progress indicator is shown across the top of the gallery if
// the user is _already_ at the top of the gallery and scrolls upwards. This
// attempts to fetch new content from "later" (more recently) than the current
// query. This is hidden by default.
@state()
protected _showLoaderTop = false;
protected _media?: ViewMedia[];
protected _boundWheelHandler = this._wheelHandler.bind(this);
protected _boundTouchStartHandler = this._touchStartHandler.bind(this);
protected _boundTouchEndHandler = this._touchEndHandler.bind(this);
// Wheel / touch events may be voluminous, throttle extension calls.
protected _throttleExtendGalleryLater = throttle(
this._extendGallery.bind(this),
MIN_GALLERY_EXTENSION_SECONDS * 1000,
{
leading: true,
trailing: false,
},
);
protected _touchScrollYPosition: number | null = null;
constructor() {
super();
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
this._intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this),
);
}
// Since the scroll event does not fire if the user is already at the top of
// the container, instead we manually use the wheel and touchstart/end events
// to detect "top upwards scrolling" (to trigger an extension of the gallery).
protected _touchStartHandler(ev: TouchEvent): void {
// Remember the Y touch position on touch start, so that we can calculate if
// the user gestured upwards or downards on touchend.
if (ev.touches.length === 1) {
this._touchScrollYPosition = ev.touches[0].screenY;
} else {
this._touchScrollYPosition = null;
}
}
protected async _touchEndHandler(ev: TouchEvent): Promise<void> {
if (
!this.scrollTop &&
ev.changedTouches.length === 1 &&
this._touchScrollYPosition
) {
if (ev.changedTouches[0].screenY > this._touchScrollYPosition) {
await this._extendLater();
}
}
this._touchScrollYPosition = null;
}
protected async _wheelHandler(ev: WheelEvent): Promise<void> {
if (!this.scrollTop && ev.deltaY < 0) {
await this._extendLater();
}
}
protected async _extendLater(): Promise<void> {
const start = new Date();
this._showLoaderTop = true;
await this._throttleExtendGalleryLater(
'later',
// Ask the engine to avoid use of cache since the user is explicitly
// looking for the freshest possible data.
false,
);
const delta = new Date().getTime() - start.getTime();
if (delta < MIN_GALLERY_EXTENSION_SECONDS * 1000) {
// Hidden gem: "legitimate" (?!) use of sleep() :-)
// These calls can return very quickly even with caching disabled since
// the time window constraints on the query will usually be very narrow
// and the backend can thus very quickly reply. It's often so fast it
// actually looks like a rendering issue where the progress indictor
// barely registers before it's gone again. This optional pause ensures
// there is at least some visual feedback to the user that last long
// enough they can 'feel' the fetch has happened.
await sleep(MIN_GALLERY_EXTENSION_SECONDS - delta / 1000);
}
this._showLoaderTop = false;
}
/**
* Component connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
this._resizeObserver.observe(this);
this.addEventListener('wheel', this._boundWheelHandler, { passive: true });
this.addEventListener('touchstart', this._boundTouchStartHandler, { passive: true });
this.addEventListener('touchend', this._boundTouchEndHandler);
// Request update in order to ensure the intersection observer reconnects
// with the loader sentinel.
this.requestUpdate();
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
this.removeEventListener('wheel', this._boundWheelHandler);
this.removeEventListener('touchstart', this._boundTouchStartHandler);
this.removeEventListener('touchend', this._boundTouchEndHandler);
this._resizeObserver.disconnect();
this._intersectionObserver.disconnect();
super.disconnectedCallback();
}
/**
* Set gallery columns.
*/
protected _setColumnCount(): void {
const thumbnailSize =
this.galleryConfig?.controls.thumbnails.size ??
configDefaults.media_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 / thumbnailSize));
this.style.setProperty('--advanced-camera-card-gallery-columns', String(columns));
}
/**
* Handle gallery resize.
*/
protected _resizeHandler(): void {
this._setColumnCount();
}
protected async _intersectionHandler(
entries: IntersectionObserverEntry[],
): Promise<void> {
if (entries.every((entry) => !entry.isIntersecting)) {
return;
}
this._showLoaderBottom = false;
await this._extendGallery('earlier');
}
protected async _extendGallery(
direction: 'earlier' | 'later',
useCache = true,
): Promise<void> {
const view = this.viewManagerEpoch?.manager.getView();
if (!this.cameraManager || !this.hass || !view) {
return;
}
const query = view.query;
const rawQueries = query?.getQueries() ?? null;
const existingMedia = view.queryResults?.getResults();
if (!query || !rawQueries || !existingMedia) {
return;
}
let extension: ExtendedMediaQueryResult<MediaQuery> | null;
try {
extension = await this.cameraManager.extendMediaQueries<MediaQuery>(
rawQueries,
existingMedia,
direction,
{
useCache: useCache,
},
);
} catch (e) {
errorToConsole(e as Error);
return;
}
if (extension) {
const newMediaQueries = MediaQueriesClassifier.areEventQueries(query)
? new EventMediaQueries(extension.queries as EventQuery[])
: MediaQueriesClassifier.areRecordingQueries(query)
? new RecordingMediaQueries(extension.queries as RecordingQuery[])
: null;
if (newMediaQueries) {
this.viewManagerEpoch?.manager.setViewByParameters({
baseView: view,
params: {
query: newMediaQueries,
queryResults: new MediaQueriesResults({
results: extension.results,
}).selectResultIfFound(
(media) => media === view.queryResults?.getSelectedResult(),
),
},
});
}
}
}
/**
* 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(
'--advanced-camera-card-thumbnail-size',
`${this.galleryConfig.controls.thumbnails.size}px`,
);
}
}
if (changedProps.has('viewManagerEpoch')) {
// If the view changes, always render the bottom loader to allow for the
// view to be extended once the bottom loader becomes visible.
this._showLoaderBottom = true;
const view = this.viewManagerEpoch?.manager.getView();
const oldView = this.viewManagerEpoch?.oldView;
if (
!this._media ||
oldView?.queryResults?.getResults() !== view?.queryResults?.getResults()
) {
// Gallery places the most recent media at the top (the query results place
// the most recent media at the end for use in the viewer). This is copied
// to a new array to avoid reversing the query results in place.
this._media = [...(view?.queryResults?.getResults() ?? [])].reverse();
}
}
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (!this._media || !this.hass) {
return html``;
}
const view = this.viewManagerEpoch?.manager.getView();
if (!view?.queryResults || view.queryResults.getResultsCount() === 0) {
// Note that this is not throwing up an error message for the card to
// handle (as typical), but rather directly rendering the message into the
// gallery. This is to allow the filter to still be available when a given
// filter selection returns no media.
const loadingMedia = !!view?.context?.loading?.query;
return renderMessage({
type: 'info',
message: loadingMedia
? localize('error.awaiting_media')
: localize('common.no_media'),
icon: 'mdi:multimedia',
dotdotdot: loadingMedia,
});
}
const selected = view.queryResults.getSelectedResult();
return html` <div class="grid">
${this._showLoaderTop
? html`${renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
classes: {
top: true,
},
size: 'small',
})}`
: ''}
${this._media.map(
(media, index) =>
html`<advanced-camera-card-thumbnail
${media === selected ? ref(this._refSelected) : ''}
class=${classMap({
selected: media === selected,
})}
.hass=${this.hass}
.cameraManager=${this.cameraManager}
.media=${media}
.viewManagerEpoch=${this.viewManagerEpoch}
?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) => {
if (this._media) {
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
view: 'media',
queryResults: view.queryResults?.clone().selectIndex(
// Media in the gallery is reversed vs the queryResults (see
// note above).
this._media.length - index - 1,
),
},
});
}
stopEventFromActivatingCardWideActions(ev);
}}
>
</advanced-camera-card-thumbnail>`,
)}
${this._showLoaderBottom
? html`${renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
componentRef: this._refLoaderBottom,
})}`
: ''}
</div>`;
}
public updated(changedProps: PropertyValues): void {
if (this._refLoaderBottom.value) {
this._intersectionObserver.disconnect();
this._intersectionObserver.observe(this._refLoaderBottom.value);
}
// This wait for updateComplete is necessary for the scrolling to work
// correctly.
this.updateComplete.then(() => {
// As a special case, if the view has changed and did not previously exist
// (i.e. first setting of it), we intentionally scroll the gallery to the
// selected element in that view (if any).
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
if (
// If this update cycle updated the view ...
changedProps.has('viewManagerEpoch') &&
// ... and it wasn't set at all prior ...
!changedProps.get('viewManagerEpoch') &&
// ... and there is a thumbnail rendered that is selected.
this._refSelected.value
) {
scrollIntoView(this._refSelected.value, {
boundary: this,
block: 'center',
});
}
});
}
static get styles(): CSSResultGroup {
return unsafeCSS(galleryCoreStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-gallery-core': AdvancedCameraCardGalleryCore;
'advanced-camera-card-gallery': AdvancedCameraCardGallery;
}
}
+147
View File
@@ -0,0 +1,147 @@
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 { 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;
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);
}
}),
)}
`;
}
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;
}
}
+119
View File
@@ -0,0 +1,119 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import {
GalleryColumnCountRoundMethod,
GalleryCoreController,
} from '../../components-lib/gallery/gallery-core-controller.js';
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { HomeAssistant } from '../../ha/types.js';
import galleryCoreStyle from '../../scss/gallery-core.scss';
import '../message.js';
import '../progress-indicator.js';
import { renderProgressIndicator } from '../progress-indicator.js';
@customElement('advanced-camera-card-gallery-core')
export class AdvancedCameraCardGalleryCore extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public columnWidth: number = THUMBNAIL_WIDTH_DEFAULT;
@property({ attribute: false })
public columnCountRoundMethod?: GalleryColumnCountRoundMethod;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public extendUp = false;
@property({ attribute: false })
public extendDown = false;
private _refSentinelBottom: Ref<HTMLElement> = createRef();
private _refSlot: Ref<HTMLSlotElement> = createRef();
private _controller = new GalleryCoreController(
this,
() => this._refSlot.value ?? null,
() => this._refSentinelBottom.value ?? null,
(show) => {
this._showLoaderTop = show;
},
(show) => {
this._showSentinelBottom = show;
},
);
// Top loader: A progress indicator is shown across the top of the gallery if
// the user is _already_ at the top of the gallery and scrolls upwards. This
// attempts to fetch new content from "later" (more recently) than the current
// query. This is hidden by default.
@state()
private _showLoaderTop = false;
// Bottom sentinel: A progress indicator shown in a "cell" (not across) at the
// bottom of the gallery. Once visible an attempt is optionally made to extend
// the gallery downwards. This is rendered by default (so intersection can be
// detected), and hidden during fetches.
@state()
private _showSentinelBottom = true;
protected willUpdate(changedProps: PropertyValues): void {
if (
['columnCountRoundMethod', 'columnWidth', 'extendUp', 'extendDown'].some((prop) =>
changedProps.has(prop),
)
) {
this._controller.setOptions({
extendUp: this.extendUp,
extendDown: this.extendDown,
columnWidth: this.columnWidth,
columnCountRoundMethod: this.columnCountRoundMethod,
});
}
}
protected render(): TemplateResult | void {
return html` <div class="grid">
${this.extendUp && this._showLoaderTop
? html`${renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
classes: {
top: true,
},
size: 'small',
})}`
: ''}
<slot ${ref(this._refSlot)} @slotchange=${() => this._controller.updateContents()}>
</slot>
${this.extendDown && this._showSentinelBottom
? html`${renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
componentRef: this._refSentinelBottom,
size: 'small',
})}`
: ''}
</div>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(galleryCoreStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-gallery-core': AdvancedCameraCardGalleryCore;
}
}
+194
View File
@@ -0,0 +1,194 @@
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;
}
}
+3
View File
@@ -0,0 +1,3 @@
export interface GalleryExtendEvent {
resolve: () => void;
}
+8 -3
View File
@@ -4,7 +4,12 @@ import { ifDefined } from 'lit/directives/if-defined.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { ImageMediaPlayerController } from '../components-lib/media-player/image';
import imagePlayerStyle from '../scss/image-player.scss';
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types';
import {
MediaPlayer,
MediaPlayerController,
MediaPlayerElement,
MediaTechnology,
} from '../types';
import { dispatchMediaLoadedEvent } from '../utils/media-info';
/**
@@ -16,7 +21,7 @@ export class AdvancedCameraCardImagePlayer extends LitElement implements MediaPl
public url?: string;
@property()
public filetype?: string;
public technology?: MediaTechnology;
protected _refImage: Ref<MediaPlayerElement<HTMLImageElement>> = createRef();
protected _mediaPlayerController = new ImageMediaPlayerController(
@@ -37,7 +42,7 @@ export class AdvancedCameraCardImagePlayer extends LitElement implements MediaPl
...(this._mediaPlayerController && {
mediaPlayerController: this._mediaPlayerController,
}),
technology: [this.filetype ?? 'jpg'],
technology: [this.technology ?? ('jpg' as const)],
});
}}
/>`;
+2 -2
View File
@@ -10,7 +10,7 @@ import {
import { customElement, property, state } from 'lit/decorators.js';
import { live } from 'lit/directives/live.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
import { CameraManager } from '../camera-manager/manager.js';
import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js';
import { CachedValueController } from '../components-lib/cached-value-controller.js';
@@ -18,6 +18,7 @@ import { UpdatingImageMediaPlayerController } from '../components-lib/media-play
import { CameraConfig } from '../config/schema/cameras.js';
import { ImageMode } from '../config/schema/common/image.js';
import { ImageViewConfig } from '../config/schema/image.js';
import { isHassDifferent } from '../ha/is-hass-different.js';
import { HomeAssistant } from '../ha/types.js';
import defaultImage from '../images/iris-screensaver.jpg';
import { localize } from '../localize/localize.js';
@@ -29,7 +30,6 @@ import {
Message,
} from '../types.js';
import { contentsChanged } from '../utils/basic.js';
import { isHassDifferent } from '../utils/ha/index.js';
import {
createMediaLoadedInfo,
dispatchExistingMediaLoadedInfoAsEvent,
+4 -1
View File
@@ -380,7 +380,10 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
type: 'error',
icon: 'mdi:camera-off',
message: localize('error.stream_not_loading'),
troubleshootingURL: STREAM_TROUBLESHOOTING_URL,
url: {
link: STREAM_TROUBLESHOOTING_URL,
title: localize('error.troubleshooting'),
},
},
{ overlay: true },
)
+2 -24
View File
@@ -74,11 +74,6 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
}
}
/**
* Create a JSMPEG player.
* @param url The URL for the player to connect to.
* @returns A JSMPEG player.
*/
protected async _createJSMPEGPlayer(url: string): Promise<JSMpeg.VideoElement> {
this._jsmpegVideoPlayer = await new Promise<JSMpeg.VideoElement>((resolve) => {
let videoDecoded = false;
@@ -136,16 +131,14 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
}
}
/**
* Reset / destroy the player.
*/
protected _resetPlayer(): void {
this._message = null;
this._refreshPlayerTimer.stop();
if (this._jsmpegVideoPlayer) {
try {
this._jsmpegVideoPlayer.destroy();
} catch (err) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
// Pass.
}
this._jsmpegVideoPlayer = undefined;
@@ -156,9 +149,6 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
}
}
/**
* Component connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
if (this.isConnected) {
@@ -166,9 +156,6 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
}
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
if (!this.isConnected) {
this._resetPlayer();
@@ -176,9 +163,6 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
super.disconnectedCallback();
}
/**
* Refresh the JSMPEG player.
*/
protected async _refreshPlayer(): Promise<void> {
if (!this.hass) {
return;
@@ -221,9 +205,6 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
);
}
/**
* Master render method.
*/
protected render(): TemplateResult | void {
if (this._message) {
return renderMessage(this._message);
@@ -253,9 +234,6 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
)}`;
}
/**
* Get styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(liveJSMPEGStyle);
}
@@ -120,7 +120,6 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
* Create the WebRTC element. May throw.
*/
protected _createWebRTC(): HTMLElement | null {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const webrtcElement = this._webrtcTask.value;
if (webrtcElement && this.hass && this.cameraConfig) {
const webrtc = new webrtcElement() as HTMLElement & {
+2 -2
View File
@@ -5,11 +5,11 @@ import { actionHandler } from '../action-handler-directive.js';
import { MenuController } from '../components-lib/menu-controller.js';
import { MenuItem } from '../config/schema/elements/custom/menu/types.js';
import { MenuConfig } from '../config/schema/menu.js';
import { getEntityTitle } from '../ha/get-entity-title.js';
import { EntityRegistryManager } from '../ha/registry/entity/types.js';
import { HomeAssistant } from '../ha/types.js';
import menuStyle from '../scss/menu.scss';
import { hasAction } from '../utils/action.js';
import { getEntityTitle } from '../utils/ha/index.js';
import { EntityRegistryManager } from '../utils/ha/registry/entity/types.js';
import './icon.js';
import './submenu/select-button.js';
import './submenu/submenu-button';
+3 -7
View File
@@ -2,7 +2,6 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { MessageController } from '../components-lib/message/controller.js';
import { localize } from '../localize/localize.js';
import messageStyle from '../scss/message.scss';
import { Message } from '../types.js';
import './icon.js';
@@ -33,14 +32,10 @@ export class AdvancedCameraCardMessage extends LitElement {
return;
}
const url = this._controller.getURL(this.message);
const messageTemplate = html`
${this._controller.getMessageString(this.message)}
${this._controller.shouldShowTroubleshootingURL(this.message)
? html`.
<a href="${this._controller.getTroubleshootingURL(this.message)}"
>${localize('error.troubleshooting')}</a
>`
: ''}
${url ? html`. <a href="${url.link}">${url.title}</a>` : ''}
`;
const icon = this._controller.getIcon(this.message);
@@ -52,6 +47,7 @@ export class AdvancedCameraCardMessage extends LitElement {
<div class="message padded">
<div class="icon">
<advanced-camera-card-icon
part="icon"
.icon="${{ icon: icon }}"
></advanced-camera-card-icon>
</div>
+20 -15
View File
@@ -41,9 +41,6 @@ export class AdvancedCameraCardNextPreviousControl extends LitElement {
// Label that is used for ARIA support and as tooltip.
@property() label = '';
@state()
protected _thumbnailError = false;
protected _embedThumbnailTask = createFetchThumbnailTask(
this,
() => this.hass,
@@ -55,35 +52,45 @@ export class AdvancedCameraCardNextPreviousControl extends LitElement {
return html``;
}
const renderIcon =
!this.thumbnail ||
['chevrons', 'icons'].includes(this._controlConfig.style) ||
this._thumbnailError;
const shouldRenderIcon =
!this.thumbnail || ['chevrons', 'icons'].includes(this._controlConfig.style);
const classes = {
const classesBase = {
controls: true,
left: this.side === 'left',
right: this.side === 'right',
thumbnails: !renderIcon,
icons: renderIcon,
};
if (renderIcon) {
const renderIcon = (): TemplateResult => {
const icon =
this.icon && !this._thumbnailError && this._controlConfig.style !== 'chevrons'
this.icon && this._controlConfig?.style !== 'chevrons'
? this.icon
: this.side === 'left'
? { icon: 'mdi:chevron-left' }
: { icon: 'mdi:chevron-right' };
const classes = {
...classesBase,
icons: true,
};
return html` <ha-icon-button class="${classMap(classes)}" .label=${this.label}>
<advanced-camera-card-icon
.hass=${this.hass}
.icon=${icon}
></advanced-camera-card-icon>
</ha-icon-button>`;
};
if (shouldRenderIcon) {
return renderIcon();
}
const classes = {
...classesBase,
thumbnails: true,
};
return renderTask(
this._embedThumbnailTask,
(embeddedThumbnail: string | null) =>
@@ -98,9 +105,7 @@ export class AdvancedCameraCardNextPreviousControl extends LitElement {
{
inProgressFunc: () => html`<div class=${classMap(classes)}></div>`,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
errorFunc: (_e: Error) => {
this._thumbnailError = true;
},
errorFunc: (_ev: Error) => renderIcon(),
},
);
}
+1 -2
View File
@@ -42,8 +42,7 @@ export class AdvancedCameraCardProgressIndicator extends LitElement {
protected render(): TemplateResult {
return html` <div class="message vertical">
${this.animated
? html`<ha-circular-progress indeterminate size="${this.size}">
</ha-circular-progress>`
? html`<ha-spinner indeterminate size="${this.size}"> </ha-spinner>`
: html`<advanced-camera-card-icon
.icon=${{ icon: 'mdi:timer-sand' }}
></advanced-camera-card-icon>`}
+1 -1
View File
@@ -13,7 +13,7 @@ import { contentsChanged } from '../utils/basic';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event';
import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin';
import { grSelectElements } from '../scoped-elements/gr-select';
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
import '../scoped-elements/gr-select';
export interface SelectOption {
+1 -1
View File
@@ -3,13 +3,13 @@ import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import { actionHandler } from '../../action-handler-directive.js';
import { getEntityTitle } from '../../ha/get-entity-title.js';
import { HomeAssistant } from '../../ha/types.js';
import submenuStyle from '../../scss/submenu.scss';
import {
hasAction,
stopEventFromActivatingCardWideActions,
} from '../../utils/action.js';
import { getEntityTitle } from '../../utils/ha';
import '../icon.js';
import { SubmenuInteraction, SubmenuItem } from './types.js';
+4 -3
View File
@@ -11,13 +11,14 @@ import { styleMap } from 'lit/directives/style-map.js';
import { MenuSubmenuSelect } from '../../config/schema/elements/custom/menu/submenu-select.js';
import { MenuSubmenuItem } from '../../config/schema/elements/custom/menu/submenu.js';
import { computeDomain } from '../../ha/compute-domain.js';
import { getEntityStateTranslation } from '../../ha/entity-state-translation.js';
import { getEntityTitle } from '../../ha/get-entity-title.js';
import { isHassDifferent } from '../../ha/is-hass-different.js';
import { EntityRegistryManager } from '../../ha/registry/entity/types.js';
import { HomeAssistant } from '../../ha/types.js';
import menuButtonStyle from '../../scss/menu-button.scss';
import { Icon } from '../../types.js';
import { createSelectOptionAction } from '../../utils/action.js';
import { getEntityTitle, isHassDifferent } from '../../utils/ha';
import { getEntityStateTranslation } from '../../utils/ha/entity-state-translation.js';
import { EntityRegistryManager } from '../../utils/ha/registry/entity/types.js';
import '../icon.js';
import './index.js';
+13 -25
View File
@@ -8,7 +8,7 @@ import {
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../camera-manager/manager.js';
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js';
import { ViewItemManager } from '../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js';
import { MiniTimelineControlConfig } from '../config/schema/common/controls/timeline.js';
@@ -17,8 +17,9 @@ 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 { ThumbnailCarouselTap } from './thumbnail-carousel.js';
import './thumbnail-carousel';
@customElement('advanced-camera-card-surround')
export class AdvancedCameraCardSurround extends LitElement {
@@ -37,6 +38,9 @@ export class AdvancedCameraCardSurround extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@@ -92,8 +96,10 @@ export class AdvancedCameraCardSurround extends LitElement {
.getAllDependentCameras(view.camera, capabilitySearch);
}
}
if (view.isViewerView()) {
return view.query?.getQueryCameraIDs() ?? null;
const queries = view.query;
if (view.isViewerView() && QueryClassifier.isMediaQuery(queries)) {
return queries.getQueryCameraIDs() ?? null;
}
return null;
}
@@ -118,9 +124,7 @@ export class AdvancedCameraCardSurround extends LitElement {
};
return html` <advanced-camera-card-surround-basic
@advanced-camera-card:thumbnails:open=${(ev: CustomEvent) =>
changeDrawer(ev, 'open')}
@advanced-camera-card:thumbnails:close=${(ev: CustomEvent) =>
@advanced-camera-card:thumbnails-carousel:media-select=${(ev: CustomEvent) =>
changeDrawer(ev, 'close')}
>
${this.thumbnailConfig && this.thumbnailConfig.mode !== 'none'
@@ -129,27 +133,10 @@ export class AdvancedCameraCardSurround extends LitElement {
.hass=${this.hass}
.config=${this.thumbnailConfig}
.cameraManager=${this.cameraManager}
.viewItemManager=${this.viewItemManager}
.fadeThumbnails=${view.isViewerView()}
.viewManagerEpoch=${this.viewManagerEpoch}
.selected=${view.queryResults?.getSelectedIndex() ?? undefined}
@advanced-camera-card:thumbnail-carousel:tap=${(
ev: CustomEvent<ThumbnailCarouselTap>,
) => {
const media = ev.detail.queryResults.getSelectedResult();
if (media) {
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
view: 'media',
queryResults: ev.detail.queryResults,
...(media.getCameraID() && { camera: media.getCameraID() }),
},
modifiers: [
new RemoveContextViewModifier(['timeline', 'mediaViewer']),
],
});
changeDrawer(ev, 'close');
}
}}
>
</advanced-camera-card-thumbnail-carousel>`
: ''}
@@ -168,6 +155,7 @@ export class AdvancedCameraCardSurround extends LitElement {
.timelineConfig=${this.timelineConfig}
.thumbnailConfig=${this.thumbnailConfig}
.cameraManager=${this.cameraManager}
.viewItemManager=${this.viewItemManager}
.cardWideConfig=${this.cardWideConfig}
>
</advanced-camera-card-timeline-core>`
+122 -46
View File
@@ -9,19 +9,27 @@ import {
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 { 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';
import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js';
import { HomeAssistant } from '../ha/types.js';
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { CarouselDirection } from '../utils/embla/carousel-controller.js';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
import { MediaQueriesResults } from '../view/media-queries-results';
import { ViewItemClassifier } from '../view/item-classifier.js';
import { ViewItem, ViewMedia } from '../view/item.js';
import { QueryClassifier } from '../view/query-classifier.js';
import './carousel.js';
import './thumbnail.js';
import './thumbnail/thumbnail.js';
export interface ThumbnailCarouselTap {
queryResults: MediaQueriesResults;
export interface ThumbnailMediaSelect {
media: ViewMedia;
}
@customElement('advanced-camera-card-thumbnail-carousel')
@@ -35,13 +43,16 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: false })
public config?: ThumbnailsControlConfig;
@property({ attribute: false })
public fadeThumbnails = false;
protected _thumbnailSlides: TemplateResult[] = [];
protected _thumbnails: TemplateResult[] = [];
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('config')) {
@@ -66,7 +77,7 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
'viewManagerEpoch',
] as const;
if (renderProperties.some((prop) => changedProps.has(prop))) {
this._thumbnailSlides = this._renderSlides();
this._thumbnails = this._renderThumbnails();
}
if (changedProps.has('viewManagerEpoch')) {
@@ -83,49 +94,114 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
);
}
protected _renderSlides(): TemplateResult[] {
const slides: TemplateResult[] = [];
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 }),
},
modifiers: [new RemoveContextViewModifier(['timeline', 'mediaViewer'])],
});
} else if (
QueryClassifier.isFolderQuery(query) &&
ViewItemClassifier.isFolder(item)
) {
const id = item.getID();
const rawQuery = query.getQuery();
if (!id || !rawQuery) {
return;
}
this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: {
query: query.clone().setQuery({
folder: rawQuery.folder,
path: [...(rawQuery.path ?? []), { id }],
}),
},
});
}
}
protected _renderThumbnail(
item: ViewItem,
selected: boolean,
clickCallback: (item: ViewItem, ev: Event) => void,
seekTarget?: Date,
): TemplateResult {
const classes = {
embla__slide: true,
'slide-selected': selected,
};
return html` <advanced-camera-card-thumbnail
class="${classMap(classes)}"
.cameraManager=${this.cameraManager}
.hass=${this.hass}
.item=${item}
.viewManagerEpoch=${this.viewManagerEpoch}
.seek=${seekTarget &&
ViewItemClassifier.isMedia(item) &&
item.includesTime(seekTarget)
? seekTarget
: undefined}
?details=${!!this.config?.show_details}
?show_favorite_control=${this.config?.show_favorite_control}
?show_timeline_control=${this.config?.show_timeline_control}
?show_download_control=${this.config?.show_download_control}
@click=${(ev: Event) => 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 view = this.viewManagerEpoch?.manager.getView();
const seekTarget = view?.context?.mediaViewer?.seek;
const selectedIndex = this._getSelectedSlide();
for (const media of view?.queryResults?.getResults() ?? []) {
const index = slides.length;
const classes = {
embla__slide: true,
'slide-selected': selectedIndex === index,
};
slides.push(
html` <advanced-camera-card-thumbnail
class="${classMap(classes)}"
.cameraManager=${this.cameraManager}
.hass=${this.hass}
.media=${media}
.viewManagerEpoch=${this.viewManagerEpoch}
.seek=${seekTarget && media.includesTime(seekTarget) ? seekTarget : undefined}
?details=${!!this.config?.show_details}
?show_favorite_control=${this.config?.show_favorite_control}
?show_timeline_control=${this.config?.show_timeline_control}
?show_download_control=${this.config?.show_download_control}
@click=${(ev: Event) => {
const view = this.viewManagerEpoch?.manager.getView();
if (view && view.queryResults) {
fireAdvancedCameraCardEvent<ThumbnailCarouselTap>(
this,
'thumbnail-carousel:tap',
{
queryResults: view.queryResults.clone().selectIndex(index),
},
);
}
stopEventFromActivatingCardWideActions(ev);
}}
>
</advanced-camera-card-thumbnail>`,
for (const item of view?.queryResults?.getResults() ?? []) {
thumbnails.push(
this._renderThumbnail(
item,
selectedIndex === thumbnails.length,
(item: ViewItem, ev: Event) => this._itemClickCallback(item, ev),
view?.context?.mediaViewer?.seek,
),
);
}
return slides;
return thumbnails;
}
protected _getDirection(): CarouselDirection | null {
@@ -139,7 +215,7 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
protected render(): TemplateResult | void {
const direction = this._getDirection();
if (!this._thumbnailSlides.length || !this.config || !direction) {
if (!this._thumbnails.length || !this.config || !direction) {
return;
}
@@ -148,7 +224,7 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
.selected=${this._getSelectedSlide() ?? 0}
.dragFree=${true}
>
${this._thumbnailSlides}
${this._thumbnails}
</advanced-camera-card-carousel>`;
}
-553
View File
@@ -1,553 +0,0 @@
import { Task, TaskStatus } from '@lit-labs/task';
import { format } from 'date-fns';
import {
CSSResult,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
import { CameraManagerCameraMetadata } from '../camera-manager/types.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 thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
import thumbnailFeatureTextStyle from '../scss/thumbnail-feature-text.scss';
import thumbnailFeatureThumbnailStyle from '../scss/thumbnail-feature-thumbnail.scss';
import thumbnailStyle from '../scss/thumbnail.scss';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import {
errorToConsole,
formatDateAndTime,
getDurationString,
prettifyTitle,
} from '../utils/basic.js';
import { downloadMedia } from '../utils/download.js';
import { renderTask } from '../utils/task.js';
import { createFetchThumbnailTask, FetchThumbnailTaskArgs } from '../utils/thumbnail.js';
import { ViewMediaClassifier } from '../view/media-classifier.js';
import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js';
// The minimum width of a thumbnail with details enabled.
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
@customElement('advanced-camera-card-thumbnail-feature-thumbnail')
export class AdvancedCameraCardThumbnailFeatureThumbnail extends LitElement {
@property({ attribute: false })
public thumbnail?: string;
@property({ attribute: false })
public hass?: HomeAssistant;
@state()
protected _thumbnailError = false;
protected _embedThumbnailTask?: Task<FetchThumbnailTaskArgs, string | null>;
// Only load thumbnails on view in case there is a very large number of them.
protected _intersectionObserver: IntersectionObserver;
constructor() {
super();
this._intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this),
);
}
/**
* Component connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
this._intersectionObserver.observe(this);
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
this._intersectionObserver.disconnect();
super.disconnectedCallback();
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('thumbnail')) {
this._embedThumbnailTask = createFetchThumbnailTask(
this,
() => this.hass,
() => this.thumbnail,
false,
);
// Reset the observer so the initial intersection handler call will set
// the visibility correctly.
this._intersectionObserver.unobserve(this);
this._intersectionObserver.observe(this);
}
}
/**
* Called when the live view intersects with the viewport.
* @param entries The IntersectionObserverEntry entries (should be only 1).
*/
protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
if (
this._embedThumbnailTask?.status === TaskStatus.INITIAL &&
entries.some((entry) => entry.isIntersecting)
) {
this._embedThumbnailTask?.run();
}
}
protected render(): TemplateResult | void {
const imageOff = html`<advanced-camera-card-icon
.icon=${{ icon: 'mdi:image-off' }}
title=${localize('thumbnail.no_thumbnail')}
></advanced-camera-card-icon> `;
if (!this._embedThumbnailTask || this._thumbnailError) {
return imageOff;
}
return html`${this.thumbnail
? renderTask(
this._embedThumbnailTask,
(embeddedThumbnail: string | null) =>
embeddedThumbnail ? html`<img src="${embeddedThumbnail}" />` : html``,
{
inProgressFunc: () => imageOff,
errorFunc: () => {
this._thumbnailError = true;
},
},
)
: imageOff} `;
}
static get styles(): CSSResult {
return unsafeCSS(thumbnailFeatureThumbnailStyle);
}
}
@customElement('advanced-camera-card-thumbnail-feature-text')
export class AdvancedCameraCardThumbnailFeatureText extends LitElement {
@property({ attribute: false })
public date?: Date;
@property({ attribute: false })
public cameraMetadata?: CameraManagerCameraMetadata;
@property({ attribute: false })
public showCameraTitle?: boolean;
protected render(): TemplateResult | void {
if (!this.date) {
return;
}
return html`
${this.cameraMetadata?.engineIcon
? html`<advanced-camera-card-icon
class="background"
.icon=${{ icon: this.cameraMetadata.engineIcon }}
></advanced-camera-card-icon>`
: ''}
<div class="content">
<div class="title">${format(this.date, 'HH:mm')}</div>
<div class="subtitle">${format(this.date, 'MMM do')}</div>
${this.showCameraTitle && this.cameraMetadata?.title
? html`<div class="camera">${this.cameraMetadata.title}</div>`
: html``}
</div>
`;
}
static get styles(): CSSResult {
return unsafeCSS(thumbnailFeatureTextStyle);
}
}
@customElement('advanced-camera-card-thumbnail-details-event')
export class AdvancedCameraCardThumbnailDetailsEvent extends LitElement {
@property({ attribute: false })
public media?: EventViewMedia;
@property({ attribute: false })
public seek?: Date;
@property({ attribute: false })
public cameraTitle?: string;
protected render(): TemplateResult | void {
if (!this.media) {
return;
}
const rawScore = this.media.getScore();
const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null;
const rawStartTime = this.media.getStartTime();
const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null;
const rawEndTime = this.media.getEndTime();
const duration =
rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null;
const inProgress = this.media.inProgress() ? localize('event.in_progress') : null;
const what = prettifyTitle(this.media.getWhat()?.join(', ')) ?? null;
const where = prettifyTitle(this.media.getWhere()?.join(', ')) ?? null;
const tags = prettifyTitle(this.media.getTags()?.join(', ')) ?? null;
const whatWithTags =
what || tags ? (what ?? '') + (what && tags ? ': ' : '') + (tags ?? '') : null;
const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null;
return html`
${whatWithTags
? html` <div class="title">
<span title=${whatWithTags}>${whatWithTags}</span>
${score ? html`<span title="${score}">${score}</span>` : ''}
</div>`
: ``}
<div class="details">
${startTime
? html` <div>
<advanced-camera-card-icon
title=${localize('event.start')}
.icon=${{ icon: 'mdi:calendar-clock-outline' }}
></advanced-camera-card-icon>
<span title="${startTime}">${startTime}</span>
</div>
${duration || inProgress
? html` <div>
<advanced-camera-card-icon
title=${localize('event.duration')}
.icon=${{ icon: 'mdi:clock-outline' }}
></advanced-camera-card-icon>
${duration ? html`<span title="${duration}">${duration}</span>` : ''}
${inProgress
? html`<span title="${inProgress}">${inProgress}</span>`
: ''}
</div>`
: ''}`
: ''}
${this.cameraTitle
? html` <div>
<advanced-camera-card-icon
title=${localize('event.camera')}
.icon=${{ icon: 'mdi:cctv' }}
></advanced-camera-card-icon>
<span title="${this.cameraTitle}">${this.cameraTitle}</span>
</div>`
: ''}
${where
? html` <div>
<advanced-camera-card-icon
title=${localize('event.where')}
.icon=${{ icon: 'mdi:map-marker-outline' }}
></advanced-camera-card-icon>
<span title="${where}">${where}</span>
</div>`
: html``}
${tags
? html` <div>
<advanced-camera-card-icon
title=${localize('event.tag')}
.icon=${{ icon: 'mdi:tag' }}
></advanced-camera-card-icon>
<span title="${tags}">${tags}</span>
</div>`
: html``}
${seek
? html` <div>
<advanced-camera-card-icon
title=${localize('event.seek')}
.icon=${{ icon: 'mdi:clock-fast' }}
></advanced-camera-card-icon>
<span title="${seek}">${seek}</span>
</div>`
: html``}
</div>
`;
}
static get styles(): CSSResult {
return unsafeCSS(thumbnailDetailsStyle);
}
}
@customElement('advanced-camera-card-thumbnail-details-recording')
export class AdvancedCameraCardThumbnailDetailsRecording extends LitElement {
@property({ attribute: false })
public media?: RecordingViewMedia;
@property({ attribute: false })
public seek?: Date;
@property({ attribute: false })
public cameraTitle?: string;
protected render(): TemplateResult | void {
if (!this.media) {
return;
}
const rawStartTime = this.media.getStartTime();
const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null;
const rawEndTime = this.media.getEndTime();
const duration =
rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null;
const inProgress = this.media.inProgress()
? localize('recording.in_progress')
: null;
const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null;
const eventCount = this.media.getEventCount();
return html`
${this.cameraTitle
? html` <div class="title">
<span title="${this.cameraTitle}">${this.cameraTitle}</span>
</div>`
: ``}
<div class="details">
${startTime
? html` <div>
<advanced-camera-card-icon
title=${localize('recording.start')}
.icon=${{ icon: 'mdi:calendar-clock-outline' }}
></advanced-camera-card-icon>
<span title="${startTime}">${startTime}</span>
</div>
${duration || inProgress
? html` <div>
<advanced-camera-card-icon
title=${localize('recording.duration')}
.icon=${{ icon: 'mdi:clock-outline' }}
></advanced-camera-card-icon>
${duration ? html`<span title="${duration}">${duration}</span>` : ''}
${inProgress
? html`<span title="${inProgress}">${inProgress}</span>`
: ''}
</div>`
: ''}`
: ''}
${seek
? html` <div>
<advanced-camera-card-icon
title=${localize('event.seek')}
.icon=${{ icon: 'mdi:clock-fast' }}
></advanced-camera-card-icon>
<span title="${seek}">${seek}</span>
</div>`
: html``}
${eventCount !== null
? html`<div>
<advanced-camera-card-icon
title=${localize('recording.events')}
.icon=${{ icon: 'mdi:shield-alert' }}
></advanced-camera-card-icon>
<span title="${eventCount}">${eventCount}</span>
</div>`
: ``}
</div>
`;
}
static get styles(): CSSResult {
return unsafeCSS(thumbnailDetailsStyle);
}
}
@customElement('advanced-camera-card-thumbnail')
export class AdvancedCameraCardThumbnail extends LitElement {
// Performance: During timeline scrubbing, hass may be updated continuously.
// As it is not needed for the thumbnail rendering itself, it does not trigger
// a re-render. The HomeAssistant object may be required for thumbnail signing
// (after initial signing the thumbnail is stored in a data URL, so the
// signing will not expire).
public hass?: HomeAssistant;
// Performance: During timeline scrubbing, the view will be updated
// continuously. As it is not needed for the thumbnail rendering itself, it
// does not trigger a re-render.
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public media?: ViewMedia;
@property({ attribute: true, type: Boolean })
public details = false;
@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: false })
public seek?: Date;
/**
* Render the element.
* @returns A template to display to the user.
*/
protected render(): TemplateResult | void {
if (!this.media || !this.cameraManager || !this.hass) {
return;
}
const thumbnail = this.media.getThumbnail();
const title = this.media.getTitle() ?? '';
const starClasses = {
star: true,
starred: !!this.media?.isFavorite(),
};
const shouldShowTimelineControl =
this.show_timeline_control &&
(!ViewMediaClassifier.isRecording(this.media) ||
// Only show timeline control if the recording has a start & end time.
(this.media.getStartTime() && this.media.getEndTime()));
const mediaCapabilities = this.cameraManager?.getMediaCapabilities(this.media);
const shouldShowFavoriteControl =
this.show_favorite_control &&
this.media &&
this.hass &&
mediaCapabilities?.canFavorite;
const shouldShowDownloadControl =
this.show_download_control &&
this.hass &&
this.media.getID() &&
mediaCapabilities?.canDownload;
const cameraMetadata = this.cameraManager.getCameraMetadata(
this.media.getCameraID(),
);
return html`
${ViewMediaClassifier.isEvent(this.media) && thumbnail
? html`<advanced-camera-card-thumbnail-feature-thumbnail
aria-label="${title ?? ''}"
title=${title}
.hass=${this.hass}
.date=${this.media.getStartTime() ?? undefined}
.thumbnail=${thumbnail ?? undefined}
></advanced-camera-card-thumbnail-feature-thumbnail>`
: ViewMediaClassifier.isEvent(this.media) ||
ViewMediaClassifier.isRecording(this.media)
? html`<advanced-camera-card-thumbnail-feature-text
aria-label="${title ?? ''}"
title="${title ?? ''}"
.cameraMetadata=${cameraMetadata}
.showCameraTitle=${!this.details}
.date=${this.media.getStartTime() ?? undefined}
></advanced-camera-card-thumbnail-feature-text>`
: html``}
${shouldShowFavoriteControl
? html` <advanced-camera-card-icon
class="${classMap(starClasses)}"
title=${localize('thumbnail.retain_indefinitely')}
.icon=${{ icon: this.media.isFavorite() ? 'mdi:star' : 'mdi:star-outline' }}
@click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (this.hass && this.media) {
try {
await this.cameraManager?.favoriteMedia(
this.media,
!this.media?.isFavorite(),
);
} catch (e) {
errorToConsole(e as Error);
return;
}
this.requestUpdate();
}
}}
/></advanced-camera-card-icon>`
: ``}
${this.details && ViewMediaClassifier.isEvent(this.media)
? html`<advanced-camera-card-thumbnail-details-event
.media=${this.media ?? undefined}
.cameraTitle=${cameraMetadata?.title}
.seek=${this.seek}
></advanced-camera-card-thumbnail-details-event>`
: this.details && ViewMediaClassifier.isRecording(this.media)
? html`<advanced-camera-card-thumbnail-details-recording
.media=${this.media ?? undefined}
.cameraTitle=${cameraMetadata?.title}
.seek=${this.seek}
></advanced-camera-card-thumbnail-details-recording>`
: html``}
${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.media) {
return;
}
this.viewManagerEpoch.manager.setViewByParameters({
params: {
view: 'timeline',
queryResults: this.viewManagerEpoch?.manager
.getView()
?.queryResults?.clone()
.selectResultIfFound((media) => media === this.media),
},
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.cameraManager && this.media) {
try {
await downloadMedia(this.hass, this.cameraManager, this.media);
} catch (error: unknown) {
dispatchAdvancedCameraCardErrorEvent(this, error);
}
}
}}
></advanced-camera-card-icon>`
: ``}
`;
}
/**
* Get element styles.
*/
static get styles(): CSSResult {
return unsafeCSS(thumbnailStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-thumbnail': AdvancedCameraCardThumbnail;
'advanced-camera-card-thumbnail-details-recording': AdvancedCameraCardThumbnailDetailsRecording;
'advanced-camera-card-thumbnail-details-event': AdvancedCameraCardThumbnailDetailsEvent;
'advanced-camera-card-thumbnail-feature-text': AdvancedCameraCardThumbnailFeatureText;
'advanced-camera-card-thumbnail-feature-thumbnail': AdvancedCameraCardThumbnailFeatureThumbnail;
}
}
+75
View File
@@ -0,0 +1,75 @@
import {
CSSResult,
LitElement,
PropertyValues,
TemplateResult,
html,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../../camera-manager/manager';
import { ThumbnailDetailsController } from '../../components-lib/thumbnail/details-controller';
import { HomeAssistant } from '../../ha/types';
import thumbnailDetailsStyle from '../../scss/thumbnail-details.scss';
import { ViewItem } from '../../view/item';
import '../icon';
@customElement('advanced-camera-card-thumbnail-details')
export class AdvancedCameraCardThumbnailDetails extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public item?: ViewItem;
@property({ attribute: false })
public seek?: Date;
private _controller = new ThumbnailDetailsController();
protected willUpdate(changedProperties: PropertyValues): void {
if (['item', 'seek', 'cameraManager'].some((prop) => changedProperties.has(prop))) {
this._controller.calculate(this.cameraManager, this.item, this.seek);
}
}
protected render(): TemplateResult | void {
const heading = this._controller.getHeading();
const details = this._controller.getDetails();
return html`<div class="details">
${heading
? html` <div class="title">
<span title=${heading}>${heading}</span>
</div>`
: ``}
${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> `;
}
static get styles(): CSSResult {
return unsafeCSS(thumbnailDetailsStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-thumbnail-details': AdvancedCameraCardThumbnailDetails;
}
}
@@ -0,0 +1,94 @@
import {
CSSResult,
LitElement,
PropertyValues,
TemplateResult,
html,
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 { ThumbnailFeatureController } from '../../../components-lib/thumbnail/feature/controller';
import { HomeAssistant } from '../../../ha/types';
import thumbnailFeatureStyle from '../../../scss/thumbnail-feature.scss';
import { ViewItem } from '../../../view/item';
import '../../icon.js';
import './thumbnail.js';
@customElement('advanced-camera-card-thumbnail-feature')
export class AdvancedCameraCardThumbnailFeature extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public item?: ViewItem;
@property({ attribute: false })
public hasDetails?: boolean;
private _controller = new ThumbnailFeatureController();
protected willUpdate(changedProperties: PropertyValues): void {
if (
['item', 'hasDetails', 'cameraManager'].some((prop) => changedProperties.has(prop))
) {
this._controller.calculate(this.cameraManager, this.item, this.hasDetails);
}
}
protected render(): TemplateResult | void {
const title = this._controller.getTitle();
const subtitles = this._controller.getSubtitles();
const iconClasses = classMap({
background: title || subtitles.length,
});
const thumbnailClass = this._controller.getThumbnailClass();
const thumbnailClasses = classMap({
...(thumbnailClass && { [thumbnailClass]: true }),
});
return html`
${this._controller.getThumbnail()
? html` <advanced-camera-card-thumbnail-feature-thumbnail
class="${thumbnailClasses}"
.hass=${this.hass}
.thumbnail=${this._controller.getThumbnail()}
aria-label=${this._controller.getTitle() ?? ''}
title=${this._controller.getTitle() ?? ''}
></advanced-camera-card-thumbnail-feature-thumbnail>`
: this._controller.getIcon()
? html`<advanced-camera-card-icon
class="${iconClasses}"
.icon=${{ icon: this._controller.getIcon() }}
></advanced-camera-card-icon>`
: ''}
${title || subtitles.length
? html`
${title ? html`<div class="title">${title}</div>` : ''}
${subtitles.length
? html`<div>
${subtitles.map(
(subtitle) => html`<div class="subtitle">${subtitle}</div>`,
)}
</div>`
: ''}
`
: html``}
`;
}
static get styles(): CSSResult {
return unsafeCSS(thumbnailFeatureStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-thumbnail-feature': AdvancedCameraCardThumbnailFeature;
}
}
@@ -0,0 +1,105 @@
import { Task, TaskStatus } from '@lit-labs/task';
import {
CSSResult,
LitElement,
PropertyValues,
TemplateResult,
html,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { HomeAssistant } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import thumbnailFeatureThumbnailStyle from '../../../scss/thumbnail-feature-thumbnail.scss';
import { renderTask } from '../../../utils/task';
import {
FetchThumbnailTaskArgs,
createFetchThumbnailTask,
} from '../../../utils/thumbnail';
@customElement('advanced-camera-card-thumbnail-feature-thumbnail')
export class AdvancedCameraCardThumbnailFeatureThumbnail extends LitElement {
@property({ attribute: false })
public thumbnail?: string;
@property({ attribute: false })
public hass?: HomeAssistant;
protected _embedThumbnailTask?: Task<FetchThumbnailTaskArgs, string | null>;
// Only load thumbnails on view in case there is a very large number of them.
protected _intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this),
);
connectedCallback(): void {
super.connectedCallback();
this._intersectionObserver.observe(this);
}
disconnectedCallback(): void {
this._intersectionObserver.disconnect();
super.disconnectedCallback();
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('thumbnail')) {
this._embedThumbnailTask = createFetchThumbnailTask(
this,
() => this.hass,
() => this.thumbnail,
false,
);
// Reset the observer so the initial intersection handler call will set
// the visibility correctly.
this._intersectionObserver.unobserve(this);
this._intersectionObserver.observe(this);
}
}
protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
if (
this._embedThumbnailTask?.status === TaskStatus.INITIAL &&
entries.some((entry) => entry.isIntersecting)
) {
this._embedThumbnailTask?.run();
}
}
protected render(): TemplateResult | void {
const imageOff = html`<advanced-camera-card-icon
.icon=${{ icon: 'mdi:image-off' }}
title=${localize('thumbnail.no_thumbnail')}
></advanced-camera-card-icon> `;
if (!this._embedThumbnailTask) {
return imageOff;
}
return html`${this.thumbnail
? renderTask(
this._embedThumbnailTask,
(embeddedThumbnail: string | null) =>
embeddedThumbnail ? html`<img src="${embeddedThumbnail}" />` : html``,
{
inProgressFunc: () =>
html`<advanced-camera-card-icon
.icon=${{ icon: 'mdi:image-refresh' }}
title=${localize('thumbnail.no_thumbnail')}
></advanced-camera-card-icon> `,
errorFunc: () => imageOff,
},
)
: imageOff} `;
}
static get styles(): CSSResult {
return unsafeCSS(thumbnailFeatureThumbnailStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-thumbnail-feature-thumbnail': AdvancedCameraCardThumbnailFeatureThumbnail;
}
}
+188
View File
@@ -0,0 +1,188 @@
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';
import './feature/thumbnail.js';
@customElement('advanced-camera-card-thumbnail')
export class AdvancedCameraCardThumbnail extends LitElement {
// Performance: During timeline scrubbing, hass may be updated continuously.
// As it is not needed for the thumbnail rendering itself, it does not trigger
// a re-render. The HomeAssistant object may be required for thumbnail signing
// (after initial signing the thumbnail is stored in a data URL, so the
// signing will not expire).
public hass?: HomeAssistant;
// Performance: During timeline scrubbing, the view will be updated
// continuously. As it is not needed for the thumbnail rendering itself, it
// does not trigger a re-render.
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: false })
public folderManager?: FoldersManager;
@property({ attribute: false })
public item?: ViewItem;
@property({ attribute: true, type: Boolean })
public details = false;
@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: false })
public seek?: Date;
/**
* Render the element.
* @returns A template to display to the user.
*/
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.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
aria-label=${this.item.getTitle() ?? ''}
.cameraManager=${this.cameraManager}
.hasDetails=${this.details}
.hass=${this.hass}
.item=${this.item}
>
</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}
.item=${this.item ?? undefined}
.cameraManager=${this.cameraManager}
.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>`
: ``}
`;
}
static get styles(): CSSResult {
return unsafeCSS(thumbnailStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-thumbnail': AdvancedCameraCardThumbnail;
}
}
+48 -37
View File
@@ -9,8 +9,7 @@ import {
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import isEqual from 'lodash-es/isEqual';
import throttle from 'lodash-es/throttle';
import { isEqual, throttle } from 'lodash-es';
import { ViewContext } from 'view';
import { DataSet } from 'vis-data/esnext';
import type {
@@ -31,6 +30,7 @@ import { CameraManager } from '../camera-manager/manager';
import { rangesOverlap } from '../camera-manager/range';
import { MediaQuery } from '../camera-manager/types';
import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly';
import { ViewItemManager } from '../card-controller/view/item-manager';
import { MergeContextViewModifier } from '../card-controller/view/modifiers/merge-context';
import { ViewManagerEpoch } from '../card-controller/view/types';
import {
@@ -56,25 +56,18 @@ import {
isTruthy,
setOrRemoveAttribute,
} from '../utils/basic';
import { findBestMediaIndex } from '../utils/find-best-media-index';
import { findBestMediaTimeIndex } from '../utils/find-best-media-time-index';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event';
import { ViewMedia } from '../view/media';
import { ViewMediaClassifier } from '../view/media-classifier';
import {
EventMediaQueries,
MediaQueries,
RecordingMediaQueries,
} from '../view/media-queries';
import {
MediaQueriesClassifier,
MediaQueriesType,
} from '../view/media-queries-classifier';
import { MediaQueriesResults } from '../view/media-queries-results';
import { ViewMedia } from '../view/item';
import { ViewItemClassifier } from '../view/item-classifier';
import { EventMediaQuery, MediaQueries, RecordingMediaQuery } from '../view/query';
import { QueryClassifier, QueryType } from '../view/query-classifier';
import { QueryResults } from '../view/query-results';
import { mergeViewContext } from '../view/view';
import './date-picker.js';
import { AdvancedCameraCardDatePicker, DatePickerEvent } from './date-picker.js';
import './icon';
import './thumbnail.js';
import './thumbnail/thumbnail.js';
interface AdvancedCameraCardGroupData {
id: string;
@@ -112,6 +105,7 @@ interface ThumbnailDataRequest {
cameraConfig?: CameraConfig;
media?: ViewMedia;
viewManagerEpoch?: ViewManagerEpoch;
viewItemManager?: ViewItemManager;
}
class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
@@ -119,7 +113,7 @@ class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
const TIMELINE_TARGET_BAR_ID = 'target_bar';
/**
* A simgple thumbnail wrapper class for use in the timeline where Lit data
* A simple thumbnail wrapper class for use in the timeline where Lit data
* bindings are not available.
*/
@customElement('advanced-camera-card-timeline-thumbnail')
@@ -166,6 +160,7 @@ export class AdvancedCameraCardTimelineThumbnail extends LitElement {
!dataRequest.hass ||
!dataRequest.cameraManager ||
!dataRequest.cameraConfig ||
!dataRequest.viewItemManager ||
!dataRequest.media ||
!dataRequest.viewManagerEpoch
) {
@@ -175,7 +170,8 @@ export class AdvancedCameraCardTimelineThumbnail extends LitElement {
return html` <advanced-camera-card-thumbnail
.hass=${dataRequest.hass}
.cameraManager=${dataRequest.cameraManager}
.media=${dataRequest.media}
.viewItemManager=${dataRequest.viewItemManager}
.item=${dataRequest.media}
.viewManagerEpoch=${dataRequest.viewManagerEpoch}
?details=${this.details}
>
@@ -210,6 +206,9 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@@ -275,6 +274,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
request.detail.hass = this.hass;
request.detail.cameraConfig = cameraConfig;
request.detail.cameraManager = this.cameraManager;
request.detail.viewItemManager = this.viewItemManager;
request.detail.media = media;
request.detail.viewManagerEpoch = this.viewManagerEpoch;
}
@@ -477,13 +477,13 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
}
const canSeek = this._shouldSupportSeeking();
let newResults: MediaQueriesResults | null = null;
let newResults: QueryResults | null = null;
if (panMode === 'seek') {
newResults = results
.clone()
.selectBestResult(
(mediaArray) => findBestMediaIndex(mediaArray, targetTime, view?.camera),
(mediaArray) => findBestMediaTimeIndex(mediaArray, targetTime, view?.camera),
{
allCameras: true,
main: true,
@@ -492,9 +492,12 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
} else if (panMode === 'seek-in-camera') {
newResults = results
.clone()
.selectBestResult((mediaArray) => findBestMediaIndex(mediaArray, targetTime), {
cameraID: view.camera,
})
.selectBestResult(
(mediaArray) => findBestMediaTimeIndex(mediaArray, targetTime),
{
cameraID: view.camera,
},
)
.promoteCameraSelectionToMainSelection(view.camera);
} else if (panMode === 'seek-in-media') {
newResults = results;
@@ -506,7 +509,10 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
: 'media'
: view.view;
const selectedCamera = newResults?.getSelectedResult()?.getCameraID();
const selectedItem = newResults?.getSelectedResult();
const selectedCamera = ViewItemClassifier.isMedia(selectedItem)
? selectedItem.getCameraID()
: null;
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
@@ -675,7 +681,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
await this._timelineSource?.refresh(this._getPrefetchWindow(properties));
const queryType = MediaQueriesClassifier.getQueriesType(view.query);
const queryType = QueryClassifier.getQueryType(view.query);
if (!queryType) {
return;
}
@@ -702,7 +708,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
}
protected _createMediaQueries(
type: MediaQueriesType,
type: QueryType,
options?: {
window?: TimelineWindow;
},
@@ -717,11 +723,11 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
if (type === 'event') {
const queries = this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow);
return queries ? new EventMediaQueries(queries) : null;
return queries ? new EventMediaQuery(queries) : null;
} else if (type === 'recording') {
const queries =
this._timelineSource.getTimelineRecordingQueries(cacheFriendlyWindow);
return queries ? new RecordingMediaQueries(queries) : null;
return queries ? new RecordingMediaQuery(queries) : null;
}
return null;
}
@@ -877,8 +883,8 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
!selectedIDs.includes(second.id) &&
!!firstMedia &&
!!secondMedia &&
ViewMediaClassifier.isEvent(firstMedia) &&
ViewMediaClassifier.isEvent(secondMedia) &&
ViewItemClassifier.isEvent(firstMedia) &&
ViewItemClassifier.isEvent(secondMedia) &&
firstMedia.isGroupableWith(secondMedia)
);
},
@@ -938,7 +944,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
...(view.isGrid() && { allCameras: true }),
}) ?? []
)
.filter((media) => ViewMediaClassifier.isEvent(media))
.filter((media) => ViewItemClassifier.isEvent(media))
.map((media) => media.getID())
.filter(isTruthy);
}
@@ -961,10 +967,11 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
// perfectly center on the media.
let desiredWindow = timelineWindow;
const media = view.queryResults?.getSelectedResult();
const item = view.queryResults?.getSelectedResult();
const media = item && ViewItemClassifier.isMedia(item) ? item : null;
const mediaStartTime = media?.getStartTime() ?? null;
const mediaEndTime = media?.getEndTime() ?? null;
const mediaIsEvent = media ? ViewMediaClassifier.isEvent(media) : false;
const mediaIsEvent = media ? ViewItemClassifier.isEvent(media) : false;
const mediaWindow: TimelineWindow | null =
media && mediaStartTime
@@ -1044,7 +1051,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
// Also don't generate thumbnails in mini-timelines (they will already have
// been generated).
const queryType = MediaQueriesClassifier.getQueriesType(view.query);
const queryType = QueryClassifier.getQueryType(view.query);
if (!queryType) {
return;
}
@@ -1080,15 +1087,19 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
protected _alreadyHasAcceptableMediaQuery(freshMediaQuery: MediaQueries): boolean {
const view = this.viewManagerEpoch?.manager.getView();
const query = view?.query;
const currentQueries = view?.query?.getQueries();
if (!this.cameraManager || !query || !QueryClassifier.isMediaQuery(query)) {
return false;
}
const currentQueries = query?.getQuery();
const currentResultTimestamp = view?.queryResults?.getResultsTimestamp();
return (
!!this.cameraManager &&
!!currentQueries &&
!!currentResultTimestamp &&
!!view?.query?.isSupersetOf(freshMediaQuery) &&
!!query?.isSupersetOf(freshMediaQuery) &&
this.cameraManager.areMediaQueriesResultsFresh<MediaQuery>(
currentQueries,
currentResultTimestamp,
+5
View File
@@ -1,6 +1,7 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../camera-manager/manager';
import { ViewItemManager } from '../card-controller/view/item-manager';
import { ViewManagerEpoch } from '../card-controller/view/types';
import { TimelineConfig } from '../config/schema/timeline';
import { CardWideConfig } from '../config/schema/types';
@@ -23,6 +24,9 @@ export class AdvancedCameraCardTimeline extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@@ -38,6 +42,7 @@ export class AdvancedCameraCardTimeline extends LitElement {
.timelineConfig=${this.timelineConfig}
.thumbnailConfig=${this.timelineConfig.controls.thumbnails}
.cameraManager=${this.cameraManager}
.viewItemManager=${this.viewItemManager}
.cameraIDs=${this.cameraManager?.getStore().getCameraIDsWithCapability({
anyCapabilities: ['clips', 'snapshots', 'recordings'],
})}
+97 -59
View File
@@ -16,6 +16,7 @@ import { MediaActionsController } from '../../components-lib/media-actions-contr
import { TransitionEffect } from '../../config/schema/common/transition-effect.js';
import { CardWideConfig, configDefaults } from '../../config/schema/types.js';
import { ViewerConfig } from '../../config/schema/viewer.js';
import { ResolvedMediaCache } from '../../ha/resolved-media.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import '../../patches/ha-hls-player.js';
@@ -26,9 +27,9 @@ import { contentsChanged, setOrRemoveAttribute } from '../../utils/basic.js';
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
import { ResolvedMediaCache } from '../../utils/ha/resolved-media.js';
import { getTextDirection } from '../../utils/text-direction.js';
import { ViewMedia } from '../../view/media.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { ViewMedia } from '../../view/item.js';
import '../carousel';
import type { EmblaCarouselPlugins } from '../carousel.js';
import { renderMessage } from '../message.js';
@@ -80,33 +81,13 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
public showControls = true;
@state()
protected _selected = 0;
protected _selected: number | null = null;
protected _media: ViewMedia[] | null = null;
protected _mediaActionsController = new MediaActionsController();
protected _loadedMediaPlayerController: MediaPlayerController | null = null;
protected _refCarousel: Ref<HTMLElement> = createRef();
updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has('viewManagerEpoch')) {
// 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).
if (
this.viewManagerEpoch?.manager.getView()?.context?.mediaViewer !==
this.viewManagerEpoch?.oldView?.context?.mediaViewer
) {
this._seekHandler();
}
}
if (this._refCarousel.value) {
this._mediaActionsController.setRoot(this._refCarousel.value);
}
}
public connectedCallback(): void {
super.connectedCallback();
@@ -145,7 +126,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
*/
protected _getMediaNeighbors(): MediaNeighbors | null {
const mediaCount = this._media?.length ?? 0;
if (!this._media) {
if (!this._media || this._selected === null) {
return null;
}
@@ -180,16 +161,20 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
// index).
return;
}
const newResults = view?.queryResults
?.clone()
.selectIndex(index, this.viewFilterCameraID);
.selectResultIfFound((item) => item === this._media?.[index], {
main: true,
cameraID: this.viewFilterCameraID,
});
if (!newResults) {
return;
}
const cameraID = newResults
.getSelectedResult(this.viewFilterCameraID)
?.getCameraID();
const selectedItem = newResults.getSelectedResult(this.viewFilterCameraID);
const cameraID = ViewItemClassifier.isMedia(selectedItem)
? selectedItem.getCameraID()
: null;
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
@@ -243,33 +228,43 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
}
if (changedProps.has('viewManagerEpoch')) {
const view = this.viewManagerEpoch?.manager.getView();
const newMedia = view?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
const newSelected =
view?.queryResults?.getSelectedIndex(this.viewFilterCameraID) ?? 0;
const newSeek = view?.context?.mediaViewer?.seek;
const newView = this.viewManagerEpoch?.manager.getView();
if (newMedia !== this._media || newSelected !== this._selected || !newSeek) {
if (!newView?.context?.mediaViewer?.seek) {
setOrRemoveAttribute(this, false, 'unseekable');
this._media = newMedia;
this._selected = newSelected;
}
if (!newMedia?.length) {
// No media will be rendered.
this._mediaActionsController.unsetTarget();
} else {
if (this.viewFilterCameraID) {
this._mediaActionsController.setTarget(
newSelected,
// Camera in this carousel is only selected if the camera from the
// view matches the filtered camera.
view?.camera === this.viewFilterCameraID,
);
} else {
// Carousel is not filtered, so the targeted camera is always selected.
this._mediaActionsController.setTarget(newSelected, true);
}
const oldView = this.viewManagerEpoch?.oldView;
const oldItems =
oldView?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
const newItems =
newView?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
let resetMedia = false;
if (!this._media || oldItems !== newItems) {
this._media =
newItems?.filter((item) => ViewItemClassifier.isMedia(item)) ?? null;
resetMedia = true;
}
const oldSelectedItem = oldView?.queryResults?.getSelectedResult(
this.viewFilterCameraID,
);
const newSelectedItem = newView?.queryResults?.getSelectedResult(
this.viewFilterCameraID,
);
// _selected is an index, it needs to be updated if either the selected
// item or the media changes.
if (oldSelectedItem !== newSelectedItem || resetMedia) {
const newSelected =
this._media?.findIndex((item) => item === newSelectedItem) ?? null;
// If there's no selected item, just choose the last (most recent one) to
// avoid rendering a blank. This could happen if the selected item was a
// folder.
this._selected =
newSelected ??
(this._media && this._media.length ? this._media.length - 1 : null);
}
}
}
@@ -327,12 +322,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
});
}
// If there's no selected media, just choose the last (most recent one) to
// avoid rendering a blank. This situation should not occur in practice, as
// this view should not be called without a selected media.
const selectedMedia = this._media[this._selected] ?? this._media[mediaCount - 1];
if (!this.hass || !this.cameraManager || !selectedMedia) {
if (!this.hass || !this.cameraManager || this._selected === null) {
return;
}
@@ -379,13 +369,61 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
`;
}
updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
const rootChanged = this._refCarousel.value
? this._mediaActionsController.setRoot(this._refCarousel.value)
: false;
// If the view has changed, or if the media actions controller has just been
// initialized, then call the necessary media action.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1626
if (rootChanged || changedProperties.has('viewManagerEpoch')) {
this._setMediaTarget();
}
if (changedProperties.has('viewManagerEpoch')) {
// 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).
if (
this.viewManagerEpoch?.manager.getView()?.context?.mediaViewer !==
this.viewManagerEpoch?.oldView?.context?.mediaViewer
) {
this._seekHandler();
}
}
}
protected _setMediaTarget(): void {
if (!this._media?.length || this._selected === null) {
this._mediaActionsController.unsetTarget();
} else {
this._mediaActionsController.setTarget(
this._selected,
// Camera in this carousel is only selected if the camera from the view
// matches the filtered camera.
this.viewFilterCameraID
? this.viewManagerEpoch?.manager.getView()?.camera === this.viewFilterCameraID
: true,
);
}
}
/**
* Fire a media show event when a slide is selected.
*/
protected async _seekHandler(): Promise<void> {
const view = this.viewManagerEpoch?.manager.getView();
const seek = view?.context?.mediaViewer?.seek;
if (!this.hass || !seek || !this._media || !this._loadedMediaPlayerController) {
if (
!this.hass ||
!seek ||
!this._media ||
!this._loadedMediaPlayerController ||
this._selected === null
) {
return;
}
const selectedMedia = this._media[this._selected];
+1 -1
View File
@@ -14,9 +14,9 @@ import { MediaGridSelected } from '../../components-lib/media-grid-controller.js
import { CardWideConfig } from '../../config/schema/types.js';
import { ViewerConfig } from '../../config/schema/viewer.js';
import { HomeAssistant } from '../../ha/types.js';
import { ResolvedMediaCache } from '../../ha/resolved-media.js';
import '../../patches/ha-hls-player.js';
import basicBlockStyle from '../../scss/basic-block.scss';
import { ResolvedMediaCache } from '../../utils/ha/resolved-media.js';
import './carousel';
@customElement('advanced-camera-card-viewer-grid')
+23 -3
View File
@@ -1,14 +1,22 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { ViewerConfig } from '../../config/schema/viewer.js';
import { ResolvedMediaCache } from '../../ha/resolved-media.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import '../../patches/ha-hls-player.js';
import viewerStyle from '../../scss/viewer.scss';
import { ResolvedMediaCache } from '../../utils/ha/resolved-media.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { renderMessage } from '../message.js';
import './grid';
@@ -42,6 +50,18 @@ export class AdvancedCameraCardViewer extends LitElement {
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: 'empty', reflect: true, type: Boolean })
public isEmpty = false;
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has('viewManagerEpoch')) {
const view = this.viewManagerEpoch?.manager.getView();
this.isEmpty = !view?.queryResults
?.getResults()
?.filter((result) => ViewItemClassifier.isMedia(result)).length;
}
}
protected render(): TemplateResult | void {
if (
!this.hass ||
@@ -53,7 +73,7 @@ export class AdvancedCameraCardViewer extends LitElement {
return;
}
if (!this.viewManagerEpoch.manager.getView()?.queryResults?.hasResults()) {
if (this.isEmpty) {
// Directly render an error message (instead of dispatching it upwards)
// to preserve the mini-timeline if the user pans into an area with no
// media.
+22 -26
View File
@@ -16,31 +16,24 @@ import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { ViewerConfig } from '../../config/schema/viewer.js';
import { HomeAssistant } from '../../ha/types.js';
import '../../patches/ha-hls-player.js';
import viewerProviderStyle from '../../scss/viewer-provider.scss';
import {
MediaPlayer,
MediaPlayerController,
MediaPlayerElement,
ResolvedMedia,
} from '../../types.js';
import { aspectRatioToString, errorToConsole } from '../../utils/basic.js';
import {
canonicalizeHAURL,
homeAssistantSignPath,
isHARelativeURL,
} from '../../utils/ha/index.js';
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media.js';
import { canonicalizeHAURL } from '../../ha/canonical-url.js';
import { isHARelativeURL } from '../../ha/is-ha-relative-url.js';
import { ResolvedMediaCache, resolveMedia } from '../../ha/resolved-media.js';
import { homeAssistantSignPath } from '../../ha/sign-path.js';
import { HomeAssistant, ResolvedMedia } from '../../ha/types.js';
import {
addDynamicProxyURL,
getWebProxiedURL,
shouldUseWebProxy,
} from '../../utils/ha/web-proxy.js';
} from '../../ha/web-proxy.js';
import '../../patches/ha-hls-player.js';
import viewerProviderStyle from '../../scss/viewer-provider.scss';
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js';
import { aspectRatioToString, errorToConsole } from '../../utils/basic.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
import { ViewMediaClassifier } from '../../view/media-classifier.js';
import { MediaQueriesClassifier } from '../../view/media-queries-classifier.js';
import { VideoContentType, ViewMedia } from '../../view/media.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { VideoContentType, ViewMedia } from '../../view/item.js';
import { QueryClassifier } from '../../view/query-classifier.js';
import '../image-player.js';
import { renderProgressIndicator } from '../progress-indicator.js';
import '../video-player.js';
@@ -93,8 +86,8 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
!this.media ||
// If this specific media item has no clip, then do nothing (even if all
// the other media items do).
!ViewMediaClassifier.isEvent(this.media) ||
!MediaQueriesClassifier.areEventQueries(view.query)
!ViewItemClassifier.isEvent(this.media) ||
!QueryClassifier.isEventQuery(view.query)
) {
return;
}
@@ -103,7 +96,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
const clipQuery = view.query.clone();
clipQuery.convertToClipsQueries();
const queries = clipQuery.getQueries();
const queries = clipQuery.getQuery();
if (!queries) {
return;
}
@@ -154,7 +147,8 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
return;
}
const camera = this.cameraManager?.getStore().getCamera(this.media.getCameraID());
const cameraID = this.media.getCameraID();
const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null;
const proxyConfig = camera?.getProxyConfig();
if (proxyConfig && shouldUseWebProxy(this.hass, proxyConfig, 'media')) {
@@ -224,7 +218,9 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
}
const cameraID = this.media.getCameraID();
const mediaID = this.media.getID() ?? undefined;
const cameraConfig = this.cameraManager?.getStore().getCameraConfig(cameraID);
const cameraConfig = cameraID
? this.cameraManager?.getStore().getCameraConfig(cameraID) ?? null
: null;
const view = this.viewManagerEpoch?.manager.getView();
return this.viewerConfig?.zoomable
@@ -269,7 +265,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
// Note: crossorigin="anonymous" is required on <video> below in order to
// allow screenshot of motionEye videos which currently go cross-origin.
return this._useZoomIfRequired(html`
${ViewMediaClassifier.isVideo(this.media)
${ViewItemClassifier.isVideo(this.media)
? this.media.getVideoContentType() === VideoContentType.HLS
? html`<advanced-camera-card-ha-hls-player
${ref(this._refProvider)}
+24 -7
View File
@@ -10,13 +10,14 @@ import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
import { MicrophoneState } from '../card-controller/types.js';
import { ViewItemManager } from '../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import { AdvancedCameraCardConfig, CardWideConfig } from '../config/schema/types.js';
import { RawAdvancedCameraCardConfig } from '../config/types.js';
import { DeviceRegistryManager } from '../ha/registry/device/index.js';
import { ResolvedMediaCache } from '../ha/resolved-media.js';
import { HomeAssistant } from '../ha/types.js';
import viewsStyle from '../scss/views.scss';
import { DeviceRegistryManager } from '../utils/ha/registry/device/index.js';
import { ResolvedMediaCache } from '../utils/ha/resolved-media.js';
import './surround.js';
// As a special case: The diagnostics view is not dynamically loaded in case
@@ -34,6 +35,9 @@ export class AdvancedCameraCardViews extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: false })
public config?: AdvancedCameraCardConfig;
@@ -64,14 +68,16 @@ export class AdvancedCameraCardViews extends LitElement {
if (view?.is('live') || this._shouldLivePreload()) {
import('./live/index.js');
}
if (view?.isGalleryView()) {
import('./gallery.js');
if (view?.isMediaGalleryView() && !view.is('folder')) {
import('./gallery/media-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('folder')) {
import('./gallery/folder-gallery.js');
}
}
@@ -138,6 +144,7 @@ export class AdvancedCameraCardViews extends LitElement {
.thumbnailConfig=${!this.hide ? thumbnailConfig : undefined}
.timelineConfig=${!this.hide ? miniTimelineConfig : undefined}
.cameraManager=${this.cameraManager}
.viewItemManager=${this.viewItemManager}
.cardWideConfig=${this.cardWideConfig}
>
${!this.hide && view?.is('image') && cameraConfig
@@ -150,15 +157,16 @@ export class AdvancedCameraCardViews extends LitElement {
>
</advanced-camera-card-image>`
: ``}
${!this.hide && view?.isGalleryView()
? html` <advanced-camera-card-gallery
${!this.hide && view?.isMediaGalleryView() && !view.is('folder')
? html` <advanced-camera-card-media-gallery
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.galleryConfig=${this.config.media_gallery}
.cameraManager=${this.cameraManager}
.viewItemManager=${this.viewItemManager}
.cardWideConfig=${this.cardWideConfig}
>
</advanced-camera-card-gallery>`
</advanced-camera-card-media-gallery>`
: ``}
${!this.hide && view?.isViewerView()
? html`
@@ -179,6 +187,7 @@ export class AdvancedCameraCardViews extends LitElement {
.viewManagerEpoch=${this.viewManagerEpoch}
.timelineConfig=${this.config.timeline}
.cameraManager=${this.cameraManager}
.viewItemManager=${this.viewItemManager}
.cardWideConfig=${this.cardWideConfig}
>
</advanced-camera-card-timeline>`
@@ -191,6 +200,14 @@ export class AdvancedCameraCardViews extends LitElement {
>
</advanced-camera-card-diagnostics>`
: ``}
${!this.hide && view?.is('folder')
? html` <advanced-camera-card-folder-gallery
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewItemManager=${this.viewItemManager}
.galleryConfig=${this.config.media_gallery}
></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.