perf: Change media fetches to be asynchronous to view render (#1702)
This is a fairly non-trivial change in terms of consequence, so a greater than average chance something breaks. This is necessary since some cameras (e.g. Reolink) are materially slower to fetch media, and this change substantially improves card responsiveness.
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { sub } from 'date-fns';
|
||||
import {
|
||||
FRIGATE_CARD_VIEW_DEFAULT,
|
||||
FrigateCardConfig,
|
||||
@@ -6,27 +5,17 @@ import {
|
||||
ViewDisplayMode,
|
||||
} from '../../config/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { ClipsOrSnapshotsOrAll } from '../../types';
|
||||
import { MediaQueriesClassifier } from '../../view/media-queries-classifier';
|
||||
import { View, ViewParameters } from '../../view/view';
|
||||
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
|
||||
import { CardViewAPI } from '../types';
|
||||
import { QueryExecutor } from './query-executor';
|
||||
import {
|
||||
QueryExecutorOptions,
|
||||
QueryWithResults,
|
||||
ViewFactoryOptions,
|
||||
ViewIncompatible,
|
||||
ViewNoCameraError,
|
||||
} from './types';
|
||||
import { applyViewModifiers } from './modifiers';
|
||||
import { ViewFactoryOptions, ViewIncompatible, ViewNoCameraError } from './types';
|
||||
|
||||
export class ViewFactory {
|
||||
protected _api: CardViewAPI;
|
||||
protected _executor: QueryExecutor;
|
||||
|
||||
constructor(api: CardViewAPI, executor?: QueryExecutor) {
|
||||
constructor(api: CardViewAPI) {
|
||||
this._api = api;
|
||||
this._executor = executor ?? new QueryExecutor(api);
|
||||
}
|
||||
|
||||
public getViewDefault(options?: ViewFactoryOptions): View | null {
|
||||
@@ -91,7 +80,7 @@ export class ViewFactory {
|
||||
);
|
||||
|
||||
// Reset to the default camera.
|
||||
cameraID = viewCameraIDs.keys().next().value;
|
||||
cameraID = viewCameraIDs.keys().next().value ?? null;
|
||||
}
|
||||
|
||||
if (!cameraID) {
|
||||
@@ -149,239 +138,18 @@ export class ViewFactory {
|
||||
? options.baseView.evolve(viewParameters)
|
||||
: new View(viewParameters);
|
||||
|
||||
if (options?.modifiers) {
|
||||
options.modifiers.forEach((modifier) => modifier.modify(view));
|
||||
}
|
||||
applyViewModifiers(view, options?.modifiers);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
public async getViewDefaultWithNewQuery(
|
||||
options?: ViewFactoryOptions,
|
||||
): Promise<View | null> {
|
||||
return this._executeNewQuery(this.getViewDefault(options), {
|
||||
...options,
|
||||
queryExecutorOptions: {
|
||||
useCache: false,
|
||||
...options?.queryExecutorOptions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async getViewByParametersWithNewQuery(
|
||||
options?: ViewFactoryOptions,
|
||||
): Promise<View | null> {
|
||||
return this._executeNewQuery(this.getViewByParameters(options), {
|
||||
...options,
|
||||
queryExecutorOptions: {
|
||||
useCache: false,
|
||||
...options?.queryExecutorOptions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async getViewByParametersWithExistingQuery(
|
||||
options?: ViewFactoryOptions,
|
||||
): Promise<View | null> {
|
||||
const view = this.getViewByParameters(options);
|
||||
if (view?.query) {
|
||||
view.queryResults = await this._executor.execute(
|
||||
view.query,
|
||||
options?.queryExecutorOptions,
|
||||
);
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
protected async _executeNewQuery(
|
||||
view: View | null,
|
||||
options?: ViewFactoryOptions,
|
||||
): Promise<View | null> {
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
if (
|
||||
!config ||
|
||||
/* istanbul ignore next: this path cannot be reached as the only way for
|
||||
view to be null here, is if the config is also null -- @preserve */
|
||||
!view
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const executeMediaQuery = async (
|
||||
mediaType: ClipsOrSnapshotsOrAll | 'recordings' | null,
|
||||
): Promise<boolean> => {
|
||||
/* istanbul ignore if: this path cannot be reached -- @preserve */
|
||||
if (!mediaType) {
|
||||
return false;
|
||||
}
|
||||
return await this._executeMediaQuery(
|
||||
view,
|
||||
mediaType === 'recordings' ? 'recordings' : 'events',
|
||||
{
|
||||
eventsMediaType: mediaType === 'recordings' ? undefined : mediaType,
|
||||
executorOptions: options?.queryExecutorOptions,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Implementation note: For new queries, if the query itself fails that is
|
||||
// just ignored and the view is returned anyway (e.g. if the user changes to
|
||||
// live but the thumbnail fetch fails, it is better to change to live and
|
||||
// show no thumbnails than not change to live).
|
||||
const mediaType = view.getDefaultMediaType();
|
||||
const baseView = options?.baseView;
|
||||
const switchingToGalleryFromViewer =
|
||||
baseView?.isViewerView() && view.isGalleryView();
|
||||
|
||||
const alreadyHasMatchingQuery =
|
||||
mediaType === MediaQueriesClassifier.getMediaType(baseView?.query);
|
||||
|
||||
if (
|
||||
switchingToGalleryFromViewer &&
|
||||
alreadyHasMatchingQuery &&
|
||||
baseView?.query &&
|
||||
baseView?.queryResults
|
||||
) {
|
||||
// If the user is currently using the viewer, and then switches to the
|
||||
// gallery we make an attempt to keep the query/queryResults the same so
|
||||
// the gallery can be used to click back and forth to the viewer, and the
|
||||
// selected media can be centered in the gallery. See the matching code in
|
||||
// `updated()` in `gallery.ts`. We specifically must ensure that the new
|
||||
// target media of the gallery (e.g. clips, snapshots or recordings) is
|
||||
// equal to the queries that are currently used in the viewer.
|
||||
//
|
||||
// See: https://github.com/dermotduffy/frigate-hass-card/issues/885
|
||||
view.query = baseView.query;
|
||||
view.queryResults = baseView.queryResults;
|
||||
} else {
|
||||
switch (view.view) {
|
||||
case 'live':
|
||||
if (config.live.controls.thumbnails.mode !== 'none') {
|
||||
await executeMediaQuery(
|
||||
config.live.controls.thumbnails.media_type === 'recordings'
|
||||
? 'recordings'
|
||||
: config.live.controls.thumbnails.events_media_type,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'media':
|
||||
// If the user is looking at media in the `media` view and then
|
||||
// changes camera (via the menu) it should default to showing clips
|
||||
// for the new camera.
|
||||
if (baseView && view.camera !== baseView.camera) {
|
||||
await executeMediaQuery('clips');
|
||||
}
|
||||
break;
|
||||
|
||||
// Gallery views:
|
||||
case 'clips':
|
||||
case 'snapshots':
|
||||
case 'recordings':
|
||||
await executeMediaQuery(mediaType);
|
||||
break;
|
||||
|
||||
// Viewer views:
|
||||
case 'clip':
|
||||
case 'snapshot':
|
||||
case 'recording':
|
||||
if (config.media_viewer.controls.thumbnails.mode !== 'none') {
|
||||
await executeMediaQuery(mediaType);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this._setOrRemoveTimelineWindow(view);
|
||||
this._setOrRemoveSeekTime(
|
||||
view,
|
||||
options?.queryExecutorOptions?.selectResult?.time?.time,
|
||||
);
|
||||
return view;
|
||||
}
|
||||
|
||||
protected _setOrRemoveTimelineWindow(view: View): void {
|
||||
if (view.is('live')) {
|
||||
// For live views, always force the timeline to now, regardless of
|
||||
// presence or not of events.
|
||||
const now = new Date();
|
||||
const liveConfig = this._api.getConfigManager().getConfig()?.live;
|
||||
|
||||
/* istanbul ignore if: this if branch cannot be reached as if the config is
|
||||
empty this function is never called -- @preserve */
|
||||
if (!liveConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
view.mergeInContext({
|
||||
// Force the window to start at the most recent time, not
|
||||
// necessarily when the most recent event/recording was:
|
||||
// https://github.com/dermotduffy/frigate-hass-card/issues/1301
|
||||
timeline: {
|
||||
window: {
|
||||
start: sub(now, {
|
||||
seconds: liveConfig.controls.timeline.window_seconds,
|
||||
}),
|
||||
end: now,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// For non-live views stick to default timeline behavior (will select and
|
||||
// scroll to event).
|
||||
view.removeContextProperty('timeline', 'window');
|
||||
}
|
||||
}
|
||||
|
||||
protected _setOrRemoveSeekTime(view: View, time?: Date): void {
|
||||
if (time) {
|
||||
view.mergeInContext({
|
||||
mediaViewer: {
|
||||
seek: time,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
view.removeContextProperty('mediaViewer', 'seek');
|
||||
}
|
||||
}
|
||||
|
||||
protected async _executeMediaQuery(
|
||||
view: View,
|
||||
mediaType: 'events' | 'recordings',
|
||||
options?: {
|
||||
eventsMediaType?: ClipsOrSnapshotsOrAll;
|
||||
executorOptions?: QueryExecutorOptions;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const queryWithResults: QueryWithResults | null =
|
||||
mediaType === 'events'
|
||||
? await this._executor.executeDefaultEventQuery({
|
||||
...(!view.isGrid() && { cameraID: view.camera }),
|
||||
eventsMediaType: options?.eventsMediaType,
|
||||
executorOptions: options?.executorOptions,
|
||||
})
|
||||
: mediaType === 'recordings'
|
||||
? await this._executor.executeDefaultRecordingQuery({
|
||||
...(!view.isGrid() && { cameraID: view.camera }),
|
||||
executorOptions: options?.executorOptions,
|
||||
})
|
||||
: /* istanbul ignore next -- @preserve */
|
||||
null;
|
||||
if (!queryWithResults) {
|
||||
return false;
|
||||
}
|
||||
|
||||
view.query = queryWithResults.query;
|
||||
view.queryResults = queryWithResults.queryResults;
|
||||
return true;
|
||||
}
|
||||
|
||||
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
|
||||
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
|
||||
}
|
||||
|
||||
protected _getDefaultDisplayModeForView(
|
||||
viewName: FrigateCardView,
|
||||
config?: FrigateCardConfig,
|
||||
config: FrigateCardConfig,
|
||||
): ViewDisplayMode {
|
||||
let mode: ViewDisplayMode | null = null;
|
||||
switch (viewName) {
|
||||
@@ -389,10 +157,10 @@ export class ViewFactory {
|
||||
case 'clip':
|
||||
case 'recording':
|
||||
case 'snapshot':
|
||||
mode = config?.media_viewer.display?.mode ?? null;
|
||||
mode = config.media_viewer.display?.mode ?? null;
|
||||
break;
|
||||
case 'live':
|
||||
mode = config?.live.display?.mode ?? null;
|
||||
mode = config.live.display?.mode ?? null;
|
||||
break;
|
||||
}
|
||||
return mode ?? 'single';
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { View } from '../../../view/view';
|
||||
import { ViewModifier } from '../types';
|
||||
|
||||
export const applyViewModifiers = (
|
||||
view: View,
|
||||
modifiers?: ViewModifier[] | null,
|
||||
): void => {
|
||||
modifiers?.forEach((modifier) => modifier.modify(view));
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MediaQueries } from '../../../view/media-queries';
|
||||
import { MediaQueriesResults } from '../../../view/media-queries-results';
|
||||
import { View } from '../../../view/view';
|
||||
import { ViewModifier } from '../types';
|
||||
|
||||
export class SetQueryViewModifier implements ViewModifier {
|
||||
protected _query?: MediaQueries | null;
|
||||
protected _queryResults?: MediaQueriesResults | null;
|
||||
|
||||
constructor(options?: {
|
||||
query?: MediaQueries | null;
|
||||
queryResults?: MediaQueriesResults | null;
|
||||
}) {
|
||||
this._query = options?.query;
|
||||
this._queryResults = options?.queryResults;
|
||||
}
|
||||
|
||||
public modify(view: View): void {
|
||||
if (this._query !== undefined) {
|
||||
view.query = this._query;
|
||||
}
|
||||
if (this._queryResults !== undefined) {
|
||||
view.queryResults = this._queryResults;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '../../view/media-queries';
|
||||
import { MediaQueriesResults } from '../../view/media-queries-results';
|
||||
import { CardViewAPI } from '../types';
|
||||
import { QueryExecutorOptions, QueryWithResults } from './types';
|
||||
import { QueryExecutorOptions, QueryExecutorResult } from './types';
|
||||
|
||||
export class QueryExecutor {
|
||||
protected _api: CardViewAPI;
|
||||
@@ -22,7 +22,7 @@ export class QueryExecutor {
|
||||
cameraID?: string;
|
||||
eventsMediaType?: ClipsOrSnapshotsOrAll;
|
||||
executorOptions?: QueryExecutorOptions;
|
||||
}): Promise<QueryWithResults | null> {
|
||||
}): Promise<QueryExecutorResult | null> {
|
||||
const capabilitySearch: CapabilitySearchOptions =
|
||||
!options?.eventsMediaType || options?.eventsMediaType === 'all'
|
||||
? {
|
||||
@@ -61,7 +61,7 @@ export class QueryExecutor {
|
||||
public async executeDefaultRecordingQuery(options?: {
|
||||
cameraID?: string;
|
||||
executorOptions?: QueryExecutorOptions;
|
||||
}): Promise<QueryWithResults | null> {
|
||||
}): Promise<QueryExecutorResult | null> {
|
||||
const cameraManager = this._api.getCameraManager();
|
||||
const cameraIDs = options?.cameraID
|
||||
? cameraManager.getStore().getAllDependentCameras(options.cameraID, 'recordings')
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface QueryExecutorOptions {
|
||||
useCache?: boolean;
|
||||
}
|
||||
|
||||
export interface QueryWithResults {
|
||||
export interface QueryExecutorResult {
|
||||
query: MediaQueries;
|
||||
queryResults: MediaQueriesResults;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export interface ViewManagerInterface {
|
||||
setViewWithMergedContext(context: ViewContext | null): void;
|
||||
|
||||
isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean;
|
||||
hasMajorMediaChange(oldView?: View | null): boolean;
|
||||
hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean;
|
||||
}
|
||||
|
||||
export class ViewNoCameraError extends FrigateCardError {}
|
||||
|
||||
@@ -2,21 +2,42 @@ import { ViewContext } from 'view';
|
||||
import { FrigateCardView } from '../../config/types';
|
||||
import { log } from '../../utils/debug';
|
||||
import { getStreamCameraID } from '../../utils/substream';
|
||||
import { MediaQueriesClassifier } from '../../view/media-queries-classifier';
|
||||
import { View } from '../../view/view';
|
||||
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
|
||||
import { CardViewAPI } from '../types';
|
||||
import { ViewFactory } from './factory';
|
||||
import { ViewFactoryOptions, ViewManagerEpoch, ViewManagerInterface } from './types';
|
||||
import {
|
||||
QueryExecutorOptions,
|
||||
ViewFactoryOptions,
|
||||
ViewManagerEpoch,
|
||||
ViewManagerInterface,
|
||||
ViewModifier,
|
||||
} from './types';
|
||||
import { ViewQueryExecutor } from './view-query-executor';
|
||||
import { applyViewModifiers } from './modifiers';
|
||||
|
||||
export class ViewManager implements ViewManagerInterface {
|
||||
protected _view: View | null = null;
|
||||
protected _factory: ViewFactory;
|
||||
protected _viewFactory: ViewFactory;
|
||||
protected _viewQueryExecutor: ViewQueryExecutor;
|
||||
protected _api: CardViewAPI;
|
||||
protected _epoch: ViewManagerEpoch = this._createEpoch();
|
||||
|
||||
constructor(api: CardViewAPI, factory?: ViewFactory) {
|
||||
// Used to mark as a view as "loading" with a given index. Each subsequent
|
||||
// async update will use a higher index.
|
||||
protected _loadingIndex = 1;
|
||||
|
||||
constructor(
|
||||
api: CardViewAPI,
|
||||
options?: {
|
||||
viewFactory?: ViewFactory;
|
||||
viewQueryExecutor?: ViewQueryExecutor;
|
||||
},
|
||||
) {
|
||||
this._api = api;
|
||||
this._factory = factory ?? new ViewFactory(api);
|
||||
this._viewFactory = options?.viewFactory ?? new ViewFactory(api);
|
||||
this._viewQueryExecutor = options?.viewQueryExecutor ?? new ViewQueryExecutor(api);
|
||||
}
|
||||
|
||||
public getEpoch(): ViewManagerEpoch {
|
||||
@@ -42,63 +63,168 @@ export class ViewManager implements ViewManagerInterface {
|
||||
}
|
||||
|
||||
setViewDefault = (options?: ViewFactoryOptions): void =>
|
||||
this._setViewGeneric(this._factory.getViewDefault.bind(this._factory), options);
|
||||
this._setViewGeneric(
|
||||
this._viewFactory.getViewDefault.bind(this._viewFactory),
|
||||
options,
|
||||
);
|
||||
|
||||
setViewByParameters = (options?: ViewFactoryOptions): void =>
|
||||
this._setViewGeneric(this._factory.getViewByParameters.bind(this._factory), options);
|
||||
this._setViewGeneric(
|
||||
this._viewFactory.getViewByParameters.bind(this._viewFactory),
|
||||
options,
|
||||
);
|
||||
|
||||
setViewDefaultWithNewQuery = async (options?: ViewFactoryOptions): Promise<void> =>
|
||||
await this._setViewGenericAsync(
|
||||
this._factory.getViewDefaultWithNewQuery.bind(this._factory),
|
||||
await this._setViewThenModifyAsync(
|
||||
this._viewFactory.getViewDefault.bind(this._viewFactory),
|
||||
this._viewQueryExecutor.getNewQueryModifiers.bind(this._viewQueryExecutor),
|
||||
options,
|
||||
);
|
||||
|
||||
setViewByParametersWithNewQuery = async (
|
||||
options?: ViewFactoryOptions,
|
||||
): Promise<void> =>
|
||||
await this._setViewGenericAsync(
|
||||
this._factory.getViewByParametersWithNewQuery.bind(this._factory),
|
||||
await this._setViewThenModifyAsync(
|
||||
this._viewFactory.getViewByParameters.bind(this._viewFactory),
|
||||
this._viewQueryExecutor.getNewQueryModifiers.bind(this._viewQueryExecutor),
|
||||
options,
|
||||
);
|
||||
|
||||
setViewByParametersWithExistingQuery = async (
|
||||
options?: ViewFactoryOptions,
|
||||
): Promise<void> =>
|
||||
await this._setViewGenericAsync(
|
||||
this._factory.getViewByParametersWithExistingQuery.bind(this._factory),
|
||||
await this._setViewThenModifyAsync(
|
||||
this._viewFactory.getViewByParameters.bind(this._viewFactory),
|
||||
this._viewQueryExecutor.getExistingQueryModifiers.bind(this._viewQueryExecutor),
|
||||
options,
|
||||
);
|
||||
|
||||
protected _setViewGeneric(
|
||||
factoryFunc: (options?: ViewFactoryOptions) => View | null,
|
||||
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
|
||||
options?: ViewFactoryOptions,
|
||||
): void {
|
||||
let view: View | null = null;
|
||||
try {
|
||||
view = factoryFunc({
|
||||
view = viewFactoryFunc({
|
||||
baseView: this._view,
|
||||
...options,
|
||||
});
|
||||
} catch (e) {
|
||||
return this._api.getMessageManager().setErrorIfHigherPriority(e);
|
||||
this._api.getMessageManager().setErrorIfHigherPriority(e);
|
||||
}
|
||||
view && this._setView(view);
|
||||
}
|
||||
|
||||
protected async _setViewGenericAsync(
|
||||
factoryFunc: (options?: ViewFactoryOptions) => Promise<View | null>,
|
||||
protected _markViewLoadingQuery(view: View, index: number): View {
|
||||
return view.mergeInContext({ loading: { query: index } });
|
||||
}
|
||||
protected _markViewAsNotLoadingQuery(view: View): View {
|
||||
return view.removeContextProperty('loading', 'query');
|
||||
}
|
||||
|
||||
protected async _setViewThenModifyAsync(
|
||||
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
|
||||
viewModifiersFunc: (
|
||||
view: View,
|
||||
queryExecutorOptions?: QueryExecutorOptions,
|
||||
) => Promise<ViewModifier[] | null>,
|
||||
options?: ViewFactoryOptions,
|
||||
): Promise<void> {
|
||||
let view: View | null = null;
|
||||
let initialView: View | null = null;
|
||||
try {
|
||||
view = await factoryFunc({
|
||||
initialView = viewFactoryFunc({
|
||||
baseView: this._view,
|
||||
...options,
|
||||
params: {
|
||||
query: null,
|
||||
queryResults: null,
|
||||
...options?.params,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return this._api.getMessageManager().setErrorIfHigherPriority(e);
|
||||
this._api.getMessageManager().setErrorIfHigherPriority(e);
|
||||
}
|
||||
view && this._setView(view);
|
||||
|
||||
if (!initialView) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._view && this._shouldAdoptQueryAndResults(initialView)) {
|
||||
initialView.query = this._view.query;
|
||||
initialView.queryResults = this._view.queryResults;
|
||||
this._markViewAsNotLoadingQuery(initialView);
|
||||
this._setView(initialView);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the view as loading with the current value of _updateIndex. This is
|
||||
// used to ensure that the loading state is subsequently only removed for
|
||||
// _this_ async update.
|
||||
const loadingIndex = this._loadingIndex++;
|
||||
this._markViewLoadingQuery(initialView, loadingIndex);
|
||||
|
||||
this._setView(initialView);
|
||||
|
||||
let viewModifiers: ViewModifier[] | null = null;
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
viewModifiers = await viewModifiersFunc(
|
||||
initialView,
|
||||
options?.queryExecutorOptions,
|
||||
);
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
if (this._view && this.hasMajorMediaChange(this._view, initialView)) {
|
||||
// If there has been a major media change in the time async operations
|
||||
// have occurred, ignore the result. For example: A slow Reolink query is
|
||||
// dispatched, the user changes the view in the interim, then the query
|
||||
// returns -- it should not be applied, nor should any errors be shown. On
|
||||
// the contrary, small changes such as the user zooming in are fine to
|
||||
// merge into the resultant view.
|
||||
if (this._view.context?.loading?.query === loadingIndex) {
|
||||
this._setView(this._markViewAsNotLoadingQuery(this._view.clone()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
this._api.getMessageManager().setErrorIfHigherPriority(error);
|
||||
return;
|
||||
}
|
||||
|
||||
/* istanbul ignore if: the if path cannot be reached as the view is set
|
||||
above -- @preserve */
|
||||
if (!this._view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newView = this._view.clone();
|
||||
if (this._view.context?.loading?.query === loadingIndex) {
|
||||
this._markViewAsNotLoadingQuery(newView);
|
||||
}
|
||||
applyViewModifiers(newView, viewModifiers);
|
||||
this._setView(newView);
|
||||
}
|
||||
|
||||
protected _shouldAdoptQueryAndResults(newView: View): boolean {
|
||||
// If the user is currently using the viewer, and then switches to the
|
||||
// gallery we make an attempt to keep the query/queryResults the same so
|
||||
// the gallery can be used to click back and forth to the viewer, and the
|
||||
// selected media can be centered in the gallery. See the matching code in
|
||||
// `updated()` in `gallery.ts`. We specifically must ensure that the new
|
||||
// target media of the gallery (e.g. clips, snapshots or recordings) is
|
||||
// equal to the queries that are currently used in the viewer.
|
||||
//
|
||||
// See: https://github.com/dermotduffy/frigate-hass-card/issues/885
|
||||
|
||||
const switchingFromViewerToGallery =
|
||||
this._view?.isViewerView() && newView?.isGalleryView();
|
||||
const newMediaType = newView?.getDefaultMediaType();
|
||||
const alreadyHasMatchingQuery =
|
||||
MediaQueriesClassifier.getMediaType(this._view?.query) === newMediaType;
|
||||
return !!switchingFromViewerToGallery && alreadyHasMatchingQuery;
|
||||
}
|
||||
|
||||
public setViewWithMergedContext(context: ViewContext | null): void {
|
||||
@@ -116,20 +242,23 @@ export class ViewManager implements ViewManagerInterface {
|
||||
* @param oldView The previous view.
|
||||
* @returns True if the view change is a real media change.
|
||||
*/
|
||||
public hasMajorMediaChange(oldView?: View | null): boolean {
|
||||
public hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean {
|
||||
const compareView = newView ?? this._view;
|
||||
|
||||
return (
|
||||
!!oldView !== !!this._view ||
|
||||
oldView?.view !== this._view?.view ||
|
||||
oldView?.camera !== this._view?.camera ||
|
||||
!!oldView !== !!compareView ||
|
||||
oldView?.view !== compareView?.view ||
|
||||
oldView?.camera !== compareView?.camera ||
|
||||
// When in live mode, take overrides (substreams) into account in deciding
|
||||
// if this is a major media change.
|
||||
(this._view?.view === 'live' &&
|
||||
(compareView?.view === 'live' &&
|
||||
oldView &&
|
||||
getStreamCameraID(oldView) !== getStreamCameraID(this._view)) ||
|
||||
getStreamCameraID(oldView) !== getStreamCameraID(compareView)) ||
|
||||
// When in the live view, the queryResults contain the events that
|
||||
// happened in the past -- not reflective of the actual live media viewer
|
||||
// the user is seeing.
|
||||
(this._view?.view !== 'live' && oldView?.queryResults !== this._view?.queryResults)
|
||||
(compareView?.view !== 'live' &&
|
||||
oldView?.queryResults !== compareView?.queryResults)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,12 +268,14 @@ export class ViewManager implements ViewManagerInterface {
|
||||
// query actions (called at least once per render cycle).
|
||||
// Related: https://github.com/dermotduffy/frigate-hass-card/issues/1200
|
||||
if (!this._api.getQueryStringManager().hasViewRelatedActionsToRun()) {
|
||||
await this.setViewDefaultWithNewQuery({ failSafe: true });
|
||||
// This is not awaited to allow the initialization to complete before the
|
||||
// query is answered.
|
||||
this.setViewDefaultWithNewQuery({ failSafe: true });
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
protected _setView(view: View | null): void {
|
||||
protected _setView(view: Readonly<View> | null): void {
|
||||
const oldView = this._view;
|
||||
|
||||
log(
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { sub } from 'date-fns';
|
||||
import { ClipsOrSnapshotsOrAll } from '../../types';
|
||||
import { View } from '../../view/view';
|
||||
import { CardViewAPI } from '../types';
|
||||
import { MergeContextViewModifier } from './modifiers/merge-context';
|
||||
import { RemoveContextPropertyViewModifier } from './modifiers/remove-context-property';
|
||||
import { SetQueryViewModifier } from './modifiers/set-query';
|
||||
import { QueryExecutor } from './query-executor';
|
||||
import { QueryExecutorOptions, ViewModifier } from './types';
|
||||
|
||||
/**
|
||||
* This class executes media queries and returns an array of ViewModifiers that
|
||||
* can be applied to a view. This allows a view to be set when the user acts,
|
||||
* and if a query is made as part of this view the result can be applied later.
|
||||
*/
|
||||
export class ViewQueryExecutor {
|
||||
protected _api: CardViewAPI;
|
||||
protected _executor: QueryExecutor;
|
||||
|
||||
constructor(api: CardViewAPI, executor?: QueryExecutor) {
|
||||
this._api = api;
|
||||
this._executor = executor ?? new QueryExecutor(api);
|
||||
}
|
||||
|
||||
public async getExistingQueryModifiers(
|
||||
view: View,
|
||||
queryExecutorOptions?: QueryExecutorOptions,
|
||||
): Promise<ViewModifier[] | null> {
|
||||
return view.query
|
||||
? [
|
||||
new SetQueryViewModifier({
|
||||
queryResults: await this._executor.execute(view.query, queryExecutorOptions),
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
public async getNewQueryModifiers(
|
||||
view: View,
|
||||
queryExecutorOptions?: QueryExecutorOptions,
|
||||
): Promise<ViewModifier[] | null> {
|
||||
return await this._executeNewQuery(view, {
|
||||
useCache: false,
|
||||
...queryExecutorOptions,
|
||||
});
|
||||
}
|
||||
|
||||
protected async _executeNewQuery(
|
||||
view: View,
|
||||
queryExecutorOptions?: QueryExecutorOptions,
|
||||
): Promise<ViewModifier[] | null> {
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaType = view?.getDefaultMediaType();
|
||||
const viewModifiers: ViewModifier[] = [];
|
||||
|
||||
const executeMediaQuery = async (
|
||||
mediaType: ClipsOrSnapshotsOrAll | 'recordings' | null,
|
||||
): Promise<ViewModifier[]> => {
|
||||
/* istanbul ignore if: this path cannot be reached -- @preserve */
|
||||
if (!mediaType) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results =
|
||||
mediaType === 'recordings'
|
||||
? await this._executor.executeDefaultRecordingQuery({
|
||||
...(!view.isGrid() && { cameraID: view.camera }),
|
||||
executorOptions: queryExecutorOptions,
|
||||
})
|
||||
: mediaType === 'clips' || mediaType === 'snapshots' || mediaType === 'all'
|
||||
? await this._executor.executeDefaultEventQuery({
|
||||
...(!view.isGrid() && { cameraID: view.camera }),
|
||||
eventsMediaType: mediaType,
|
||||
executorOptions: queryExecutorOptions,
|
||||
})
|
||||
: /* istanbul ignore next -- @preserve */
|
||||
null;
|
||||
|
||||
return results ? [new SetQueryViewModifier(results)] : [];
|
||||
};
|
||||
|
||||
switch (view.view) {
|
||||
case 'live':
|
||||
if (config.live.controls.thumbnails.mode !== 'none') {
|
||||
viewModifiers.push(
|
||||
...(await executeMediaQuery(
|
||||
config.live.controls.thumbnails.media_type === 'recordings'
|
||||
? 'recordings'
|
||||
: config.live.controls.thumbnails.events_media_type,
|
||||
)),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'media':
|
||||
// If the user is looking at media in the `media` view and then
|
||||
// changes camera (via the menu) it should default to showing clips
|
||||
// for the new camera.
|
||||
viewModifiers.push(...(await executeMediaQuery('clips')));
|
||||
break;
|
||||
|
||||
// Gallery views:
|
||||
case 'clips':
|
||||
case 'snapshots':
|
||||
case 'recordings':
|
||||
viewModifiers.push(...(await executeMediaQuery(mediaType)));
|
||||
break;
|
||||
|
||||
// Viewer views:
|
||||
case 'clip':
|
||||
case 'snapshot':
|
||||
case 'recording':
|
||||
if (config.media_viewer.controls.thumbnails.mode !== 'none') {
|
||||
viewModifiers.push(...(await executeMediaQuery(mediaType)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
viewModifiers.push(...this._getTimelineWindowViewModifier(view));
|
||||
viewModifiers.push(
|
||||
...this._getSeekTimeModifier(queryExecutorOptions?.selectResult?.time?.time),
|
||||
);
|
||||
return viewModifiers;
|
||||
}
|
||||
|
||||
protected _getTimelineWindowViewModifier(view: View): ViewModifier[] {
|
||||
if (view.is('live')) {
|
||||
// For live views, always force the timeline to now, regardless of
|
||||
// presence or not of events.
|
||||
const now = new Date();
|
||||
const liveConfig = this._api.getConfigManager().getConfig()?.live;
|
||||
|
||||
/* istanbul ignore if: this if branch cannot be reached as if the config is
|
||||
empty this function is never called -- @preserve */
|
||||
if (!liveConfig) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
new MergeContextViewModifier({
|
||||
// Force the window to start at the most recent time, not
|
||||
// necessarily when the most recent event/recording was:
|
||||
// https://github.com/dermotduffy/frigate-hass-card/issues/1301
|
||||
timeline: {
|
||||
window: {
|
||||
start: sub(now, {
|
||||
seconds: liveConfig.controls.timeline.window_seconds,
|
||||
}),
|
||||
end: now,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
} else {
|
||||
// For non-live views stick to default timeline behavior (will select and
|
||||
// scroll to event).
|
||||
return [new RemoveContextPropertyViewModifier('timeline', 'window')];
|
||||
}
|
||||
}
|
||||
|
||||
protected _getSeekTimeModifier(time?: Date): ViewModifier[] {
|
||||
if (time) {
|
||||
return [
|
||||
new MergeContextViewModifier({
|
||||
mediaViewer: {
|
||||
seek: time,
|
||||
},
|
||||
}),
|
||||
];
|
||||
} else {
|
||||
return [new RemoveContextPropertyViewModifier('mediaViewer', 'seek')];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user