Refactor views to support dynamic updates.
This commit is contained in:
@@ -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,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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user