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:
Dermot Duffy
2024-12-01 20:57:48 -08:00
committed by GitHub
parent c69fdff5e3
commit 9ac6134446
23 changed files with 1489 additions and 829 deletions
+161 -30
View File
@@ -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(