Add support for 'scroll up refresh' in the gallery.
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
CameraEndpoints,
|
||||
MediaMetadataQuery,
|
||||
MediaMetadataQueryResultsMap,
|
||||
EngineOptions,
|
||||
} from './types';
|
||||
|
||||
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
|
||||
@@ -58,18 +59,21 @@ export interface CameraManagerEngine {
|
||||
hass: HomeAssistant,
|
||||
cameras: CameraConfigs,
|
||||
query: EventQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<EventQueryResultsMap | null>;
|
||||
|
||||
getRecordings(
|
||||
hass: HomeAssistant,
|
||||
cameras: CameraConfigs,
|
||||
query: RecordingQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<RecordingQueryResultsMap | null>;
|
||||
|
||||
getRecordingSegments(
|
||||
hass: HomeAssistant,
|
||||
cameras: CameraConfigs,
|
||||
query: RecordingSegmentsQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<RecordingSegmentsQueryResultsMap | null>;
|
||||
|
||||
generateMediaFromEvents(
|
||||
@@ -102,12 +106,14 @@ export interface CameraManagerEngine {
|
||||
cameras: CameraConfigs,
|
||||
media: ViewMedia,
|
||||
target: Date,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<number | null>;
|
||||
|
||||
getMediaMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameras: CameraConfigs,
|
||||
query: MediaMetadataQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<MediaMetadataQueryResultsMap | null>;
|
||||
|
||||
getCameraMetadata(
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
MediaMetadataQuery,
|
||||
MediaMetadataQueryResults,
|
||||
MediaMetadataQueryResultsMap,
|
||||
EngineOptions,
|
||||
} from '../types';
|
||||
import {
|
||||
FrigateEventQueryResults,
|
||||
@@ -426,6 +427,7 @@ export class FrigateCameraManagerEngine
|
||||
hass: HomeAssistant,
|
||||
cameras: CameraConfigs,
|
||||
query: EventQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<EventQueryResultsMap | null> {
|
||||
const output: EventQueryResultsMap = new Map();
|
||||
|
||||
@@ -437,7 +439,8 @@ export class FrigateCameraManagerEngine
|
||||
return;
|
||||
}
|
||||
const instanceQuery = { ...query, cameraIDs: cameraIDs };
|
||||
const cachedResult = this._requestCache.get(instanceQuery);
|
||||
const cachedResult =
|
||||
engineOptions?.useCache ?? true ? this._requestCache.get(instanceQuery) : null;
|
||||
if (cachedResult) {
|
||||
output.set(query, cachedResult as EventQueryResults);
|
||||
return;
|
||||
@@ -467,7 +470,9 @@ export class FrigateCameraManagerEngine
|
||||
cached: false,
|
||||
};
|
||||
|
||||
if (engineOptions?.useCache ?? true) {
|
||||
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
|
||||
}
|
||||
output.set(instanceQuery, result);
|
||||
};
|
||||
|
||||
@@ -491,6 +496,7 @@ export class FrigateCameraManagerEngine
|
||||
hass: HomeAssistant,
|
||||
cameras: CameraConfigs,
|
||||
query: RecordingQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<RecordingQueryResultsMap | null> {
|
||||
const output: RecordingQueryResultsMap = new Map();
|
||||
|
||||
@@ -499,7 +505,8 @@ export class FrigateCameraManagerEngine
|
||||
cameraID: string,
|
||||
): Promise<void> => {
|
||||
const query = { ...baseQuery, cameraIDs: new Set([cameraID]) };
|
||||
const cachedResult = this._requestCache.get(query);
|
||||
const cachedResult =
|
||||
engineOptions?.useCache ?? true ? this._requestCache.get(query) : null;
|
||||
if (cachedResult) {
|
||||
output.set(query, cachedResult as RecordingQueryResults);
|
||||
return;
|
||||
@@ -557,7 +564,9 @@ export class FrigateCameraManagerEngine
|
||||
}),
|
||||
cached: false,
|
||||
};
|
||||
if (engineOptions?.useCache ?? true) {
|
||||
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
|
||||
}
|
||||
output.set(query, result);
|
||||
};
|
||||
|
||||
@@ -573,6 +582,7 @@ export class FrigateCameraManagerEngine
|
||||
hass: HomeAssistant,
|
||||
cameras: CameraConfigs,
|
||||
query: RecordingSegmentsQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<RecordingSegmentsQueryResultsMap | null> {
|
||||
const output: RecordingSegmentsQueryResultsMap = new Map();
|
||||
|
||||
@@ -595,7 +605,10 @@ export class FrigateCameraManagerEngine
|
||||
// query is different -- the segments won't be). This is since the
|
||||
// volume of data in segment transfers can be high, and the segments can
|
||||
// be used in high frequency situations (e.g. video seeking).
|
||||
const cachedSegments = this._recordingSegmentsCache.get(cameraID, range);
|
||||
const cachedSegments =
|
||||
engineOptions?.useCache ?? true
|
||||
? this._recordingSegmentsCache.get(cameraID, range)
|
||||
: null;
|
||||
if (cachedSegments) {
|
||||
output.set(query, <FrigateRecordingSegmentsQueryResults>{
|
||||
type: QueryResultsType.RecordingSegments,
|
||||
@@ -615,7 +628,10 @@ export class FrigateCameraManagerEngine
|
||||
};
|
||||
|
||||
const segments = await getRecordingSegments(hass, request);
|
||||
|
||||
if (engineOptions?.useCache ?? true) {
|
||||
this._recordingSegmentsCache.add(cameraID, range, segments);
|
||||
}
|
||||
|
||||
output.set(query, <FrigateRecordingSegmentsQueryResults>{
|
||||
type: QueryResultsType.RecordingSegments,
|
||||
@@ -757,6 +773,7 @@ export class FrigateCameraManagerEngine
|
||||
cameras: CameraConfigs,
|
||||
media: ViewMedia,
|
||||
target: Date,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<number | null> {
|
||||
const start = media.getStartTime();
|
||||
const end = media.getEndTime();
|
||||
@@ -772,7 +789,7 @@ export class FrigateCameraManagerEngine
|
||||
type: QueryType.RecordingSegments,
|
||||
};
|
||||
|
||||
const results = await this.getRecordingSegments(hass, cameras, query);
|
||||
const results = await this.getRecordingSegments(hass, cameras, query, engineOptions);
|
||||
|
||||
if (results) {
|
||||
return this._getSeekTimeInSegments(
|
||||
@@ -811,9 +828,10 @@ export class FrigateCameraManagerEngine
|
||||
hass: HomeAssistant,
|
||||
cameras: CameraConfigs,
|
||||
query: MediaMetadataQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<MediaMetadataQueryResultsMap | null> {
|
||||
const output: MediaMetadataQueryResultsMap = new Map();
|
||||
if (this._requestCache.has(query)) {
|
||||
if ((engineOptions?.useCache ?? true) && this._requestCache.has(query)) {
|
||||
const cachedResult = <MediaMetadataQueryResults | null>(
|
||||
this._requestCache.get(query)
|
||||
);
|
||||
@@ -860,10 +878,15 @@ export class FrigateCameraManagerEngine
|
||||
};
|
||||
|
||||
const processRecordings = async (cameraIDs: Set<string>): Promise<void> => {
|
||||
const recordings = await this.getRecordings(hass, cameras, {
|
||||
const recordings = await this.getRecordings(
|
||||
hass,
|
||||
cameras,
|
||||
{
|
||||
type: QueryType.Recording,
|
||||
cameraIDs: cameraIDs,
|
||||
});
|
||||
},
|
||||
engineOptions,
|
||||
);
|
||||
if (!recordings) {
|
||||
return;
|
||||
}
|
||||
@@ -902,7 +925,9 @@ export class FrigateCameraManagerEngine
|
||||
cached: false,
|
||||
};
|
||||
|
||||
if (engineOptions?.useCache ?? true) {
|
||||
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
|
||||
}
|
||||
output.set(query, result);
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
DataQuery,
|
||||
EventQuery,
|
||||
EventQueryResultsMap,
|
||||
MediaMetadata,
|
||||
PartialEventQuery,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
CameraEndpoints,
|
||||
MediaMetadataQuery,
|
||||
MediaMetadataQueryResultsMap,
|
||||
EngineOptions,
|
||||
} from '../types';
|
||||
import { getEntityIcon, getEntityTitle } from '../../utils/ha';
|
||||
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
|
||||
@@ -71,6 +71,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
_hass: HomeAssistant,
|
||||
_cameras: CameraConfigs,
|
||||
_query: EventQuery,
|
||||
_engineOptions?: EngineOptions,
|
||||
): Promise<EventQueryResultsMap | null> {
|
||||
return null;
|
||||
}
|
||||
@@ -79,6 +80,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
_hass: HomeAssistant,
|
||||
_cameras: CameraConfigs,
|
||||
_query: RecordingQuery,
|
||||
_engineOptions?: EngineOptions,
|
||||
): Promise<RecordingQueryResultsMap | null> {
|
||||
return null;
|
||||
}
|
||||
@@ -87,6 +89,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
_hass: HomeAssistant,
|
||||
_cameras: CameraConfigs,
|
||||
_query: RecordingSegmentsQuery,
|
||||
_engineOptions?: EngineOptions,
|
||||
): Promise<RecordingSegmentsQueryResultsMap | null> {
|
||||
return null;
|
||||
}
|
||||
@@ -134,6 +137,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
_cameras: CameraConfigs,
|
||||
_media: ViewMedia,
|
||||
_target: Date,
|
||||
_engineOptions?: EngineOptions,
|
||||
): Promise<number | null> {
|
||||
return null;
|
||||
}
|
||||
@@ -142,6 +146,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
_hass: HomeAssistant,
|
||||
_cameras: CameraConfigs,
|
||||
_query: MediaMetadataQuery,
|
||||
_engineOptions?: EngineOptions,
|
||||
): Promise<MediaMetadataQueryResultsMap | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
Engine,
|
||||
MediaMetadataQuery,
|
||||
MediaMetadataQueryResults,
|
||||
EngineOptions,
|
||||
} from './types.js';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import { CameraManagerEngineFactory } from './engine-factory.js';
|
||||
@@ -361,31 +362,35 @@ export class CameraManager {
|
||||
public async getEvents(
|
||||
hass: HomeAssistant,
|
||||
query: EventQuery | EventQuery[],
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<EventQueryResultsMap> {
|
||||
return await this._handleQuery(hass, query);
|
||||
return await this._handleQuery(hass, query, engineOptions);
|
||||
}
|
||||
|
||||
public async getRecordings(
|
||||
hass: HomeAssistant,
|
||||
query: RecordingQuery | RecordingQuery[],
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<RecordingQueryResultsMap> {
|
||||
return await this._handleQuery(hass, query);
|
||||
return await this._handleQuery(hass, query, engineOptions);
|
||||
}
|
||||
|
||||
public async getRecordingSegments(
|
||||
hass: HomeAssistant,
|
||||
query: RecordingSegmentsQuery | RecordingSegmentsQuery[],
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<RecordingSegmentsQueryResultsMap> {
|
||||
return await this._handleQuery(hass, query);
|
||||
return await this._handleQuery(hass, query, engineOptions);
|
||||
}
|
||||
|
||||
public async executeMediaQueries<T extends MediaQuery>(
|
||||
hass: HomeAssistant,
|
||||
queries: T[],
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<ViewMedia[] | null> {
|
||||
return this._convertQueryResultsToMedia(
|
||||
hass,
|
||||
await this._handleQuery(hass, queries),
|
||||
await this._handleQuery(hass, queries, engineOptions),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -394,6 +399,7 @@ export class CameraManager {
|
||||
queries: T[],
|
||||
results: ViewMedia[],
|
||||
direction: 'earlier' | 'later',
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<ExtendedMediaQueryResult<T> | null> {
|
||||
const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => {
|
||||
let output: Date | null = null;
|
||||
@@ -446,7 +452,7 @@ export class CameraManager {
|
||||
|
||||
const newChunkMedia = this._convertQueryResultsToMedia(
|
||||
hass,
|
||||
await this._handleQuery(hass, newChunkQueries),
|
||||
await this._handleQuery(hass, newChunkQueries, engineOptions),
|
||||
);
|
||||
|
||||
if (!newChunkMedia.length) {
|
||||
@@ -554,6 +560,7 @@ export class CameraManager {
|
||||
protected async _handleQuery<QT extends DataQuery>(
|
||||
hass: HomeAssistant,
|
||||
query: QT | QT[],
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<Map<QT, QueryReturnType<QT>>> {
|
||||
const _queries = arrayify(query);
|
||||
const results = new Map<QT, QueryReturnType<QT>>();
|
||||
@@ -573,24 +580,28 @@ export class CameraManager {
|
||||
hass,
|
||||
this._store.getCameras(),
|
||||
query,
|
||||
engineOptions,
|
||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||
} else if (QueryClassifier.isRecordingQuery(query)) {
|
||||
engineResult = (await engine.getRecordings(
|
||||
hass,
|
||||
this._store.getCameras(),
|
||||
query,
|
||||
engineOptions,
|
||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
|
||||
engineResult = (await engine.getRecordingSegments(
|
||||
hass,
|
||||
this._store.getCameras(),
|
||||
query,
|
||||
engineOptions,
|
||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||
} else if (QueryClassifier.isMediaMetadataQuery(query)) {
|
||||
engineResult = (await engine.getMediaMetadata(
|
||||
hass,
|
||||
this._store.getCameras(),
|
||||
query,
|
||||
engineOptions,
|
||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,10 @@ export interface CameraEndpoints {
|
||||
|
||||
export type CameraConfigs = Map<string, CameraConfig>;
|
||||
|
||||
export interface EngineOptions {
|
||||
useCache?: boolean;
|
||||
}
|
||||
|
||||
// ===========
|
||||
// Event Query
|
||||
// ===========
|
||||
|
||||
+135
-14
@@ -31,17 +31,21 @@ 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 } from '../utils/basic';
|
||||
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 {
|
||||
@property({ attribute: false })
|
||||
@@ -167,14 +171,42 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
|
||||
protected _intersectionObserver: IntersectionObserver;
|
||||
protected _resizeObserver: ResizeObserver;
|
||||
protected _refLoader: Ref<HTMLElement> = createRef();
|
||||
protected _refLoaderBottom: Ref<HTMLElement> = createRef();
|
||||
protected _refSelected: Ref<HTMLElement> = 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 _showExtensionLoader = true;
|
||||
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));
|
||||
@@ -183,12 +215,72 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
if (!this.scrollTop && ev.deltaY < 0) {
|
||||
await this._extendLater();
|
||||
}
|
||||
}
|
||||
|
||||
protected async _extendLater(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component connected callback.
|
||||
*/
|
||||
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.
|
||||
@@ -199,6 +291,9 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
* 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();
|
||||
@@ -232,14 +327,21 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
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;
|
||||
this._showLoaderBottom = false;
|
||||
await this._extendGallery('earlier');
|
||||
}
|
||||
|
||||
protected async _extendGallery(
|
||||
direction: 'earlier' | 'later',
|
||||
useCache = true,
|
||||
): Promise<void> {
|
||||
if (!this.cameraManager || !this.hass || !this.view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = this.view?.query;
|
||||
const rawQueries = query?.getQueries() ?? null;
|
||||
@@ -254,7 +356,10 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
this.hass,
|
||||
rawQueries,
|
||||
existingMedia,
|
||||
'earlier',
|
||||
direction,
|
||||
{
|
||||
useCache: useCache,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
@@ -272,7 +377,9 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
this.view
|
||||
?.evolve({
|
||||
query: newMediaQueries,
|
||||
queryResults: new MediaQueriesResults(extension.results),
|
||||
queryResults: new MediaQueriesResults(extension.results).selectResultIfFound(
|
||||
(media) => media === this.view?.queryResults?.getSelectedResult(),
|
||||
),
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
@@ -299,7 +406,9 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
}
|
||||
}
|
||||
if (changedProps.has('view')) {
|
||||
this._showExtensionLoader = true;
|
||||
// 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 (
|
||||
@@ -330,10 +439,22 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
|
||||
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`<frigate-card-thumbnail
|
||||
${media === selected ? ref(this._refSelected) : ''}
|
||||
class=${classMap({
|
||||
selected: media === selected,
|
||||
})}
|
||||
.hass=${this.hass}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.media=${media}
|
||||
@@ -361,19 +482,19 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
>
|
||||
</frigate-card-thumbnail>`,
|
||||
)}
|
||||
${this._showExtensionLoader
|
||||
${this._showLoaderBottom
|
||||
? html`${renderProgressIndicator({
|
||||
cardWideConfig: this.cardWideConfig,
|
||||
componentRef: this._refLoader,
|
||||
componentRef: this._refLoaderBottom,
|
||||
})}`
|
||||
: ''}
|
||||
`;
|
||||
}
|
||||
|
||||
public updated(changedProps: PropertyValues): void {
|
||||
if (this._refLoader.value) {
|
||||
if (this._refLoaderBottom.value) {
|
||||
this._intersectionObserver.disconnect();
|
||||
this._intersectionObserver.observe(this._refLoader.value);
|
||||
this._intersectionObserver.observe(this._refLoaderBottom.value);
|
||||
}
|
||||
|
||||
// This wait for updateComplete is necessary for the scrolling to work
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { ClassInfo, classMap } from 'lit/directives/class-map.js';
|
||||
import { ref, Ref } from 'lit/directives/ref.js';
|
||||
import { TROUBLESHOOTING_URL } from '../const.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
@@ -29,7 +29,7 @@ export class FrigateCardMessage extends LitElement {
|
||||
dotdotdot: !!this.dotdotdot,
|
||||
};
|
||||
return html` <div class="wrapper">
|
||||
<div class="message">
|
||||
<div class="message padded">
|
||||
<div class="icon">
|
||||
<ha-icon icon="${icon}"> </ha-icon>
|
||||
</div>
|
||||
@@ -78,6 +78,8 @@ export class FrigateCardErrorMessage extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
type FrigateCardProgressIndicatorSize = 'tiny' | 'small' | 'medium' | 'large';
|
||||
|
||||
@customElement('frigate-card-progress-indicator')
|
||||
export class FrigateCardProgressIndicator extends LitElement {
|
||||
@property({ attribute: false })
|
||||
@@ -86,10 +88,14 @@ export class FrigateCardProgressIndicator extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public animated = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public size: FrigateCardProgressIndicatorSize = 'large';
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html` <div class="message vertical">
|
||||
${this.animated
|
||||
? html`<ha-circular-progress active="true" size="large"> </ha-circular-progress>`
|
||||
? html`<ha-circular-progress active="true" size="${this.size}">
|
||||
</ha-circular-progress>`
|
||||
: html`<ha-icon icon="mdi:timer-sand"></ha-icon>`}
|
||||
${this.message ? html`<span>${this.message}</span>` : html``}
|
||||
</div>`;
|
||||
@@ -119,10 +125,14 @@ export function renderMessage(message: Message): TemplateResult {
|
||||
export function renderProgressIndicator(options?: {
|
||||
message?: string;
|
||||
cardWideConfig?: CardWideConfig;
|
||||
componentRef?: Ref<HTMLElement>,
|
||||
componentRef?: Ref<HTMLElement>;
|
||||
classes?: ClassInfo;
|
||||
size?: FrigateCardProgressIndicatorSize;
|
||||
}): TemplateResult {
|
||||
return html`
|
||||
<frigate-card-progress-indicator
|
||||
class="${classMap(options?.classes ?? {})}"
|
||||
.size=${options?.size}
|
||||
${options?.componentRef ? ref(options.componentRef) : ''}
|
||||
.message=${options?.message || ''}
|
||||
.animated=${options?.cardWideConfig?.performance?.features
|
||||
|
||||
@@ -30,3 +30,19 @@ frigate-card-thumbnail {
|
||||
frigate-card-thumbnail:not([details]) {
|
||||
width: 100%;
|
||||
}
|
||||
frigate-card-thumbnail.selected {
|
||||
border: 4px solid var(--accent-color);
|
||||
// Because this is box-sizing: border-box, the border is effectively
|
||||
// 'padding'. To get the curved borders to line up between the thumbnail and
|
||||
// this outer border, we need to add the size of the border to the thumbnail
|
||||
// image border radius.
|
||||
// Related: https://www.30secondsofcode.org/articles/s/css-nested-border-radius
|
||||
border-radius: calc(
|
||||
var(--frigate-card-css-border-radius, var(--ha-card-border-radius, 4px)) + 4px
|
||||
);
|
||||
}
|
||||
|
||||
frigate-card-progress-indicator.top {
|
||||
// The top loading progress indicator should span the whole width.
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
|
||||
@@ -24,10 +24,13 @@ div.message {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
div.message.padded {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
div.message div.contents {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -55,7 +58,3 @@ div.message div.icon {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.message ha-icon, ha-circular-progress {
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -191,3 +191,8 @@ export const isSuperset = (superset: Set<unknown>, subset: Set<unknown>) => {
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Usage of this function needs to be justified with a comment.
|
||||
export const sleep = async (seconds: number) => {
|
||||
await new Promise((r) => setTimeout(r, seconds * 1000));
|
||||
};
|
||||
|
||||
@@ -5,12 +5,3 @@ export const log = (cardWideConfig?: CardWideConfig, ...args: unknown[]) => {
|
||||
console.debug(...args);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* For debug purposes only.
|
||||
* @param seconds
|
||||
*/
|
||||
// ts-prune-ignore-next
|
||||
export const sleep = async (seconds: number) => {
|
||||
await new Promise((r) => setTimeout(r, seconds * 1000));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user