Add 'infinite' gallery support.
This commit is contained in:
+117
-38
@@ -5,6 +5,7 @@ import {
|
||||
DataQuery,
|
||||
EventQuery,
|
||||
EventQueryResults,
|
||||
MediaQuery,
|
||||
PartialDataQuery,
|
||||
PartialEventQuery,
|
||||
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 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<MediaQueriesResults | null> {
|
||||
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<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 {
|
||||
const cameraConfig = this._cameras.get(media.getCameraID());
|
||||
const engine = cameraConfig
|
||||
@@ -362,4 +400,45 @@ export class CameraManager {
|
||||
);
|
||||
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
@@ -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) {
|
||||
|
||||
@@ -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<HTMLElement> = 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<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.
|
||||
* @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` ${results.map(
|
||||
return html`
|
||||
${results.map(
|
||||
(media, index) =>
|
||||
html`<frigate-card-thumbnail
|
||||
.hass=${this.hass}
|
||||
@@ -248,7 +324,20 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
}}
|
||||
>
|
||||
</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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+2
-24
@@ -1,3 +1,5 @@
|
||||
@use 'dotdotdot.scss';
|
||||
|
||||
:host {
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
@@ -57,27 +59,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -7,7 +7,7 @@ export type MediaQueries = EventMediaQueries | RecordingMediaQueries;
|
||||
export class MediaQueriesBase<T extends MediaQuery> {
|
||||
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<T extends MediaQuery> {
|
||||
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<EventQuery> {
|
||||
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<EventQuery> {
|
||||
}
|
||||
}
|
||||
|
||||
export class RecordingMediaQueries extends MediaQueriesBase<RecordingQuery> {
|
||||
constructor(queries?: RecordingQuery[]) {
|
||||
super(queries);
|
||||
}
|
||||
}
|
||||
export class RecordingMediaQueries extends MediaQueriesBase<RecordingQuery> {}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user