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",