Files
advanced-camera-card/src/card-controller/view/view-manager.ts
T

424 lines
15 KiB
TypeScript

import type { ViewContext } from 'view';
import { log } from '../../utils/debug';
import { getStreamCameraID } from '../../view/substream';
import { getViewTargetID } from '../../view/target-id';
import type { View } from '../../view/view';
import { InitializationAspect } from '../initialization-manager';
import type { CardViewAPI } from '../types';
import { ViewFactory } from './factory';
import { applyViewModifiers } from './modifiers';
import type {
QueryExecutorOptions,
ViewFactoryOptions,
ViewManagerEpoch,
ViewManagerInterface,
ViewModifier,
} from './types';
import { ViewQueryExecutor } from './view-query-executor';
export class ViewManager implements ViewManagerInterface {
private _view: View | null = null;
private _viewFactory: ViewFactory;
private _viewQueryExecutor: ViewQueryExecutor;
private _api: CardViewAPI;
private _epoch: ViewManagerEpoch = this._createEpoch();
// Used to mark as a view as "loading" with a given index. Each subsequent
// async update will use a higher index.
private _loadingIndex = 1;
constructor(
api: CardViewAPI,
options?: {
viewFactory?: ViewFactory;
viewQueryExecutor?: ViewQueryExecutor;
},
) {
this._api = api;
this._viewFactory = options?.viewFactory ?? new ViewFactory(api);
this._viewQueryExecutor = options?.viewQueryExecutor ?? new ViewQueryExecutor(api);
}
public getEpoch(): ViewManagerEpoch {
return this._epoch;
}
private _createEpoch(oldView?: View | null): ViewManagerEpoch {
return {
manager: this,
...(oldView && { oldView }),
};
}
public getView(): View | null {
return this._view;
}
public hasView(): boolean {
return !!this.getView();
}
public reset(): void {
if (this._view) {
this._setView(null);
}
}
setViewDefault = (options?: ViewFactoryOptions): void =>
this._setViewGeneric(
this._viewFactory.getViewDefault.bind(this._viewFactory),
options,
);
setViewByParameters = (options?: ViewFactoryOptions): void =>
this._setViewGeneric(
this._viewFactory.getViewByParameters.bind(this._viewFactory),
options,
);
setViewDefaultWithNewQuery = async (options?: ViewFactoryOptions): Promise<void> =>
await this._setViewThenModifyAsync(
this._viewFactory.getViewDefault.bind(this._viewFactory),
this._viewQueryExecutor.getNewQueryModifiers.bind(this._viewQueryExecutor),
options,
);
setViewByParametersWithNewQuery = async (
options?: ViewFactoryOptions,
): Promise<void> =>
await this._setViewThenModifyAsync(
this._viewFactory.getViewByParameters.bind(this._viewFactory),
this._viewQueryExecutor.getNewQueryModifiers.bind(this._viewQueryExecutor),
options,
);
setViewByParametersWithExistingQuery = async (
options?: ViewFactoryOptions,
): Promise<void> => {
// Default the query to the base view's own, so a bare `baseView`
// re-executes its query (`_setViewThenModifyAsync` otherwise nulls it).
// Only an omitted query falls back; an explicit query -- including `null`
// to clear it -- is left as the caller specified.
const baseView = options?.baseView ?? this._view;
const explicitQuery = options?.params?.query;
await this._setViewThenModifyAsync(
this._viewFactory.getViewByParameters.bind(this._viewFactory),
this._viewQueryExecutor.getExistingQueryModifiers.bind(this._viewQueryExecutor),
{
...options,
params: {
...options?.params,
query: explicitQuery !== undefined ? explicitQuery : baseView?.query ?? null,
},
},
);
};
private _setViewGeneric(
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
options?: ViewFactoryOptions,
): void {
if (!this._isAllowedToProposeView()) {
return;
}
let view: View | null = null;
try {
view = viewFactoryFunc({
baseView: this._view,
...options,
});
// A non-throwing factory call clears any prior view_incompatible /
// media_query state -- ensures a previously-dismissed mid-session popup
// does not linger invisibly and re-pop on the next evaluation cycle,
// and that a stale media_query failure from an abandoned gallery /
// viewer doesn't follow the user into an unrelated view.
this._api.getIssueManager().reset('view_incompatible');
this._api.getIssueManager().reset('media_query');
} catch (e) {
if (!this._view) {
view = this._getFailSafeView(viewFactoryFunc);
}
this._api.getIssueManager().trigger('view_incompatible', { error: e });
}
if (view && !this._isAllowedToSetView(view, options)) {
return;
}
if (view) {
this._setView(view);
}
}
private _getFailSafeView(
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
): View | null {
try {
return viewFactoryFunc({ baseView: null, failSafe: true });
} catch {
return null;
}
}
private _markViewLoadingQuery(view: View, index: number): void {
view.mergeInContext({ loading: { query: index } });
}
private _markViewAsNotLoadingQuery(view: View): void {
view.removeContextProperty('loading', 'query');
}
// Pre-computation gate: whether we should even attempt to build a candidate
// view. Skipped here for race conditions that would otherwise generate a
// spurious `view_incompatible` issue.
private _isAllowedToProposeView(): boolean {
// It is possible to have a race condition where the view is being set at
// the same time as the cameras being initialized. Test case: Open
// folder-based media in the media viewer carousel, then attempt to edit the
// card -- this causes the cameras to re-initialize at the same time as
// folder media is reporting observed zoom settings in the view context.
// Without this check, that will result in a "No cameras support this view"
// message.
return this._api
.getInitializationManager()
.isInitialized(InitializationAspect.CAMERAS);
}
// Post-computation gate: given a freshly proposed view, whether we should
// actually commit it. Respects the lock state by potentially rejecting
// changes that would disrupt the active session (camera, view name, or
// substream).
private _isAllowedToSetView(
proposedView: View,
options?: ViewFactoryOptions,
): boolean {
if (options?.force || !this._api.getLockManager().isLocked()) {
return true;
}
return !this.hasMajorMediaChange(this._view, proposedView);
}
private async _setViewThenModifyAsync(
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
viewModifiersFunc: (
view: View,
queryExecutorOptions?: QueryExecutorOptions,
) => Promise<ViewModifier[] | null>,
options?: ViewFactoryOptions,
): Promise<void> {
if (!this._isAllowedToProposeView()) {
return;
}
let initialView: View | null = null;
try {
initialView = viewFactoryFunc({
baseView: this._view,
...options,
params: {
query: null,
queryResults: null,
...options?.params,
},
});
this._api.getIssueManager().reset('view_incompatible');
} catch (e) {
if (!this._view) {
initialView = this._getFailSafeView(viewFactoryFunc);
}
this._api.getIssueManager().trigger('view_incompatible', { error: e });
}
if (!initialView || !this._isAllowedToSetView(initialView, options)) {
return;
}
// A new query is about to run and is allowed to commit, so any stale
// media_query error from a previous attempt is no longer meaningful (on
// failure it re-triggers below). A retry is the exception: it re-runs the
// same failing query, so its error must persist until this attempt resolves
// rather than being cleared here and re-appearing on the next failure.
if (options?.intent !== 'retry') {
this._api.getIssueManager().reset('media_query');
}
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) {
const view = this._view.clone();
this._markViewAsNotLoadingQuery(view);
this._setView(view);
}
return;
}
if (error) {
// Clear the loading flag before surfacing the error. Otherwise the
// view stays marked in-flight and components (gallery, viewer) keep
// rendering "Awaiting media" on top of the error notification.
if (this._view?.context?.loading?.query === loadingIndex) {
const view = this._view.clone();
this._markViewAsNotLoadingQuery(view);
this._setView(view);
}
this._api.getIssueManager().trigger('media_query', { error });
return;
}
/* v8 ignore if: the if path cannot be reached as the view is set
above -- @preserve */
if (!this._view) {
return;
}
this._api.getIssueManager().reset('media_query');
const newView = this._view.clone();
if (this._view.context?.loading?.query === loadingIndex) {
this._markViewAsNotLoadingQuery(newView);
}
applyViewModifiers(newView, viewModifiers);
this._setView(newView);
}
private _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: https://github.com/dermotduffy/advanced-camera-card/issues/885
if (!this._view?.isViewerView() || !newView?.isGalleryView()) {
return false;
}
// Check if the viewer came from this gallery type. If they did, we preserve
// the query/results to ensure consistency in navigation between media &
// gallery.
const originView = this._view?.context?.gallery?.originView;
return originView === newView.view;
}
public setViewWithMergedContext(context: ViewContext | null): void {
if (this._view) {
return this._setView(this._view?.clone().mergeInContext(context));
}
}
public setViewWithModifiers(modifiers: ViewModifier[]): void {
if (this._view) {
return this._setView(applyViewModifiers(this._view.clone(), modifiers));
}
}
/**
* Detect if the current view has a major "media change" for the given previous view.
* @param oldView The previous view.
* @returns True if the view change is a real media change.
*/
public hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean {
const compareView = newView ?? this._view;
return (
!!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.
(compareView?.view === 'live' &&
oldView &&
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.
(compareView?.view !== 'live' &&
oldView?.queryResults !== compareView?.queryResults)
);
}
// Returns whether view initialization succeeded. A `false` return leaves the
// view "aspect" uninitialized so a later initialization attempt retries --
// the default-view set can legitimately produce no view (e.g. it declines to
// run while the cameras are concurrently re-initializing), and treating that
// as initialized would leave the card without a view permanently.
public initialize = async (): Promise<boolean> => {
// If the query string contains a view related action, we don't set any view
// here and allow that action to be triggered by the next call of to execute
// query actions (called at least once per render cycle).
// Related: https://github.com/dermotduffy/advanced-camera-card/issues/1200
if (this._api.getQueryStringManager().hasViewRelatedActionsToRun()) {
return true;
}
// This is not awaited to allow the initialization to complete before the
// query is answered. The view itself (without query results) is set
// synchronously before the query executes, so whether a view exists on
// return accurately reports success.
void this.setViewDefaultWithNewQuery({ failSafe: true });
return this.hasView();
};
private _setView(view: Readonly<View> | null): void {
const oldView = this._view;
log(
this._api.getConfigManager().getCardWideConfig(),
`Advanced Camera Card view change: `,
view,
);
this._view = view;
this._epoch = this._createEpoch(oldView);
this._api
.getMediaLoadedInfoManager()
.setSelected(view ? getViewTargetID(view) : null);
if (oldView?.view !== view?.view) {
this._api.getCardElementManager().scrollReset();
}
this._api.getStyleManager().setExpandedMode();
const stream = view ? getStreamCameraID(view) : null;
this._api.getConditionStateManager()?.setState({
view: view?.view,
camera: view?.camera ?? undefined,
displayMode: view?.displayMode ?? undefined,
targetID: view ? getViewTargetID(view) ?? undefined : undefined,
substreamID: stream && stream !== view?.camera ? stream : undefined,
});
this._api.getCardElementManager().update();
}
}