424 lines
15 KiB
TypeScript
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();
|
|
}
|
|
}
|