Refactor views to support dynamic updates.

This commit is contained in:
Dermot Duffy
2024-07-26 19:15:38 -07:00
parent 33dee47205
commit d5c4e56c45
80 changed files with 3714 additions and 4021 deletions
@@ -16,9 +16,11 @@ export class CameraSelectAction extends FrigateCardAction<CameraSelectActionConf
const viewOnCameraSelect = config?.view.camera_select ?? 'current';
const targetViewName =
viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect;
api.getViewManager().setViewByParameters({
viewName: targetViewName,
cameraID: selectCameraID,
await api.getViewManager().setViewByParametersWithNewQuery({
params:{
view: targetViewName,
camera: selectCameraID,
},
failSafe: true,
});
}
@@ -4,6 +4,10 @@ import { FrigateCardAction } from './base';
export class DisplayModeSelectAction extends FrigateCardAction<DisplayModeActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getViewManager().setViewWithNewDisplayMode(this._action.display_mode);
await api.getViewManager().setViewByParametersWithNewQuery({
params: {
displayMode: this._action.display_mode,
},
});
}
}
@@ -1,9 +1,12 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { SubstreamOffViewModifier } from '../../view/modifiers/substream-off';
import { FrigateCardAction } from './base';
export class SubstreamOffAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithoutSubstream();
api.getViewManager().setViewByParameters({
modifiers: [new SubstreamOffViewModifier()],
});
}
}
@@ -1,9 +1,12 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { SubstreamOnViewModifier } from '../../view/modifiers/substream-on';
import { FrigateCardAction } from './base';
export class SubstreamOnAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithSubstream();
api.getViewManager().setViewByParameters({
modifiers: [new SubstreamOnViewModifier(api)],
});
}
}
@@ -1,9 +1,12 @@
import { SubstreamSelectActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { SubstreamSelectViewModifier } from '../../view/modifiers/substream-select';
import { FrigateCardAction } from './base';
export class SubstreamSelectAction extends FrigateCardAction<SubstreamSelectActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithSubstream(this._action.camera);
api.getViewManager().setViewByParameters({
modifiers: [new SubstreamSelectViewModifier(this._action.camera)],
});
}
}
+4 -7
View File
@@ -4,13 +4,10 @@ import { FrigateCardAction } from './base';
export class ViewAction extends FrigateCardAction<ViewActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewByParameters({
viewName: this._action.frigate_card_action,
// Note: This function needs to process (view-related) commands even when
// _view has not yet been initialized (since it may be used to set a view
// via the querystring).
cameraID: api.getViewManager().getView()?.camera,
api.getViewManager().setViewByParametersWithNewQuery({
params: {
view: this._action.frigate_card_action,
},
});
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ import {
CardTriggersAPI,
CardViewAPI,
} from './types';
import { ViewManager } from './view-manager';
import { ViewManager } from './view/view-manager';
import { KeyboardStateManager } from './keyboard-state-manager';
export class CardController
@@ -100,10 +100,10 @@ export class InitializationManager {
if (hasViewRelatedActions) {
this._api.getQueryStringManager().executeViewRelated();
} else {
this._api.getViewManager().setViewDefault({ failSafe: true });
this._api.getViewManager().setViewDefaultWithNewQuery({ failSafe: true });
}
} else {
// If we already have a view something (e.g. cameras) may have been
// If we already have a view, something (e.g. cameras) may have been
// reinitialized, be sure to ask for an update.
this._api.getCardElementManager().update();
}
+23 -13
View File
@@ -1,11 +1,13 @@
import { FrigateCardCustomAction, ViewActionConfig } from '../config/types';
import { createCameraAction, createGeneralAction } from '../utils/action.js';
import { ViewParameters } from '../view/view';
import { CardQueryStringAPI } from './types';
import { ViewManagerSetViewParameters } from './view-manager';
import { SubstreamSelectViewModifier } from './view/modifiers/substream-select';
interface QueryStringViewIntent {
view?: ViewManagerSetViewParameters & {
view?: Partial<ViewParameters> & {
default?: boolean;
substream?: string;
};
other?: FrigateCardCustomAction[];
}
@@ -35,18 +37,26 @@ export class QueryStringManager {
this._executeNonViewRelated(intent);
};
protected _executeViewRelated(intent: QueryStringViewIntent): void {
protected async _executeViewRelated(intent: QueryStringViewIntent): Promise<void> {
if (intent.view) {
if (intent.view.default) {
this._api.getViewManager().setViewDefault({
...(intent.view.cameraID && { cameraID: intent.view.cameraID }),
...(intent.view.substream && { substream: intent.view.substream }),
await this._api.getViewManager().setViewDefaultWithNewQuery({
params: {
camera: intent.view.camera,
},
...(intent.view.substream && {
modifiers: [new SubstreamSelectViewModifier(intent.view.substream)],
}),
});
} else {
this._api.getViewManager().setViewByParameters({
...(intent.view.viewName && { viewName: intent.view.viewName }),
...(intent.view.cameraID && { cameraID: intent.view.cameraID }),
...(intent.view.substream && { substream: intent.view.substream }),
await this._api.getViewManager().setViewByParametersWithNewQuery({
params: {
...(intent.view.view && { view: intent.view.view }),
...(intent.view.camera && { camera: intent.view.camera }),
},
...(intent.view.substream && {
modifiers: [new SubstreamSelectViewModifier(intent.view.substream)],
}),
});
}
}
@@ -68,13 +78,13 @@ export class QueryStringManager {
const result: QueryStringViewIntent = {};
for (const action of this._getActions()) {
if (this._isViewAction(action)) {
(result.view ??= {}).viewName = action.frigate_card_action;
(result.view ??= {}).view = action.frigate_card_action;
(result.view ??= {}).default = undefined;
} else if (action.frigate_card_action === 'default') {
(result.view ??= {}).default = true;
(result.view ??= {}).viewName = undefined;
(result.view ??= {}).view = undefined;
} else if (action.frigate_card_action === 'camera_select') {
(result.view ??= {}).cameraID = action.camera;
(result.view ??= {}).camera = action.camera;
} else if (action.frigate_card_action === 'live_substream_select') {
(result.view ??= {}).substream = action.camera;
} else {
+26 -26
View File
@@ -36,7 +36,7 @@ export class TriggersManager {
return sorted.length ? sorted[0][0] : null;
}
public handleCameraEvent(ev: CameraEvent): void {
public async handleCameraEvent(ev: CameraEvent): Promise<void> {
const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers;
const selectedCameraID = this._api.getViewManager().getView()?.camera;
@@ -60,7 +60,7 @@ export class TriggersManager {
this._triggeredCameras.set(ev.cameraID, new Date());
this._setConditionStateIfNecessary();
this._throttledTriggerAction(ev);
await this._throttledTriggerAction(ev);
}
protected _hasAllowableInteractionStateForAction(): boolean {
@@ -75,7 +75,7 @@ export class TriggersManager {
);
}
protected _triggerAction(ev: CameraEvent): void {
protected async _triggerAction(ev: CameraEvent): Promise<void> {
const triggerAction = this._api.getConfigManager().getConfig()?.view.triggers
.actions.trigger;
const defaultView = this._api.getConfigManager().getConfig()?.view.default;
@@ -98,30 +98,30 @@ export class TriggersManager {
if (this._hasAllowableInteractionStateForAction()) {
if (triggerAction === 'update') {
const view = this._api.getViewManager().getView()?.evolve({
// Reset the media queries to catch media to be refetched in the
// current view.
query: null,
queryResults: null,
});
/* istanbul ignore else: the else path cannot be reached, as the camera
cannot be triggered without a view -- @preserve */
if (view) {
this._api.getViewManager().setView(view);
}
await this._api
.getViewManager()
.setViewByParametersWithNewQuery({
queryExecutorOptions: { useCache: false },
});
} else if (triggerAction === 'live') {
this._api.getViewManager().setViewByParameters({
viewName: 'live',
cameraID: ev.cameraID,
await this._api.getViewManager().setViewByParametersWithNewQuery({
params: {
view: 'live',
camera: ev.cameraID,
},
});
} else if (triggerAction === 'default') {
this._api.getViewManager().setViewDefault({
cameraID: ev.cameraID,
await this._api.getViewManager().setViewDefaultWithNewQuery({
params: {
camera: ev.cameraID,
},
});
} else if (ev.fidelity === 'high' && triggerAction === 'media') {
this._api.getViewManager().setViewByParameters({
viewName: ev.clip ? 'clip' : 'snapshot',
cameraID: ev.cameraID,
await this._api.getViewManager().setViewByParametersWithNewQuery({
params: {
view: ev.clip ? 'clip' : 'snapshot',
camera: ev.cameraID,
},
});
}
}
@@ -143,12 +143,12 @@ export class TriggersManager {
}
}
protected _untriggerAction(cameraID: string): void {
protected async _untriggerAction(cameraID: string): Promise<void> {
const action = this._api.getConfigManager().getConfig()?.view.triggers
.actions.untrigger;
if (action === 'default' && this._hasAllowableInteractionStateForAction()) {
this._api.getViewManager().setViewDefault();
await this._api.getViewManager().setViewDefaultWithNewQuery();
}
this._triggeredCameras.delete(cameraID);
this._deleteTimer(cameraID);
@@ -168,8 +168,8 @@ export class TriggersManager {
reached, as there's no way to have the untrigger call happen without
a config. -- @preserve */
this._api.getConfigManager().getConfig()?.view.triggers.untrigger_seconds ?? 0,
() => {
this._untriggerAction(cameraID);
async () => {
await this._untriggerAction(cameraID);
},
);
}
+1 -1
View File
@@ -20,7 +20,7 @@ import type { MessageManager } from './message-manager';
import type { MicrophoneManager } from './microphone-manager';
import type { StyleManager } from './style-manager';
import type { TriggersManager } from './triggers-manager';
import type { ViewManager } from './view-manager';
import type { ViewManager } from './view/view-manager';
import type { QueryStringManager } from './query-string-manager';
import { KeyboardStateManager } from './keyboard-state-manager';
import { Automation } from '../config/types';
-323
View File
@@ -1,323 +0,0 @@
import isEqual from 'lodash-es/isEqual';
import { ViewContext } from 'view';
import {
FRIGATE_CARD_VIEW_DEFAULT,
FrigateCardConfig,
FrigateCardView,
ViewDisplayMode,
} from '../config/types';
import { localize } from '../localize/localize';
import { log } from '../utils/debug';
import { executeMediaQueryForView } from '../utils/media-to-view';
import { View } from '../view/view';
import { getCameraIDsForViewName } from '../view/view-to-cameras';
import { CardViewAPI } from './types';
interface ViewManagerSetViewDefaultParameters {
cameraID?: string;
substream?: string;
// When failSafe is true, the view will be changed to the default view, or the
// `live` view if the default view is not supported, or failing that an error
// message is shown. Without `failSafe` the view will just not be changed if
// unsupported.
failSafe?: boolean;
}
export interface ViewManagerSetViewParameters
extends ViewManagerSetViewDefaultParameters {
viewName?: FrigateCardView;
}
export class ViewManager {
protected _view: View | null = null;
protected _api: CardViewAPI;
constructor(api: CardViewAPI) {
this._api = api;
}
public getView(): View | null {
return this._view;
}
public hasView(): boolean {
return !!this.getView();
}
public setView(view: View): void {
this._setView(view);
}
public setViewDefault(params?: ViewManagerSetViewDefaultParameters): void {
const config = this._api.getConfigManager().getConfig();
if (config) {
let forceCameraID: string | null = params?.cameraID ?? null;
const viewName = config.view.default;
if (!forceCameraID && this._view?.camera && config.view.default_cycle_camera) {
const cameraIDs = [
...getCameraIDsForViewName(this._api.getCameraManager(), viewName),
];
const currentIndex = cameraIDs.indexOf(this._view.camera);
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
forceCameraID = cameraIDs[targetIndex];
}
this.setViewByParameters({
...params,
viewName: viewName,
...(forceCameraID && { cameraID: forceCameraID }),
});
}
}
public setViewByParameters(params: ViewManagerSetViewParameters): void {
const config = this._api.getConfigManager().getConfig();
if (config) {
let cameraID: string | null = null;
let viewName = params?.viewName ?? this._view?.view ?? config.view.default;
const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs();
if (params?.cameraID && allCameraIDs.has(params.cameraID)) {
cameraID = params.cameraID;
} else {
const viewCameraIDs = getCameraIDsForViewName(
this._api.getCameraManager(),
viewName,
);
// Reset to the default camera.
cameraID = viewCameraIDs.keys().next().value;
}
if (!cameraID) {
if (params.failSafe) {
const camerasToCapabilities = [
...this._api.getCameraManager().getStore().getCameras(),
].reduce((acc, [cameraID, camera]) => {
const capabilities = camera.getCapabilities()?.getRawCapabilities();
if (capabilities) {
acc[cameraID] = capabilities;
}
return acc;
}, {});
this._api.getMessageManager().setMessageIfHigherPriority({
type: 'error',
message: localize('error.no_supported_cameras'),
context: {
view: viewName,
cameras_capabilities: camerasToCapabilities,
},
});
}
return;
}
if (!this.isViewSupportedByCamera(cameraID, viewName)) {
if (params.failSafe) {
if (this.isViewSupportedByCamera(cameraID, FRIGATE_CARD_VIEW_DEFAULT)) {
viewName = FRIGATE_CARD_VIEW_DEFAULT;
} else {
const capabilities = this._api
.getCameraManager()
.getStore()
.getCamera(cameraID)
?.getCapabilities()
?.getRawCapabilities();
this._api.getMessageManager().setMessageIfHigherPriority({
type: 'error',
message: localize('error.no_supported_camera'),
context: {
view: viewName,
camera: cameraID,
...(capabilities && { camera_capabilities: capabilities }),
},
});
return;
}
} else {
return;
}
}
const displayMode =
this._view?.displayMode ?? this._getDefaultDisplayModeForView(viewName, config);
let view: View = new View({
view: viewName,
camera: cameraID,
displayMode: displayMode,
});
if (params.substream) {
view = this._createViewWithSelectedSubstream(view, params.substream);
}
this._setView(view);
}
}
public setViewWithMergedContext(context: ViewContext | null): void {
if (this._view) {
return this._setView(this._view?.clone().mergeInContext(context));
}
}
public reset(): void {
this._view = null;
}
protected _getCameraIDsInvolvedInView(view: View): Set<string> {
return view.supportsMultipleDisplayModes() && view.isGrid()
? getCameraIDsForViewName(this._api.getCameraManager(), view.view)
: getCameraIDsForViewName(this._api.getCameraManager(), view.view, view.camera);
}
public async setViewWithNewDisplayMode(displayMode: ViewDisplayMode): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
if (this._view && hass) {
const view = this._view.evolve({
displayMode: displayMode,
});
const expectedCameraIDs = this._getCameraIDsInvolvedInView(view);
const queryCameraIDs = view.query?.getQueryCameraIDs();
if (!isEqual(expectedCameraIDs, queryCameraIDs) && view && view.query) {
// If the user requests a grid but the current query does not have a
// query for more than one camera, reset the query results, change the
// existing query to refer to all cameras and execute it to fetch new
// results.
let viewWithNewQuery: View | null = null;
try {
viewWithNewQuery = await executeMediaQueryForView(
this._api.getCameraManager(),
view,
view.query.clone().setQueryCameraIDs(expectedCameraIDs),
);
} catch (e: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
}
if (viewWithNewQuery) {
return this._setView(viewWithNewQuery);
}
} else {
return this._setView(view);
}
}
}
public setViewWithSubstream(substream?: string): void {
if (!this._view) {
return;
}
this._setView(
substream
? this._createViewWithSelectedSubstream(this._view, substream)
: this._createViewWithNextStream(this._view),
);
}
public setViewWithoutSubstream(): void {
const view = this._createViewWithoutSubstream();
if (view) {
return this._setView(view);
}
}
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
}
protected _getDefaultDisplayModeForView(
viewName: FrigateCardView,
config?: FrigateCardConfig,
): ViewDisplayMode {
let mode: ViewDisplayMode | null = null;
switch (viewName) {
case 'media':
case 'clip':
case 'recording':
case 'snapshot':
mode = config?.media_viewer.display?.mode ?? null;
break;
case 'live':
mode = config?.live.display?.mode ?? null;
break;
}
return mode ?? 'single';
}
protected _setView(view: View): void {
const oldView = this._view;
View.adoptFromViewIfAppropriate(view, oldView);
log(
this._api.getConfigManager().getCardWideConfig(),
`Frigate Card view change: `,
view,
);
this._view = view;
if (View.isMajorMediaChange(oldView, view)) {
this._api.getMediaLoadedInfoManager().clear();
}
if (oldView?.view !== view.view) {
this._api.getCardElementManager().scrollReset();
}
this._api.getMessageManager().reset();
this._api.getStyleManager().setExpandedMode();
this._api.getConditionsManager()?.setState({
view: view.view,
camera: view.camera,
displayMode: view.displayMode ?? undefined,
});
this._api.getCardElementManager().update();
}
protected _createViewWithSelectedSubstream(baseView: View, substreamID: string): View {
const overrides: Map<string, string> =
baseView?.context?.live?.overrides ?? new Map();
overrides.set(baseView.camera, substreamID);
return baseView.clone().mergeInContext({
live: { overrides: overrides },
});
}
protected _createViewWithNextStream(baseView: View): View {
const dependencies = [
...this._api.getCameraManager().getStore().getAllDependentCameras(baseView.camera),
];
if (dependencies.length <= 1) {
return baseView.clone();
}
const view = baseView.clone();
const overrides: Map<string, string> = view.context?.live?.overrides ?? new Map();
const currentOverride = overrides.get(view.camera) ?? view.camera;
const currentIndex = dependencies.indexOf(currentOverride);
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
overrides.set(view.camera, dependencies[newIndex]);
view.mergeInContext({ live: { overrides: overrides } });
return view;
}
protected _createViewWithoutSubstream(): View | null {
if (!this._view) {
return null;
}
const view = this._view.clone();
const overrides: Map<string, string> | undefined = view.context?.live?.overrides;
if (overrides && overrides.has(view.camera)) {
view.context?.live?.overrides?.delete(view.camera);
}
return view;
}
}
+373
View File
@@ -0,0 +1,373 @@
import { sub } from 'date-fns';
import {
FRIGATE_CARD_VIEW_DEFAULT,
FrigateCardConfig,
FrigateCardView,
ViewDisplayMode,
} from '../../config/types';
import { localize } from '../../localize/localize';
import { ClipsOrSnapshotsOrAll } from '../../types';
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';
export class ViewFactory {
protected _api: CardViewAPI;
protected _executor: QueryExecutor;
constructor(api: CardViewAPI, executor?: QueryExecutor) {
this._api = api;
this._executor = executor ?? new QueryExecutor(api);
}
public getViewDefault(options?: ViewFactoryOptions): View | null {
const config = this._api.getConfigManager().getConfig();
if (!config) {
return null;
}
let forceCameraID: string | null = options?.params?.camera ?? null;
const viewName = options?.params?.view ?? config.view.default;
if (
!forceCameraID &&
options?.baseView?.camera &&
config.view.default_cycle_camera
) {
const cameraIDs = [
...getCameraIDsForViewName(this._api.getCameraManager(), viewName),
];
const currentIndex = cameraIDs.indexOf(options?.baseView?.camera);
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
forceCameraID = cameraIDs[targetIndex];
}
return this.getViewByParameters({
params: {
...options?.params,
view: viewName,
...(forceCameraID && { camera: forceCameraID }),
},
baseView: options?.baseView,
});
}
public getViewByParameters(options?: ViewFactoryOptions): View | null {
const config = this._api.getConfigManager().getConfig();
if (!config) {
return null;
}
let cameraID: string | null =
options?.params?.camera ?? options?.baseView?.camera ?? null;
let viewName =
options?.params?.view ?? options?.baseView?.view ?? config.view.default;
const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs();
if (!cameraID || !allCameraIDs.has(cameraID)) {
const viewCameraIDs = getCameraIDsForViewName(
this._api.getCameraManager(),
viewName,
);
// Reset to the default camera.
cameraID = viewCameraIDs.keys().next().value;
}
if (!cameraID) {
const camerasToCapabilities = [
...this._api.getCameraManager().getStore().getCameras(),
].reduce((acc, [cameraID, camera]) => {
const capabilities = camera.getCapabilities()?.getRawCapabilities();
if (capabilities) {
acc[cameraID] = capabilities;
}
return acc;
}, {});
throw new ViewNoCameraError(localize('error.no_supported_cameras'), {
view: viewName,
cameras_capabilities: camerasToCapabilities,
});
}
if (!this.isViewSupportedByCamera(cameraID, viewName)) {
if (
options?.failSafe &&
this.isViewSupportedByCamera(cameraID, FRIGATE_CARD_VIEW_DEFAULT)
) {
viewName = FRIGATE_CARD_VIEW_DEFAULT;
} else {
const capabilities = this._api
.getCameraManager()
.getStore()
.getCamera(cameraID)
?.getCapabilities()
?.getRawCapabilities();
throw new ViewIncompatible(localize('error.no_supported_camera'), {
view: viewName,
camera: cameraID,
...(capabilities && { camera_capabilities: capabilities }),
});
}
}
const displayMode =
options?.params?.displayMode ??
options?.baseView?.displayMode ??
this._getDefaultDisplayModeForView(viewName, config);
const viewParameters: ViewParameters = {
...options?.params,
view: viewName,
camera: cameraID,
displayMode: displayMode,
};
const view = options?.baseView
? options.baseView.evolve(viewParameters)
: new View(viewParameters);
if (options?.modifiers) {
options.modifiers.forEach((modifier) => modifier.modify(view));
}
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();
if (switchingToGalleryFromViewer && 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':
this._setTimelineWindowToLive(view);
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._setOrRemoveSeekTime(
view,
options?.queryExecutorOptions?.selectResult?.time?.time,
);
return view;
}
protected _setTimelineWindowToLive(view: View): void {
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,
},
},
});
}
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,
): ViewDisplayMode {
let mode: ViewDisplayMode | null = null;
switch (viewName) {
case 'media':
case 'clip':
case 'recording':
case 'snapshot':
mode = config?.media_viewer.display?.mode ?? null;
break;
case 'live':
mode = config?.live.display?.mode ?? null;
break;
}
return mode ?? 'single';
}
}
@@ -0,0 +1,15 @@
import { ViewContext } from "view";
import { View } from "../../../view/view";
import { ViewModifier } from "../types";
export class MergeContextViewModifier implements ViewModifier {
protected _context?: ViewContext | null;
constructor(context?: ViewContext | null) {
this._context = context;
}
public modify(view: View): void {
view.mergeInContext(this._context);
}
}
@@ -0,0 +1,17 @@
import { ViewContext } from 'view';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class RemoveContextPropertyViewModifier implements ViewModifier {
protected _key: keyof ViewContext;
protected _property: PropertyKey;
constructor(key: keyof ViewContext, property: PropertyKey) {
this._key = key;
this._property = property;
}
public modify(view: View): void {
view.removeContextProperty(this._key, this._property);
}
}
@@ -0,0 +1,15 @@
import { ViewContext } from "view";
import { View } from "../../../view/view";
import { ViewModifier } from "../types";
export class RemoveContextViewModifier implements ViewModifier {
protected _keys: (keyof ViewContext)[];
constructor(keys: (keyof ViewContext)[]) {
this._keys = keys;
}
public modify(view: View): void {
this._keys.forEach((key) => view.removeContext(key));
}
}
@@ -0,0 +1,10 @@
import { removeSubstream } from '../../../utils/substream';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class SubstreamOffViewModifier implements ViewModifier {
public modify(view: View): void {
removeSubstream(view);
}
}
@@ -0,0 +1,35 @@
import { CameraManager } from '../../../camera-manager/manager';
import { getStreamCameraID, setSubstream } from '../../../utils/substream';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
interface SubstreamOnViewModifierAPI {
getCameraManager(): CameraManager;
}
export class SubstreamOnViewModifier implements ViewModifier {
protected _api: SubstreamOnViewModifierAPI;
constructor(api: SubstreamOnViewModifierAPI) {
this._api = api;
}
public modify(view: View): void {
const dependencies = [
...this._api
.getCameraManager()
.getStore()
.getAllDependentCameras(view.camera, 'substream'),
];
if (dependencies.length <= 1) {
return;
}
const currentOverride = getStreamCameraID(view);
const currentIndex = dependencies.indexOf(currentOverride);
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
setSubstream(view, dependencies[newIndex]);
}
}
@@ -0,0 +1,15 @@
import { setSubstream } from '../../../utils/substream';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class SubstreamSelectViewModifier implements ViewModifier {
protected _substreamID: string;
constructor(substreamID: string) {
this._substreamID = substreamID;
}
public modify(view: View): void {
setSubstream(view, this._substreamID);
}
}
+131
View File
@@ -0,0 +1,131 @@
import { CapabilitySearchOptions, MediaQuery } from '../../camera-manager/types';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
import { ClipsOrSnapshotsOrAll } from '../../types';
import { findBestMediaIndex } from '../../utils/find-best-media-index';
import {
EventMediaQueries,
MediaQueries,
RecordingMediaQueries,
} from '../../view/media-queries';
import { MediaQueriesResults } from '../../view/media-queries-results';
import { CardViewAPI } from '../types';
import { QueryExecutorOptions, QueryWithResults } from './types';
export class QueryExecutor {
protected _api: CardViewAPI;
constructor(api: CardViewAPI) {
this._api = api;
}
public async executeDefaultEventQuery(options?: {
cameraID?: string;
eventsMediaType?: ClipsOrSnapshotsOrAll;
executorOptions?: QueryExecutorOptions;
}): Promise<QueryWithResults | null> {
const capabilitySearch: CapabilitySearchOptions =
!options?.eventsMediaType || options?.eventsMediaType === 'all'
? {
anyCapabilities: ['clips', 'snapshots'],
}
: options.eventsMediaType;
const cameraManager = this._api.getCameraManager();
const cameraIDs = options?.cameraID
? cameraManager
.getStore()
.getAllDependentCameras(options.cameraID, capabilitySearch)
: cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch);
if (!cameraIDs.size) {
return null;
}
const rawQueries = cameraManager.generateDefaultEventQueries(cameraIDs, {
limit: this._getChunkLimit(),
...(options?.eventsMediaType === 'clips' && { hasClip: true }),
...(options?.eventsMediaType === 'snapshots' && { hasSnapshot: true }),
});
if (!rawQueries) {
return null;
}
const queries = new EventMediaQueries(rawQueries);
const results = await this.execute(queries, options?.executorOptions);
return results
? {
query: queries,
queryResults: results,
}
: null;
}
public async executeDefaultRecordingQuery(options?: {
cameraID?: string;
executorOptions?: QueryExecutorOptions;
}): Promise<QueryWithResults | null> {
const cameraManager = this._api.getCameraManager();
const cameraIDs = options?.cameraID
? cameraManager.getStore().getAllDependentCameras(options.cameraID, 'recordings')
: cameraManager.getStore().getCameraIDsWithCapability('recordings');
if (!cameraIDs.size) {
return null;
}
const rawQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, {
limit: this._getChunkLimit(),
});
if (!rawQueries) {
return null;
}
const queries = new RecordingMediaQueries(rawQueries);
const results = await this.execute(queries, options?.executorOptions);
return results ? { query: queries, queryResults: results } : null;
}
public async execute(
query: MediaQueries,
executorOptions?: QueryExecutorOptions,
): Promise<MediaQueriesResults | null> {
const queries = query.getQueries();
if (!queries) {
return null;
}
const mediaArray = await this._api
.getCameraManager()
.executeMediaQueries<MediaQuery>(queries, {
useCache: executorOptions?.useCache,
});
if (!mediaArray) {
return null;
}
const queryResults = new MediaQueriesResults({ results: mediaArray });
if (executorOptions?.rejectResults?.(queryResults)) {
return null;
}
if (executorOptions?.selectResult?.id) {
queryResults.selectBestResult((media) =>
media.findIndex((m) => m.getID() === executorOptions.selectResult?.id),
);
} else if (executorOptions?.selectResult?.func) {
queryResults.selectResultIfFound(executorOptions.selectResult.func);
} else if (executorOptions?.selectResult?.time) {
queryResults.selectBestResult((media) =>
findBestMediaIndex(
media,
executorOptions.selectResult?.time?.time as Date,
executorOptions.selectResult?.time?.favorCameraID,
),
);
}
return queryResults;
}
protected _getChunkLimit(): number {
const cardWideConfig = this._api.getConfigManager().getCardWideConfig();
return (
cardWideConfig?.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT
);
}
}
+80
View File
@@ -0,0 +1,80 @@
import { ViewContext } from 'view';
import { FrigateCardView } from '../../config/types.js';
import { FrigateCardError } from '../../types.js';
import { MediaQueriesResults } from '../../view/media-queries-results.js';
import { MediaQueries } from '../../view/media-queries.js';
import { ViewMedia } from '../../view/media.js';
import { View, ViewParameters } from '../../view/view.js';
export interface ViewModifier {
modify(view: View): void;
}
export interface QueryExecutorOptions {
// Select the result of a query, based on time, an id match or an arbitrary
// function. If no parameter is specified, the latest media will be selected
// by default.
selectResult?: {
time?: {
time: Date;
favorCameraID?: string;
};
id?: string;
func?: (media: ViewMedia) => boolean;
};
rejectResults?: (results: MediaQueriesResults) => boolean;
useCache?: boolean;
}
export interface QueryWithResults {
query: MediaQueries;
queryResults: MediaQueriesResults;
}
export interface ViewFactoryOptions {
// An existing view to evolve from.
baseView?: View | null;
// View parameters to set/evolve.
params?: Partial<ViewParameters>;
// Modifiers to the view once created.
modifiers?: ViewModifier[];
// When failSafe is true the view will be changed to the default view, or the
// `live` view if the configured default view is not supported.
failSafe?: boolean;
// Options for the query executor that control how a query is executed and the
// result selected.
queryExecutorOptions?: QueryExecutorOptions;
}
export interface ViewManagerEpoch {
manager: ViewManagerInterface;
oldView?: View;
}
export interface ViewManagerInterface {
getEpoch(): ViewManagerEpoch;
getView(): View | null;
hasView(): boolean;
reset(): void;
setViewDefault(options?: ViewFactoryOptions): void;
setViewByParameters(options?: ViewFactoryOptions): void;
setViewDefaultWithNewQuery(options?: ViewFactoryOptions): Promise<void>;
setViewByParametersWithNewQuery(options?: ViewFactoryOptions): Promise<void>;
setViewByParametersWithExistingQuery(options?: ViewFactoryOptions): Promise<void>;
setViewWithMergedContext(context: ViewContext | null): void;
isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean;
hasMajorMediaChange(oldView?: View | null): boolean;
}
export class ViewNoCameraError extends FrigateCardError {}
export class ViewIncompatible extends FrigateCardError {}
+164
View File
@@ -0,0 +1,164 @@
import { ViewContext } from 'view';
import { FrigateCardView } from '../../config/types';
import { log } from '../../utils/debug';
import { getStreamCameraID } from '../../utils/substream';
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';
export class ViewManager implements ViewManagerInterface {
protected _view: View | null = null;
protected _factory: ViewFactory;
protected _api: CardViewAPI;
protected _epoch: ViewManagerEpoch = this._createEpoch();
constructor(api: CardViewAPI, factory?: ViewFactory) {
this._api = api;
this._factory = factory ?? new ViewFactory(api);
}
public getEpoch(): ViewManagerEpoch {
return this._epoch;
}
protected _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._factory.getViewDefault, options);
setViewByParameters = (options?: ViewFactoryOptions): void =>
this._setViewGeneric(this._factory.getViewByParameters, options);
setViewDefaultWithNewQuery = async (options?: ViewFactoryOptions): Promise<void> =>
await this._setViewGenericAsync(this._factory.getViewDefaultWithNewQuery, options);
setViewByParametersWithNewQuery = async (
options?: ViewFactoryOptions,
): Promise<void> =>
await this._setViewGenericAsync(
this._factory.getViewByParametersWithNewQuery,
options,
);
setViewByParametersWithExistingQuery = async (
options?: ViewFactoryOptions,
): Promise<void> =>
await this._setViewGenericAsync(
this._factory.getViewByParametersWithExistingQuery,
options,
);
protected _setViewGeneric(
factoryFunc: (options?: ViewFactoryOptions) => View | null,
options?: ViewFactoryOptions,
): void {
let view: View | null = null;
try {
view = factoryFunc({
baseView: this._view,
...options,
});
} catch (e) {
return this._api.getMessageManager().setErrorIfHigherPriority(e);
}
view && this._setView(view);
}
protected async _setViewGenericAsync(
factoryFunc: (options?: ViewFactoryOptions) => Promise<View | null>,
options?: ViewFactoryOptions,
): Promise<void> {
let view: View | null = null;
try {
view = await factoryFunc({
baseView: this._view,
...options,
});
} catch (e) {
return this._api.getMessageManager().setErrorIfHigherPriority(e);
}
view && this._setView(view);
}
public setViewWithMergedContext(context: ViewContext | null): void {
if (this._view) {
return this._setView(this._view?.clone().mergeInContext(context));
}
}
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
}
/**
* 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): boolean {
return (
!!oldView !== !!this._view ||
oldView?.view !== this._view?.view ||
oldView?.camera !== this._view?.camera ||
// When in live mode, take overrides (substreams) into account in deciding
// if this is a major media change.
(this._view?.view === 'live' &&
oldView &&
getStreamCameraID(oldView) !== getStreamCameraID(this._view)) ||
// 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)
);
}
protected _setView(view: View | null): void {
const oldView = this._view;
log(
this._api.getConfigManager().getCardWideConfig(),
`Frigate Card view change: `,
view,
);
this._view = view;
this._epoch = this._createEpoch(oldView);
if (this.hasMajorMediaChange(oldView)) {
this._api.getMediaLoadedInfoManager().clear();
}
if (oldView?.view !== view?.view) {
this._api.getCardElementManager().scrollReset();
}
this._api.getMessageManager().reset();
this._api.getStyleManager().setExpandedMode();
this._api.getConditionsManager()?.setState({
view: view?.view,
camera: view?.camera,
displayMode: view?.displayMode ?? undefined,
});
this._api.getCardElementManager().update();
}
}