Add initial media filtering for the gallery.

This commit is contained in:
Dermot Duffy
2023-01-24 21:55:38 -08:00
parent 6ca7ef85ec
commit bd19ed7a74
10 changed files with 208 additions and 116 deletions
+3 -2
View File
@@ -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,
};
+1
View File
@@ -103,6 +103,7 @@ export interface NativeFrigateEventQuery {
limit?: number;
has_clip?: boolean;
has_snapshot?: boolean;
favorites?: boolean;
}
/**
+6 -6
View File
@@ -87,31 +87,31 @@ export class CameraManager {
public generateDefaultEventQueries(
cameraIDs: string | Set<string>,
partialQuery: PartialEventQuery,
partialQuery?: PartialEventQuery,
): EventQuery[] | null {
return this._generateDefaultQueries(cameraIDs, {
...partialQuery,
type: QueryType.Event,
...partialQuery,
});
}
public generateDefaultRecordingQueries(
cameraIDs: string | Set<string>,
partialQuery: PartialRecordingQuery,
partialQuery?: PartialRecordingQuery,
): RecordingQuery[] | null {
return this._generateDefaultQueries(cameraIDs, {
...partialQuery,
type: QueryType.Recording,
...partialQuery,
});
}
public generateDefaultRecordingSegmentsQueries(
cameraIDs: string | Set<string>,
partialQuery: PartialRecordingSegmentsQuery,
partialQuery?: PartialRecordingSegmentsQuery,
): RecordingSegmentsQuery[] | null {
return this._generateDefaultQueries(cameraIDs, {
...partialQuery,
type: QueryType.RecordingSegments,
...partialQuery,
});
}
+2
View File
@@ -86,6 +86,8 @@ export interface EventQuery extends MediaQuery {
// Frigate equivalent: zone
where?: Set<string>;
favorite?: boolean;
}
export type PartialEventQuery = Partial<EventQuery>;
+2
View File
@@ -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"
>
</frigate-card-media-filter>
+118 -79
View File
@@ -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<T> {
}
export interface MediaFilterCoreSelection {
camera?: string[];
what?: string[];
where?: string[];
cameraIDs?: Set<string>;
what?: Set<string>;
where?: Set<string>;
when?: MediaFilterCoreWhenSelection;
favorite?: MediaFilterCoreFavoriteSelection;
}
@@ -25,13 +32,11 @@ type FilterElement<T> = 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<MediaFilterCoreWhenSelection>[];
public cameraOptions?: ValueLabel<string>[];
@property({ attribute: false })
public cameraOptions?: ValueLabel<string>[];
public whenOptions?: ValueLabel<MediaFilterCoreWhenSelection>[];
@property({ attribute: false })
public whatOptions?: ValueLabel<string>[];
@@ -61,54 +66,27 @@ export class FrigateCardMediaFilterCore extends LitElement {
@property({ attribute: false })
public whereOptions?: ValueLabel<string>[];
protected _refWhen: Ref<FilterElement<MediaFilterCoreWhenSelection>> = createRef();
@property({ attribute: false })
public defaults?: MediaFilterCoreSelection;
protected _cameraOptions?: ValueLabel<string>[];
protected _whenOptions?: ValueLabel<MediaFilterCoreWhenSelection>[];
protected _whatOptions?: ValueLabel<string>[];
protected _whereOptions?: ValueLabel<string>[];
protected _favoriteOptions: ValueLabel<MediaFilterCoreFavoriteSelection>[];
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();
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<MediaFilterCoreFavoriteSelection>[] = [
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`
<ha-combo-box
${ref(this._refWhen)}
.hass=${this.hass}
.label=${localize('media_filter.when')}
.items=${whenOptions}
.items=${this._whenOptions}
.allowCustomValue=${false}
@value-changed=${this._valueChangedHandler.bind(this)}
></ha-combo-box>
@@ -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)}
></ha-combo-box>`
@@ -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)}
></ha-combo-box>`
@@ -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)}
></ha-combo-box>`
@@ -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)}
></ha-combo-box>
+50 -16
View File
@@ -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<string>[] = [];
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<MediaFilterCoreSelection>): 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<MediaFilterCoreSelection>,
): Promise<void> {
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 {
+24 -12
View File
@@ -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<FetchThumbnailTaskArgs, string | null>;
// 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`<ha-icon
icon="mdi:image-off"
title=${localize('thumbnail.no_thumbnail')}
+1 -1
View File
@@ -42,7 +42,7 @@ export const fetchThumbnail = async (
});
};
type FetchThumbnailTaskArgs = [boolean, string | undefined];
export type FetchThumbnailTaskArgs = [boolean, string | undefined];
/**
* Create a Lit task to fetch a thumbnail.
+1
View File
@@ -9,6 +9,7 @@
// - TODO: Handle all TODOs in media filter and media filter core.
// - TODO: Make media filter slot choice configurable (gallery.ts).
// - TODO: Filter panel expands from right can occasionally 'stick' open.
// - TODO: Media type selection, i.e. events or recordings.
// Hard:
// - TODO: Implement gallery.