From d0a6f0928f6781b3aa6f5cf71f269c4f0a9116e9 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 15 Jan 2023 11:30:32 -0800 Subject: [PATCH] Add 'infinite' gallery support. --- src/camera/manager.ts | 155 ++++++++++++++++++++++++++--------- src/card.ts | 4 +- src/components/gallery.ts | 147 ++++++++++++++++++++++++++------- src/components/viewer.ts | 2 +- src/scss/dotdotdot.scss | 23 ++++++ src/scss/gallery.scss | 7 +- src/scss/message.scss | 28 +------ src/utils/basic.ts | 8 ++ src/utils/media-to-view.ts | 4 +- src/utils/timeline-source.ts | 2 +- src/view/media-queries.ts | 19 +---- src/view/view.ts | 2 + 12 files changed, 286 insertions(+), 115 deletions(-) create mode 100644 src/scss/dotdotdot.scss diff --git a/src/camera/manager.ts b/src/camera/manager.ts index 2dc1cb2d..27bb146b 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -5,6 +5,7 @@ import { DataQuery, EventQuery, EventQueryResults, + MediaQuery, PartialDataQuery, PartialEventQuery, PartialQueryConcreteType, @@ -61,6 +62,11 @@ export class QueryResultClassifier { } } +export interface ExtendedMediaQueryResult { + queries: T[]; + results: ViewMedia[]; +} + export type RequestCache = MemoryRequestCache; export class CameraManager { @@ -171,57 +177,89 @@ export class CameraManager { return await this._handleQuery(hass, query); } - public async executeMediaQuery( + public async executeMediaQueries( hass: HomeAssistant, - mediaQuerys: MediaQueries, + mediaQueries: MediaQueries, ): Promise { - const queries: (RecordingQuery | EventQuery)[] | null = mediaQuerys.getQueries(); + const queries: (RecordingQuery | EventQuery)[] | null = mediaQueries.getQueries(); if (!queries) { return null; } - - const results = 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); - } - } - } + const mediaArray = this._convertQueryResultsToMedia( + await this._handleQuery(hass, queries), + ); return new MediaQueriesResults( - 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', - ), + mediaArray, // Select the first (most-recent) item. mediaArray.length ? 0 : null, ); } + public async extendMediaQueries( + hass: HomeAssistant, + queries: T[], + results: ViewMedia[], + direction: 'earlier' | 'later', + chunkSize: number, + ): Promise | 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 { const cameraConfig = this._cameras.get(media.getCameraID()); const engine = cameraConfig @@ -362,4 +400,45 @@ export class CameraManager { ); return results; } + + protected _convertQueryResultsToMedia( + results: Map>, + ): 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', + ); + } } diff --git a/src/card.ts b/src/card.ts index bccf1e5a..8a224290 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1021,9 +1021,11 @@ export class FrigateCard extends LitElement { if (View.isMediaChange(this._view, view)) { this._currentMediaLoadedInfo = null; } + if (this._view?.view !== view.view) { + this._resetMainScroll(); + } this._view = view; this._generateConditionState(); - this._resetMainScroll(); }; if (args?.resetMessage ?? true) { diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 6ac59663..766534b2 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -7,7 +7,7 @@ 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 { CameraConfig, @@ -22,12 +22,20 @@ import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents, } 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 { 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'; + +const GALLERY_MEDIA_CHUNK_SIZE = 100; @customElement('frigate-card-gallery') export class FrigateCardGallery extends LitElement { @@ -136,11 +144,19 @@ export class FrigateCardGalleryCore extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + protected _intersectionObserver: IntersectionObserver; protected _resizeObserver: ResizeObserver; + protected _refSentinel: Ref = createRef(); + + @state() + protected _showExtensionLoader = true; constructor() { super(); 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 { super.connectedCallback(); 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 { this._resizeObserver.disconnect(); + this._intersectionObserver.disconnect(); super.disconnectedCallback(); } @@ -184,6 +205,57 @@ export class FrigateCardGalleryCore extends LitElement { this._setColumnCount(); } + protected async _intersectionHandler( + entries: IntersectionObserverEntry[], + ): Promise { + 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 | 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. * @param changedProps The changed properties @@ -203,6 +275,9 @@ export class FrigateCardGalleryCore extends LitElement { ); } } + if (changedProps.has('view')) { + this._showExtensionLoader = true; + } } /** @@ -222,33 +297,47 @@ export class FrigateCardGalleryCore extends LitElement { return html``; } - return html` ${results.map( - (media, index) => - html` { - if (this.view) { - this.view - .evolve({ - view: 'media', - queryResults: this.view.queryResults?.clone().selectResult(index), - }) - .dispatchChangeEvent(this); - } - stopEventFromActivatingCardWideActions(ev); - }} - > - `, - )}`; + return html` + ${results.map( + (media, index) => + html` { + if (this.view) { + this.view + .evolve({ + view: 'media', + queryResults: this.view.queryResults?.clone().selectResult(index), + }) + .dispatchChangeEvent(this); + } + stopEventFromActivatingCardWideActions(ev); + }} + > + `, + )} + ${this._showExtensionLoader + ? html` + + ` + : ''} + `; + } + + public updated(): void { + if (this._refSentinel.value) { + this._intersectionObserver.disconnect(); + this._intersectionObserver.observe(this._refSentinel.value); + } } /** diff --git a/src/components/viewer.ts b/src/components/viewer.ts index dfc9310b..3c53afe1 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -382,7 +382,7 @@ export class FrigateCardViewerCarousel extends LitElement { let results: MediaQueriesResults | null; try { - results = await this.cameraManager.executeMediaQuery(this.hass, clipQuery); + results = await this.cameraManager.executeMediaQueries(this.hass, clipQuery); } catch (e) { errorToConsole(e as Error); return; diff --git a/src/scss/dotdotdot.scss b/src/scss/dotdotdot.scss new file mode 100644 index 00000000..784a6b45 --- /dev/null +++ b/src/scss/dotdotdot.scss @@ -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; +} diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss index ad22eb9d..93f95bda 100644 --- a/src/scss/gallery.scss +++ b/src/scss/gallery.scss @@ -1,3 +1,5 @@ +@use 'dotdotdot.scss'; + :host { width: 100%; height: auto; @@ -31,7 +33,10 @@ text-align: center; color: var(--primary-text-color, white); 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 // color. diff --git a/src/scss/message.scss b/src/scss/message.scss index 953c9331..ff52434d 100644 --- a/src/scss/message.scss +++ b/src/scss/message.scss @@ -1,3 +1,5 @@ +@use 'dotdotdot.scss'; + :host { min-height: 100%; width: 100%; @@ -56,28 +58,4 @@ div.message div.icon { .message ha-icon, ha-circular-progress { 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; -} +} \ No newline at end of file diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 4a1c5e81..040781bd 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -155,3 +155,11 @@ export function getDurationString(start: Date, end: Date): string { duration += `${seconds}s`; return duration; } + +/** + * For debug purposes only. + * @param seconds + */ +export const sleep = async (seconds: number) => { + await new Promise(r => setTimeout(r, seconds * 1000)); +} \ No newline at end of file diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 22517e6d..58f70f8a 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -63,7 +63,7 @@ export const createViewForEvents = async ( let queryResults: MediaQueriesResults | null; try { - queryResults = await cameraManager.executeMediaQuery(hass, query); + queryResults = await cameraManager.executeMediaQueries(hass, query); } catch (e) { errorToConsole(e as Error); dispatchFrigateCardErrorEvent(element, e as Error); @@ -146,7 +146,7 @@ export const createViewForRecordings = async ( let queryResults: MediaQueriesResults | null; try { - queryResults = await cameraManager.executeMediaQuery(hass, query); + queryResults = await cameraManager.executeMediaQueries(hass, query); } catch (e) { errorToConsole(e as Error); dispatchFrigateCardErrorEvent(element, e as Error); diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index 9caf462c..3ddd9136 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -139,7 +139,7 @@ export class TimelineDataSource { 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() ?? []) { const endTime = media.getEndTime(); const startTime = media.getStartTime(); diff --git a/src/view/media-queries.ts b/src/view/media-queries.ts index 0a674619..abdb8784 100644 --- a/src/view/media-queries.ts +++ b/src/view/media-queries.ts @@ -7,7 +7,7 @@ export type MediaQueries = EventMediaQueries | RecordingMediaQueries; export class MediaQueriesBase { protected _queries: T[] | null = null; - protected constructor(queries?: T[]) { + public constructor(queries?: T[]) { if (queries) { this._queries = queries; } @@ -28,20 +28,9 @@ export class MediaQueriesBase { public setQueries(queries: T[]): void { 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 { - constructor(queries?: EventQuery[]) { - super(queries); - } - public convertToClipsQueries(): void { for (const query of this._queries ?? []) { delete query.hasSnapshot; @@ -54,8 +43,4 @@ export class EventMediaQueries extends MediaQueriesBase { } } -export class RecordingMediaQueries extends MediaQueriesBase { - constructor(queries?: RecordingQuery[]) { - super(queries); - } -} +export class RecordingMediaQueries extends MediaQueriesBase {} diff --git a/src/view/view.ts b/src/view/view.ts index 24893d6c..fb355802 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,6 +1,8 @@ // Minor / later: // - 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. +// - TODO: Event gallery show_details default does not work. // Hard: // - TODO: Implement gallery.