From e5924db352ee3ce85b236b8768313d8fba95bd16 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 16 Jan 2023 14:07:20 -0800 Subject: [PATCH] Initial version of media filter components. --- src/components/gallery.ts | 38 +++-- src/components/media-filter-core.ts | 209 ++++++++++++++++++++++++++++ src/components/media-filter.ts | 143 +++++++++++++++++++ src/localize/languages/en.json | 13 ++ src/localize/languages/it.json | 13 ++ src/localize/languages/pt-BR.json | 13 ++ src/scss/gallery.scss | 4 +- src/scss/media-filter.scss | 5 + src/view/view.ts | 7 + tsconfig.json | 1 + 10 files changed, 431 insertions(+), 15 deletions(-) create mode 100644 src/components/media-filter-core.ts create mode 100644 src/components/media-filter.ts create mode 100644 src/scss/media-filter.scss diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 766534b2..aa2ce708 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -27,13 +27,13 @@ import { View } from '../view/view.js'; import { renderProgressIndicator } from './message.js'; import './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; -import './media-filter'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; import { EventQuery, MediaQuery, RecordingQuery } from '../camera/types'; import { MediaQueriesResults } from '../view/media-queries-results'; import { errorToConsole } from '../utils/basic'; +import "./media-filter"; const GALLERY_MEDIA_CHUNK_SIZE = 100; @@ -101,15 +101,25 @@ export class FrigateCardGallery extends LitElement { return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); } + // TODO Make this slot choice configuration left/right. return html` - - + + + + + + `; } @@ -146,7 +156,7 @@ export class FrigateCardGalleryCore extends LitElement { protected _intersectionObserver: IntersectionObserver; protected _resizeObserver: ResizeObserver; - protected _refSentinel: Ref = createRef(); + protected _refLoader: Ref = createRef(); @state() protected _showExtensionLoader = true; @@ -167,7 +177,7 @@ export class FrigateCardGalleryCore extends LitElement { this._resizeObserver.observe(this); // Request update in order to ensure the intersection observer reconnects - // with the sentinel. + // with the loader sentinel. this.requestUpdate(); } @@ -326,7 +336,7 @@ export class FrigateCardGalleryCore extends LitElement { `, )} ${this._showExtensionLoader - ? html` + ? html` ` : ''} @@ -334,9 +344,9 @@ export class FrigateCardGalleryCore extends LitElement { } public updated(): void { - if (this._refSentinel.value) { + if (this._refLoader.value) { this._intersectionObserver.disconnect(); - this._intersectionObserver.observe(this._refSentinel.value); + this._intersectionObserver.observe(this._refLoader.value); } } diff --git a/src/components/media-filter-core.ts b/src/components/media-filter-core.ts new file mode 100644 index 00000000..692edcde --- /dev/null +++ b/src/components/media-filter-core.ts @@ -0,0 +1,209 @@ +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { DateRange } from '../camera/range'; +import { localize } from '../localize/localize'; +import mediaFilterStyle from '../scss/media-filter.scss'; +import { ExtendedHomeAssistant } from '../types'; +import { dispatchFrigateCardEvent } from '../utils/basic'; + +export interface ValueLabel { + value?: T; + label: string; +} + +export interface MediaFilterCoreSelection { + camera?: string[]; + what?: string[]; + where?: string[]; + when?: MediaFilterCoreWhenSelection; + favorite?: MediaFilterCoreFavoriteSelection; +} + +type FilterElement = HTMLElement & { + selectedItem?: ValueLabel; +}; + +export enum MediaFilterCoreFavoriteSelection { + All = 'all', + Favorite = 'favorite', + NotFavorite = 'not-favorite', +} + +export enum MediaFilterCoreWhen { + All = 'all', + Today = 'today', + Yesterday = 'yesterday', + PastWeek = 'past-week', + PastMonth = 'past-month', + Custom = 'custom', +} + +export interface MediaFilterCoreWhenSelection { + selection: MediaFilterCoreWhen; + custom?: DateRange; +} + +@customElement('frigate-card-media-filter-core') +export class FrigateCardMediaFilterCore extends LitElement { + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public whenOptions?: ValueLabel[]; + + @property({ attribute: false }) + public cameraOptions?: ValueLabel[]; + + @property({ attribute: false }) + public whatOptions?: ValueLabel[]; + + @property({ attribute: false }) + public whereOptions?: ValueLabel[]; + + protected _refWhen: Ref> = createRef(); + protected _refCamera: 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[] = [ + { + value: MediaFilterCoreFavoriteSelection.All, + label: localize('media_filter.all'), + }, + { + value: MediaFilterCoreFavoriteSelection.Favorite, + label: localize('media_filter.favorite'), + }, + { + value: MediaFilterCoreFavoriteSelection.NotFavorite, + 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 ?? []), + ]; + + return html` + + ${this.cameraOptions + ? html` ` + : ''} + ${this.whatOptions + ? html` ` + : ''} + ${this.whereOptions + ? html`` + : ''} + + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(mediaFilterStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-media-filter-core': FrigateCardMediaFilterCore; + } +} diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts new file mode 100644 index 00000000..8841720e --- /dev/null +++ b/src/components/media-filter.ts @@ -0,0 +1,143 @@ +import { sub } from 'date-fns'; +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 startOfYesterday from 'date-fns/startOfYesterday'; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { CameraManager } from '../camera/manager'; +import { DateRange } from '../camera/range'; +import { CameraConfig, ExtendedHomeAssistant } from '../types'; +import { getCameraTitle } from '../utils/camera'; +import { + MediaFilterCoreFavoriteSelection, + MediaFilterCoreSelection, + MediaFilterCoreWhen, + MediaFilterCoreWhenSelection, + ValueLabel, +} from './media-filter-core'; +import './surround.js'; +import './timeline-core.js'; + +@customElement('frigate-card-media-filter') +export class FrigateCardMediaFilter extends LitElement { + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public cameras?: Map; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + protected _cameraOptions: ValueLabel[] = []; + + protected _convertWhenToDateRange( + value?: MediaFilterCoreWhenSelection, + ): DateRange | null { + if (!value) { + return null; + } + if (value.selection === MediaFilterCoreWhen.Custom && value.custom) { + return value.custom; + } + const now = new Date(); + switch (value.selection) { + case MediaFilterCoreWhen.Today: + return { start: startOfToday(), end: endOfToday() }; + case MediaFilterCoreWhen.Yesterday: + return { start: startOfYesterday(), end: endOfYesterday() }; + case MediaFilterCoreWhen.PastWeek: + return { start: sub(now, { days: 7 }), end: endOfDay(now) }; + case MediaFilterCoreWhen.PastMonth: + return { start: sub(now, { months: 1 }), end: endOfDay(now) }; + } + return null; + } + + protected _convertFavoriteToBoolean( + value?: MediaFilterCoreFavoriteSelection, + ): boolean | null { + if (!value || value === MediaFilterCoreFavoriteSelection.All) { + 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 }), + }; + // TODO: remove. + console.debug('Received media filter choices:', details); + } + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('cameras') && this.cameras) { + this._cameraOptions = Array.from(this.cameras.entries()).map( + ([cameraID, cameraConfig]) => ({ + value: cameraID, + label: getCameraTitle(this.hass, cameraConfig), + }), + ); + } + } + + protected render(): TemplateResult | void { + // TODO Replace with real custom when options + // TODO Replace with real custom what options + // TODO Replace with real custom where options + const whereOptions = [{ value: 'steps', label: 'Front Steps' }]; + + const whatOptions = [ + { value: 'car', label: 'Car' }, + { value: 'person', label: 'Person' }, + ]; + + const whenOptions = [ + { + value: { + selection: MediaFilterCoreWhen.Custom, + custom: { start: startOfToday(), end: endOfToday() }, + }, + label: 'December 2021', + }, + ]; + + return html` + `; + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: block; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-media-filter': FrigateCardMediaFilter; + } +} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 47220e12..3aab668b 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -364,6 +364,19 @@ "start": "Start", "seek": "Seek" }, + "media_filter": { + "all": "All", + "camera": "Camera", + "favorite": "Favorite", + "not_favorite": "Not Favorite", + "past_month": "Past Month", + "past_week": "Past Week", + "today": "Today", + "what": "What", + "when": "When", + "where": "Where", + "yesterday": "Yesterday" + }, "recording": { "events": "Events", "seek": "Seek" diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 5889e7c1..3c55142b 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -335,6 +335,19 @@ "start": "Avvia", "seek": "Cercare" }, + "media_filter": { + "all": "", + "camera": "", + "favorite": "", + "not_favorite": "", + "past_month": "", + "past_week": "", + "today": "", + "what": "", + "when": "", + "where": "", + "yesterday": "" + }, "recording": { "events": "Eventi", "seek": "Cercare" diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 61295352..03d1bfb7 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -335,6 +335,19 @@ "start": "InĂ­cio", "seek": "Procurar" }, + "media_filter": { + "all": "", + "camera": "", + "favorite": "", + "not_favorite": "", + "past_month": "", + "past_week": "", + "today": "", + "what": "", + "when": "", + "where": "", + "yesterday": "" + }, "recording": { "events": "Eventos", "seek": "Procurar" diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss index 93f95bda..173261eb 100644 --- a/src/scss/gallery.scss +++ b/src/scss/gallery.scss @@ -16,7 +16,7 @@ display: grid; grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr)); - grid-auto-rows: 1fr; + grid-auto-rows: min-content; gap: var(--frigate-card-gallery-gap); } @@ -26,6 +26,8 @@ } :host ha-card { + min-width: 100%; + aspect-ratio: 1/1; display: flex; justify-content: center; align-items: center; diff --git a/src/scss/media-filter.scss b/src/scss/media-filter.scss new file mode 100644 index 00000000..895364c4 --- /dev/null +++ b/src/scss/media-filter.scss @@ -0,0 +1,5 @@ +:host { + display: flex; + flex-direction: column; + overflow: auto; +} diff --git a/src/view/view.ts b/src/view/view.ts index fb355802..ac8a3655 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -2,7 +2,14 @@ // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript // - TODO: getRecordingTitle should use getCameraTitle but need hass. // - TODO: Take MediaQueries wrappers out of the camera manager. + +// Gallery: // - TODO: Event gallery show_details default does not work. +// - TODO: Use spinner for gallery loading / cardwideconfig not dotdotdot +// - TODO: Handle all TODOs in media filter and media filter core. +// - TODO: Make media filter slot choice configurable (gallery.ts). +// - TODO: Configurable drawer icons (e.g. filter). +// - TODO: Filter panel expands from right can occasionally 'stick' open. // Hard: // - TODO: Implement gallery. diff --git a/tsconfig.json b/tsconfig.json index b5045a7e..0b7a905d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ // imported by a custom card directly. "globalTags": [ "ha-card", + "ha-combo-box", "ha-icon", "ha-icon-button", "ha-button-menu",