From c86a091546d0452fde0093aac1888cd314a9cb3e Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 5 Feb 2023 20:27:24 -0800 Subject: [PATCH] Implement media filtering. --- package.json | 2 + src/camera-manager/types.ts | 8 +- src/components/gallery.ts | 1 + src/components/media-filter-core.ts | 313 ------------------ src/components/media-filter.ts | 480 +++++++++++++++++++--------- src/components/select.ts | 89 ++++++ src/localize/languages/en.json | 6 + src/localize/languages/it.json | 6 + src/localize/languages/pt-BR.json | 6 + src/scoped-elements/gr-select.js | 24 ++ src/scss/media-filter-core.scss | 16 - src/scss/media-filter.scss | 18 +- src/scss/select.scss | 11 + src/view/view.ts | 2 +- yarn.lock | 62 ++++ 15 files changed, 558 insertions(+), 486 deletions(-) delete mode 100644 src/components/media-filter-core.ts create mode 100644 src/components/select.ts create mode 100644 src/scoped-elements/gr-select.js delete mode 100644 src/scss/media-filter-core.scss create mode 100644 src/scss/select.scss diff --git a/package.json b/package.json index 0d78b3ca..838c052f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "dependencies": { "@cycjimmy/jsmpeg-player": "^6.0.4", "@egjs/hammerjs": "^2.0.17", + "@graphiteds/core": "^1.9.6", + "@lit-labs/scoped-registry-mixin": "^1.0.1", "@lit-labs/task": "^1.1.3", "@types/bluebird": "^3.5.36", "component-emitter": "^1.3.0", diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index a736bec9..2e85a6d0 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -38,7 +38,9 @@ interface LimitedDataQuery { export interface MediaQuery extends DataQuery, Partial, - Partial {} + Partial { + favorite?: boolean; +} export interface QueryResults { type: QueryResultsType; @@ -114,8 +116,6 @@ export interface EventQuery extends MediaQuery { // Frigate equivalent: zone where?: Set; - - favorite?: boolean; } export type PartialEventQuery = Partial; @@ -129,8 +129,6 @@ export interface EventQueryResults extends QueryResults { export interface RecordingQuery extends MediaQuery { type: QueryType.Recording; - - favorite?: boolean; } export type PartialRecordingQuery = Partial; diff --git a/src/components/gallery.ts b/src/components/gallery.ts index ee1432d2..2959e4ca 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -34,6 +34,7 @@ import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types' import { MediaQueriesResults } from '../view/media-queries-results'; import { errorToConsole } from '../utils/basic'; import './media-filter'; +import "./surround-basic"; const GALLERY_MEDIA_CHUNK_SIZE = 100; diff --git a/src/components/media-filter-core.ts b/src/components/media-filter-core.ts deleted file mode 100644 index c356b70c..00000000 --- a/src/components/media-filter-core.ts +++ /dev/null @@ -1,313 +0,0 @@ -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-manager/range'; -import { localize } from '../localize/localize'; -import mediaFilterCoreStyle from '../scss/media-filter-core.scss'; -import { ExtendedHomeAssistant } from '../types'; -import { dispatchFrigateCardEvent } from '../utils/basic'; - -export interface ValueLabel { - value?: T; - label: string; -} - -export interface MediaFilterCoreSelection { - mediaType?: MediaFilterMediaType; - cameraIDs?: Set; - what?: Set; - where?: Set; - when?: MediaFilterCoreWhenSelection; - favorite?: MediaFilterCoreFavoriteSelection; -} - -type FilterElement = HTMLElement & { - selectedItem?: ValueLabel; -}; - -export enum MediaFilterCoreFavoriteSelection { - Favorite = 'favorite', - NotFavorite = 'not-favorite', -} - -export enum MediaFilterCoreWhen { - Today = 'today', - Yesterday = 'yesterday', - PastWeek = 'past-week', - PastMonth = 'past-month', - Custom = 'custom', -} - -export enum MediaFilterMediaType { - Clips = 'clips', - Snapshots = 'snapshots', - Recordings = 'recordings', -} - -export interface MediaFilterCoreWhenSelection { - selection: MediaFilterCoreWhen; - custom?: DateRange; -} - -export type MediaFilterControls = { - mediaType?: boolean; - when?: boolean; - camera?: boolean; - what?: boolean; - where?: boolean; - favorite?: boolean; -}; - -@customElement('frigate-card-media-filter-core') -class FrigateCardMediaFilterCore extends LitElement { - @property({ attribute: false }) - public hass?: ExtendedHomeAssistant; - - @property({ attribute: false }) - public cameraOptions?: ValueLabel[]; - - @property({ attribute: false }) - public whenOptions?: ValueLabel[]; - - @property({ attribute: false }) - public whatOptions?: ValueLabel[]; - - @property({ attribute: false }) - public whereOptions?: ValueLabel[]; - - @property({ attribute: false }) - public defaults?: MediaFilterCoreSelection; - - @property({ attribute: false }) - public controls?: MediaFilterControls; - - protected _cameraOptions?: ValueLabel[]; - protected _whenOptions?: ValueLabel[]; - protected _whatOptions?: ValueLabel[]; - protected _whereOptions?: ValueLabel[]; - protected _favoriteOptions: ValueLabel[]; - protected _mediaTypeOptions: ValueLabel[]; - - protected _refMediaType: Ref> = createRef(); - protected _refCamera: Ref> = createRef(); - protected _refWhen: Ref> = createRef(); - protected _refWhat: Ref> = createRef(); - protected _refWhere: Ref> = createRef(); - protected _refFavorite: Ref> = - createRef(); - - constructor() { - super(); - this._favoriteOptions = [ - { - value: undefined, - label: localize('media_filter.all'), - }, - { - value: MediaFilterCoreFavoriteSelection.Favorite, - label: localize('media_filter.favorite'), - }, - { - value: MediaFilterCoreFavoriteSelection.NotFavorite, - label: localize('media_filter.not_favorite'), - }, - ]; - this._mediaTypeOptions = [ - { - value: MediaFilterMediaType.Clips, - label: localize('media_filter.media_types.clips'), - }, - { - value: MediaFilterMediaType.Snapshots, - label: localize('media_filter.media_types.snapshots'), - }, - { - value: MediaFilterMediaType.Recordings, - label: localize('media_filter.media_types.recordings'), - }, - ]; - } - - protected _valueChangedHandler(ev: CustomEvent<{ value: unknown }>): void { - // Handler is called on initial load -- skip it. - if (!ev.detail.value) { - return; - } - const values: MediaFilterCoreSelection = { - ...(this._refMediaType.value && - this._refMediaType.value.selectedItem?.value && { - mediaType: this._refMediaType.value.selectedItem?.value, - }), - ...(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.whens.today'), - }, - { - value: { selection: MediaFilterCoreWhen.Yesterday }, - label: localize('media_filter.whens.yesterday'), - }, - { - value: { selection: MediaFilterCoreWhen.PastWeek }, - label: localize('media_filter.whens.past_week'), - }, - { - value: { selection: MediaFilterCoreWhen.PastMonth }, - label: localize('media_filter.whens.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` ${this.controls?.mediaType ?? true - ? html` ` - : ''} - ${this.controls?.when ?? true - ? html`` - : ''} - ${(this.controls?.camera ?? true) && this.cameraOptions - ? html` ` - : ''} - ${(this.controls?.what ?? true) && this.whatOptions - ? html` ` - : ''} - ${(this.controls?.where ?? true) && this.whereOptions - ? html`` - : ''} - ${this.controls?.favorite ?? true - ? html` - - ` - : ''}`; - } - - static get styles(): CSSResultGroup { - return unsafeCSS(mediaFilterCoreStyle); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'frigate-card-media-filter-core': FrigateCardMediaFilterCore; - } -} diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index f6f343de..f8fd3f1a 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -1,10 +1,3 @@ -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 { CSSResultGroup, html, @@ -16,38 +9,73 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { CameraManager } from '../camera-manager/manager'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { DateRange } from '../camera-manager/range'; -import { CameraConfig, ExtendedHomeAssistant } from '../types'; -import { View } from '../view/view'; -import { - MediaFilterControls, - MediaFilterCoreFavoriteSelection, - MediaFilterCoreSelection, - MediaFilterCoreWhen, - MediaFilterCoreWhenSelection, - MediaFilterMediaType, - ValueLabel, -} from './media-filter-core'; -import './surround.js'; -import './timeline-core.js'; -import { EventQuery, MediaMetadata, QueryType, RecordingQuery } from '../camera-manager/types'; -import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; -import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js'; -import { HomeAssistant } from 'custom-card-helpers'; -import { errorToConsole, prettifyTitle } from '../utils/basic'; -import format from 'date-fns/format'; -import parse from 'date-fns/parse'; -import endOfMonth from 'date-fns/endOfMonth'; +import { localize } from '../localize/localize'; import mediaFilterStyle from '../scss/media-filter.scss'; -import { MediaQueriesClassifier } from '../view/media-queries-classifier'; +import { CameraConfig } from '../types'; +import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js'; +import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic'; +import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin'; +import './select'; +import { FrigateCardSelect, SelectOption, SelectValues } from './select'; import uniqWith from 'lodash-es/uniqWith'; +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 parse from 'date-fns/parse'; +import { MediaQueriesClassifier } from '../view/media-queries-classifier'; +import { View } from '../view/view'; +import { CameraManager } from '../camera-manager/manager'; +import { HomeAssistant } from 'custom-card-helpers'; +import { + EventQuery, + MediaMetadata, + QueryType, + RecordingQuery, +} from '../camera-manager/types'; +import format from 'date-fns/format'; +import endOfMonth from 'date-fns/endOfMonth'; import isEqual from 'lodash-es/isEqual'; +import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; +import './select.js'; +import orderBy from 'lodash-es/orderBy'; + +interface MediaFilterCoreDefaults { + mediaType?: MediaFilterMediaType; + cameraIDs?: string[]; + what?: string[]; + where?: string[]; + favorite?: MediaFilterCoreFavoriteSelection; + when?: string; +} + +export enum MediaFilterCoreFavoriteSelection { + Favorite = 'favorite', + NotFavorite = 'not-favorite', +} + +export enum MediaFilterCoreWhen { + Today = 'today', + Yesterday = 'yesterday', + PastWeek = 'past-week', + PastMonth = 'past-month', +} + +export enum MediaFilterMediaType { + Clips = 'clips', + Snapshots = 'snapshots', + Recordings = 'recordings', +} @customElement('frigate-card-media-filter') -export class FrigateCardMediaFilter extends LitElement { +class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { @property({ attribute: false }) - public hass?: ExtendedHomeAssistant; + public hass?: HomeAssistant; @property({ attribute: false }) public cameras?: Map; @@ -61,17 +89,73 @@ export class FrigateCardMediaFilter extends LitElement { @property({ attribute: false }) public mediaLimit?: number; - protected _cameraOptions: ValueLabel[] = []; + static elementDefinitions = { + 'frigate-card-select': FrigateCardSelect, + }; + protected _mediaMetadataController?: MediaMetadataController; - protected _convertWhenToDateRange( - value?: MediaFilterCoreWhenSelection, - ): DateRange | null { - if (!value) { + protected _mediaTypeOptions: SelectOption[]; + protected _cameraOptions?: SelectOption[]; + protected _whenOptions?: SelectOption[]; + protected _favoriteOptions: SelectOption[]; + + protected _defaults: MediaFilterCoreDefaults | null = null; + + protected _refMediaType: Ref = createRef(); + protected _refCamera: Ref = createRef(); + protected _refWhen: Ref = createRef(); + protected _refWhat: Ref = createRef(); + protected _refWhere: Ref = createRef(); + protected _refFavorite: Ref = createRef(); + + constructor() { + super(); + this._favoriteOptions = [ + { + value: MediaFilterCoreFavoriteSelection.Favorite, + label: localize('media_filter.favorite'), + }, + { + value: MediaFilterCoreFavoriteSelection.NotFavorite, + label: localize('media_filter.not_favorite'), + }, + ]; + this._mediaTypeOptions = [ + { + value: MediaFilterMediaType.Clips, + label: localize('media_filter.media_types.clips'), + }, + { + value: MediaFilterMediaType.Snapshots, + label: localize('media_filter.media_types.snapshots'), + }, + { + value: MediaFilterMediaType.Recordings, + label: localize('media_filter.media_types.recordings'), + }, + ]; + } + + protected _stringToDateRange(input: string): DateRange { + const dates = input.split(','); + return { + start: parse(dates[0], 'yyyy-MM-dd', new Date()), + end: parse(dates[1], 'yyyy-MM-dd', new Date()), + }; + } + + protected _dateRangeToString(when: DateRange): string { + return `${formatDate(when.start)},${formatDate(when.end)}`; + } + + protected _getWhen(): DateRange | null { + const value = this._refWhen.value?.value; + if (!value || Array.isArray(value)) { return null; } const now = new Date(); - switch (value.selection) { + switch (value) { case MediaFilterCoreWhen.Today: return { start: startOfToday(), end: endOfToday() }; case MediaFilterCoreWhen.Yesterday: @@ -80,40 +164,37 @@ export class FrigateCardMediaFilter extends LitElement { return { start: startOfDay(sub(now, { days: 7 })), end: endOfDay(now) }; case MediaFilterCoreWhen.PastMonth: return { start: startOfDay(sub(now, { months: 1 })), end: endOfDay(now) }; - case MediaFilterCoreWhen.Custom: - if (value.custom) { - return value.custom; - } + default: + return this._stringToDateRange(value); } - return null; } - protected _convertFavoriteToBoolean( - value?: MediaFilterCoreFavoriteSelection, - ): boolean | null { - if (!value) { - return null; - } - return value === MediaFilterCoreFavoriteSelection.Favorite; - } - - protected async _mediaFilterHandler( - ev: CustomEvent, + protected async _valueChangedHandler( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _ev: CustomEvent<{ value: unknown }>, ): Promise { - const mediaFilter = ev.detail; - if ( - !this.cameras || - !this.cameraManager || - !this.hass || - !this.view || - !mediaFilter.mediaType - ) { + if (!this.hass || !this.cameras || !this.cameraManager || !this.view) { return; } - const convertedTime = this._convertWhenToDateRange(mediaFilter.when); - const convertedFavorite = this._convertFavoriteToBoolean(mediaFilter.favorite); - const cameraIDs = mediaFilter.cameraIDs ?? new Set(this.cameras.keys()); + const getArrayValueAsSet = (val?: SelectValues): Set | null => { + // The reported value may be '' if the field is clearable (i.e. the user + // can click 'x'). + if (val && Array.isArray(val) && val.length && !val.includes('')) { + return new Set([...val]); + } + return null; + }; + + const cameraIDs = + getArrayValueAsSet(this._refCamera.value?.value) ?? new Set(this.cameras.keys()); + const mediaType = this._refMediaType.value?.value as + | MediaFilterMediaType + | undefined; + const when = this._getWhen(); + const favorite = this._refFavorite.value?.value + ? this._refFavorite.value.value === MediaFilterCoreFavoriteSelection.Favorite + : null; // A note on views: // - In the below, if the user selects a camera to view media for, the main @@ -125,22 +206,27 @@ export class FrigateCardMediaFilter extends LitElement { // to 'clips' or 'snapshots' in order to ensure the right icon is shown as // selected in the menu. if ( - mediaFilter.mediaType === MediaFilterMediaType.Clips || - mediaFilter.mediaType === MediaFilterMediaType.Snapshots + mediaType === MediaFilterMediaType.Clips || + mediaType === MediaFilterMediaType.Snapshots ) { - const query: EventQuery = { - type: QueryType.Event, - cameraIDs: cameraIDs, - ...(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 }), - ...(mediaFilter.mediaType === MediaFilterMediaType.Clips && { hasClip: true }), - ...(mediaFilter.mediaType === MediaFilterMediaType.Snapshots && { - hasSnapshot: true, - }), - }; + const where = getArrayValueAsSet(this._refWhere.value?.value); + const what = getArrayValueAsSet(this._refWhat.value?.value); + + const queries: EventQuery[] = [ + { + type: QueryType.Event, + cameraIDs: cameraIDs, + ...(what && { what: what }), + ...(where && { where: where }), + ...(favorite !== null && { favorite: favorite }), + ...(when && { start: when.start, end: when.end }), + ...(this.mediaLimit && { limit: this.mediaLimit }), + ...(mediaType === MediaFilterMediaType.Clips && { hasClip: true }), + ...(mediaType === MediaFilterMediaType.Snapshots && { + hasSnapshot: true, + }), + }, + ]; ( await createViewForEvents( @@ -150,22 +236,19 @@ export class FrigateCardMediaFilter extends LitElement { this.cameras, this.view, { - query: new EventMediaQueries([query]), + query: new EventMediaQueries(queries), // See 'A note on views' above for these two arguments. ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), - targetView: - mediaFilter.mediaType === MediaFilterMediaType.Clips - ? 'clips' - : 'snapshots', + targetView: mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots', }, ) )?.dispatchChangeEvent(this); - } else if (mediaFilter.mediaType === MediaFilterMediaType.Recordings) { + } else if (mediaType === MediaFilterMediaType.Recordings) { const query: RecordingQuery = { type: QueryType.Recording, cameraIDs: cameraIDs, - ...(convertedTime && { start: convertedTime.start, end: convertedTime.end }), + ...(when && { start: when.start, end: when.end }), }; ( @@ -206,23 +289,74 @@ export class FrigateCardMediaFilter extends LitElement { this.cameraManager, ); } + + // Relative 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: MediaFilterCoreWhen.Today, + label: localize('media_filter.whens.today'), + }, + { + value: MediaFilterCoreWhen.Yesterday, + label: localize('media_filter.whens.yesterday'), + }, + { + value: MediaFilterCoreWhen.PastWeek, + label: localize('media_filter.whens.past_week'), + }, + { + value: MediaFilterCoreWhen.PastMonth, + label: localize('media_filter.whens.past_month'), + }, + ...(this._mediaMetadataController?.whenOptions ?? []), + ]; + + if (changedProps.has('view')) { + const newDefaults = this._getDefaultsFromView(); + if (!isEqual(newDefaults, this._defaults)) { + this._defaults = newDefaults; + } + } } - protected _getDefaultsFromView(): MediaFilterCoreSelection | undefined { - if (!this.view) { - return undefined; + protected _getDefaultsFromView(): MediaFilterCoreDefaults | null { + const queries = this.view?.query?.getQueries(); + if (!this.view || !queries) { + return null; } let mediaType: MediaFilterMediaType | undefined; - let cameraIDs: Set | undefined; - let what: Set | undefined; - let where: Set | undefined; - let favorite: boolean | undefined; + let cameraIDs: string[] | undefined; + let what: string[] | undefined; + let where: string[] | undefined; + let favorite: MediaFilterCoreFavoriteSelection | undefined; + + const cameraIDSets = uniqWith( + queries.map((query) => query.cameraIDs), + isEqual, + ); + // Special note: If all cameras are selected, this is the same as no + // selector at all. + if (cameraIDSets.length === 1 && queries[0].cameraIDs.size !== this.cameras?.size) { + cameraIDs = [...queries[0].cameraIDs]; + } + + const favoriteValues = uniqWith( + queries.map((query) => query.favorite), + isEqual, + ); + if (favoriteValues.length === 1 && queries[0].favorite !== undefined) { + favorite = queries[0].favorite + ? MediaFilterCoreFavoriteSelection.Favorite + : MediaFilterCoreFavoriteSelection.NotFavorite; + } if (MediaQueriesClassifier.areEventQueries(this.view.query)) { const queries = this.view.query.getQueries(); if (!queries) { - return; + return null; } const hasClips = uniqWith( @@ -241,34 +375,22 @@ export class FrigateCardMediaFilter extends LitElement { : undefined; } - const cameraIDSets = uniqWith( - queries.map((query) => query.cameraIDs), - isEqual, - ); - if (cameraIDSets.length === 1) { - cameraIDs = queries[0].cameraIDs; - } const whatSets = uniqWith( queries.map((query) => query.what), isEqual, ); - if (whatSets.length === 1) { - what = queries[0].what; + if (whatSets.length === 1 && queries[0].what?.size) { + what = [...queries[0].what]; } const whereSets = uniqWith( queries.map((query) => query.where), isEqual, ); - if (whereSets.length === 1) { - where = queries[0].where; - } - const favoriteValues = uniqWith( - queries.map((query) => query.favorite), - isEqual, - ); - if (favoriteValues.length === 1) { - favorite = queries[0].favorite; + if (whereSets.length === 1 && queries[0].where?.size) { + where = [...queries[0].where]; } + } else if (MediaQueriesClassifier.areRecordingQueries(this.view.query)) { + mediaType = MediaFilterMediaType.Recordings; } return { @@ -276,15 +398,15 @@ export class FrigateCardMediaFilter extends LitElement { ...(cameraIDs && { cameraIDs: cameraIDs }), ...(what && { what: what }), ...(where && { where: where }), - ...(favorite !== undefined && { - favorite: favorite - ? MediaFilterCoreFavoriteSelection.Favorite - : MediaFilterCoreFavoriteSelection.NotFavorite, - }), + ...(favorite !== undefined && { favorite: favorite }), }; } protected render(): TemplateResult | void { + if (!this._mediaMetadataController) { + return; + } + const areEvents = !!( this.view?.query && MediaQueriesClassifier.areEventQueries(this.view.query) ); @@ -294,28 +416,82 @@ export class FrigateCardMediaFilter extends LitElement { const managerCapabilities = this.cameraManager?.getCapabilities(); // Which media controls are shown depends on the view. - const controls: MediaFilterControls = { - what: areEvents, - where: areEvents, - favorite: areEvents - ? !!managerCapabilities?.canFavoriteEvents - : areRecordings - ? !!managerCapabilities?.canFavoriteRecordings - : false, - }; - const defaults = this._getDefaultsFromView(); + const showFavoriteControl = areEvents + ? !!managerCapabilities?.canFavoriteEvents + : areRecordings + ? !!managerCapabilities?.canFavoriteRecordings + : false; - return html` - `; + return html` + + + + + + ${areEvents && this._mediaMetadataController.whatOptions.length + ? html` + ` + : ''} + ${areEvents && this._mediaMetadataController.whereOptions.length + ? html` + ` + : ''} + ${showFavoriteControl + ? html` + + + ` + : ''}`; } static get styles(): CSSResultGroup { @@ -328,9 +504,9 @@ export class MediaMetadataController implements ReactiveController { protected _hass: HomeAssistant; protected _cameraManager: CameraManager; - public whenOptions: ValueLabel[] = []; - public whatOptions: ValueLabel[] = []; - public whereOptions: ValueLabel[] = []; + public whenOptions: SelectOption[] = []; + public whatOptions: SelectOption[] = []; + public whereOptions: SelectOption[] = []; constructor( host: ReactiveControllerHost, @@ -343,6 +519,10 @@ export class MediaMetadataController implements ReactiveController { host.addController(this); } + protected _dateRangeToString(when: DateRange): string { + return `${formatDate(when.start)},${formatDate(when.end)}`; + } + async hostConnected() { let metadata: MediaMetadata | null; try { @@ -375,15 +555,15 @@ export class MediaMetadataController implements ReactiveController { yearMonths.forEach((yearMonth) => { monthStarts.push(parse(yearMonth, 'yyyy-MM', new Date())); }); - this.whenOptions = monthStarts - .sort() - .map((monthStart) => ({ + this.whenOptions = orderBy(monthStarts, (date) => date.getTime(), 'desc').map( + (monthStart) => ({ label: format(monthStart, 'MMMM yyyy'), - value: { - selection: MediaFilterCoreWhen.Custom, - custom: { start: monthStart, end: endOfMonth(monthStart) }, - }, - })); + value: this._dateRangeToString({ + start: monthStart, + end: endOfMonth(monthStart), + }), + }), + ); } this._host.requestUpdate(); } diff --git a/src/components/select.ts b/src/components/select.ts new file mode 100644 index 00000000..a56930a9 --- /dev/null +++ b/src/components/select.ts @@ -0,0 +1,89 @@ +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import selectStyle from '../scss/select.scss'; +import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic'; +import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin'; +import { grSelectElements } from '../scoped-elements/gr-select'; +import isEqual from 'lodash-es/isEqual'; +import '../scoped-elements/gr-select'; + +export interface SelectOption { + label: string; + value: string; +} + +export type SelectValues = string | string[]; + +type SelectElement = HTMLElement & { + value: SelectValues; +}; + +export class FrigateCardSelect extends ScopedRegistryHost(LitElement) { + @property({ attribute: false, hasChanged: contentsChanged }) + public options?: SelectOption[]; + + @property({ attribute: false, hasChanged: contentsChanged }) + public value?: SelectValues; + + @property({ attribute: true }) + public label?: string; + + @property({ attribute: true }) + public placeholder?: string; + + @property({ attribute: true, type: Boolean }) + public multiple?: boolean = false; + + @property({ attribute: true, type: Boolean }) + public clearable?: boolean = false; + + protected _previouslyReportedValue?: SelectValues; + protected _refSelect: Ref = createRef(); + + static elementDefinitions = { + ...grSelectElements, + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected _valueChangedHandler(_ev: CustomEvent<{ value: unknown }>): void { + const value: SelectValues | undefined = this._refSelect.value?.value; + // The underlying gr-select element is very sensitive and occasionally fires + // the change event even if the value has not actually changed. Prevent that + // from propagating upwards. + if (value !== undefined && !isEqual(this.value, value)) { + this.value = value; + dispatchFrigateCardEvent(this, 'select:change', value); + } + } + + protected render(): TemplateResult | void { + return html` + ${this.options?.map( + (option) => + html`${option.label}`, + )} + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(selectStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-select': FrigateCardSelect; + } +} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 3820cfd5..c2dafc51 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -384,6 +384,12 @@ "recordings": "Recordings" }, "not_favorite": "Not Favorite", + "select_camera": "Select camera...", + "select_favorite": "Select favorite...", + "select_media_type": "Select media type...", + "select_what": "Select what...", + "select_when": "Select when...", + "select_where": "Select where...", "what": "What", "when": "When", "whens": { diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index cd3b2146..dd735149 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -355,6 +355,12 @@ "recordings": "" }, "not_favorite": "", + "select_camera": "", + "select_favorite": "", + "select_media_type": "", + "select_what": "", + "select_when": "", + "select_where": "", "what": "", "when": "", "whens": { diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 6eeda043..24b125b3 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -355,6 +355,12 @@ "recordings": "" }, "not_favorite": "", + "select_camera": "", + "select_favorite": "", + "select_media_type": "", + "select_what": "", + "select_when": "", + "select_where": "", "what": "", "when": "", "whens": { diff --git a/src/scoped-elements/gr-select.js b/src/scoped-elements/gr-select.js new file mode 100644 index 00000000..ac71fe58 --- /dev/null +++ b/src/scoped-elements/gr-select.js @@ -0,0 +1,24 @@ +import { GrSelect } from '@graphiteds/core/components/gr-select'; +import { GrMenuItem } from '@graphiteds/core/components/gr-menu-item'; + +// It was difficult to find a multi-select web component that matches these criteria: +// - Open source. +// - Supports being in a ScopedRegistry out of the box (i.e. does not auto-register with customElements). +// - Looks attractive / compatible with mostly Material elements. +// - Styleable +// - Does not bloat output size considerably. + +// Web components evaluated (https://open-wc.org/guides/community/component-libraries/): +// - Material: No multiselect component. +// - Freshwords/@crayon: Considerable bloat in output due to i18n translations +// that are used by _other_ components. +// - Carbon Design System: Workable, but less moderm / Material-like. +// - UI5: Auto-registers globally. +// - Vaadin: Auto-registers globally. +// - Liquid: Not open source. +// - [Many others]: No multiselect component. + +export const grSelectElements = { + 'gr-select': GrSelect, + 'gr-menu-item': GrMenuItem, +}; diff --git a/src/scss/media-filter-core.scss b/src/scss/media-filter-core.scss deleted file mode 100644 index 101909bf..00000000 --- a/src/scss/media-filter-core.scss +++ /dev/null @@ -1,16 +0,0 @@ -:host { - height: 100%; - display: flex; - flex-direction: column; - overflow: auto; - - // Hide scrollbar: Firefox - scrollbar-width: none; - // Hide scrollbar: IE and Edge - -ms-overflow-style: none; -} - -/* Hide scrollbar for Chrome, Safari and Opera */ -:host::-webkit-scrollbar { - display: none; -} diff --git a/src/scss/media-filter.scss b/src/scss/media-filter.scss index 478abc91..ae4eec8c 100644 --- a/src/scss/media-filter.scss +++ b/src/scss/media-filter.scss @@ -1,4 +1,20 @@ :host { - display: block; + display: flex; + + flex-direction: column; + overflow: auto; + + // Hide scrollbar: Firefox + scrollbar-width: none; + // Hide scrollbar: IE and Edge + -ms-overflow-style: none; + height: 100%; + width: 300px; + margin: 5px; +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +:host::-webkit-scrollbar { + display: none; } diff --git a/src/scss/select.scss b/src/scss/select.scss new file mode 100644 index 00000000..408f457d --- /dev/null +++ b/src/scss/select.scss @@ -0,0 +1,11 @@ +@use '@graphiteds/core/css/core.css'; + +:host { + // The graphite css (above) loads variables into :root, which is lost in the + // shadow DOM, so copy them into the host. + @extend :root; +} + +gr-select { + padding: 5px; +} diff --git a/src/view/view.ts b/src/view/view.ts index 040dcc45..d6896995 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,5 +1,5 @@ // Gallery: -// - TODO: Filter panel expands from right can occasionally 'stick' open. +// - TODO: View a recording from the gallery => timeline should span the hour. import { ViewContext } from 'view'; import { diff --git a/yarn.lock b/yarn.lock index c6950d31..4158f5c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -270,6 +270,13 @@ resolved "https://registry.yarnpkg.com/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.0.4.tgz#2ad00118a48bef30db0115ba873b335fa1559ae7" integrity sha512-w3E1nwSDKPJaBD3bq/aMvR3hgUJpyCrqX2jJbQAjE9g4xk9bRkeAIH1h1mBtdRFMpTzUlE3GX8r44lXEH0dVVQ== +"@duetds/date-picker@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@duetds/date-picker/-/date-picker-1.4.0.tgz#4bdbc15aa4b6307b8b156d8d9c5fdf67f6b6783b" + integrity sha512-jx4oSIrZAVsXYLyQGXbEmuo0gEXAqrNIGp3rG8L03XSxFFFca/bTpCj5v6yd48vVWOtAXD8y04S/+flOimra0A== + dependencies: + "@stencil/core" "^2.3.0" + "@egjs/hammerjs@^2.0.17": version "2.0.17" resolved "https://registry.yarnpkg.com/@egjs/hammerjs/-/hammerjs-2.0.17.tgz#5dc02af75a6a06e4c2db0202cae38c9263895124" @@ -338,6 +345,15 @@ dependencies: emojis-list "^3.0.0" +"@graphiteds/core@^1.9.6": + version "1.9.6" + resolved "https://registry.yarnpkg.com/@graphiteds/core/-/core-1.9.6.tgz#bbfb862a917b52d3610a09b2b8168bf9428e4007" + integrity sha512-aJ8f17wj7qPbVXH13ZR+xSztN45H0pH46I2cBV4NKnDO3xrqf88VLnOU7USGTJZ53FqgL/UGBAJfv5WjbU0dHw== + dependencies: + "@duetds/date-picker" "^1.4.0" + "@popperjs/core" "^2.11.5" + "@stencil/core" "^2.20.0" + "@humanwhocodes/config-array@^0.10.4": version "0.10.4" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c" @@ -410,6 +426,19 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@lit-labs/scoped-registry-mixin@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@lit-labs/scoped-registry-mixin/-/scoped-registry-mixin-1.0.1.tgz#0ad266c029f1eb385711d2cd26252baadf145a5d" + integrity sha512-7aKnBKb5izcTjICO4VdeQLRYpZoB8FepJjG7TmE2oe0cU8cb4MUA4NFCjze+vbE+wP/55xv4dAMFOuJN9EG26w== + dependencies: + "@lit/reactive-element" "^1.0.0" + lit "^2.0.0" + +"@lit-labs/ssr-dom-shim@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.0.0.tgz#427e19a2765681fd83411cd72c55ba80a01e0523" + integrity sha512-ic93MBXfApIFTrup4a70M/+ddD8xdt2zxxj9sRwHQzhS9ag/syqkD8JPdTXsc1gUy2K8TTirhlCqyTEM/sifNw== + "@lit-labs/task@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@lit-labs/task/-/task-1.1.3.tgz#1215ae8ca746ecc895813463b81b2ce4b823098a" @@ -417,6 +446,13 @@ dependencies: "@lit/reactive-element" "^1.1.0" +"@lit/reactive-element@^1.0.0", "@lit/reactive-element@^1.6.0": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.6.1.tgz#0d958b6d479d0e3db5fc1132ecc4fa84be3f0b93" + integrity sha512-va15kYZr7KZNNPZdxONGQzpUr+4sxVu7V/VG7a8mRfPPXUyhEYj5RzXCQmGrlP3tAh0L3HHm5AjBMFYRqlM9SA== + dependencies: + "@lit-labs/ssr-dom-shim" "^1.0.0" + "@lit/reactive-element@^1.1.0", "@lit/reactive-element@^1.3.0", "@lit/reactive-element@^1.4.0": version "1.4.1" resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.4.1.tgz#3f587eec5708692135bc9e94cf396130604979f3" @@ -443,6 +479,11 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@popperjs/core@^2.11.5": + version "2.11.6" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45" + integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw== + "@rollup/plugin-babel@^5.3.1": version "5.3.1" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283" @@ -516,6 +557,11 @@ estree-walker "^2.0.1" picomatch "^2.2.2" +"@stencil/core@^2.20.0", "@stencil/core@^2.3.0": + version "2.22.2" + resolved "https://registry.yarnpkg.com/@stencil/core/-/core-2.22.2.tgz#f518b6bfae85352c72cd07fcaf2f2b61e0581fd9" + integrity sha512-r+vbxsGNcBaV1VDOYW25lv4QfXTlNoIb5GpUX7rZ+cr59yqYCZC5tlV+IzX6YgHKW62ulCc9M3RYtTfHtNbNNw== + "@trysound/sax@0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" @@ -2079,6 +2125,22 @@ lit-html@^2.2.0, lit-html@^2.3.0: dependencies: "@types/trusted-types" "^2.0.2" +lit-html@^2.6.0: + version "2.6.1" + resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.6.1.tgz#eb29f0b0c2ab54ea77379db11fc011b0c71f1cda" + integrity sha512-Z3iw+E+3KKFn9t2YKNjsXNEu/LRLI98mtH/C6lnFg7kvaqPIzPn124Yd4eT/43lyqrejpc5Wb6BHq3fdv4S8Rw== + dependencies: + "@types/trusted-types" "^2.0.2" + +lit@^2.0.0: + version "2.6.1" + resolved "https://registry.yarnpkg.com/lit/-/lit-2.6.1.tgz#5951a2098b9bde5b328c73b55c15fdc0eefd96d7" + integrity sha512-DT87LD64f8acR7uVp7kZfhLRrHkfC/N4BVzAtnw9Yg8087mbBJ//qedwdwX0kzDbxgPccWRW6mFwGbRQIxy0pw== + dependencies: + "@lit/reactive-element" "^1.6.0" + lit-element "^3.2.0" + lit-html "^2.6.0" + lit@^2.1.1, lit@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/lit/-/lit-2.3.1.tgz#2cf1c2042da1e44c7a7cc72dff2d72303fd26f48"