Merge pull request #997 from dermotduffy/scroll-up-extend

Add support for 'scroll up refresh' in the gallery
This commit is contained in:
Dermot Duffy
2023-03-07 19:30:18 -08:00
committed by GitHub
16 changed files with 277 additions and 59 deletions
+6
View File
@@ -23,6 +23,7 @@ import {
CameraEndpoints, CameraEndpoints,
MediaMetadataQuery, MediaMetadataQuery,
MediaMetadataQueryResultsMap, MediaMetadataQueryResultsMap,
EngineOptions,
} from './types'; } from './types';
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
@@ -58,18 +59,21 @@ export interface CameraManagerEngine {
hass: HomeAssistant, hass: HomeAssistant,
cameras: CameraConfigs, cameras: CameraConfigs,
query: EventQuery, query: EventQuery,
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null>; ): Promise<EventQueryResultsMap | null>;
getRecordings( getRecordings(
hass: HomeAssistant, hass: HomeAssistant,
cameras: CameraConfigs, cameras: CameraConfigs,
query: RecordingQuery, query: RecordingQuery,
engineOptions?: EngineOptions,
): Promise<RecordingQueryResultsMap | null>; ): Promise<RecordingQueryResultsMap | null>;
getRecordingSegments( getRecordingSegments(
hass: HomeAssistant, hass: HomeAssistant,
cameras: CameraConfigs, cameras: CameraConfigs,
query: RecordingSegmentsQuery, query: RecordingSegmentsQuery,
engineOptions?: EngineOptions,
): Promise<RecordingSegmentsQueryResultsMap | null>; ): Promise<RecordingSegmentsQueryResultsMap | null>;
generateMediaFromEvents( generateMediaFromEvents(
@@ -102,12 +106,14 @@ export interface CameraManagerEngine {
cameras: CameraConfigs, cameras: CameraConfigs,
media: ViewMedia, media: ViewMedia,
target: Date, target: Date,
engineOptions?: EngineOptions,
): Promise<number | null>; ): Promise<number | null>;
getMediaMetadata( getMediaMetadata(
hass: HomeAssistant, hass: HomeAssistant,
cameras: CameraConfigs, cameras: CameraConfigs,
query: MediaMetadataQuery, query: MediaMetadataQuery,
engineOptions?: EngineOptions,
): Promise<MediaMetadataQueryResultsMap | null>; ): Promise<MediaMetadataQueryResultsMap | null>;
getCameraMetadata( getCameraMetadata(
+32 -7
View File
@@ -39,6 +39,7 @@ import {
MediaMetadataQuery, MediaMetadataQuery,
MediaMetadataQueryResults, MediaMetadataQueryResults,
MediaMetadataQueryResultsMap, MediaMetadataQueryResultsMap,
EngineOptions,
} from '../types'; } from '../types';
import { import {
FrigateEventQueryResults, FrigateEventQueryResults,
@@ -426,6 +427,7 @@ export class FrigateCameraManagerEngine
hass: HomeAssistant, hass: HomeAssistant,
cameras: CameraConfigs, cameras: CameraConfigs,
query: EventQuery, query: EventQuery,
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null> { ): Promise<EventQueryResultsMap | null> {
const output: EventQueryResultsMap = new Map(); const output: EventQueryResultsMap = new Map();
@@ -437,7 +439,8 @@ export class FrigateCameraManagerEngine
return; return;
} }
const instanceQuery = { ...query, cameraIDs: cameraIDs }; const instanceQuery = { ...query, cameraIDs: cameraIDs };
const cachedResult = this._requestCache.get(instanceQuery); const cachedResult =
engineOptions?.useCache ?? true ? this._requestCache.get(instanceQuery) : null;
if (cachedResult) { if (cachedResult) {
output.set(query, cachedResult as EventQueryResults); output.set(query, cachedResult as EventQueryResults);
return; return;
@@ -467,7 +470,9 @@ export class FrigateCameraManagerEngine
cached: false, cached: false,
}; };
if (engineOptions?.useCache ?? true) {
this._requestCache.set(query, { ...result, cached: true }, result.expiry); this._requestCache.set(query, { ...result, cached: true }, result.expiry);
}
output.set(instanceQuery, result); output.set(instanceQuery, result);
}; };
@@ -491,6 +496,7 @@ export class FrigateCameraManagerEngine
hass: HomeAssistant, hass: HomeAssistant,
cameras: CameraConfigs, cameras: CameraConfigs,
query: RecordingQuery, query: RecordingQuery,
engineOptions?: EngineOptions,
): Promise<RecordingQueryResultsMap | null> { ): Promise<RecordingQueryResultsMap | null> {
const output: RecordingQueryResultsMap = new Map(); const output: RecordingQueryResultsMap = new Map();
@@ -499,7 +505,8 @@ export class FrigateCameraManagerEngine
cameraID: string, cameraID: string,
): Promise<void> => { ): Promise<void> => {
const query = { ...baseQuery, cameraIDs: new Set([cameraID]) }; 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) { if (cachedResult) {
output.set(query, cachedResult as RecordingQueryResults); output.set(query, cachedResult as RecordingQueryResults);
return; return;
@@ -557,7 +564,9 @@ export class FrigateCameraManagerEngine
}), }),
cached: false, cached: false,
}; };
if (engineOptions?.useCache ?? true) {
this._requestCache.set(query, { ...result, cached: true }, result.expiry); this._requestCache.set(query, { ...result, cached: true }, result.expiry);
}
output.set(query, result); output.set(query, result);
}; };
@@ -573,6 +582,7 @@ export class FrigateCameraManagerEngine
hass: HomeAssistant, hass: HomeAssistant,
cameras: CameraConfigs, cameras: CameraConfigs,
query: RecordingSegmentsQuery, query: RecordingSegmentsQuery,
engineOptions?: EngineOptions,
): Promise<RecordingSegmentsQueryResultsMap | null> { ): Promise<RecordingSegmentsQueryResultsMap | null> {
const output: RecordingSegmentsQueryResultsMap = new Map(); const output: RecordingSegmentsQueryResultsMap = new Map();
@@ -595,7 +605,10 @@ export class FrigateCameraManagerEngine
// query is different -- the segments won't be). This is since the // 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 // volume of data in segment transfers can be high, and the segments can
// be used in high frequency situations (e.g. video seeking). // 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) { if (cachedSegments) {
output.set(query, <FrigateRecordingSegmentsQueryResults>{ output.set(query, <FrigateRecordingSegmentsQueryResults>{
type: QueryResultsType.RecordingSegments, type: QueryResultsType.RecordingSegments,
@@ -615,7 +628,10 @@ export class FrigateCameraManagerEngine
}; };
const segments = await getRecordingSegments(hass, request); const segments = await getRecordingSegments(hass, request);
if (engineOptions?.useCache ?? true) {
this._recordingSegmentsCache.add(cameraID, range, segments); this._recordingSegmentsCache.add(cameraID, range, segments);
}
output.set(query, <FrigateRecordingSegmentsQueryResults>{ output.set(query, <FrigateRecordingSegmentsQueryResults>{
type: QueryResultsType.RecordingSegments, type: QueryResultsType.RecordingSegments,
@@ -757,6 +773,7 @@ export class FrigateCameraManagerEngine
cameras: CameraConfigs, cameras: CameraConfigs,
media: ViewMedia, media: ViewMedia,
target: Date, target: Date,
engineOptions?: EngineOptions,
): Promise<number | null> { ): Promise<number | null> {
const start = media.getStartTime(); const start = media.getStartTime();
const end = media.getEndTime(); const end = media.getEndTime();
@@ -772,7 +789,7 @@ export class FrigateCameraManagerEngine
type: QueryType.RecordingSegments, type: QueryType.RecordingSegments,
}; };
const results = await this.getRecordingSegments(hass, cameras, query); const results = await this.getRecordingSegments(hass, cameras, query, engineOptions);
if (results) { if (results) {
return this._getSeekTimeInSegments( return this._getSeekTimeInSegments(
@@ -811,9 +828,10 @@ export class FrigateCameraManagerEngine
hass: HomeAssistant, hass: HomeAssistant,
cameras: CameraConfigs, cameras: CameraConfigs,
query: MediaMetadataQuery, query: MediaMetadataQuery,
engineOptions?: EngineOptions,
): Promise<MediaMetadataQueryResultsMap | null> { ): Promise<MediaMetadataQueryResultsMap | null> {
const output: MediaMetadataQueryResultsMap = new Map(); const output: MediaMetadataQueryResultsMap = new Map();
if (this._requestCache.has(query)) { if ((engineOptions?.useCache ?? true) && this._requestCache.has(query)) {
const cachedResult = <MediaMetadataQueryResults | null>( const cachedResult = <MediaMetadataQueryResults | null>(
this._requestCache.get(query) this._requestCache.get(query)
); );
@@ -860,10 +878,15 @@ export class FrigateCameraManagerEngine
}; };
const processRecordings = async (cameraIDs: Set<string>): Promise<void> => { 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, type: QueryType.Recording,
cameraIDs: cameraIDs, cameraIDs: cameraIDs,
}); },
engineOptions,
);
if (!recordings) { if (!recordings) {
return; return;
} }
@@ -902,7 +925,9 @@ export class FrigateCameraManagerEngine
cached: false, cached: false,
}; };
if (engineOptions?.useCache ?? true) {
this._requestCache.set(query, { ...result, cached: true }, result.expiry); this._requestCache.set(query, { ...result, cached: true }, result.expiry);
}
output.set(query, result); output.set(query, result);
return output; return output;
} }
+6 -1
View File
@@ -9,7 +9,6 @@ import {
DataQuery, DataQuery,
EventQuery, EventQuery,
EventQueryResultsMap, EventQueryResultsMap,
MediaMetadata,
PartialEventQuery, PartialEventQuery,
PartialRecordingQuery, PartialRecordingQuery,
PartialRecordingSegmentsQuery, PartialRecordingSegmentsQuery,
@@ -25,6 +24,7 @@ import {
CameraEndpoints, CameraEndpoints,
MediaMetadataQuery, MediaMetadataQuery,
MediaMetadataQueryResultsMap, MediaMetadataQueryResultsMap,
EngineOptions,
} from '../types'; } from '../types';
import { getEntityIcon, getEntityTitle } from '../../utils/ha'; import { getEntityIcon, getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry'; import { EntityRegistryManager } from '../../utils/ha/entity-registry';
@@ -71,6 +71,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
_hass: HomeAssistant, _hass: HomeAssistant,
_cameras: CameraConfigs, _cameras: CameraConfigs,
_query: EventQuery, _query: EventQuery,
_engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null> { ): Promise<EventQueryResultsMap | null> {
return null; return null;
} }
@@ -79,6 +80,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
_hass: HomeAssistant, _hass: HomeAssistant,
_cameras: CameraConfigs, _cameras: CameraConfigs,
_query: RecordingQuery, _query: RecordingQuery,
_engineOptions?: EngineOptions,
): Promise<RecordingQueryResultsMap | null> { ): Promise<RecordingQueryResultsMap | null> {
return null; return null;
} }
@@ -87,6 +89,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
_hass: HomeAssistant, _hass: HomeAssistant,
_cameras: CameraConfigs, _cameras: CameraConfigs,
_query: RecordingSegmentsQuery, _query: RecordingSegmentsQuery,
_engineOptions?: EngineOptions,
): Promise<RecordingSegmentsQueryResultsMap | null> { ): Promise<RecordingSegmentsQueryResultsMap | null> {
return null; return null;
} }
@@ -134,6 +137,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
_cameras: CameraConfigs, _cameras: CameraConfigs,
_media: ViewMedia, _media: ViewMedia,
_target: Date, _target: Date,
_engineOptions?: EngineOptions,
): Promise<number | null> { ): Promise<number | null> {
return null; return null;
} }
@@ -142,6 +146,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
_hass: HomeAssistant, _hass: HomeAssistant,
_cameras: CameraConfigs, _cameras: CameraConfigs,
_query: MediaMetadataQuery, _query: MediaMetadataQuery,
_engineOptions?: EngineOptions,
): Promise<MediaMetadataQueryResultsMap | null> { ): Promise<MediaMetadataQueryResultsMap | null> {
return null; return null;
} }
+16 -5
View File
@@ -33,6 +33,7 @@ import {
Engine, Engine,
MediaMetadataQuery, MediaMetadataQuery,
MediaMetadataQueryResults, MediaMetadataQueryResults,
EngineOptions,
} from './types.js'; } from './types.js';
import orderBy from 'lodash-es/orderBy'; import orderBy from 'lodash-es/orderBy';
import { CameraManagerEngineFactory } from './engine-factory.js'; import { CameraManagerEngineFactory } from './engine-factory.js';
@@ -361,31 +362,35 @@ export class CameraManager {
public async getEvents( public async getEvents(
hass: HomeAssistant, hass: HomeAssistant,
query: EventQuery | EventQuery[], query: EventQuery | EventQuery[],
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap> { ): Promise<EventQueryResultsMap> {
return await this._handleQuery(hass, query); return await this._handleQuery(hass, query, engineOptions);
} }
public async getRecordings( public async getRecordings(
hass: HomeAssistant, hass: HomeAssistant,
query: RecordingQuery | RecordingQuery[], query: RecordingQuery | RecordingQuery[],
engineOptions?: EngineOptions,
): Promise<RecordingQueryResultsMap> { ): Promise<RecordingQueryResultsMap> {
return await this._handleQuery(hass, query); return await this._handleQuery(hass, query, engineOptions);
} }
public async getRecordingSegments( public async getRecordingSegments(
hass: HomeAssistant, hass: HomeAssistant,
query: RecordingSegmentsQuery | RecordingSegmentsQuery[], query: RecordingSegmentsQuery | RecordingSegmentsQuery[],
engineOptions?: EngineOptions,
): Promise<RecordingSegmentsQueryResultsMap> { ): Promise<RecordingSegmentsQueryResultsMap> {
return await this._handleQuery(hass, query); return await this._handleQuery(hass, query, engineOptions);
} }
public async executeMediaQueries<T extends MediaQuery>( public async executeMediaQueries<T extends MediaQuery>(
hass: HomeAssistant, hass: HomeAssistant,
queries: T[], queries: T[],
engineOptions?: EngineOptions,
): Promise<ViewMedia[] | null> { ): Promise<ViewMedia[] | null> {
return this._convertQueryResultsToMedia( return this._convertQueryResultsToMedia(
hass, hass,
await this._handleQuery(hass, queries), await this._handleQuery(hass, queries, engineOptions),
); );
} }
@@ -394,6 +399,7 @@ export class CameraManager {
queries: T[], queries: T[],
results: ViewMedia[], results: ViewMedia[],
direction: 'earlier' | 'later', direction: 'earlier' | 'later',
engineOptions?: EngineOptions,
): Promise<ExtendedMediaQueryResult<T> | null> { ): Promise<ExtendedMediaQueryResult<T> | null> {
const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => { const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => {
let output: Date | null = null; let output: Date | null = null;
@@ -446,7 +452,7 @@ export class CameraManager {
const newChunkMedia = this._convertQueryResultsToMedia( const newChunkMedia = this._convertQueryResultsToMedia(
hass, hass,
await this._handleQuery(hass, newChunkQueries), await this._handleQuery(hass, newChunkQueries, engineOptions),
); );
if (!newChunkMedia.length) { if (!newChunkMedia.length) {
@@ -554,6 +560,7 @@ export class CameraManager {
protected async _handleQuery<QT extends DataQuery>( protected async _handleQuery<QT extends DataQuery>(
hass: HomeAssistant, hass: HomeAssistant,
query: QT | QT[], query: QT | QT[],
engineOptions?: EngineOptions,
): Promise<Map<QT, QueryReturnType<QT>>> { ): Promise<Map<QT, QueryReturnType<QT>>> {
const _queries = arrayify(query); const _queries = arrayify(query);
const results = new Map<QT, QueryReturnType<QT>>(); const results = new Map<QT, QueryReturnType<QT>>();
@@ -573,24 +580,28 @@ export class CameraManager {
hass, hass,
this._store.getCameras(), this._store.getCameras(),
query, query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null; )) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isRecordingQuery(query)) { } else if (QueryClassifier.isRecordingQuery(query)) {
engineResult = (await engine.getRecordings( engineResult = (await engine.getRecordings(
hass, hass,
this._store.getCameras(), this._store.getCameras(),
query, query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null; )) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) { } else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
engineResult = (await engine.getRecordingSegments( engineResult = (await engine.getRecordingSegments(
hass, hass,
this._store.getCameras(), this._store.getCameras(),
query, query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null; )) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isMediaMetadataQuery(query)) { } else if (QueryClassifier.isMediaMetadataQuery(query)) {
engineResult = (await engine.getMediaMetadata( engineResult = (await engine.getMediaMetadata(
hass, hass,
this._store.getCameras(), this._store.getCameras(),
query, query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null; )) as Map<QT, QueryReturnType<QT>> | null;
} }
+4
View File
@@ -130,6 +130,10 @@ export interface CameraEndpoints {
export type CameraConfigs = Map<string, CameraConfig>; export type CameraConfigs = Map<string, CameraConfig>;
export interface EngineOptions {
useCache?: boolean;
}
// =========== // ===========
// Event Query // Event Query
// =========== // ===========
+135 -14
View File
@@ -31,17 +31,21 @@ import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types'; import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types';
import { MediaQueriesResults } from '../view/media-queries-results'; import { MediaQueriesResults } from '../view/media-queries-results';
import { errorToConsole } from '../utils/basic'; import { errorToConsole, sleep } from '../utils/basic';
import './media-filter'; import './media-filter';
import './surround-basic'; import './surround-basic';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { localize } from '../localize/localize'; 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 = { const GALLERY_MEDIA_FILTER_MENU_ICONS = {
closed: 'mdi:filter-cog-outline', closed: 'mdi:filter-cog-outline',
open: 'mdi:filter-cog', open: 'mdi:filter-cog',
}; };
const MIN_GALLERY_EXTENSION_SECONDS = 0.5;
@customElement('frigate-card-gallery') @customElement('frigate-card-gallery')
export class FrigateCardGallery extends LitElement { export class FrigateCardGallery extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
@@ -167,14 +171,42 @@ export class FrigateCardGalleryCore extends LitElement {
protected _intersectionObserver: IntersectionObserver; protected _intersectionObserver: IntersectionObserver;
protected _resizeObserver: ResizeObserver; protected _resizeObserver: ResizeObserver;
protected _refLoader: Ref<HTMLElement> = createRef(); protected _refLoaderBottom: Ref<HTMLElement> = createRef();
protected _refSelected: 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() @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 _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() { constructor() {
super(); super();
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); 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. * Component connected callback.
*/ */
connectedCallback(): void { connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
this._resizeObserver.observe(this); 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 // Request update in order to ensure the intersection observer reconnects
// with the loader sentinel. // with the loader sentinel.
@@ -199,6 +291,9 @@ export class FrigateCardGalleryCore extends LitElement {
* Component disconnected callback. * Component disconnected callback.
*/ */
disconnectedCallback(): void { disconnectedCallback(): void {
this.removeEventListener('wheel', this._boundWheelHandler);
this.removeEventListener('touchstart', this._boundTouchStartHandler);
this.removeEventListener('touchend', this._boundTouchEndHandler);
this._resizeObserver.disconnect(); this._resizeObserver.disconnect();
this._intersectionObserver.disconnect(); this._intersectionObserver.disconnect();
super.disconnectedCallback(); super.disconnectedCallback();
@@ -232,14 +327,21 @@ export class FrigateCardGalleryCore extends LitElement {
protected async _intersectionHandler( protected async _intersectionHandler(
entries: IntersectionObserverEntry[], entries: IntersectionObserverEntry[],
): Promise<void> { ): Promise<void> {
if (!this.cameraManager || !this.hass || !this.view) {
return;
}
if (entries.every((entry) => !entry.isIntersecting)) { if (entries.every((entry) => !entry.isIntersecting)) {
return; 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 query = this.view?.query;
const rawQueries = query?.getQueries() ?? null; const rawQueries = query?.getQueries() ?? null;
@@ -254,7 +356,10 @@ export class FrigateCardGalleryCore extends LitElement {
this.hass, this.hass,
rawQueries, rawQueries,
existingMedia, existingMedia,
'earlier', direction,
{
useCache: useCache,
},
); );
} catch (e) { } catch (e) {
errorToConsole(e as Error); errorToConsole(e as Error);
@@ -272,7 +377,9 @@ export class FrigateCardGalleryCore extends LitElement {
this.view this.view
?.evolve({ ?.evolve({
query: newMediaQueries, query: newMediaQueries,
queryResults: new MediaQueriesResults(extension.results), queryResults: new MediaQueriesResults(extension.results).selectResultIfFound(
(media) => media === this.view?.queryResults?.getSelectedResult(),
),
}) })
.dispatchChangeEvent(this); .dispatchChangeEvent(this);
} }
@@ -299,7 +406,9 @@ export class FrigateCardGalleryCore extends LitElement {
} }
} }
if (changedProps.has('view')) { 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'); const oldView: View | undefined = changedProps.get('view');
if ( if (
@@ -330,10 +439,22 @@ export class FrigateCardGalleryCore extends LitElement {
const selected = this.view?.queryResults?.getSelectedResult(); const selected = this.view?.queryResults?.getSelectedResult();
return html` return html`
${this._showLoaderTop
? html`${renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
classes: {
top: true,
},
size: 'small',
})}`
: ''}
${this._media.map( ${this._media.map(
(media, index) => (media, index) =>
html`<frigate-card-thumbnail html`<frigate-card-thumbnail
${media === selected ? ref(this._refSelected) : ''} ${media === selected ? ref(this._refSelected) : ''}
class=${classMap({
selected: media === selected,
})}
.hass=${this.hass} .hass=${this.hass}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.media=${media} .media=${media}
@@ -361,19 +482,19 @@ export class FrigateCardGalleryCore extends LitElement {
> >
</frigate-card-thumbnail>`, </frigate-card-thumbnail>`,
)} )}
${this._showExtensionLoader ${this._showLoaderBottom
? html`${renderProgressIndicator({ ? html`${renderProgressIndicator({
cardWideConfig: this.cardWideConfig, cardWideConfig: this.cardWideConfig,
componentRef: this._refLoader, componentRef: this._refLoaderBottom,
})}` })}`
: ''} : ''}
`; `;
} }
public updated(changedProps: PropertyValues): void { public updated(changedProps: PropertyValues): void {
if (this._refLoader.value) { if (this._refLoaderBottom.value) {
this._intersectionObserver.disconnect(); 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 // This wait for updateComplete is necessary for the scrolling to work
+14 -4
View File
@@ -1,6 +1,6 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; 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 { ref, Ref } from 'lit/directives/ref.js';
import { TROUBLESHOOTING_URL } from '../const.js'; import { TROUBLESHOOTING_URL } from '../const.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
@@ -29,7 +29,7 @@ export class FrigateCardMessage extends LitElement {
dotdotdot: !!this.dotdotdot, dotdotdot: !!this.dotdotdot,
}; };
return html` <div class="wrapper"> return html` <div class="wrapper">
<div class="message"> <div class="message padded">
<div class="icon"> <div class="icon">
<ha-icon icon="${icon}"> </ha-icon> <ha-icon icon="${icon}"> </ha-icon>
</div> </div>
@@ -78,6 +78,8 @@ export class FrigateCardErrorMessage extends LitElement {
} }
} }
type FrigateCardProgressIndicatorSize = 'tiny' | 'small' | 'medium' | 'large';
@customElement('frigate-card-progress-indicator') @customElement('frigate-card-progress-indicator')
export class FrigateCardProgressIndicator extends LitElement { export class FrigateCardProgressIndicator extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
@@ -86,10 +88,14 @@ export class FrigateCardProgressIndicator extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public animated = false; public animated = false;
@property({ attribute: false })
public size: FrigateCardProgressIndicatorSize = 'large';
protected render(): TemplateResult { protected render(): TemplateResult {
return html` <div class="message vertical"> return html` <div class="message vertical">
${this.animated ${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>`} : html`<ha-icon icon="mdi:timer-sand"></ha-icon>`}
${this.message ? html`<span>${this.message}</span>` : html``} ${this.message ? html`<span>${this.message}</span>` : html``}
</div>`; </div>`;
@@ -119,10 +125,14 @@ export function renderMessage(message: Message): TemplateResult {
export function renderProgressIndicator(options?: { export function renderProgressIndicator(options?: {
message?: string; message?: string;
cardWideConfig?: CardWideConfig; cardWideConfig?: CardWideConfig;
componentRef?: Ref<HTMLElement>, componentRef?: Ref<HTMLElement>;
classes?: ClassInfo;
size?: FrigateCardProgressIndicatorSize;
}): TemplateResult { }): TemplateResult {
return html` return html`
<frigate-card-progress-indicator <frigate-card-progress-indicator
class="${classMap(options?.classes ?? {})}"
.size=${options?.size}
${options?.componentRef ? ref(options.componentRef) : ''} ${options?.componentRef ? ref(options.componentRef) : ''}
.message=${options?.message || ''} .message=${options?.message || ''}
.animated=${options?.cardWideConfig?.performance?.features .animated=${options?.cardWideConfig?.performance?.features
+1
View File
@@ -94,6 +94,7 @@ export class FrigateCardSurround extends LitElement {
{ {
targetView: this.view.view, targetView: this.view.view,
mediaType: this.fetchMedia, mediaType: this.fetchMedia,
select: 'latest',
}, },
); );
} }
+3
View File
@@ -490,6 +490,7 @@ export class FrigateCardTimelineCore extends LitElement {
properties.what === 'background' properties.what === 'background'
? properties.time ? properties.time
: this._timeline.getWindow().end, : this._timeline.getWindow().end,
select: 'time',
}, },
); );
} }
@@ -513,6 +514,7 @@ export class FrigateCardTimelineCore extends LitElement {
{ {
targetView: 'recording', targetView: 'recording',
targetTime: properties.time, targetTime: properties.time,
select: 'time',
}, },
); );
} }
@@ -661,6 +663,7 @@ export class FrigateCardTimelineCore extends LitElement {
query, query,
{ {
targetView: options?.targetView, targetView: options?.targetView,
select: 'latest',
}, },
); );
if (!view) { if (!view) {
+10 -2
View File
@@ -120,6 +120,7 @@ export class FrigateCardViewer extends LitElement {
this.view, this.view,
{ {
targetView: 'recording', targetView: 'recording',
select: 'latest',
}, },
); );
} else { } else {
@@ -132,6 +133,7 @@ export class FrigateCardViewer extends LitElement {
{ {
targetView: 'media', targetView: 'media',
mediaType: mediaType, mediaType: mediaType,
select: 'latest',
}, },
); );
} }
@@ -543,13 +545,19 @@ export class FrigateCardViewerCarousel extends LitElement {
* @returns A template to display to the user. * @returns A template to display to the user.
*/ */
protected _render(): TemplateResult | void { protected _render(): TemplateResult | void {
if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) { const resultCount = this.view?.queryResults?.getResultsCount() ?? 0;
if (!resultCount) {
return dispatchMessageEvent(this, localize('common.no_media'), 'info', { return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
icon: 'mdi:multimedia', icon: 'mdi:multimedia',
}); });
} }
const media = this.view?.queryResults?.getSelectedResult(); // If there's no selected media, just choose the last (most recent one) to
// avoid rendering a blank. This situation should not occur in practice, as
// this view should not be called without a selected media.
const media =
this.view?.queryResults?.getSelectedResult() ??
this.view?.queryResults?.getResult(resultCount - 1);
if (!media || !this.view || !this.view.queryResults) { if (!media || !this.view || !this.view.queryResults) {
return; return;
} }
+16
View File
@@ -30,3 +30,19 @@ frigate-card-thumbnail {
frigate-card-thumbnail:not([details]) { frigate-card-thumbnail:not([details]) {
width: 100%; 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;
}
+4 -5
View File
@@ -24,10 +24,13 @@ div.message {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
padding: 20px;
height: 100%; height: 100%;
} }
div.message.padded {
padding: 20px;
}
div.message div.contents { div.message div.contents {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -55,7 +58,3 @@ div.message div.icon {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
} }
.message ha-icon, ha-circular-progress {
padding: 10px;
}
+5
View File
@@ -191,3 +191,8 @@ export const isSuperset = (superset: Set<unknown>, subset: Set<unknown>) => {
} }
return true; 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));
};
-9
View File
@@ -5,12 +5,3 @@ export const log = (cardWideConfig?: CardWideConfig, ...args: unknown[]) => {
console.debug(...args); 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));
};
+15 -4
View File
@@ -16,6 +16,8 @@ import { errorToConsole } from './basic';
import { MediaQuery } from '../camera-manager/types'; import { MediaQuery } from '../camera-manager/types';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const';
type ResultSelectType = 'latest' | 'time' | 'none';
export const changeViewToRecentEventsForCameraAndDependents = async ( export const changeViewToRecentEventsForCameraAndDependents = async (
element: HTMLElement, element: HTMLElement,
hass: HomeAssistant, hass: HomeAssistant,
@@ -25,6 +27,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
options?: { options?: {
mediaType?: ClipsOrSnapshotsOrAll; mediaType?: ClipsOrSnapshotsOrAll;
targetView?: FrigateCardView; targetView?: FrigateCardView;
select?: ResultSelectType;
}, },
): Promise<void> => { ): Promise<void> => {
const cameraIDs = getAllDependentCameras(cameraManager, view.camera); const cameraIDs = getAllDependentCameras(cameraManager, view.camera);
@@ -42,6 +45,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
( (
await executeMediaQueryForView(element, hass, cameraManager, view, queries, { await executeMediaQueryForView(element, hass, cameraManager, view, queries, {
targetView: options?.targetView, targetView: options?.targetView,
select: options?.select,
}) })
)?.dispatchChangeEvent(element); )?.dispatchChangeEvent(element);
}; };
@@ -81,6 +85,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
view: View, view: View,
options?: { options?: {
targetView?: 'recording' | 'recordings'; targetView?: 'recording' | 'recordings';
select?: ResultSelectType;
}, },
): Promise<void> => { ): Promise<void> => {
const cameraIDs = getAllDependentCameras(cameraManager, view.camera); const cameraIDs = getAllDependentCameras(cameraManager, view.camera);
@@ -100,6 +105,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
( (
await executeMediaQueryForView(element, hass, cameraManager, view, queries, { await executeMediaQueryForView(element, hass, cameraManager, view, queries, {
targetView: options?.targetView, targetView: options?.targetView,
select: options?.select,
}) })
)?.dispatchChangeEvent(element); )?.dispatchChangeEvent(element);
}; };
@@ -133,6 +139,7 @@ export const executeMediaQueryForView = async (
targetCameraID?: string; targetCameraID?: string;
targetView?: FrigateCardView; targetView?: FrigateCardView;
targetTime?: Date; targetTime?: Date;
select?: ResultSelectType;
}, },
): Promise<View | null> => { ): Promise<View | null> => {
let mediaArray: ViewMedia[] | null; let mediaArray: ViewMedia[] | null;
@@ -153,12 +160,16 @@ export const executeMediaQueryForView = async (
if (!mediaArray) { if (!mediaArray) {
return null; return null;
} }
// Select the last item by default (which is the most recent).
const selectedIndex = mediaArray.length ? mediaArray.length - 1 : undefined; const queryResults = new MediaQueriesResults(
const queryResults = new MediaQueriesResults(mediaArray, selectedIndex); mediaArray,
options?.select === 'latest' && mediaArray.length
? mediaArray.length - 1
: undefined,
);
let viewerContext: ViewContext | undefined = {}; let viewerContext: ViewContext | undefined = {};
if (options?.targetTime) { if (options?.select === 'time' && options?.targetTime) {
queryResults.selectBestResult((media) => queryResults.selectBestResult((media) =>
findClosestMediaIndex(media, options.targetTime as Date), findClosestMediaIndex(media, options.targetTime as Date),
); );
+3 -1
View File
@@ -11,7 +11,9 @@ export class MediaQueriesResults {
if (results) { if (results) {
this.setResults(results); this.setResults(results);
} }
this.selectResult(selectedIndex ?? 0); if (selectedIndex !== undefined) {
this.selectResult(selectedIndex);
}
} }
public clone(): MediaQueriesResults { public clone(): MediaQueriesResults {