(this, 'carousel:select', selected);
@@ -256,8 +221,10 @@ export class FrigateCardCarousel extends LitElement {
// Make sure every select causes a refresh to allow for re-paint of the
// next/previous controls.
this.requestUpdate();
- });
+ };
+ this._carousel.on('init', selectSlide);
+ this._carousel.on('select', selectSlide);
this._carousel.on('scroll', () => {
this._scrolling = true;
});
@@ -286,18 +253,14 @@ export class FrigateCardCarousel extends LitElement {
protected _slotChanged(): void {
// Cannot just re-init, because the slide elements themselves may have
// changed, and only a carousel init can pass in new (slotted) children. If
- // the slides themselves change, any position the user has set is assumed to
- // be abandoned and so the startIndex is reset to whatever the carousel was
- // originally configured with.
- this._destroyCarousel({ savePosition: false });
+ this._destroyCarousel();
this.requestUpdate();
}
protected render(): TemplateResult | void {
const slides = this._refSlot.value?.assignedElements({ flatten: true }) || [];
- const currentSlide = this._carousel?.selectedScrollSnap() ?? 0;
- const showPrevious = this.carouselOptions?.loop || currentSlide > 0;
- const showNext = this.carouselOptions?.loop || currentSlide + 1 < slides.length;
+ const showPrevious = this.carouselOptions?.loop || this.selected > 0;
+ const showNext = this.carouselOptions?.loop || this.selected + 1 < slides.length;
return html`
${showPrevious ? html`` : ``}
diff --git a/src/components/date-picker.ts b/src/components/date-picker.ts
new file mode 100644
index 00000000..36cdd277
--- /dev/null
+++ b/src/components/date-picker.ts
@@ -0,0 +1,44 @@
+import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
+import 'lit-flatpickr';
+import { LitFlatpickr } from 'lit-flatpickr';
+import { customElement } from 'lit/decorators.js';
+import { createRef, ref, Ref } from 'lit/directives/ref.js';
+import datePickerStyle from '../scss/date-picker.scss';
+import { dispatchFrigateCardEvent } from '../utils/basic';
+
+export interface DatePickerEvent {
+ date: Date;
+}
+
+@customElement('frigate-card-date-picker')
+export class FrigateCardDatePicker extends LitElement {
+ protected _refInput: Ref = createRef();
+
+ public open(): void {
+ this._refInput.value?.open();
+ }
+
+ protected render(): TemplateResult {
+ return html` {
+ if (dates.length) {
+ // This is a single date picker, there should be only a single date.
+ dispatchFrigateCardEvent(this, 'date-picker:change', {
+ date: dates[0],
+ });
+ }
+ }}
+ >`;
+ }
+
+ static get styles(): CSSResultGroup {
+ return unsafeCSS(datePickerStyle);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-date-picker': FrigateCardDatePicker;
+ }
+}
diff --git a/src/components/drawer.ts b/src/components/drawer.ts
index dfff296a..0639c707 100644
--- a/src/components/drawer.ts
+++ b/src/components/drawer.ts
@@ -15,6 +15,11 @@ import drawerStyle from '../scss/drawer.scss';
import { stopEventFromActivatingCardWideActions } from '../utils/action';
import { isHoverableDevice } from '../utils/basic';
+export interface DrawerIcons {
+ open?: string;
+ closed?: string;
+}
+
@customElement('frigate-card-drawer')
export class FrigateCardDrawer extends LitElement {
@property({ attribute: true, reflect: true })
@@ -26,6 +31,9 @@ export class FrigateCardDrawer extends LitElement {
@property({ type: Boolean, reflect: true, attribute: true })
public open = false;
+ @property({ attribute: false })
+ public icons?: DrawerIcons;
+
// The 'empty' attribute is used in the styling to change the drawer
// visibility and that of all descendants if there is no content. Styling is
// used rather than display or hidden in order to ensure the contents continue
@@ -111,7 +119,9 @@ export class FrigateCardDrawer extends LitElement {
>
{
// Only open the drawer on mousenter when the device
// supports hover (otherwise iOS may end up passing on
@@ -126,7 +136,7 @@ export class FrigateCardDrawer extends LitElement {
`
: ''}
-
+ this._slotChanged()}>
`;
}
diff --git a/src/components/embla-plugins/automedia.ts b/src/components/embla-plugins/automedia.ts
index 078eac90..a4f3dbd6 100644
--- a/src/components/embla-plugins/automedia.ts
+++ b/src/components/embla-plugins/automedia.ts
@@ -27,7 +27,7 @@ const defaultOptions: OptionsType = {
breakpoints: {},
};
-export type AutoMediaOptionsType = Partial
+type AutoMediaOptionsType = Partial
export type AutoMediaType = CreatePluginType<
{
diff --git a/src/components/embla-plugins/lazyload.ts b/src/components/embla-plugins/lazyload.ts
index 3fbdb9f4..311ee6cc 100644
--- a/src/components/embla-plugins/lazyload.ts
+++ b/src/components/embla-plugins/lazyload.ts
@@ -3,7 +3,7 @@ import { CreatePluginType } from 'embla-carousel/components/Plugins';
import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
import { LazyUnloadCondition } from '../../types';
-export type OptionsType = CreateOptionsType<{
+type OptionsType = CreateOptionsType<{
// Number of slides to lazyload left/right of selected (0 == only selected
// slide).
lazyLoadCount?: number;
@@ -13,15 +13,15 @@ export type OptionsType = CreateOptionsType<{
lazyUnloadCallback?: (index: number, slide: HTMLElement) => void;
}>;
-export const defaultOptions: OptionsType = {
+const defaultOptions: OptionsType = {
active: true,
breakpoints: {},
lazyLoadCount: 0,
};
-export type LazyloadOptionsType = Partial;
+type LazyloadOptionsType = Partial;
-export type LazyloadType = CreatePluginType<
+type LazyloadType = CreatePluginType<
{
hasLazyloaded(index: number): boolean;
},
diff --git a/src/components/gallery.ts b/src/components/gallery.ts
index d0391272..c331be0d 100644
--- a/src/components/gallery.ts
+++ b/src/components/gallery.ts
@@ -1,6 +1,4 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
import {
- css,
CSSResultGroup,
html,
LitElement,
@@ -8,10 +6,11 @@ import {
TemplateResult,
unsafeCSS,
} from 'lit';
-import { customElement, property } from 'lit/decorators.js';
+import { customElement, property, state } from 'lit/decorators.js';
import galleryStyle from '../scss/gallery.scss';
+import galleryCoreStyle from '../scss/gallery-core.scss';
import {
- CameraConfig,
+ CardWideConfig,
ExtendedHomeAssistant,
frigateCardConfigDefaults,
GalleryConfig,
@@ -19,14 +18,33 @@ import {
} from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import {
- fetchChildMediaAndDispatchViewChange,
- fetchLatestMediaAndDispatchViewChange,
- getFullDependentBrowseMediaQueryParametersOrDispatchError,
-} from '../utils/ha/browse-media';
-import { View } from '../view.js';
-import { renderProgressIndicator } from './message.js';
+ changeViewToRecentEventsForCameraAndDependents,
+ changeViewToRecentRecordingForCameraAndDependents,
+} from '../utils/media-to-view.js';
+import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js';
+import { View } from '../view/view.js';
+import { renderMessage, renderProgressIndicator } from './message.js';
import './thumbnail.js';
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
+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-manager/types';
+import { MediaQueriesResults } from '../view/media-queries-results';
+import { errorToConsole, sleep } from '../utils/basic';
+import './media-filter';
+import './surround-basic';
+import { ViewMedia } from '../view/media';
+import { localize } from '../localize/localize';
+import throttle from 'lodash-es/throttle';
+import { classMap } from 'lit/directives/class-map.js';
+
+const GALLERY_MEDIA_FILTER_MENU_ICONS = {
+ closed: 'mdi:filter-cog-outline',
+ open: 'mdi:filter-cog',
+};
+
+const MIN_GALLERY_EXTENSION_SECONDS = 0.5;
@customElement('frigate-card-gallery')
export class FrigateCardGallery extends LitElement {
@@ -40,69 +58,88 @@ export class FrigateCardGallery extends LitElement {
public galleryConfig?: GalleryConfig;
@property({ attribute: false })
- public cameras?: Map;
+ public cameraManager?: CameraManager;
+
+ @property({ attribute: false })
+ public cardWideConfig?: CardWideConfig;
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
- const mediaType = this.view?.getMediaType();
if (
!this.hass ||
!this.view ||
- !this.cameras ||
!this.view.isGalleryView() ||
- !mediaType
+ !this.cameraManager ||
+ !this.cardWideConfig
) {
return;
}
- if (!this.view.target) {
- const browseMediaQueryParameters =
- getFullDependentBrowseMediaQueryParametersOrDispatchError(
+ if (!this.view.query) {
+ if (this.view.is('recordings')) {
+ changeViewToRecentRecordingForCameraAndDependents(
this,
this.hass,
- this.cameras,
- this.view.camera,
- mediaType,
+ this.cameraManager,
+ this.cardWideConfig,
+ this.view,
+ );
+ } else {
+ const mediaType = this.view.is('snapshots')
+ ? 'snapshots'
+ : this.view.is('clips')
+ ? 'clips'
+ : null;
+ changeViewToRecentEventsForCameraAndDependents(
+ this,
+ this.hass,
+ this.cameraManager,
+ this.cardWideConfig,
+ this.view,
+ {
+ ...(mediaType && { mediaType: mediaType }),
+ },
);
-
- if (!browseMediaQueryParameters) {
- return;
}
-
- fetchLatestMediaAndDispatchViewChange(
- this,
- this.hass,
- this.view,
- browseMediaQueryParameters,
- );
- return renderProgressIndicator();
+ return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
}
return html`
-
-
+ ${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none'
+ ? html`
+ `
+ : ''}
+
+
+
`;
}
- /**
- * Get element styles.
- */
static get styles(): CSSResultGroup {
- return css`
- :host {
- display: block;
- width: 100%;
- height: 100%;
- }
- `;
+ return unsafeCSS(galleryStyle);
}
}
@@ -118,13 +155,112 @@ export class FrigateCardGalleryCore extends LitElement {
public galleryConfig?: GalleryConfig;
@property({ attribute: false })
- public cameras?: Map;
+ public cameraManager?: CameraManager;
+ @property({ attribute: false })
+ public cardWideConfig?: CardWideConfig;
+
+ protected _intersectionObserver: IntersectionObserver;
protected _resizeObserver: ResizeObserver;
+ protected _refLoaderBottom: Ref = createRef();
+ protected _refSelected: Ref = createRef();
+
+ // Bottom loader: A progress indicator shown in a "cell" (not across) at the
+ // bottom of the gallery. Once visible this attempts to fetch new content from
+ // "earlier" (less recently) than the current query. This is rendered by
+ // default (and once visible, the fetch is triggered after which it is
+ // re-hidden).
+ @state()
+ protected _showLoaderBottom = true;
+
+ // Top loader: A progress indicator is shown across the top of the gallery if
+ // the user is _already_ at the top of the gallery and scrolls upwards. This
+ // attempts to fetch new content from "later" (more recently) than the current
+ // query. This is hidden by default.
+ @state()
+ protected _showLoaderTop = false;
+
+ protected _media?: ViewMedia[];
+
+ protected _boundWheelHandler = this._wheelHandler.bind(this);
+ protected _boundTouchStartHandler = this._touchStartHandler.bind(this);
+ protected _boundTouchEndHandler = this._touchEndHandler.bind(this);
+
+ // Wheel / touch events may be voluminous, throttle extension calls.
+ protected _throttleExtendGalleryLater = throttle(
+ this._extendGallery.bind(this),
+ MIN_GALLERY_EXTENSION_SECONDS * 1000,
+ {
+ leading: true,
+ trailing: false,
+ },
+ );
+
+ protected _touchScrollYPosition: number | null = null;
constructor() {
super();
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
+ this._intersectionObserver = new IntersectionObserver(
+ this._intersectionHandler.bind(this),
+ );
+ }
+
+ // Since the scroll event does not fire if the user is already at the top of
+ // the container, instead we manually use the wheel and touchstart/end events
+ // to detect "top upwards scrolling" (to trigger an extension of the gallery).
+
+ protected _touchStartHandler(ev: TouchEvent): void {
+ // Remember the Y touch position on touch start, so that we can calculate if
+ // the user gestured upwards or downards on touchend.
+ if (ev.touches.length === 1) {
+ this._touchScrollYPosition = ev.touches[0].screenY;
+ } else {
+ this._touchScrollYPosition = null;
+ }
+ }
+
+ protected async _touchEndHandler(ev: TouchEvent): Promise {
+ if (
+ !this.scrollTop &&
+ ev.changedTouches.length === 1 &&
+ this._touchScrollYPosition
+ ) {
+ if (ev.changedTouches[0].screenY > this._touchScrollYPosition) {
+ await this._extendLater();
+ }
+ }
+ this._touchScrollYPosition = null;
+ }
+
+ protected async _wheelHandler(ev: WheelEvent): Promise {
+ if (!this.scrollTop && ev.deltaY < 0) {
+ await this._extendLater();
+ }
+ }
+
+ protected async _extendLater(): Promise {
+ const start = new Date();
+ this._showLoaderTop = true;
+ await this._throttleExtendGalleryLater(
+ 'later',
+ // Ask the engine to avoid use of cache since the user is explicitly
+ // looking for the freshest possible data.
+ false,
+ );
+ const delta = new Date().getTime() - start.getTime();
+ if (delta < MIN_GALLERY_EXTENSION_SECONDS * 1000) {
+ // Hidden gem: "legitimate" (?!) use of sleep() :-)
+ // These calls can return very quickly even with caching disabled since
+ // the time window constraints on the query will usually be very narrow
+ // and the backend can thus very quickly reply. It's often so fast it
+ // actually looks like a rendering issue where the progress indictor
+ // barely registers before it's gone again. This optional pause ensures
+ // there is at least some visual feedback to the user that last long
+ // enough they can 'feel' the fetch has happened.
+ await sleep(MIN_GALLERY_EXTENSION_SECONDS - delta / 1000);
+ }
+ this._showLoaderTop = false;
}
/**
@@ -133,13 +269,24 @@ export class FrigateCardGalleryCore extends LitElement {
connectedCallback(): void {
super.connectedCallback();
this._resizeObserver.observe(this);
+ this.addEventListener('wheel', this._boundWheelHandler, { passive: true });
+ this.addEventListener('touchstart', this._boundTouchStartHandler, { passive: true });
+ this.addEventListener('touchend', this._boundTouchEndHandler);
+
+ // Request update in order to ensure the intersection observer reconnects
+ // with the loader sentinel.
+ this.requestUpdate();
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
+ this.removeEventListener('wheel', this._boundWheelHandler);
+ this.removeEventListener('touchstart', this._boundTouchStartHandler);
+ this.removeEventListener('touchend', this._boundTouchEndHandler);
this._resizeObserver.disconnect();
+ this._intersectionObserver.disconnect();
super.disconnectedCallback();
}
@@ -149,7 +296,7 @@ export class FrigateCardGalleryCore extends LitElement {
protected _setColumnCount(): void {
const thumbnailSize =
this.galleryConfig?.controls.thumbnails.size ??
- frigateCardConfigDefaults.event_gallery.controls.thumbnails.size;
+ frigateCardConfigDefaults.media_gallery.controls.thumbnails.size;
const columns = this.galleryConfig?.controls.thumbnails.show_details
? Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN))
: Math.max(
@@ -168,16 +315,66 @@ export class FrigateCardGalleryCore extends LitElement {
this._setColumnCount();
}
- /**
- * Determine whether the back arrow should be displayed.
- * @returns `true` if the back arrow should be displayed, `false` otherwise.
- */
- protected _showBackArrow(): boolean {
- return (
- !!this.view?.previous &&
- !!this.view.previous.target &&
- this.view.previous.view === this.view.view
- );
+ protected async _intersectionHandler(
+ entries: IntersectionObserverEntry[],
+ ): Promise {
+ if (entries.every((entry) => !entry.isIntersecting)) {
+ return;
+ }
+
+ this._showLoaderBottom = false;
+ await this._extendGallery('earlier');
+ }
+
+ protected async _extendGallery(
+ direction: 'earlier' | 'later',
+ useCache = true,
+ ): Promise {
+ if (!this.cameraManager || !this.hass || !this.view) {
+ return;
+ }
+
+ const query = this.view?.query;
+ const rawQueries = query?.getQueries() ?? null;
+ const existingMedia = this.view.queryResults?.getResults();
+ if (!query || !rawQueries || !existingMedia) {
+ return;
+ }
+
+ let extension: ExtendedMediaQueryResult | null;
+ try {
+ extension = await this.cameraManager.extendMediaQueries(
+ this.hass,
+ rawQueries,
+ existingMedia,
+ direction,
+ {
+ useCache: useCache,
+ },
+ );
+ } catch (e) {
+ errorToConsole(e as Error);
+ return;
+ }
+
+ if (extension) {
+ const newMediaQueries = MediaQueriesClassifier.areEventQueries(query)
+ ? new EventMediaQueries(extension.queries as EventQuery[])
+ : MediaQueriesClassifier.areRecordingQueries(query)
+ ? new RecordingMediaQueries(extension.queries as RecordingQuery[])
+ : null;
+
+ if (newMediaQueries) {
+ this.view
+ ?.evolve({
+ query: newMediaQueries,
+ queryResults: new MediaQueriesResults(extension.results).selectResultIfFound(
+ (media) => media === this.view?.queryResults?.getSelectedResult(),
+ ),
+ })
+ .dispatchChangeEvent(this);
+ }
+ }
}
/**
@@ -199,6 +396,21 @@ export class FrigateCardGalleryCore extends LitElement {
);
}
}
+ if (changedProps.has('view')) {
+ // If the view changes, always render the bottom loader to allow for the
+ // view to be extended once the bottom loader becomes visible.
+ this._showLoaderBottom = true;
+ const oldView: View | undefined = changedProps.get('view');
+
+ if (
+ oldView?.queryResults?.getResults() !== this.view?.queryResults?.getResults()
+ ) {
+ // Gallery places the most recent media at the top (the query results place
+ // the most recent media at the end for use in the viewer). This is copied
+ // to a new array to avoid reversing the query results in place.
+ this._media = [...(this.view?.queryResults?.getResults() ?? [])].reverse();
+ }
+ }
}
/**
@@ -206,90 +418,106 @@ export class FrigateCardGalleryCore extends LitElement {
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
- if (
- !this.hass ||
- !this.view ||
- !this.view.target ||
- !this.view.target.children ||
- !(this.view.is('clips') || this.view.is('snapshots')) ||
- !this.cameras
- ) {
+ if (!this._media || !this.hass || !this.view || !this.view.isGalleryView()) {
return html``;
}
- const cameraConfig = this.cameras.get(this.view.camera);
- return html`
- ${this._showBackArrow()
- ? html` {
- if (this.view && this.view.previous) {
- this.view.previous.dispatchChangeEvent(this);
+ if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) {
+ // Note that this is not throwing up an error message for the card to
+ // handle (as typical), but rather directly rendering the message into the
+ // gallery. This is to allow the filter to still be available when a given
+ // filter selection returns no media.
+ return renderMessage({
+ type: 'info',
+ message: localize('common.no_media'),
+ icon: 'mdi:multimedia',
+ });
+ }
+
+ const selected = this.view?.queryResults?.getSelectedResult();
+ return html`
+ ${this._showLoaderTop
+ ? html`${renderProgressIndicator({
+ cardWideConfig: this.cardWideConfig,
+ classes: {
+ top: true,
+ },
+ size: 'small',
+ })}`
+ : ''}
+ ${this._media.map(
+ (media, index) =>
+ html`