Implement media filtering.

This commit is contained in:
Dermot Duffy
2023-02-05 20:27:24 -08:00
parent c898f038bd
commit c86a091546
15 changed files with 558 additions and 486 deletions
+1
View File
@@ -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;
-313
View File
@@ -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<T> {
value?: T;
label: string;
}
export interface MediaFilterCoreSelection {
mediaType?: MediaFilterMediaType;
cameraIDs?: Set<string>;
what?: Set<string>;
where?: Set<string>;
when?: MediaFilterCoreWhenSelection;
favorite?: MediaFilterCoreFavoriteSelection;
}
type FilterElement<T> = HTMLElement & {
selectedItem?: ValueLabel<T>;
};
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<string>[];
@property({ attribute: false })
public whenOptions?: ValueLabel<MediaFilterCoreWhenSelection>[];
@property({ attribute: false })
public whatOptions?: ValueLabel<string>[];
@property({ attribute: false })
public whereOptions?: ValueLabel<string>[];
@property({ attribute: false })
public defaults?: MediaFilterCoreSelection;
@property({ attribute: false })
public controls?: MediaFilterControls;
protected _cameraOptions?: ValueLabel<string>[];
protected _whenOptions?: ValueLabel<MediaFilterCoreWhenSelection>[];
protected _whatOptions?: ValueLabel<string>[];
protected _whereOptions?: ValueLabel<string>[];
protected _favoriteOptions: ValueLabel<MediaFilterCoreFavoriteSelection>[];
protected _mediaTypeOptions: ValueLabel<MediaFilterMediaType>[];
protected _refMediaType: Ref<FilterElement<MediaFilterMediaType>> = createRef();
protected _refCamera: Ref<FilterElement<string>> = createRef();
protected _refWhen: Ref<FilterElement<MediaFilterCoreWhenSelection>> = createRef();
protected _refWhat: Ref<FilterElement<string>> = createRef();
protected _refWhere: Ref<FilterElement<string>> = createRef();
protected _refFavorite: Ref<FilterElement<MediaFilterCoreFavoriteSelection>> =
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` <ha-combo-box
${ref(this._refMediaType)}
.hass=${this.hass}
.label=${localize('media_filter.media_type')}
.items=${this._mediaTypeOptions}
.allowCustomValue=${false}
.value=${this.defaults?.mediaType}
@value-changed=${this._valueChangedHandler.bind(this)}
></ha-combo-box>`
: ''}
${this.controls?.when ?? true
? html`<ha-combo-box
${ref(this._refWhen)}
.hass=${this.hass}
.label=${localize('media_filter.when')}
.items=${this._whenOptions}
.allowCustomValue=${false}
.value=${this.defaults?.when}
@value-changed=${this._valueChangedHandler.bind(this)}
></ha-combo-box>`
: ''}
${(this.controls?.camera ?? true) && this.cameraOptions
? html` <ha-combo-box
${ref(this._refCamera)}
.hass=${this.hass}
.label=${localize('media_filter.camera')}
.items=${this._cameraOptions}
.allowCustomValue=${false}
.value=${this.defaults?.cameraIDs?.size === 1
? [...this.defaults.cameraIDs][0]
: undefined}
@value-changed=${this._valueChangedHandler.bind(this)}
></ha-combo-box>`
: ''}
${(this.controls?.what ?? true) && this.whatOptions
? html` <ha-combo-box
${ref(this._refWhat)}
.hass=${this.hass}
.label=${localize('media_filter.what')}
.items="${this._whatOptions}"
.allowCustomValue=${false}
.value=${this.defaults?.what?.size === 1
? [...this.defaults.what][0]
: undefined}
@value-changed=${this._valueChangedHandler.bind(this)}
></ha-combo-box>`
: ''}
${(this.controls?.where ?? true) && this.whereOptions
? html`<ha-combo-box
${ref(this._refWhere)}
.hass=${this.hass}
.label=${localize('media_filter.where')}
.items=${this._whereOptions}
.allowCustomValue=${false}
.value=${this.defaults?.where?.size === 1
? [...this.defaults.where][0]
: undefined}
@value-changed=${this._valueChangedHandler.bind(this)}
></ha-combo-box>`
: ''}
${this.controls?.favorite ?? true
? html`
<ha-combo-box
${ref(this._refFavorite)}
.hass=${this.hass}
.label=${localize('media_filter.favorite')}
.items=${this._favoriteOptions}
.allowCustomValue=${false}
.value=${this.defaults?.favorite}
@value-changed=${this._valueChangedHandler.bind(this)}
></ha-combo-box>
`
: ''}`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(mediaFilterCoreStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-media-filter-core': FrigateCardMediaFilterCore;
}
}
+330 -150
View File
@@ -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<string, CameraConfig>;
@@ -61,17 +89,73 @@ export class FrigateCardMediaFilter extends LitElement {
@property({ attribute: false })
public mediaLimit?: number;
protected _cameraOptions: ValueLabel<string>[] = [];
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<FrigateCardSelect> = createRef();
protected _refCamera: Ref<FrigateCardSelect> = createRef();
protected _refWhen: Ref<FrigateCardSelect> = createRef();
protected _refWhat: Ref<FrigateCardSelect> = createRef();
protected _refWhere: Ref<FrigateCardSelect> = createRef();
protected _refFavorite: Ref<FrigateCardSelect> = 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<MediaFilterCoreSelection>,
protected async _valueChangedHandler(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_ev: CustomEvent<{ value: unknown }>,
): Promise<void> {
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<string> | 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<string> | undefined;
let what: Set<string> | undefined;
let where: Set<string> | 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` <frigate-card-media-filter-core
.hass=${this.hass}
.whenOptions=${this._mediaMetadataController?.whenOptions}
.cameraOptions=${this._cameraOptions}
.whatOptions=${this._mediaMetadataController?.whatOptions}
.whereOptions=${this._mediaMetadataController?.whereOptions}
.controls=${controls}
.defaults=${defaults}
@frigate-card:media-filter-core:change=${this._mediaFilterHandler.bind(this)}
>
</frigate-card-media-filter-core>`;
return html` <frigate-card-select
${ref(this._refMediaType)}
label=${localize('media_filter.media_type')}
placeholder=${localize('media_filter.select_media_type')}
.options=${this._mediaTypeOptions}
.value=${this._defaults?.mediaType}
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
>
</frigate-card-select>
<frigate-card-select
${ref(this._refWhen)}
.label=${localize('media_filter.when')}
placeholder=${localize('media_filter.select_when')}
.options=${this._whenOptions}
.value=${this._defaults?.when}
clearable
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
>
</frigate-card-select>
<frigate-card-select
${ref(this._refCamera)}
.label=${localize('media_filter.camera')}
placeholder=${localize('media_filter.select_camera')}
.options=${this._cameraOptions}
.value=${this._defaults?.cameraIDs}
clearable
multiple
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
>
</frigate-card-select>
${areEvents && this._mediaMetadataController.whatOptions.length
? html` <frigate-card-select
${ref(this._refWhat)}
label=${localize('media_filter.what')}
placeholder=${localize('media_filter.select_what')}
clearable
multiple
.options=${this._mediaMetadataController.whatOptions}
.value=${this._defaults?.what}
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
>
</frigate-card-select>`
: ''}
${areEvents && this._mediaMetadataController.whereOptions.length
? html` <frigate-card-select
${ref(this._refWhere)}
label=${localize('media_filter.where')}
placeholder=${localize('media_filter.select_where')}
clearable
multiple
.options=${this._mediaMetadataController.whereOptions}
.value=${this._defaults?.where}
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
>
</frigate-card-select>`
: ''}
${showFavoriteControl
? html`
<frigate-card-select
${ref(this._refFavorite)}
label=${localize('media_filter.favorite')}
placeholder=${localize('media_filter.select_favorite')}
.options=${this._favoriteOptions}
.value=${this._defaults?.favorite}
clearable
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
>
</frigate-card-select>
`
: ''}`;
}
static get styles(): CSSResultGroup {
@@ -328,9 +504,9 @@ export class MediaMetadataController implements ReactiveController {
protected _hass: HomeAssistant;
protected _cameraManager: CameraManager;
public whenOptions: ValueLabel<MediaFilterCoreWhenSelection>[] = [];
public whatOptions: ValueLabel<string>[] = [];
public whereOptions: ValueLabel<string>[] = [];
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();
}
+89
View File
@@ -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<SelectElement> = 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` <gr-select
${ref(this._refSelect)}
label=${this.label ?? ''}
placeholder=${this.placeholder ?? ''}
size="small"
?multiple=${this.multiple}
?clearable=${this.clearable}
.value=${this.value ?? this._refSelect.value?.value ?? []}
@gr-change=${this._valueChangedHandler.bind(this)}
>
${this.options?.map(
(option) =>
html`<gr-menu-item value="${option.value ?? ''}"
>${option.label}</gr-menu-item
>`,
)}
</gr-select>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(selectStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-select': FrigateCardSelect;
}
}