Add 'infinite' gallery support.

This commit is contained in:
Dermot Duffy
2023-01-24 19:36:54 -08:00
parent 7b0cde0e50
commit d0a6f0928f
12 changed files with 286 additions and 115 deletions
+117 -38
View File
@@ -5,6 +5,7 @@ import {
DataQuery, DataQuery,
EventQuery, EventQuery,
EventQueryResults, EventQueryResults,
MediaQuery,
PartialDataQuery, PartialDataQuery,
PartialEventQuery, PartialEventQuery,
PartialQueryConcreteType, PartialQueryConcreteType,
@@ -61,6 +62,11 @@ export class QueryResultClassifier {
} }
} }
export interface ExtendedMediaQueryResult<T extends MediaQuery> {
queries: T[];
results: ViewMedia[];
}
export type RequestCache = MemoryRequestCache<DataQuery, QueryResults>; export type RequestCache = MemoryRequestCache<DataQuery, QueryResults>;
export class CameraManager { export class CameraManager {
@@ -171,57 +177,89 @@ export class CameraManager {
return await this._handleQuery(hass, query); return await this._handleQuery(hass, query);
} }
public async executeMediaQuery( public async executeMediaQueries(
hass: HomeAssistant, hass: HomeAssistant,
mediaQuerys: MediaQueries, mediaQueries: MediaQueries,
): Promise<MediaQueriesResults | null> { ): Promise<MediaQueriesResults | null> {
const queries: (RecordingQuery | EventQuery)[] | null = mediaQuerys.getQueries(); const queries: (RecordingQuery | EventQuery)[] | null = mediaQueries.getQueries();
if (!queries) { if (!queries) {
return null; return null;
} }
const mediaArray = this._convertQueryResultsToMedia(
const results = await this._handleQuery(hass, queries); await this._handleQuery(hass, queries),
);
const mediaArray: ViewMedia[] = [];
for (const [query, result] of results.entries()) {
const cameraConfig = this._cameras.get(query.cameraID);
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
if (engine && cameraConfig) {
let media: ViewMedia[] | null = null;
if (
QueryClassifier.isEventQuery(query) &&
QueryResultClassifier.isEventQueryResult(result)
) {
media = engine.generateMediaFromEvents(cameraConfig, query, result);
} else if (
QueryClassifier.isRecordingQuery(query) &&
QueryResultClassifier.isRecordingQuery(result)
) {
media = engine.generateMediaFromRecordings(cameraConfig, query, result);
}
if (media) {
mediaArray.push(...media);
}
}
}
return new MediaQueriesResults( return new MediaQueriesResults(
orderBy( mediaArray,
// Ensure uniqueness by the ID (if specified), otherwise all elements
// are assumed to be unique.
uniqBy(mediaArray, (media) => media.getID() ?? media),
// Sort all items leading with the most recent.
(media) => media.getStartTime(),
'desc',
),
// Select the first (most-recent) item. // Select the first (most-recent) item.
mediaArray.length ? 0 : null, mediaArray.length ? 0 : null,
); );
} }
public async extendMediaQueries<T extends MediaQuery>(
hass: HomeAssistant,
queries: T[],
results: ViewMedia[],
direction: 'earlier' | 'later',
chunkSize: number,
): Promise<ExtendedMediaQueryResult<T> | null> {
const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => {
let output: Date | null = null;
for (const result of results) {
const startTime = result.getStartTime();
if (
startTime &&
(!output ||
(want === 'earliest' && startTime < output) ||
(want === 'latest' && startTime > output))
) {
output = startTime;
}
}
return output;
};
// The queries associated with the chunk to fetch.
const newChunkQueries: T[] = [];
// The re-constituted combined query.
const newCombinedQueries: T[] = [];
for (const query of queries) {
const newChunkQuery = { ...query };
const newCombinedQuery = { ...query };
if (direction === 'later') {
newChunkQuery.start = getTimeFromResults('latest') ?? undefined;
newChunkQuery.end = undefined;
newCombinedQuery.end = undefined;
} else if (direction === 'earlier') {
newChunkQuery.end = getTimeFromResults('earliest') ?? undefined;
newChunkQuery.start = undefined;
newCombinedQuery.start = undefined;
}
newChunkQuery.limit = chunkSize;
newCombinedQuery.limit = (query.limit ?? 0) + chunkSize;
newCombinedQueries.push(newCombinedQuery);
newChunkQueries.push(newChunkQuery);
}
const newChunkMedia = this._convertQueryResultsToMedia(
await this._handleQuery(hass, newChunkQueries),
);
if (!newChunkMedia.length) {
return null;
}
return {
queries: newCombinedQueries,
results: this._sortMedia(results.concat(newChunkMedia)),
};
}
public getMediaDownloadPath(media: ViewMedia): string | null { public getMediaDownloadPath(media: ViewMedia): string | null {
const cameraConfig = this._cameras.get(media.getCameraID()); const cameraConfig = this._cameras.get(media.getCameraID());
const engine = cameraConfig const engine = cameraConfig
@@ -362,4 +400,45 @@ export class CameraManager {
); );
return results; return results;
} }
protected _convertQueryResultsToMedia<QT extends DataQuery>(
results: Map<QT, QueryReturnType<QT>>,
): ViewMedia[] {
const mediaArray: ViewMedia[] = [];
for (const [query, result] of results.entries()) {
const cameraConfig = this._cameras.get(query.cameraID);
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
if (engine && cameraConfig) {
let media: ViewMedia[] | null = null;
if (
QueryClassifier.isEventQuery(query) &&
QueryResultClassifier.isEventQueryResult(result)
) {
media = engine.generateMediaFromEvents(cameraConfig, query, result);
} else if (
QueryClassifier.isRecordingQuery(query) &&
QueryResultClassifier.isRecordingQuery(result)
) {
media = engine.generateMediaFromRecordings(cameraConfig, query, result);
}
if (media) {
mediaArray.push(...media);
}
}
}
return this._sortMedia(mediaArray);
}
protected _sortMedia(mediaArray: ViewMedia[]): ViewMedia[] {
return orderBy(
// Ensure uniqueness by the ID (if specified), otherwise all elements
// are assumed to be unique.
uniqBy(mediaArray, (media) => media.getID() ?? media),
// Sort all items leading with the most recent.
(media) => media.getStartTime(),
'desc',
);
}
} }
+3 -1
View File
@@ -1021,9 +1021,11 @@ export class FrigateCard extends LitElement {
if (View.isMediaChange(this._view, view)) { if (View.isMediaChange(this._view, view)) {
this._currentMediaLoadedInfo = null; this._currentMediaLoadedInfo = null;
} }
if (this._view?.view !== view.view) {
this._resetMainScroll();
}
this._view = view; this._view = view;
this._generateConditionState(); this._generateConditionState();
this._resetMainScroll();
}; };
if (args?.resetMessage ?? true) { if (args?.resetMessage ?? true) {
+93 -4
View File
@@ -7,7 +7,7 @@ import {
TemplateResult, TemplateResult,
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import galleryStyle from '../scss/gallery.scss'; import galleryStyle from '../scss/gallery.scss';
import { import {
CameraConfig, CameraConfig,
@@ -22,12 +22,20 @@ import {
changeViewToRecentEventsForCameraAndDependents, changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents,
} from '../utils/media-to-view.js'; } from '../utils/media-to-view.js';
import { CameraManager } from '../camera/manager.js'; import { CameraManager, ExtendedMediaQueryResult } from '../camera/manager.js';
import { View } from '../view/view.js'; import { View } from '../view/view.js';
import { renderProgressIndicator } from './message.js'; import { renderProgressIndicator } from './message.js';
import './thumbnail.js'; import './thumbnail.js';
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
import './media-filter'; 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';
const GALLERY_MEDIA_CHUNK_SIZE = 100;
@customElement('frigate-card-gallery') @customElement('frigate-card-gallery')
export class FrigateCardGallery extends LitElement { export class FrigateCardGallery extends LitElement {
@@ -136,11 +144,19 @@ export class FrigateCardGalleryCore extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public cameraManager?: CameraManager; public cameraManager?: CameraManager;
protected _intersectionObserver: IntersectionObserver;
protected _resizeObserver: ResizeObserver; protected _resizeObserver: ResizeObserver;
protected _refSentinel: Ref<HTMLElement> = createRef();
@state()
protected _showExtensionLoader = true;
constructor() { constructor() {
super(); super();
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
this._intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this),
);
} }
/** /**
@@ -149,6 +165,10 @@ export class FrigateCardGalleryCore extends LitElement {
connectedCallback(): void { connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
this._resizeObserver.observe(this); this._resizeObserver.observe(this);
// Request update in order to ensure the intersection observer reconnects
// with the sentinel.
this.requestUpdate();
} }
/** /**
@@ -156,6 +176,7 @@ export class FrigateCardGalleryCore extends LitElement {
*/ */
disconnectedCallback(): void { disconnectedCallback(): void {
this._resizeObserver.disconnect(); this._resizeObserver.disconnect();
this._intersectionObserver.disconnect();
super.disconnectedCallback(); super.disconnectedCallback();
} }
@@ -184,6 +205,57 @@ export class FrigateCardGalleryCore extends LitElement {
this._setColumnCount(); this._setColumnCount();
} }
protected async _intersectionHandler(
entries: IntersectionObserverEntry[],
): Promise<void> {
if (!this.cameraManager || !this.hass || !this.view) {
return;
}
if (entries.every((entry) => !entry.isIntersecting)) {
return;
}
this._showExtensionLoader = false;
const query = this.view?.query;
const rawQueries: MediaQuery[] | null = query?.getQueries() ?? null;
const existingMedia = this.view.queryResults?.getResults();
if (!query || !rawQueries || !existingMedia) {
return;
}
let extension: ExtendedMediaQueryResult<MediaQuery> | null;
try {
extension = await this.cameraManager.extendMediaQueries(
this.hass,
rawQueries,
existingMedia,
'earlier',
GALLERY_MEDIA_CHUNK_SIZE,
);
} 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),
})
.dispatchChangeEvent(this);
}
}
}
/** /**
* Called when an update will occur. * Called when an update will occur.
* @param changedProps The changed properties * @param changedProps The changed properties
@@ -203,6 +275,9 @@ export class FrigateCardGalleryCore extends LitElement {
); );
} }
} }
if (changedProps.has('view')) {
this._showExtensionLoader = true;
}
} }
/** /**
@@ -222,7 +297,8 @@ export class FrigateCardGalleryCore extends LitElement {
return html``; return html``;
} }
return html` ${results.map( return html`
${results.map(
(media, index) => (media, index) =>
html`<frigate-card-thumbnail html`<frigate-card-thumbnail
.hass=${this.hass} .hass=${this.hass}
@@ -248,7 +324,20 @@ export class FrigateCardGalleryCore extends LitElement {
}} }}
> >
</frigate-card-thumbnail>`, </frigate-card-thumbnail>`,
)}`; )}
${this._showExtensionLoader
? html` <ha-card class="sentinel" ${ref(this._refSentinel)}>
<span class="dotdotdot"></span>
</ha-card>`
: ''}
`;
}
public updated(): void {
if (this._refSentinel.value) {
this._intersectionObserver.disconnect();
this._intersectionObserver.observe(this._refSentinel.value);
}
} }
/** /**
+1 -1
View File
@@ -382,7 +382,7 @@ export class FrigateCardViewerCarousel extends LitElement {
let results: MediaQueriesResults | null; let results: MediaQueriesResults | null;
try { try {
results = await this.cameraManager.executeMediaQuery(this.hass, clipQuery); results = await this.cameraManager.executeMediaQueries(this.hass, clipQuery);
} catch (e) { } catch (e) {
errorToConsole(e as Error); errorToConsole(e as Error);
return; return;
+23
View File
@@ -0,0 +1,23 @@
.dotdotdot:after {
@keyframes dots {
0%,
20% {
content: '.';
}
40% {
content: '..';
}
60% {
content: '...';
}
90%,
100% {
content: '';
}
}
animation: dots 2s linear infinite;
content: '';
display: inline-block;
width: 3em;
}
+6 -1
View File
@@ -1,3 +1,5 @@
@use 'dotdotdot.scss';
:host { :host {
width: 100%; width: 100%;
height: auto; height: auto;
@@ -31,7 +33,10 @@
text-align: center; text-align: center;
color: var(--primary-text-color, white); color: var(--primary-text-color, white);
border: 1px solid var(--primary-color); border: 1px solid var(--primary-color);
border-radius: var(--frigate-card-css-border-radius, var(--ha-card-border-radius, 4px)); border-radius: var(
--frigate-card-css-border-radius,
var(--ha-card-border-radius, 4px)
);
// Folder background color should match the thumbnail element background // Folder background color should match the thumbnail element background
// color. // color.
+2 -24
View File
@@ -1,3 +1,5 @@
@use 'dotdotdot.scss';
:host { :host {
min-height: 100%; min-height: 100%;
width: 100%; width: 100%;
@@ -57,27 +59,3 @@ div.message div.icon {
.message ha-icon, ha-circular-progress { .message ha-icon, ha-circular-progress {
padding: 10px; padding: 10px;
} }
.dotdotdot:after {
@keyframes dots {
0%,
20% {
content: '.';
}
40% {
content: '..';
}
60% {
content: '...';
}
90%,
100% {
content: '';
}
}
animation: dots 2s linear infinite;
content: '';
display: inline-block;
width: 3em;
}
+8
View File
@@ -155,3 +155,11 @@ export function getDurationString(start: Date, end: Date): string {
duration += `${seconds}s`; duration += `${seconds}s`;
return duration; return duration;
} }
/**
* For debug purposes only.
* @param seconds
*/
export const sleep = async (seconds: number) => {
await new Promise(r => setTimeout(r, seconds * 1000));
}
+2 -2
View File
@@ -63,7 +63,7 @@ export const createViewForEvents = async (
let queryResults: MediaQueriesResults | null; let queryResults: MediaQueriesResults | null;
try { try {
queryResults = await cameraManager.executeMediaQuery(hass, query); queryResults = await cameraManager.executeMediaQueries(hass, query);
} catch (e) { } catch (e) {
errorToConsole(e as Error); errorToConsole(e as Error);
dispatchFrigateCardErrorEvent(element, e as Error); dispatchFrigateCardErrorEvent(element, e as Error);
@@ -146,7 +146,7 @@ export const createViewForRecordings = async (
let queryResults: MediaQueriesResults | null; let queryResults: MediaQueriesResults | null;
try { try {
queryResults = await cameraManager.executeMediaQuery(hass, query); queryResults = await cameraManager.executeMediaQueries(hass, query);
} catch (e) { } catch (e) {
errorToConsole(e as Error); errorToConsole(e as Error);
dispatchFrigateCardErrorEvent(element, e as Error); dispatchFrigateCardErrorEvent(element, e as Error);
+1 -1
View File
@@ -139,7 +139,7 @@ export class TimelineDataSource {
this.getTimelineEventQueries(cacheFriendlyWindow), this.getTimelineEventQueries(cacheFriendlyWindow),
); );
const results = await this._cameraManager.executeMediaQuery(hass, query); const results = await this._cameraManager.executeMediaQueries(hass, query);
for (const media of results?.getResults() ?? []) { for (const media of results?.getResults() ?? []) {
const endTime = media.getEndTime(); const endTime = media.getEndTime();
const startTime = media.getStartTime(); const startTime = media.getStartTime();
+2 -17
View File
@@ -7,7 +7,7 @@ export type MediaQueries = EventMediaQueries | RecordingMediaQueries;
export class MediaQueriesBase<T extends MediaQuery> { export class MediaQueriesBase<T extends MediaQuery> {
protected _queries: T[] | null = null; protected _queries: T[] | null = null;
protected constructor(queries?: T[]) { public constructor(queries?: T[]) {
if (queries) { if (queries) {
this._queries = queries; this._queries = queries;
} }
@@ -28,20 +28,9 @@ export class MediaQueriesBase<T extends MediaQuery> {
public setQueries(queries: T[]): void { public setQueries(queries: T[]): void {
this._queries = queries; this._queries = queries;
} }
public setQueriesTime(start: Date, end: Date) {
for (const query of this._queries ?? []) {
query.start = start;
query.end = end;
}
}
} }
export class EventMediaQueries extends MediaQueriesBase<EventQuery> { export class EventMediaQueries extends MediaQueriesBase<EventQuery> {
constructor(queries?: EventQuery[]) {
super(queries);
}
public convertToClipsQueries(): void { public convertToClipsQueries(): void {
for (const query of this._queries ?? []) { for (const query of this._queries ?? []) {
delete query.hasSnapshot; delete query.hasSnapshot;
@@ -54,8 +43,4 @@ export class EventMediaQueries extends MediaQueriesBase<EventQuery> {
} }
} }
export class RecordingMediaQueries extends MediaQueriesBase<RecordingQuery> { export class RecordingMediaQueries extends MediaQueriesBase<RecordingQuery> {}
constructor(queries?: RecordingQuery[]) {
super(queries);
}
}
+2
View File
@@ -1,6 +1,8 @@
// Minor / later: // Minor / later:
// - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript
// - TODO: getRecordingTitle should use getCameraTitle but need hass. // - TODO: getRecordingTitle should use getCameraTitle but need hass.
// - TODO: Take MediaQueries wrappers out of the camera manager.
// - TODO: Event gallery show_details default does not work.
// Hard: // Hard:
// - TODO: Implement gallery. // - TODO: Implement gallery.