diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 9446c135..0d0aa9f3 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -268,13 +268,14 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { const nativeQuery: NativeFrigateEventQuery = { instance_id: instanceID, cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs)), - ...(query.what && { label: Array.from(query.what) }), - ...(query.where && { zone: Array.from(query.where) }), + ...(query.what && { labels: Array.from(query.what) }), + ...(query.where && { zones: Array.from(query.where) }), ...(query.end && { before: Math.floor(query.end.getTime() / 1000) }), ...(query.start && { after: Math.floor(query.start.getTime() / 1000) }), ...(query.limit && { limit: query.limit }), ...(query.hasClip && { has_clip: query.hasClip }), ...(query.hasSnapshot && { has_snapshot: query.hasSnapshot }), + ...(query.favorite && { favorites: query.favorite }), limit: query?.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, }; diff --git a/src/camera/frigate/requests.ts b/src/camera/frigate/requests.ts index f4473598..dcb75ec4 100644 --- a/src/camera/frigate/requests.ts +++ b/src/camera/frigate/requests.ts @@ -103,6 +103,7 @@ export interface NativeFrigateEventQuery { limit?: number; has_clip?: boolean; has_snapshot?: boolean; + favorites?: boolean; } /** diff --git a/src/camera/manager.ts b/src/camera/manager.ts index d85869fe..17142044 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -87,31 +87,31 @@ export class CameraManager { public generateDefaultEventQueries( cameraIDs: string | Set, - partialQuery: PartialEventQuery, + partialQuery?: PartialEventQuery, ): EventQuery[] | null { return this._generateDefaultQueries(cameraIDs, { - ...partialQuery, type: QueryType.Event, + ...partialQuery, }); } public generateDefaultRecordingQueries( cameraIDs: string | Set, - partialQuery: PartialRecordingQuery, + partialQuery?: PartialRecordingQuery, ): RecordingQuery[] | null { return this._generateDefaultQueries(cameraIDs, { - ...partialQuery, type: QueryType.Recording, + ...partialQuery, }); } public generateDefaultRecordingSegmentsQueries( cameraIDs: string | Set, - partialQuery: PartialRecordingSegmentsQuery, + partialQuery?: PartialRecordingSegmentsQuery, ): RecordingSegmentsQuery[] | null { return this._generateDefaultQueries(cameraIDs, { - ...partialQuery, type: QueryType.RecordingSegments, + ...partialQuery, }); } diff --git a/src/camera/types.ts b/src/camera/types.ts index a3a947f9..ebd61e80 100644 --- a/src/camera/types.ts +++ b/src/camera/types.ts @@ -86,6 +86,8 @@ export interface EventQuery extends MediaQuery { // Frigate equivalent: zone where?: Set; + + favorite?: boolean; } export type PartialEventQuery = Partial; diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 0d14c132..c73cfb1e 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -115,6 +115,8 @@ export class FrigateCardGallery extends LitElement { .hass=${this.hass} .cameras=${this.cameras} .cameraManager=${this.cameraManager} + .view=${this.view} + .mediaLimit=${GALLERY_MEDIA_CHUNK_SIZE} slot="right" > diff --git a/src/components/media-filter-core.ts b/src/components/media-filter-core.ts index 692edcde..458c2b6c 100644 --- a/src/components/media-filter-core.ts +++ b/src/components/media-filter-core.ts @@ -1,4 +1,11 @@ -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 { createRef, ref, Ref } from 'lit/directives/ref.js'; import { DateRange } from '../camera/range'; @@ -13,9 +20,9 @@ export interface ValueLabel { } export interface MediaFilterCoreSelection { - camera?: string[]; - what?: string[]; - where?: string[]; + cameraIDs?: Set; + what?: Set; + where?: Set; when?: MediaFilterCoreWhenSelection; favorite?: MediaFilterCoreFavoriteSelection; } @@ -25,13 +32,11 @@ type FilterElement = HTMLElement & { }; export enum MediaFilterCoreFavoriteSelection { - All = 'all', Favorite = 'favorite', NotFavorite = 'not-favorite', } export enum MediaFilterCoreWhen { - All = 'all', Today = 'today', Yesterday = 'yesterday', PastWeek = 'past-week', @@ -50,10 +55,10 @@ export class FrigateCardMediaFilterCore extends LitElement { public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - public whenOptions?: ValueLabel[]; + public cameraOptions?: ValueLabel[]; @property({ attribute: false }) - public cameraOptions?: ValueLabel[]; + public whenOptions?: ValueLabel[]; @property({ attribute: false }) public whatOptions?: ValueLabel[]; @@ -61,54 +66,27 @@ export class FrigateCardMediaFilterCore extends LitElement { @property({ attribute: false }) public whereOptions?: ValueLabel[]; - protected _refWhen: Ref> = createRef(); + @property({ attribute: false }) + public defaults?: MediaFilterCoreSelection; + + protected _cameraOptions?: ValueLabel[]; + protected _whenOptions?: ValueLabel[]; + protected _whatOptions?: ValueLabel[]; + protected _whereOptions?: ValueLabel[]; + protected _favoriteOptions: ValueLabel[]; + protected _refCamera: Ref> = createRef(); + protected _refWhen: Ref> = createRef(); protected _refWhat: Ref> = createRef(); protected _refWhere: Ref> = createRef(); protected _refFavorite: Ref> = createRef(); - protected _valueChangedHandler(ev: CustomEvent<{ value: unknown }>): void { - // Handler is called on initial load -- skip it. - if (!ev.detail.value) { - return; - } - const values: MediaFilterCoreSelection = { - ...(this._refWhen.value && { - when: this._refWhen.value.selectedItem?.value as MediaFilterCoreWhenSelection, - }), - ...(this._refCamera.value && { - camera: this._refCamera.value.selectedItem?.value - ? [this._refCamera.value.selectedItem?.value] - : undefined, - }), - ...(this._refWhat.value && { - what: this._refWhat.value.selectedItem?.value - ? [this._refWhat.value.selectedItem?.value] - : undefined, - }), - ...(this._refWhere.value && { - where: this._refWhere.value.selectedItem?.value - ? [this._refWhere.value.selectedItem?.value] - : undefined, - }), - ...(this._refFavorite.value && { - favorite: this._refFavorite.value.selectedItem?.value as - | MediaFilterCoreFavoriteSelection - | undefined, - }), - }; - dispatchFrigateCardEvent(this, 'media-filter-core:change', values); - } - - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - const favoriteOptions: ValueLabel[] = [ + constructor() { + super(); + this._favoriteOptions = [ { - value: MediaFilterCoreFavoriteSelection.All, + value: undefined, label: localize('media_filter.all'), }, { @@ -120,39 +98,100 @@ export class FrigateCardMediaFilterCore extends LitElement { label: localize('media_filter.not_favorite'), }, ]; + } - // Time based options are not pre-computed here to ensure relative dates - // (e.g. 'today') are always calculated when activated not when rendered. - const whenOptions = [ - { - value: { selection: MediaFilterCoreWhen.All }, - label: localize('media_filter.all'), - }, - { - value: { selection: MediaFilterCoreWhen.Today }, - label: localize('media_filter.today'), - }, - { - value: { selection: MediaFilterCoreWhen.Yesterday }, - label: localize('media_filter.yesterday'), - }, - { - value: { selection: MediaFilterCoreWhen.PastWeek }, - label: localize('media_filter.past_week'), - }, - { - value: { selection: MediaFilterCoreWhen.PastMonth }, - label: localize('media_filter.past_month'), - }, - ...(this.whenOptions ?? []), - ]; + protected _valueChangedHandler(ev: CustomEvent<{ value: unknown }>): void { + // Handler is called on initial load -- skip it. + if (!ev.detail.value) { + return; + } + const values: MediaFilterCoreSelection = { + ...(this._refCamera.value && + this._refCamera.value.selectedItem?.value && { + cameraIDs: new Set([this._refCamera.value.selectedItem.value]), + }), + ...(this._refWhen.value && + this._refWhen.value.selectedItem?.value && { + when: this._refWhen.value.selectedItem?.value, + }), + ...(this._refWhat.value && + this._refWhat.value.selectedItem?.value && { + what: new Set([this._refWhat.value.selectedItem.value]), + }), + ...(this._refWhere.value && + this._refWhere.value.selectedItem?.value && { + where: new Set([this._refWhere.value.selectedItem?.value]), + }), + ...(this._refFavorite.value && + this._refFavorite.value.selectedItem?.value !== undefined && { + favorite: this._refFavorite.value.selectedItem?.value, + }), + }; + dispatchFrigateCardEvent(this, 'media-filter-core:change', values); + } + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('cameraOptions')) { + this._cameraOptions = [ + { + value: undefined, + label: localize('media_filter.all'), + }, + ...(this.cameraOptions ?? []), + ]; + } + if (changedProps.has('whenOptions')) { + // Time based options are not pre-computed here to ensure relative dates + // (e.g. 'today') are always calculated when activated not when rendered. + this._whenOptions = [ + { + value: undefined, + label: localize('media_filter.all'), + }, + { + value: { selection: MediaFilterCoreWhen.Today }, + label: localize('media_filter.today'), + }, + { + value: { selection: MediaFilterCoreWhen.Yesterday }, + label: localize('media_filter.yesterday'), + }, + { + value: { selection: MediaFilterCoreWhen.PastWeek }, + label: localize('media_filter.past_week'), + }, + { + value: { selection: MediaFilterCoreWhen.PastMonth }, + label: localize('media_filter.past_month'), + }, + ...(this.whenOptions ?? []), + ]; + } + if (changedProps.has('whatOptions')) { + this._whatOptions = [ + { value: undefined, label: localize('media_filter.all') }, + ...(this.whatOptions ?? []), + ]; + } + if (changedProps.has('whereOptions')) { + this._whereOptions = [ + { value: undefined, label: localize('media_filter.all') }, + ...(this.whereOptions ?? []), + ]; + } + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { return html` @@ -161,7 +200,7 @@ export class FrigateCardMediaFilterCore extends LitElement { ${ref(this._refCamera)} .hass=${this.hass} .label=${localize('media_filter.camera')} - .items=${this.cameraOptions} + .items=${this._cameraOptions} .allowCustomValue=${false} @value-changed=${this._valueChangedHandler.bind(this)} >` @@ -171,7 +210,7 @@ export class FrigateCardMediaFilterCore extends LitElement { ${ref(this._refWhat)} .hass=${this.hass} .label=${localize('media_filter.what')} - .items=${this.whatOptions} + .items="${this._whatOptions}" .allowCustomValue=${false} @value-changed=${this._valueChangedHandler.bind(this)} >` @@ -181,7 +220,7 @@ export class FrigateCardMediaFilterCore extends LitElement { ${ref(this._refWhere)} .hass=${this.hass} .label=${localize('media_filter.where')} - .items=${this.whereOptions} + .items=${this._whereOptions} .allowCustomValue=${false} @value-changed=${this._valueChangedHandler.bind(this)} >` @@ -190,7 +229,7 @@ export class FrigateCardMediaFilterCore extends LitElement { ${ref(this._refFavorite)} .hass=${this.hass} .label=${localize('media_filter.favorite')} - .items=${favoriteOptions} + .items=${this._favoriteOptions} .allowCustomValue=${false} @value-changed=${this._valueChangedHandler.bind(this)} > diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index 8841720e..9ff2c354 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -1,8 +1,9 @@ -import { sub } from 'date-fns'; +import sub from 'date-fns/sub'; import endOfDay from 'date-fns/endOfDay'; import endOfYesterday from 'date-fns/endOfYesterday'; import endOfToday from 'date-fns/esm/endOfToday'; import startOfToday from 'date-fns/esm/startOfToday'; +import startOfDay from 'date-fns/startOfDay'; import startOfYesterday from 'date-fns/startOfYesterday'; import { css, @@ -17,6 +18,7 @@ import { CameraManager } from '../camera/manager'; import { DateRange } from '../camera/range'; import { CameraConfig, ExtendedHomeAssistant } from '../types'; import { getCameraTitle } from '../utils/camera'; +import { View } from '../view/view'; import { MediaFilterCoreFavoriteSelection, MediaFilterCoreSelection, @@ -26,6 +28,9 @@ import { } from './media-filter-core'; import './surround.js'; import './timeline-core.js'; +import { EventQuery, QueryType } from '../camera/types'; +import { EventMediaQueries } from '../view/media-queries'; +import { createViewForEvents } from '../utils/media-to-view.js'; @customElement('frigate-card-media-filter') export class FrigateCardMediaFilter extends LitElement { @@ -38,6 +43,12 @@ export class FrigateCardMediaFilter extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public view?: View; + + @property({ attribute: false }) + public mediaLimit?: number; + protected _cameraOptions: ValueLabel[] = []; protected _convertWhenToDateRange( @@ -46,9 +57,6 @@ export class FrigateCardMediaFilter extends LitElement { if (!value) { return null; } - if (value.selection === MediaFilterCoreWhen.Custom && value.custom) { - return value.custom; - } const now = new Date(); switch (value.selection) { case MediaFilterCoreWhen.Today: @@ -56,9 +64,13 @@ export class FrigateCardMediaFilter extends LitElement { case MediaFilterCoreWhen.Yesterday: return { start: startOfYesterday(), end: endOfYesterday() }; case MediaFilterCoreWhen.PastWeek: - return { start: sub(now, { days: 7 }), end: endOfDay(now) }; + return { start: startOfDay(sub(now, { days: 7 })), end: endOfDay(now) }; case MediaFilterCoreWhen.PastMonth: - return { start: sub(now, { months: 1 }), end: endOfDay(now) }; + return { start: startOfDay(sub(now, { months: 1 })), end: endOfDay(now) }; + case MediaFilterCoreWhen.Custom: + if (value.custom) { + return value.custom; + } } return null; } @@ -66,22 +78,44 @@ export class FrigateCardMediaFilter extends LitElement { protected _convertFavoriteToBoolean( value?: MediaFilterCoreFavoriteSelection, ): boolean | null { - if (!value || value === MediaFilterCoreFavoriteSelection.All) { + if (!value) { return null; } return value === MediaFilterCoreFavoriteSelection.Favorite; } - protected _mediaFilterHandler(ev: CustomEvent): void { - const convertedTime = this._convertWhenToDateRange(ev.detail.when); - const convertedFavorite = this._convertFavoriteToBoolean(ev.detail.favorite); - const details = { - ...ev.detail, - ...(convertedTime && { when: convertedTime }), - ...(convertedFavorite && { favorite: convertedFavorite }), + protected async _mediaFilterHandler( + ev: CustomEvent, + ): Promise { + if (!this.cameras || !this.cameraManager || !this.hass || !this.view) { + return; + } + const mediaFilter = ev.detail; + const convertedTime = this._convertWhenToDateRange(mediaFilter.when); + const convertedFavorite = this._convertFavoriteToBoolean(mediaFilter.favorite); + + const query: EventQuery = { + type: QueryType.Event, + cameraIDs: mediaFilter.cameraIDs ?? new Set(this.cameras.keys()), + ...(mediaFilter.what && { what: mediaFilter.what }), + ...(mediaFilter.where && { where: mediaFilter.where }), + ...(convertedFavorite !== null && { favorite: convertedFavorite }), + ...(convertedTime && { start: convertedTime.start, end: convertedTime.end }), + ...(this.mediaLimit && { limit: this.mediaLimit }), }; - // TODO: remove. - console.debug('Received media filter choices:', details); + + ( + await createViewForEvents( + this, + this.hass, + this.cameraManager, + this.cameras, + this.view, + { + query: new EventMediaQueries([query]), + }, + ) + )?.dispatchChangeEvent(this); } protected willUpdate(changedProps: PropertyValues): void { diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index a5f7ab3c..dcdd7b52 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -1,6 +1,5 @@ import format from 'date-fns/format'; -import fromUnixTime from 'date-fns/fromUnixTime'; -import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { CSSResult, html, LitElement, PropertyValues, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { localize } from '../localize/localize.js'; @@ -12,9 +11,9 @@ import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { errorToConsole, getDurationString, prettifyTitle } from '../utils/basic.js'; import { getCameraTitle } from '../utils/camera.js'; import { renderTask } from '../utils/task.js'; -import { createFetchThumbnailTask } from '../utils/thumbnail.js'; +import { createFetchThumbnailTask, FetchThumbnailTaskArgs } from '../utils/thumbnail.js'; import { View } from '../view/view.js'; -import { TaskStatus } from '@lit-labs/task'; +import { Task, TaskStatus } from '@lit-labs/task'; import type { CameraConfig, ExtendedHomeAssistant } from '../types.js'; import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js'; @@ -32,12 +31,7 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; - protected _embedThumbnailTask = createFetchThumbnailTask( - this, - () => this.hass, - () => this.thumbnail, - false, - ); + protected _embedThumbnailTask?: Task; // Only load thumbnails on view in case there is a very large number of them. protected _intersectionObserver: IntersectionObserver; @@ -65,20 +59,38 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement { this._intersectionObserver.disconnect(); } + 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 && + this._embedThumbnailTask?.status === TaskStatus.INITIAL && entries.some((entry) => entry.isIntersecting) ) { - this._embedThumbnailTask.run(); + this._embedThumbnailTask?.run(); } } protected render(): TemplateResult | void { + if (!this._embedThumbnailTask) { + return; + } const imageOff = html`