diff --git a/project.inlang/.gitignore b/project.inlang/.gitignore new file mode 100644 index 00000000..5e465967 --- /dev/null +++ b/project.inlang/.gitignore @@ -0,0 +1 @@ +cache \ No newline at end of file diff --git a/rollup.config.js b/rollup.config.js index 5d40a750..33ca36e7 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -51,6 +51,7 @@ const plugins = [ typescript({ sourceMap: dev, inlineSources: dev, + exclude: ["tests/**/*.test.ts"], }), json({ exclude: 'package.json' }), replace({ diff --git a/src/card-controller/actions/actions/camera-select.ts b/src/card-controller/actions/actions/camera-select.ts index c5f82ce2..395b47d3 100644 --- a/src/card-controller/actions/actions/camera-select.ts +++ b/src/card-controller/actions/actions/camera-select.ts @@ -16,9 +16,11 @@ export class CameraSelectAction extends FrigateCardAction { public async execute(api: CardActionsAPI): Promise { - await api.getViewManager().setViewWithNewDisplayMode(this._action.display_mode); + await api.getViewManager().setViewByParametersWithNewQuery({ + params: { + displayMode: this._action.display_mode, + }, + }); } } diff --git a/src/card-controller/actions/actions/substream-off.ts b/src/card-controller/actions/actions/substream-off.ts index 8503e4a1..0a89b7a6 100644 --- a/src/card-controller/actions/actions/substream-off.ts +++ b/src/card-controller/actions/actions/substream-off.ts @@ -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 { public async execute(api: CardActionsAPI): Promise { - api.getViewManager().setViewWithoutSubstream(); + api.getViewManager().setViewByParameters({ + modifiers: [new SubstreamOffViewModifier()], + }); } } diff --git a/src/card-controller/actions/actions/substream-on.ts b/src/card-controller/actions/actions/substream-on.ts index 0fd13310..78e6976d 100644 --- a/src/card-controller/actions/actions/substream-on.ts +++ b/src/card-controller/actions/actions/substream-on.ts @@ -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 { public async execute(api: CardActionsAPI): Promise { - api.getViewManager().setViewWithSubstream(); + api.getViewManager().setViewByParameters({ + modifiers: [new SubstreamOnViewModifier(api)], + }); } } diff --git a/src/card-controller/actions/actions/substream-select.ts b/src/card-controller/actions/actions/substream-select.ts index 7935ca5d..ec55c5f6 100644 --- a/src/card-controller/actions/actions/substream-select.ts +++ b/src/card-controller/actions/actions/substream-select.ts @@ -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 { public async execute(api: CardActionsAPI): Promise { - api.getViewManager().setViewWithSubstream(this._action.camera); + api.getViewManager().setViewByParameters({ + modifiers: [new SubstreamSelectViewModifier(this._action.camera)], + }); } } diff --git a/src/card-controller/actions/actions/view.ts b/src/card-controller/actions/actions/view.ts index 045b65de..55e03e24 100644 --- a/src/card-controller/actions/actions/view.ts +++ b/src/card-controller/actions/actions/view.ts @@ -4,13 +4,10 @@ import { FrigateCardAction } from './base'; export class ViewAction extends FrigateCardAction { public async execute(api: CardActionsAPI): Promise { - 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, + }, }); } } diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index 683611cd..758f1f31 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -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 diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts index a8de6c75..7b5a78da 100644 --- a/src/card-controller/initialization-manager.ts +++ b/src/card-controller/initialization-manager.ts @@ -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(); } diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts index 15d3a1a6..9009bb67 100644 --- a/src/card-controller/query-string-manager.ts +++ b/src/card-controller/query-string-manager.ts @@ -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 & { 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 { 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 { diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index 6e3e41ae..bebd92ee 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -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 { 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 { 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 { 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); }, ); } diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index f5c627c6..cca66b7a 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -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'; diff --git a/src/card-controller/view-manager.ts b/src/card-controller/view-manager.ts deleted file mode 100644 index 655d7ab3..00000000 --- a/src/card-controller/view-manager.ts +++ /dev/null @@ -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 { - 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 { - 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 = - 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 = 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 | undefined = view.context?.live?.overrides; - if (overrides && overrides.has(view.camera)) { - view.context?.live?.overrides?.delete(view.camera); - } - return view; - } -} diff --git a/src/card-controller/view/factory.ts b/src/card-controller/view/factory.ts new file mode 100644 index 00000000..9f43d5f3 --- /dev/null +++ b/src/card-controller/view/factory.ts @@ -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 { + return this._executeNewQuery(this.getViewDefault(options), { + ...options, + queryExecutorOptions: { + useCache: false, + ...options?.queryExecutorOptions, + }, + }); + } + + public async getViewByParametersWithNewQuery( + options?: ViewFactoryOptions, + ): Promise { + return this._executeNewQuery(this.getViewByParameters(options), { + ...options, + queryExecutorOptions: { + useCache: false, + ...options?.queryExecutorOptions, + }, + }); + } + + public async getViewByParametersWithExistingQuery( + options?: ViewFactoryOptions, + ): Promise { + 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 { + 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 => { + /* 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 { + 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'; + } +} diff --git a/src/card-controller/view/modifiers/merge-context.ts b/src/card-controller/view/modifiers/merge-context.ts new file mode 100644 index 00000000..96f86b33 --- /dev/null +++ b/src/card-controller/view/modifiers/merge-context.ts @@ -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); + } +} diff --git a/src/card-controller/view/modifiers/remove-context-property.ts b/src/card-controller/view/modifiers/remove-context-property.ts new file mode 100644 index 00000000..75c2d9eb --- /dev/null +++ b/src/card-controller/view/modifiers/remove-context-property.ts @@ -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); + } +} diff --git a/src/card-controller/view/modifiers/remove-context.ts b/src/card-controller/view/modifiers/remove-context.ts new file mode 100644 index 00000000..52e33c21 --- /dev/null +++ b/src/card-controller/view/modifiers/remove-context.ts @@ -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)); + } +} diff --git a/src/card-controller/view/modifiers/substream-off.ts b/src/card-controller/view/modifiers/substream-off.ts new file mode 100644 index 00000000..c9eaf443 --- /dev/null +++ b/src/card-controller/view/modifiers/substream-off.ts @@ -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); + } +} + diff --git a/src/card-controller/view/modifiers/substream-on.ts b/src/card-controller/view/modifiers/substream-on.ts new file mode 100644 index 00000000..b3e1ac74 --- /dev/null +++ b/src/card-controller/view/modifiers/substream-on.ts @@ -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]); + } +} diff --git a/src/card-controller/view/modifiers/substream-select.ts b/src/card-controller/view/modifiers/substream-select.ts new file mode 100644 index 00000000..af36777c --- /dev/null +++ b/src/card-controller/view/modifiers/substream-select.ts @@ -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); + } +} diff --git a/src/card-controller/view/query-executor.ts b/src/card-controller/view/query-executor.ts new file mode 100644 index 00000000..8e3df7e1 --- /dev/null +++ b/src/card-controller/view/query-executor.ts @@ -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 { + 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 { + 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 { + const queries = query.getQueries(); + if (!queries) { + return null; + } + + const mediaArray = await this._api + .getCameraManager() + .executeMediaQueries(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 + ); + } +} diff --git a/src/card-controller/view/types.ts b/src/card-controller/view/types.ts new file mode 100644 index 00000000..a1aae634 --- /dev/null +++ b/src/card-controller/view/types.ts @@ -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; + + // 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; + setViewByParametersWithNewQuery(options?: ViewFactoryOptions): Promise; + setViewByParametersWithExistingQuery(options?: ViewFactoryOptions): Promise; + + 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 {} diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts new file mode 100644 index 00000000..da6ccde6 --- /dev/null +++ b/src/card-controller/view/view-manager.ts @@ -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 => + await this._setViewGenericAsync(this._factory.getViewDefaultWithNewQuery, options); + + setViewByParametersWithNewQuery = async ( + options?: ViewFactoryOptions, + ): Promise => + await this._setViewGenericAsync( + this._factory.getViewByParametersWithNewQuery, + options, + ); + + setViewByParametersWithExistingQuery = async ( + options?: ViewFactoryOptions, + ): Promise => + 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, + options?: ViewFactoryOptions, + ): Promise { + 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(); + } +} diff --git a/src/card.ts b/src/card.ts index f36bdbe3..8f9000c5 100644 --- a/src/card.ts +++ b/src/card.ts @@ -4,7 +4,6 @@ import { customElement } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js'; import { styleMap } from 'lit/directives/style-map.js'; -import { ViewContext } from 'view'; import 'web-dialog'; import pkg from '../package.json'; import { actionHandler } from './action-handler-directive.js'; @@ -26,7 +25,6 @@ import { localize } from './localize/localize.js'; import cardStyle from './scss/card.scss'; import { ExtendedHomeAssistant, MediaLoadedInfo, Message } from './types.js'; import { frigateCardHasAction } from './utils/action.js'; -import { View } from './view/view.js'; // *************************************************************************** // General Card-Wide Notes @@ -268,10 +266,6 @@ class FrigateCard extends LitElement { style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}" @frigate-card:message=${(ev: CustomEvent) => this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)} - @frigate-card:view:change=${(ev: CustomEvent) => - this._controller.getViewManager().setView(ev.detail)} - @frigate-card:view:change-context=${(ev: CustomEvent) => - this._controller.getViewManager().setViewWithMergedContext(ev.detail)} @frigate-card:media:loaded=${(ev: CustomEvent) => this._controller.getMediaLoadedInfoManager().set(ev.detail)} @frigate-card:media:unloaded=${() => @@ -299,7 +293,7 @@ class FrigateCard extends LitElement { html`; - - fetchThumbnails?: boolean; } declare module 'view' { @@ -36,8 +25,7 @@ interface LastMediaLoadedInfo { type LiveControllerHost = LitElement & FrigateCardMediaLoadedEventTarget & - FrigateCardMessageEventTarget & - FrigateCardViewChangeEventTarget; + FrigateCardMessageEventTarget; export class LiveController implements ReactiveController { protected _host: LiveControllerHost; @@ -71,7 +59,7 @@ export class LiveController implements ReactiveController { // Don't process updates if it's in the background and a message was // received (otherwise an error message thrown by the background live // component may continually be re-spammed hitting performance). - return !this._inBackground || !this._messageReceived; + return !(this._inBackground && this._messageReceived); } public hostConnected(): void { @@ -79,7 +67,6 @@ export class LiveController implements ReactiveController { this._host.addEventListener('frigate-card:media:loaded', this._handleMediaLoaded); this._host.addEventListener('frigate-card:message', this._handleMessage); - this._host.addEventListener('frigate-card:view:change', this._handleViewChange); } public hostDisconnected(): void { @@ -87,7 +74,6 @@ export class LiveController implements ReactiveController { this._host.removeEventListener('frigate-card:media:loaded', this._handleMediaLoaded); this._host.removeEventListener('frigate-card:message', this._handleMessage); - this._host.removeEventListener('frigate-card:view:change', this._handleViewChange); } public clearMessageReceived(): void { @@ -124,12 +110,6 @@ export class LiveController implements ReactiveController { } }; - protected _handleViewChange = (ev: CustomEvent): void => { - if (this._inBackground) { - ev.stopPropagation(); - } - }; - protected _intersectionHandler(entries: IntersectionObserverEntry[]): void { const wasInBackground = this._inBackground; this._inBackground = !entries.some((entry) => entry.isIntersecting); @@ -150,74 +130,4 @@ export class LiveController implements ReactiveController { this._host.requestUpdate(); } } - - /** - * Fetch thumbnail media when a target is not already specified in the view - * (e.g. first time live is visited). - */ - public async fetchMediaInBackgroundIfNecessary( - view: View, - cameraManager: CameraManager, - cardWideConfig: CardWideConfig, - overriddenLiveConfig: LiveConfig, - ): Promise { - if ( - this._inBackground || - // Only fetch media if there isn't any already. - view.query || - overriddenLiveConfig.controls.thumbnails.mode === 'none' || - view.context?.live?.fetchThumbnails === false - ) { - return; - } - - const mediaType = overriddenLiveConfig.controls.thumbnails.media_type; - const now = new Date(); - const viewContext: ViewContext = { - // 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: overriddenLiveConfig.controls.timeline.window_seconds, - }), - end: now, - }, - }, - }; - - /* istanbul ignore else: the else path cannot be reached -- @preserve */ - if (mediaType === 'events') { - await changeViewToRecentEventsForCameraAndDependents( - this._host, - cameraManager, - cardWideConfig, - view, - { - allCameras: view.isGrid(), - targetView: view.view, - eventsMediaType: overriddenLiveConfig.controls.thumbnails.events_media_type, - select: 'latest', - // Force the window to start at the most recent time, not - // necessarily when the most recent event was: - // https://github.com/dermotduffy/frigate-hass-card/issues/1301 - viewContext: viewContext, - }, - ); - } else if (mediaType === 'recordings') { - await changeViewToRecentRecordingForCameraAndDependents( - this._host, - cameraManager, - cardWideConfig, - view, - { - allCameras: view.isGrid(), - targetView: view.view, - select: 'latest', - viewContext: viewContext, - }, - ); - } - } } diff --git a/src/components-lib/media-filter-controller.ts b/src/components-lib/media-filter-controller.ts index 5a46f6f1..a92b6321 100644 --- a/src/components-lib/media-filter-controller.ts +++ b/src/components-lib/media-filter-controller.ts @@ -21,10 +21,9 @@ import { SelectOption, SelectValues } from '../components/select'; import { CardWideConfig } from '../config/types'; import { localize } from '../localize/localize'; import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic'; -import { executeMediaQueryForViewWithErrorDispatching } from '../utils/media-to-view'; import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; -import { View } from '../view/view'; +import { ViewManagerInterface } from '../card-controller/view/types'; interface MediaFilterControls { events: boolean; @@ -77,6 +76,7 @@ export class MediaFilterController { protected _favoriteOptions: SelectOption[]; protected _defaults: MediaFilterCoreDefaults | null = null; + protected _viewManager: ViewManagerInterface | null = null; constructor(host: LitElement) { this._host = host; @@ -154,10 +154,12 @@ export class MediaFilterController { public getDefaults(): MediaFilterCoreDefaults | null { return this._defaults; } + public setViewManager(viewManager: ViewManagerInterface | null): void { + this._viewManager = viewManager; + } public async valueChangeHandler( cameraManager: CameraManager, - view: View, cardWideConfig: CardWideConfig, values: { camera?: string | string[]; @@ -234,20 +236,15 @@ export class MediaFilterController { }, ]); - ( - await executeMediaQueryForViewWithErrorDispatching( - this._host, - cameraManager, - view, - queries, - { - // See 'A note on views' above for these two arguments. - ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), - targetView: - values.mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots', - }, - ) - )?.dispatchChangeEvent(this._host); + this._viewManager?.setViewByParametersWithExistingQuery({ + params: { + query: queries, + + // See 'A note on views' above for these two arguments + ...(cameraIDs.size === 1 && { camera: [...cameraIDs][0] }), + view: values.mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots', + }, + }); } else { const queries = new RecordingMediaQueries([ { @@ -262,19 +259,15 @@ export class MediaFilterController { }, ]); - ( - await executeMediaQueryForViewWithErrorDispatching( - this._host, - cameraManager, - view, - queries, - { - // See 'A note on views' above for these two arguments. - ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), - targetView: 'recordings', - }, - ) - )?.dispatchChangeEvent(this._host); + this._viewManager?.setViewByParametersWithExistingQuery({ + params: { + query: queries, + + // See 'A note on views' above for these two arguments + ...(cameraIDs.size === 1 && { camera: [...cameraIDs][0] }), + view: 'recordings', + }, + }); } // Need to ensure we update the element as the date-picker selections may @@ -288,10 +281,11 @@ export class MediaFilterController { }); } - public computeInitialDefaultsFromView(cameraManager: CameraManager, view: View): void { - const queries = view.query?.getQueries(); + public computeInitialDefaultsFromView(cameraManager: CameraManager): void { + const view = this._viewManager?.getView(); + const queries = view?.query?.getQueries(); const allCameraIDs = this._getAllCameraIDs(cameraManager); - if (!queries || !allCameraIDs.size) { + if (!view || !queries || !allCameraIDs.size) { return; } @@ -444,14 +438,10 @@ export class MediaFilterController { this._host.requestUpdate(); } - public getControlsToShow( - cameraManager: CameraManager, - view: View, - ): MediaFilterControls { - const events = !!(view.query && MediaQueriesClassifier.areEventQueries(view.query)); - const recordings = !!( - view.query && MediaQueriesClassifier.areRecordingQueries(view.query) - ); + public getControlsToShow(cameraManager: CameraManager): MediaFilterControls { + const view = this._viewManager?.getView(); + const events = MediaQueriesClassifier.areEventQueries(view?.query); + const recordings = MediaQueriesClassifier.areRecordingQueries(view?.query); const managerCapabilities = cameraManager.getAggregateCameraCapabilities(); return { diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 160b256f..5e1e7fd1 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -3,7 +3,7 @@ import { StyleInfo } from 'lit/directives/style-map'; import { CameraManager } from '../camera-manager/manager'; import { MediaPlayerManager } from '../card-controller/media-player-manager'; import { MicrophoneManager } from '../card-controller/microphone-manager'; -import { ViewManager } from '../card-controller/view-manager'; +import { ViewManager } from '../card-controller/view/view-manager'; import { FRIGATE_CARD_VIEWS_USER_SPECIFIED, FrigateCardConfig, diff --git a/src/components-lib/zoom/zoom-view-context.ts b/src/components-lib/zoom/zoom-view-context.ts index bd13e89c..3b67e80f 100644 --- a/src/components-lib/zoom/zoom-view-context.ts +++ b/src/components-lib/zoom/zoom-view-context.ts @@ -1,6 +1,7 @@ import { ViewContext } from 'view'; -import { dispatchViewContextChangeEvent } from '../../view/view.js'; -import { ZoomSettingsObserved, PartialZoomSettings } from './types.js'; +import { MergeContextViewModifier } from '../../card-controller/view/modifiers/merge-context.js'; +import { ViewManagerInterface } from '../../card-controller/view/types.js'; +import { PartialZoomSettings, ZoomSettingsObserved } from './types.js'; interface ZoomViewContext { observed?: ZoomSettingsObserved; @@ -38,19 +39,22 @@ export const generateViewContextForZoom = ( }; /** - * Convenience wrapper to convert zoom settings into a dispatched view context - * change. + * Convenience wrapper to convert zoom settings into a view change */ export const handleZoomSettingsObservedEvent = ( - element: EventTarget, ev: CustomEvent, + viewManager?: ViewManagerInterface, targetID?: string, ): void => { - targetID && - dispatchViewContextChangeEvent( - element, - generateViewContextForZoom(targetID, { - observed: ev.detail, - }), - ); + viewManager && + targetID && + viewManager.setViewByParameters({ + modifiers: [ + new MergeContextViewModifier( + generateViewContextForZoom(targetID, { + observed: ev.detail, + }), + ), + ], + }); }; diff --git a/src/components/gallery.ts b/src/components/gallery.ts index c98608c7..55e5e73a 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -24,20 +24,16 @@ import galleryStyle from '../scss/gallery.scss'; import { ExtendedHomeAssistant } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { errorToConsole, sleep } from '../utils/basic'; -import { - changeViewToRecentEventsForCameraAndDependents, - changeViewToRecentRecordingForCameraAndDependents, -} from '../utils/media-to-view.js'; import { ViewMedia } from '../view/media'; import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { MediaQueriesResults } from '../view/media-queries-results'; -import { View } from '../view/view.js'; import './media-filter'; import { renderMessage, renderProgressIndicator } from './message.js'; import './surround-basic'; import './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; +import { ViewManagerEpoch } from '../card-controller/view/types.js'; const GALLERY_MEDIA_FILTER_MENU_ICONS = { closed: 'mdi:filter-cog-outline', @@ -52,7 +48,7 @@ export class FrigateCardGallery extends LitElement { public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - public view?: Readonly; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public galleryConfig?: GalleryConfig; @@ -68,43 +64,16 @@ export class FrigateCardGallery extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { + const view = this.viewManagerEpoch?.manager.getView(); if ( !this.hass || - !this.view || - !this.view.isGalleryView() || + !view?.isGalleryView() || !this.cameraManager || !this.cardWideConfig ) { return; } - if (!this.view.query) { - if (this.view.is('recordings')) { - changeViewToRecentRecordingForCameraAndDependents( - this, - this.cameraManager, - this.cardWideConfig, - this.view, - ); - } else { - const eventsMediaType = this.view.is('snapshots') - ? 'snapshots' - : this.view.is('clips') - ? 'clips' - : null; - changeViewToRecentEventsForCameraAndDependents( - this, - this.cameraManager, - this.cardWideConfig, - this.view, - { - ...(eventsMediaType && { eventsMediaType: eventsMediaType }), - }, - ); - } - return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); - } - return html` @@ -126,7 +95,7 @@ export class FrigateCardGallery extends LitElement { : ''} ; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public galleryConfig?: GalleryConfig; @@ -328,13 +297,15 @@ export class FrigateCardGalleryCore extends LitElement { direction: 'earlier' | 'later', useCache = true, ): Promise { - if (!this.cameraManager || !this.hass || !this.view) { + const view = this.viewManagerEpoch?.manager.getView(); + + if (!this.cameraManager || !this.hass || !view) { return; } - const query = this.view?.query; + const query = view.query; const rawQueries = query?.getQueries() ?? null; - const existingMedia = this.view.queryResults?.getResults(); + const existingMedia = view.queryResults?.getResults(); if (!query || !rawQueries || !existingMedia) { return; } @@ -362,16 +333,17 @@ export class FrigateCardGalleryCore extends LitElement { : null; if (newMediaQueries) { - this.view - ?.evolve({ + this.viewManagerEpoch?.manager.setViewByParameters({ + baseView: view, + params: { query: newMediaQueries, queryResults: new MediaQueriesResults({ results: extension.results, }).selectResultIfFound( - (media) => media === this.view?.queryResults?.getSelectedResult(), + (media) => media === view.queryResults?.getSelectedResult(), ), - }) - .dispatchChangeEvent(this); + }, + }); } } } @@ -395,19 +367,18 @@ export class FrigateCardGalleryCore extends LitElement { ); } } - if (changedProps.has('view')) { + if (changedProps.has('viewManagerEpoch')) { // If the view changes, always render the bottom loader to allow for the // view to be extended once the bottom loader becomes visible. this._showLoaderBottom = true; - const oldView: View | undefined = changedProps.get('view'); - if ( - oldView?.queryResults?.getResults() !== this.view?.queryResults?.getResults() - ) { + const view = this.viewManagerEpoch?.manager.getView(); + const oldView = this.viewManagerEpoch?.oldView; + if (!this._media || oldView?.queryResults?.getResults() !== view?.queryResults?.getResults()) { // Gallery places the most recent media at the top (the query results place // the most recent media at the end for use in the viewer). This is copied // to a new array to avoid reversing the query results in place. - this._media = [...(this.view?.queryResults?.getResults() ?? [])].reverse(); + this._media = [...(view?.queryResults?.getResults() ?? [])].reverse(); } } } @@ -417,11 +388,12 @@ export class FrigateCardGalleryCore extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this._media || !this.hass || !this.view || !this.view.isGalleryView()) { + if (!this._media || !this.hass) { return html``; } - if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) { + const view = this.viewManagerEpoch?.manager.getView(); + if (!view?.queryResults || view.queryResults.getResultsCount() === 0) { // Note that this is not throwing up an error message for the card to // handle (as typical), but rather directly rendering the message into the // gallery. This is to allow the filter to still be available when a given @@ -433,7 +405,7 @@ export class FrigateCardGalleryCore extends LitElement { }); } - const selected = this.view?.queryResults?.getSelectedResult(); + const selected = view.queryResults.getSelectedResult(); return html`
${this._showLoaderTop ? html`${renderProgressIndicator({ @@ -454,7 +426,7 @@ export class FrigateCardGalleryCore extends LitElement { .hass=${this.hass} .cameraManager=${this.cameraManager} .media=${media} - .view=${this.view} + .viewManagerEpoch=${this.viewManagerEpoch} ?details=${!!this.galleryConfig?.controls.thumbnails.show_details} ?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails .show_favorite_control} @@ -463,17 +435,17 @@ export class FrigateCardGalleryCore extends LitElement { ?show_download_control=${!!this.galleryConfig?.controls.thumbnails .show_download_control} @click=${(ev: Event) => { - if (this.view && this._media) { - this.view - .evolve({ + if (this._media) { + this.viewManagerEpoch?.manager.setViewByParameters({ + params: { view: 'media', - queryResults: this.view.queryResults?.clone().selectIndex( + queryResults: view.queryResults?.clone().selectIndex( // Media in the gallery is reversed vs the queryResults (see // note above). this._media.length - index - 1, ), - }) - .dispatchChangeEvent(this); + }, + }); } stopEventFromActivatingCardWideActions(ev); }} @@ -504,9 +476,9 @@ export class FrigateCardGalleryCore extends LitElement { // See: https://github.com/dermotduffy/frigate-hass-card/issues/885 if ( // If this update cycle updated the view ... - changedProps.has('view') && + changedProps.has('viewManagerEpoch') && // ... and it wasn't set at all prior ... - !changedProps.get('view') && + !changedProps.get('viewManagerEpoch') && // ... and there is a thumbnail rendered that is selected. this._refSelected.value ) { diff --git a/src/components/live/live.ts b/src/components/live/live.ts index c435f88b..2f440e43 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -19,8 +19,14 @@ import { getOverriddenConfig, } from '../../card-controller/conditions-manager.js'; import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js'; +import { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { LiveController } from '../../components-lib/live/live-controller.js'; import { MediaGridSelected } from '../../components-lib/media-grid-controller.js'; +import { + PartialZoomSettings, + ZoomSettingsObserved, +} from '../../components-lib/zoom/types.js'; +import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js'; import { CameraConfig, CardWideConfig, @@ -48,7 +54,8 @@ import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js'; import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js'; import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; import { playMediaMutingIfNecessary } from '../../utils/media.js'; -import { dispatchViewContextChangeEvent, View } from '../../view/view.js'; +import { getStreamCameraID } from '../../utils/substream.js'; +import { View } from '../../view/view.js'; import { EmblaCarouselPlugins } from '../carousel.js'; import { renderMessage } from '../message.js'; import '../next-prev-control.js'; @@ -60,12 +67,6 @@ import { FrigateCardTitleControl, getDefaultTitleConfigForView, } from '../title-control.js'; -import { - PartialZoomSettings, - ZoomSettingsObserved, -} from '../../components-lib/zoom/types.js'; -import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js'; -import { getStreamCameraID } from '../../utils/substream.js'; const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider'; @@ -78,7 +79,7 @@ export class FrigateCardLive extends LitElement { public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - public view?: Readonly; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public nonOverriddenLiveConfig?: LiveConfig; @@ -108,38 +109,16 @@ export class FrigateCardLive extends LitElement { return this._controller.shouldUpdate(); } - protected willUpdate(changedProperties: PropertyValues): void { - if ( - ['view', 'cameraManager', 'cardWideConfig', 'overriddenLiveConfig'].some((prop) => - changedProperties.has(prop), - ) && - this.view && - this.cameraManager && - this.cardWideConfig && - this.overriddenLiveConfig - ) { - this._controller.fetchMediaInBackgroundIfNecessary( - this.view, - this.cameraManager, - this.cardWideConfig, - this.overriddenLiveConfig, - ); - } - + protected willUpdate(): void { this._controller.clearMessageReceived(); } protected render(): TemplateResult | void { - if ( - !this.hass || - !this.nonOverriddenLiveConfig || - !this.cameraManager || - !this.view - ) { + if (!this.hass || !this.nonOverriddenLiveConfig || !this.cameraManager) { return; } - // Notes: + // Implementation notes: // - See use of liveConfig and not config below -- the underlying carousel // will independently override the liveConfig to reflect the camera in the // carousel (not necessarily the selected camera). @@ -153,7 +132,7 @@ export class FrigateCardLive extends LitElement { html` ; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public nonOverriddenLiveConfig?: LiveConfig; @@ -207,13 +186,14 @@ export class FrigateCardLiveGrid extends LitElement { public triggeredCameraIDs?: Set; protected _renderCarousel(cameraID?: string): TemplateResult { - const triggeredCameraID = cameraID ?? this.view?.camera; + const view = this.viewManagerEpoch?.manager.getView(); + const triggeredCameraID = cameraID ?? view?.camera; return html` 1 ); } protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('view') && this._needsGrid()) { + if (changedProps.has('viewManagerEpoch') && this._needsGrid()) { import('../media-grid.js'); } } @@ -261,16 +242,13 @@ export class FrigateCardLiveGrid extends LitElement { if (!cameraIDs?.size || !this._needsGrid()) { return this._renderCarousel(); } + return html` ) => this._gridSelectCamera(ev.detail.selected)} - @frigate-card:view:change=${(ev: CustomEvent) => { - ev.stopPropagation(); - this._gridSelectCamera(ev.detail.camera, ev.detail); - }} > ${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))} @@ -288,7 +266,7 @@ export class FrigateCardLiveCarousel extends LitElement { public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - public view?: Readonly; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public nonOverriddenLiveConfig?: LiveConfig; @@ -331,12 +309,13 @@ export class FrigateCardLiveCarousel extends LitElement { protected _getSelectedCameraIndex(): number { const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live'); - if (!cameraIDs?.size || !this.view || this.viewFilterCameraID) { + const view = this.viewManagerEpoch?.manager.getView(); + if (!cameraIDs?.size || !view || this.viewFilterCameraID) { // If the carousel is limited to a single cameraID, the first (only) // element is always the selected one. return 0; } - return Math.max(0, Array.from(cameraIDs).indexOf(this.view.camera)); + return Math.max(0, Array.from(cameraIDs).indexOf(view.camera)); } protected _getPlugins(): EmblaCarouselPlugins { @@ -393,6 +372,7 @@ export class FrigateCardLiveCarousel extends LitElement { return [[], {}]; } + const view = this.viewManagerEpoch?.manager.getView(); const cameraIDs = this.viewFilterCameraID ? new Set([this.viewFilterCameraID]) : this.cameraManager?.getStore().getCameraIDsWithCapability('live'); @@ -403,8 +383,7 @@ export class FrigateCardLiveCarousel extends LitElement { for (const [cameraID, cameraConfig] of this.cameraManager .getStore() .getCameraConfigEntries(cameraIDs)) { - const liveCameraID = - this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID; + const liveCameraID = this._getSubstreamCameraID(cameraID, view); const liveCameraConfig = cameraID === liveCameraID ? cameraConfig @@ -430,17 +409,11 @@ export class FrigateCardLiveCarousel extends LitElement { protected _setViewCameraID(cameraID?: string | null): void { if (cameraID) { - this.view - ?.evolve({ + this.viewManagerEpoch?.manager.setViewByParametersWithNewQuery({ + params: { camera: cameraID, - // Reset the query and query results. - query: null, - queryResults: null, - }) - // Don't yet fetch thumbnails (they will be fetched when the carousel - // settles). - .mergeInContext({ live: { fetchThumbnails: false } }) - .dispatchChangeEvent(this); + }, + }); } } @@ -490,12 +463,13 @@ export class FrigateCardLiveCarousel extends LitElement { ).live as LiveConfig; const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID); + const view = this.viewManagerEpoch?.manager.getView(); return html`
) => - handleZoomSettingsObservedEvent(this, ev, cameraID)} + handleZoomSettingsObservedEvent( + ev, + this.viewManagerEpoch?.manager, + cameraID, + )} >
@@ -520,11 +498,13 @@ export class FrigateCardLiveCarousel extends LitElement { const cameraIDs = this.cameraManager ? [...this.cameraManager?.getStore().getCameraIDsWithCapability('live')] : []; - if (this.viewFilterCameraID || cameraIDs.length <= 1 || !this.view || !this.hass) { + const view = this.viewManagerEpoch?.manager.getView(); + + if (this.viewFilterCameraID || cameraIDs.length <= 1 || !view || !this.hass) { return [null, null]; } - const cameraID = this.viewFilterCameraID ?? this.view.camera; + const cameraID = this.viewFilterCameraID ?? view.camera; const currentIndex = cameraIDs.indexOf(cameraID); if (currentIndex < 0) { @@ -537,8 +517,13 @@ export class FrigateCardLiveCarousel extends LitElement { ]; } + protected _getSubstreamCameraID(cameraID: string, view?: View | null): string { + return view?.context?.live?.overrides?.get(cameraID) ?? cameraID; + } + protected render(): TemplateResult | void { - if (!this.overriddenLiveConfig || !this.view || !this.hass || !this.cameraManager) { + const view = this.viewManagerEpoch?.manager.getView(); + if (!this.overriddenLiveConfig || !this.hass || !view || !this.cameraManager) { return; } @@ -551,23 +536,19 @@ export class FrigateCardLiveCarousel extends LitElement { const hasMultipleCameras = slides.length > 1; const [prevID, nextID] = this._getCameraIDsOfNeighbors(); - const getOverrideCameraID = (cameraID: string): string => { - return this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID; - }; - const cameraMetadataPrevious = prevID - ? this.cameraManager.getCameraMetadata(getOverrideCameraID(prevID)) + ? this.cameraManager.getCameraMetadata(this._getSubstreamCameraID(prevID, view)) : null; - const cameraID = this.viewFilterCameraID ?? this.view.camera; + const cameraID = this.viewFilterCameraID ?? view.camera; const cameraMetadataCurrent = this.cameraManager.getCameraMetadata( - getOverrideCameraID(cameraID), + this._getSubstreamCameraID(cameraID, view), ); const cameraMetadataNext = nextID - ? this.cameraManager.getCameraMetadata(getOverrideCameraID(nextID)) + ? this.cameraManager.getCameraMetadata(this._getSubstreamCameraID(nextID, view)) : null; const titleConfig = getDefaultTitleConfigForView( - this.view, + view, this.overriddenLiveConfig?.controls.title, ); @@ -592,10 +573,6 @@ export class FrigateCardLiveCarousel extends LitElement { .selected=${this._getSelectedCameraIndex()} transitionEffect=${this._getTransitionEffect()} @frigate-card:carousel:select=${this._setViewHandler.bind(this)} - @frigate-card:carousel:settle=${() => { - // Fetch the thumbnails after the carousel has settled. - dispatchViewContextChangeEvent(this, { live: { fetchThumbnails: true } }); - }} @frigate-card:media:loaded=${() => { if (this._refTitleControl.value) { this._refTitleControl.value.show(); @@ -639,9 +616,8 @@ export class FrigateCardLiveCarousel extends LitElement { ${cameraMetadataCurrent && titleConfig diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index b85be987..e7a6bb06 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -21,11 +21,11 @@ import { import { CardWideConfig } from '../config/types'; import { localize } from '../localize/localize'; import mediaFilterStyle from '../scss/media-filter.scss'; -import { View } from '../view/view'; import { FrigateCardDatePicker } from './date-picker'; import './date-picker.js'; import { FrigateCardSelect } from './select'; import './select.js'; +import { ViewManagerEpoch } from '../card-controller/view/types'; @customElement('frigate-card-media-filter') class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { @@ -36,7 +36,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { public cameraManager?: CameraManager; @property({ attribute: false }) - public view?: View; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @@ -59,46 +59,49 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { protected _refTags: Ref = createRef(); protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('viewManagerEpoch')) { + this._mediaFilterController.setViewManager(this.viewManagerEpoch?.manager ?? null); + } + if (changedProps.has('cameraManager') && this.cameraManager) { this._mediaFilterController.computeCameraOptions(this.cameraManager); this._mediaFilterController.computeMetadataOptions(this.cameraManager); } + + // The first time the viewManager is set, compute the initial default selections. if ( - changedProps.has('view') && - !changedProps.get('view') && - this.view && + !changedProps.get('viewManager') && + this.viewManagerEpoch && this.cameraManager ) { - this._mediaFilterController.computeInitialDefaultsFromView( - this.cameraManager, - this.view, - ); + this._mediaFilterController.computeInitialDefaultsFromView(this.cameraManager); } } protected render(): TemplateResult | void { const valueChange = async () => { - if (!this.cameraManager || !this.view || !this.cardWideConfig) { + if (!this.cameraManager || !this.viewManagerEpoch || !this.cardWideConfig) { return; } await this._mediaFilterController.valueChangeHandler( this.cameraManager, - this.view, this.cardWideConfig, { - camera: this._refCamera.value?.value, - mediaType: this._refMediaType.value?.value as MediaFilterMediaType | undefined, + camera: this._refCamera.value?.value ?? undefined, + mediaType: (this._refMediaType.value?.value ?? undefined) as + | MediaFilterMediaType + | undefined, when: { - selected: this._refWhen.value?.value, + selected: this._refWhen.value?.value ?? undefined, from: this._refWhenFrom.value?.value, to: this._refWhenTo.value?.value, }, - favorite: this._refFavorite.value?.value as + favorite: (this._refFavorite.value?.value ?? undefined) as | MediaFilterCoreFavoriteSelection | undefined, - where: this._refWhere.value?.value, - what: this._refWhat.value?.value, - tags: this._refTags.value?.value, + where: this._refWhere.value?.value ?? undefined, + what: this._refWhat.value?.value ?? undefined, + tags: this._refTags.value?.value ?? undefined, }, ); }; @@ -119,14 +122,11 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { await valueChange(); }; - if (!this.cameraManager || !this.view) { + if (!this.cameraManager || !this.viewManagerEpoch) { return; } - const controls = this._mediaFilterController.getControlsToShow( - this.cameraManager, - this.view, - ); + const controls = this._mediaFilterController.getControlsToShow(this.cameraManager); const defaults = this._mediaFilterController.getDefaults(); const whatOptions = this._mediaFilterController.getWhatOptions(); const tagsOptions = this._mediaFilterController.getTagsOptions(); diff --git a/src/components/select.ts b/src/components/select.ts index 3a1bca79..94393e96 100644 --- a/src/components/select.ts +++ b/src/components/select.ts @@ -31,7 +31,7 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) { public options?: SelectOption[]; @property({ attribute: false, hasChanged: contentsChanged }) - public value?: SelectValues; + public value: SelectValues | null = null; @property({ attribute: false, hasChanged: contentsChanged }) public initialValue?: SelectValues; @@ -56,7 +56,7 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) { }; public reset(): void { - this.value = undefined; + this.value = null; } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -66,8 +66,15 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) { // the change event even if the value has not actually changed. Prevent that // from propagating upwards. if (value !== undefined && !isEqual(this.value, value)) { + const initialValueSet = this.value === null; this.value = value; - dispatchFrigateCardEvent(this, 'select:change', value); + + // The underlying gr-select element will call on the first first value set + // (even when the user has not interacted with the control). Do not + // dispatch events for this. + if (!initialValueSet) { + dispatchFrigateCardEvent(this, 'select:change', value); + } } } diff --git a/src/components/surround.ts b/src/components/surround.ts index 45ee50f1..c48b5c7b 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -8,6 +8,8 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { CameraManager } from '../camera-manager/manager.js'; +import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js'; +import { ViewManagerEpoch } from '../card-controller/view/types.js'; import { CardWideConfig, MiniTimelineControlConfig, @@ -16,7 +18,6 @@ import { import basicBlockStyle from '../scss/basic-block.scss'; import { ExtendedHomeAssistant } from '../types.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; -import { View } from '../view/view.js'; import './surround-basic.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; @@ -26,7 +27,7 @@ export class FrigateCardSurround extends LitElement { public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - public view?: Readonly; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false, hasChanged: contentsChanged }) public thumbnailConfig?: ThumbnailsControlConfig; @@ -60,42 +61,47 @@ export class FrigateCardSurround extends LitElement { // Only reset the timeline cameraIDs when the media or display mode // materially changes (and not on every view change, since the view will // change frequently when the user is scrubbing video). - const oldView = changedProperties.get('view'); + const view = this.viewManagerEpoch?.manager.getView(); if ( - changedProperties.has('view') && - (View.isMajorMediaChange(oldView, this.view) || - oldView.displayMode !== this.view?.displayMode) + changedProperties.has('viewManagerEpoch') && + (this.viewManagerEpoch?.manager.hasMajorMediaChange( + this.viewManagerEpoch?.oldView, + ) || + this.viewManagerEpoch?.oldView?.displayMode !== view?.displayMode) ) { this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined; } } protected _getCameraIDsForTimeline(): Set | null { - if (!this.view || !this.cameraManager) { + const view = this.viewManagerEpoch?.manager.getView(); + if (!view || !this.cameraManager) { return null; } - if (this.view.is('live')) { + + if (view.is('live')) { const capabilitySearch = { anyCapabilities: ['clips' as const, 'snapshots' as const, 'recordings' as const], }; - if (this.view.supportsMultipleDisplayModes() && this.view.isGrid()) { + if (view.supportsMultipleDisplayModes() && view.isGrid()) { return this.cameraManager .getStore() .getCameraIDsWithCapability(capabilitySearch); } else { return this.cameraManager .getStore() - .getAllDependentCameras(this.view.camera, capabilitySearch); + .getAllDependentCameras(view.camera, capabilitySearch); } } - if (this.view.isViewerView()) { - return this.view.query?.getQueryCameraIDs() ?? null; + if (view.isViewerView()) { + return view.query?.getQueryCameraIDs() ?? null; } return null; } protected render(): TemplateResult | void { - if (!this.hass || !this.view) { + const view = this.viewManagerEpoch?.manager.getView(); + if (!this.hass || !view) { return; } @@ -122,27 +128,25 @@ export class FrigateCardSurround extends LitElement { .hass=${this.hass} .config=${this.thumbnailConfig} .cameraManager=${this.cameraManager} - .fadeThumbnails=${this.view.isViewerView()} - .view=${this.view} - .selected=${this.view.queryResults?.getSelectedIndex() ?? undefined} - @frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')} + .fadeThumbnails=${view.isViewerView()} + .viewManagerEpoch=${this.viewManagerEpoch} + .selected=${view.queryResults?.getSelectedIndex() ?? undefined} @frigate-card:thumbnail-carousel:tap=${( ev: CustomEvent, ) => { const media = ev.detail.queryResults.getSelectedResult(); if (media) { - this.view - ?.evolve({ + this.viewManagerEpoch?.manager.setViewByParameters({ + params: { view: 'media', queryResults: ev.detail.queryResults, ...(media.getCameraID() && { camera: media.getCameraID() }), - }) - .removeContext('timeline') - .removeContext('mediaViewer') - // Send the view change from the source of the tap event, so - // the view change will be caught by the handler above (to - // close the drawer). - .dispatchChangeEvent(ev.composedPath()[0]); + }, + modifiers: [ + new RemoveContextViewModifier(['timeline', 'mediaViewer']), + ], + }); + changeDrawer(ev, 'close'); } }} > @@ -152,8 +156,8 @@ export class FrigateCardSurround extends LitElement { ? html` ; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public cameraManager?: CameraManager; @@ -62,13 +62,13 @@ export class FrigateCardThumbnailCarousel extends LitElement { 'cameraManager', 'config', 'transitionEffect', - 'view', + 'viewManagerEpoch', ] as const; if (renderProperties.some((prop) => changedProps.has(prop))) { this._thumbnailSlides = this._renderSlides(); } - if (changedProps.has('view')) { + if (changedProps.has('viewManagerEpoch')) { this.style.setProperty( '--frigate-card-carousel-thumbnail-opacity', !this.fadeThumbnails || this._getSelectedSlide() === null ? '1.0' : '0.4', @@ -76,16 +76,19 @@ export class FrigateCardThumbnailCarousel extends LitElement { } } - protected _getSelectedSlide(view?: View): number | null { - return (view ?? this.view)?.queryResults?.getSelectedIndex() ?? null; + protected _getSelectedSlide(): number | null { + return ( + this.viewManagerEpoch?.manager.getView()?.queryResults?.getSelectedIndex() ?? null + ); } protected _renderSlides(): TemplateResult[] { const slides: TemplateResult[] = []; - const seekTarget = this.view?.context?.mediaViewer?.seek; + const view = this.viewManagerEpoch?.manager.getView(); + const seekTarget = view?.context?.mediaViewer?.seek; const selectedIndex = this._getSelectedSlide(); - for (const media of this.view?.queryResults?.getResults() ?? []) { + for (const media of view?.queryResults?.getResults() ?? []) { const index = slides.length; const classes = { embla__slide: true, @@ -98,19 +101,20 @@ export class FrigateCardThumbnailCarousel extends LitElement { .cameraManager=${this.cameraManager} .hass=${this.hass} .media=${media} - .view=${this.view} + .viewManagerEpoch=${this.viewManagerEpoch} .seek=${seekTarget && media.includesTime(seekTarget) ? seekTarget : undefined} ?details=${!!this.config?.show_details} ?show_favorite_control=${this.config?.show_favorite_control} ?show_timeline_control=${this.config?.show_timeline_control} ?show_download_control=${this.config?.show_download_control} @click=${(ev: Event) => { - if (this.view && this.view.queryResults) { + const view = this.viewManagerEpoch?.manager.getView(); + if (view && view.queryResults) { dispatchFrigateCardEvent( this, 'thumbnail-carousel:tap', { - queryResults: this.view.queryResults.clone().selectIndex(index), + queryResults: view.queryResults.clone().selectIndex(index), }, ); } diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 61f4464f..6a34dce4 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -1,3 +1,4 @@ +import { Task, TaskStatus } from '@lit-labs/task'; import { format } from 'date-fns'; import { CSSResult, @@ -9,11 +10,14 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; +import { CameraManager } from '../camera-manager/manager.js'; +import { ViewManagerEpoch } from '../card-controller/view/types.js'; import { localize } from '../localize/localize.js'; import thumbnailDetailsStyle from '../scss/thumbnail-details.scss'; import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss'; import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss'; import thumbnailStyle from '../scss/thumbnail.scss'; +import type { ExtendedHomeAssistant } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { errorToConsole, @@ -21,17 +25,13 @@ import { getDurationString, prettifyTitle, } from '../utils/basic.js'; +import { downloadMedia } from '../utils/download.js'; import { renderTask } from '../utils/task.js'; import { createFetchThumbnailTask, FetchThumbnailTaskArgs } from '../utils/thumbnail.js'; -import { View } from '../view/view.js'; -import { Task, TaskStatus } from '@lit-labs/task'; - -import type { ExtendedHomeAssistant } from '../types.js'; -import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js'; -import { CameraManager } from '../camera-manager/manager.js'; import { ViewMediaClassifier } from '../view/media-classifier.js'; -import { downloadMedia } from '../utils/download.js'; +import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js'; import { dispatchFrigateCardErrorEvent } from './message.js'; +import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js'; // The minimum width of a thumbnail with details enabled. export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; @@ -335,22 +335,22 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement { @customElement('frigate-card-thumbnail') export class FrigateCardThumbnail extends LitElement { - // Performance: During timeline scrubbing, hass may be updated - // continuously. As it is not needed for the thumbnail rendering itself, it - // does not trigger a re-render. The HomeAssistant object may be required for - // thumbnail signing (after initial signing the thumbnail is stored in a data - // URL, so the signing will not expire). + // Performance: During timeline scrubbing, hass may be updated continuously. + // As it is not needed for the thumbnail rendering itself, it does not trigger + // a re-render. The HomeAssistant object may be required for thumbnail signing + // (after initial signing the thumbnail is stored in a data URL, so the + // signing will not expire). public hass?: ExtendedHomeAssistant; // Performance: During timeline scrubbing, the view will be updated // continuously. As it is not needed for the thumbnail rendering itself, it // does not trigger a re-render. - public view?: Readonly; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public cameraManager?: CameraManager; - @property({ attribute: true }) + @property({ attribute: false }) public media?: ViewMedia; @property({ attribute: true, type: Boolean }) @@ -467,18 +467,19 @@ export class FrigateCardThumbnail extends LitElement { title=${localize('thumbnail.timeline')} @click=${(ev: Event) => { stopEventFromActivatingCardWideActions(ev); - if (!this.view || !this.media) { + if (!this.viewManagerEpoch || !this.media) { return; } - this.view - .evolve({ + this.viewManagerEpoch.manager.setViewByParameters({ + params: { view: 'timeline', - queryResults: this.view.queryResults - ?.clone() + queryResults: this.viewManagerEpoch?.manager + .getView() + ?.queryResults?.clone() .selectResultIfFound((media) => media === this.media), - }) - .removeContext('timeline') - .dispatchChangeEvent(this); + }, + modifiers: [new RemoveContextViewModifier(['timeline'])], + }); }} >` : ''} diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 84eb0a20..502ac0cc 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -1,14 +1,14 @@ import { add, differenceInSeconds, sub } from 'date-fns'; import { CSSResultGroup, - html, LitElement, PropertyValues, TemplateResult, + html, unsafeCSS, } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { Ref, createRef, ref } from 'lit/directives/ref.js'; import isEqual from 'lodash-es/isEqual'; import throttle from 'lodash-es/throttle'; import { ViewContext } from 'view'; @@ -26,6 +26,8 @@ import { CameraManager } from '../camera-manager/manager'; import { rangesOverlap } from '../camera-manager/range'; import { MediaQuery } from '../camera-manager/types'; import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly'; +import { MergeContextViewModifier } from '../card-controller/view/modifiers/merge-context'; +import { ViewManagerEpoch } from '../card-controller/view/types'; import { FrigateCardTimelineItem, TimelineDataSource, @@ -33,11 +35,11 @@ import { import { CameraConfig, CardWideConfig, - frigateCardConfigDefaults, FrigateCardView, ThumbnailsControlBaseConfig, TimelineCoreConfig, TimelinePanMode, + frigateCardConfigDefaults, } from '../config/types'; import { localize } from '../localize/localize'; import timelineCoreStyle from '../scss/timeline-core.scss'; @@ -51,10 +53,7 @@ import { isTruthy, setOrRemoveAttribute, } from '../utils/basic'; -import { - executeMediaQueryForViewWithErrorDispatching, - findBestMediaIndex, -} from '../utils/media-to-view'; +import { findBestMediaIndex } from '../utils/find-best-media-index'; import { ViewMedia } from '../view/media'; import { ViewMediaClassifier } from '../view/media-classifier'; import { @@ -67,7 +66,7 @@ import { MediaQueriesType, } from '../view/media-queries-classifier'; import { MediaQueriesResults } from '../view/media-queries-results'; -import { View } from '../view/view'; +import { mergeViewContext } from '../view/view'; import './date-picker.js'; import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js'; import './thumbnail.js'; @@ -107,7 +106,7 @@ interface ThumbnailDataRequest { cameraManager?: CameraManager; cameraConfig?: CameraConfig; media?: ViewMedia; - view?: View; + viewManagerEpoch?: ViewManagerEpoch; } class ThumbnailDataRequestEvent extends CustomEvent {} @@ -115,7 +114,7 @@ class ThumbnailDataRequestEvent extends CustomEvent {} const TIMELINE_TARGET_BAR_ID = 'target_bar'; /** - * A simgple thumbnail wrapper class for use in the timeline where LIT data + * A simgple thumbnail wrapper class for use in the timeline where Lit data * bindings are not available. */ @customElement('frigate-card-timeline-thumbnail') @@ -160,7 +159,7 @@ export class FrigateCardTimelineThumbnail extends LitElement { !dataRequest.cameraManager || !dataRequest.cameraConfig || !dataRequest.media || - !dataRequest.view + !dataRequest.viewManagerEpoch ) { return html``; } @@ -169,7 +168,7 @@ export class FrigateCardTimelineThumbnail extends LitElement { .hass=${dataRequest.hass} .cameraManager=${dataRequest.cameraManager} .media=${dataRequest.media} - .view=${dataRequest.view} + .viewManagerEpoch=${dataRequest.viewManagerEpoch} ?details=${this.details} > `; @@ -182,12 +181,12 @@ export class FrigateCardTimelineCore extends LitElement { public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - public view?: Readonly; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false, hasChanged: contentsChanged }) public timelineConfig?: TimelineCoreConfig; - @property({ attribute: true, type: Boolean }) + @property({ attribute: false }) public thumbnailConfig?: ThumbnailsControlBaseConfig; // Whether or not this is a mini-timeline (in mini-mode the component takes a @@ -269,11 +268,11 @@ export class FrigateCardTimelineCore extends LitElement { request.detail.cameraConfig = cameraConfig; request.detail.cameraManager = this.cameraManager; request.detail.media = media; - request.detail.view = this.view; + request.detail.viewManagerEpoch = this.viewManagerEpoch; } protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.timelineConfig || !this.cameraIDs?.size) { + if (!this.hass || !this.timelineConfig || !this.cameraIDs?.size) { return; } @@ -383,6 +382,7 @@ export class FrigateCardTimelineCore extends LitElement { return; } + const view = this.viewManagerEpoch?.manager.getView(); const panMode = this._getEffectivePanMode(); const targetBarOn = this._shouldSupportSeeking() && @@ -392,7 +392,7 @@ export class FrigateCardTimelineCore extends LitElement { const item = this._timelineSource?.dataset?.get(id); return ( panMode !== 'seek-in-camera' || - item?.media?.getCameraID() === this.view?.camera, + item?.media?.getCameraID() === view?.camera, item && item.start && item.end && @@ -450,14 +450,15 @@ export class FrigateCardTimelineCore extends LitElement { targetTime: Date, properties: TimelineRangeChange, ): Promise { - const results = this.view?.queryResults; + const view = this.viewManagerEpoch?.manager.getView(); + const results = view?.queryResults; const media = results?.getResults(); const panMode = this._getEffectivePanMode(); if ( !media || !results || !this._timeline || - !this.view || + !view || !this.hass || !this.cameraManager || panMode === 'pan' @@ -473,7 +474,7 @@ export class FrigateCardTimelineCore extends LitElement { .clone() .resetSelectedResult() .selectBestResult( - (mediaArray) => findBestMediaIndex(mediaArray, targetTime, this.view?.camera), + (mediaArray) => findBestMediaIndex(mediaArray, targetTime, view?.camera), { allCameras: true, main: true, @@ -484,9 +485,9 @@ export class FrigateCardTimelineCore extends LitElement { .clone() .resetSelectedResult() .selectBestResult((mediaArray) => findBestMediaIndex(mediaArray, targetTime), { - cameraID: this.view.camera, + cameraID: view.camera, }) - .promoteCameraSelectionToMainSelection(this.view.camera); + .promoteCameraSelectionToMainSelection(view.camera); } else if (panMode === 'seek-in-media') { newResults = results; } @@ -495,20 +496,23 @@ export class FrigateCardTimelineCore extends LitElement { ? targetTime >= new Date() ? 'live' : 'media' - : this.view.view; + : view.view; const selectedCamera = newResults?.getSelectedResult()?.getCameraID(); - this.view - .evolve({ + + this.viewManagerEpoch?.manager.setViewByParameters({ + params: { ...(selectedCamera && { camera: selectedCamera }), view: desiredView, queryResults: newResults, - }) // Whether or not to set the timeline window. - .mergeInContext({ - ...(canSeek && { mediaViewer: { seek: targetTime } }), - ...this._getTimelineContext({ start: properties.start, end: properties.end }), - }) - .dispatchChangeEvent(this); + }, + modifiers: [ + new MergeContextViewModifier({ + ...(canSeek && { mediaViewer: { seek: targetTime } }), + ...this._getTimelineContext({ start: properties.start, end: properties.end }), + }), + ], + }); } protected _getEffectivePanMode(): TimelinePanMode { @@ -533,22 +537,18 @@ export class FrigateCardTimelineCore extends LitElement { stopEventFromActivatingCardWideActions(properties.event); } + const view = this.viewManagerEpoch?.manager.getView(); + if ( this._ignoreClick || - !this.hass || - !this._timeline || - !this.view || - !this.cameraManager || - !this.cardWideConfig || - !this.cameraIDs || - !this.cameraIDs.size || + !view || + !this.viewManagerEpoch || !this._timelineSource || !properties.what ) { return; } - let view: View | null = null; let drawerAction: 'open' | 'close' = 'close'; if ( @@ -558,29 +558,35 @@ export class FrigateCardTimelineCore extends LitElement { ) { const query = this._createMediaQueries('recording'); if (query) { - view = await executeMediaQueryForViewWithErrorDispatching( - this, - this.cameraManager, - this.view, - query, - { - targetView: 'recording', - targetTime: properties.time, - select: 'time', + await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ + baseView: view, + params: { view: 'recording', query: query }, + queryExecutorOptions: { + selectResult: { + time: { + time: properties.time, + }, + }, }, - ); + }); } } else if (properties.item && properties.what === 'item') { const cameraID = String(properties.group); + const id = String(properties.item); + const criteria = { main: true, - ...(cameraID && this.view.isGrid() && { cameraID: cameraID }), + ...(cameraID && view.isGrid() && { cameraID: cameraID }), }; - const newResults = this.view.queryResults + const newResults = view.queryResults ?.clone() .resetSelectedResult() .selectResultIfFound((media) => media.getID() === properties.item, criteria); + const context: ViewContext = mergeViewContext(this._getTimelineContext(), { + mediaViewer: { seek: properties.time }, + }); + if (!newResults || !newResults.hasSelectedResult()) { // This can happen in a few situations: // - If this is a recording query (with recorded hours) and an event is @@ -589,36 +595,34 @@ export class FrigateCardTimelineCore extends LitElement { // gallery (i.e. any case where the thumbnails may not be match the // events on the timeline, e.g. in the snapshots viewer but // mini-timeline showing all media). - const fullEventView = await this._createViewWithMediaQueries( - this._createMediaQueries('event'), - { - selectedItem: properties.item, - targetView: 'media', - }, - ); - if (fullEventView?.queryResults?.hasResults()) { - view = fullEventView; + const query = this._createMediaQueries('event'); + if (query) { + await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ + params: { view: 'media', query: query }, + queryExecutorOptions: { + selectResult: { + id: id, + }, + rejectResults: (results) => !results.hasResults(), + }, + modifiers: [new MergeContextViewModifier(context)], + }); } } else { - view = this.view.evolve({ - queryResults: newResults, - view: this.itemClickAction === 'play' ? 'media' : this.view.view, + this.viewManagerEpoch.manager.setViewByParameters({ + params: { + queryResults: newResults, + view: this.itemClickAction === 'play' ? 'media' : view.view, + }, + modifiers: [new MergeContextViewModifier(context)], }); } - if (view?.queryResults?.hasResults()) { - view.mergeInContext({ mediaViewer: { seek: properties.time } }); - } - view?.mergeInContext(this._getTimelineContext()); - - if (this.itemClickAction === 'select' && view) { + if (this.itemClickAction === 'select') { drawerAction = 'open'; } } - if (view) { - view.dispatchChangeEvent(this); - } dispatchFrigateCardEvent(this, `thumbnails:${drawerAction}`); this._ignoreClick = false; @@ -648,10 +652,11 @@ export class FrigateCardTimelineCore extends LitElement { event: Event & { additionalEvent: string }; }): Promise { this._removeTargetBar(); + const view = this.viewManagerEpoch?.manager.getView(); if ( !this._timeline || - !this.view || + !view || // When in mini mode, something else is in charge of the primary media // population (e.g. the live view), in this case only act when the user // themselves are interacting with the timeline. @@ -662,23 +667,34 @@ export class FrigateCardTimelineCore extends LitElement { await this._timelineSource?.refresh(this._getPrefetchWindow(properties)); - const queryType = MediaQueriesClassifier.getQueriesType(this.view.query); + const queryType = MediaQueriesClassifier.getQueriesType(view.query); if (!queryType) { return; } const mediaQuery = this._createMediaQueries(queryType); - const newView = await this._createViewWithMediaQueries(mediaQuery); - // Specifically avoid dispatching new results on range change unless there - // is something to be gained by doing so. Example usecase: On initial view - // load in mini timeline mode, the first 50 events are fetched -- the - // first drag of the timeline should not dispatch new results unless - // something is actually useful (as otherwise it creates a visible - // 'flicker' for the user as the viewer reloads all the media). - const newResults = newView?.queryResults; - if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) { - newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this); - } + await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ + params: { + query: mediaQuery, + }, + queryExecutorOptions: { + // Reject the new results unless there is something to be gained (i.e. they + // are not a subset of the existing results). Example usecase: On initial + // view load in mini timeline mode, the first 50 events are fetched -- the + // first drag of the timeline should not dispatch new results unless + // something is actually useful (as otherwise it creates a visible 'flicker' + // for the user as the viewer reloads all the media). + rejectResults: (results) => !!view.queryResults?.isSupersetOf(results), + selectResult: { + id: + this.viewManagerEpoch?.manager + .getView() + ?.queryResults?.getSelectedResult() + ?.getID() ?? undefined, + }, + }, + modifiers: [new MergeContextViewModifier(this._getTimelineContext())], + }); } protected _createMediaQueries( @@ -706,46 +722,6 @@ export class FrigateCardTimelineCore extends LitElement { return null; } - protected async _createViewWithMediaQueries( - query: MediaQueries | null, - options?: { - targetView?: FrigateCardView; - selectedItem?: IdType; - }, - ): Promise { - if (!this.hass || !this.cameraManager || !this.view || !query) { - return null; - } - const view = await executeMediaQueryForViewWithErrorDispatching( - this, - this.cameraManager, - this.view, - query, - { - targetView: options?.targetView, - select: 'latest', - }, - ); - if (!view) { - return null; - } - if (options?.selectedItem) { - view.queryResults?.selectResultIfFound( - (media) => media.getID() === options.selectedItem, - ); - } else { - // If not asked to select a new item, persist the currently selected item - // if possible. - const currentlySelectedResult = this.view.queryResults?.getSelectedResult(); - if (currentlySelectedResult) { - view.queryResults?.selectResultIfFound( - (media) => media.getID() === currentlySelectedResult.getID(), - ); - } - } - return view; - } - /** * Build the visjs dataset to render on the timeline. * @returns The dataset. @@ -937,10 +913,11 @@ export class FrigateCardTimelineCore extends LitElement { } protected _getAllSelectedMediaIDsFromView(): IdType[] { + const view = this.viewManagerEpoch?.manager.getView(); return ( - this.view?.queryResults?.getMultipleSelectedResults({ + view?.queryResults?.getMultipleSelectedResults({ main: true, - ...(this.view.isGrid() && { allCameras: true }), + ...(view.isGrid() && { allCameras: true }), }) ?? [] ) .filter((media) => ViewMediaClassifier.isEvent(media)) @@ -952,7 +929,8 @@ export class FrigateCardTimelineCore extends LitElement { * Update the timeline from the view object. */ protected async _updateTimelineFromView(): Promise { - if (!this.view || !this.timelineConfig || !this._timelineSource || !this._timeline) { + const view = this.viewManagerEpoch?.manager.getView(); + if (!view || !this.timelineConfig || !this._timelineSource || !this._timeline) { return; } @@ -965,7 +943,7 @@ export class FrigateCardTimelineCore extends LitElement { // perfectly center on the media. let desiredWindow = timelineWindow; - const media = this.view.queryResults?.getSelectedResult(); + const media = view.queryResults?.getSelectedResult(); const mediaStartTime = media?.getStartTime() ?? null; const mediaEndTime = media?.getEndTime() ?? null; const mediaIsEvent = media ? ViewMediaClassifier.isEvent(media) : false; @@ -976,7 +954,7 @@ export class FrigateCardTimelineCore extends LitElement { // range effectively starts/ends at the same time. { start: mediaStartTime, end: mediaEndTime ?? mediaStartTime } : null; - const context = this.view.context?.timeline; + const context = view.context?.timeline; if (context && context.window) { desiredWindow = context.window; @@ -1048,7 +1026,7 @@ export class FrigateCardTimelineCore extends LitElement { // Also don't generate thumbnails in mini-timelines (they will already have // been generated). - const queryType = MediaQueriesClassifier.getQueriesType(this.view.query); + const queryType = MediaQueriesClassifier.getQueriesType(view.query); if (!queryType) { return; } @@ -1062,15 +1040,31 @@ export class FrigateCardTimelineCore extends LitElement { freshMediaQuery && !this._alreadyHasAcceptableMediaQuery(freshMediaQuery) ) { - (await this._createViewWithMediaQueries(freshMediaQuery)) - ?.mergeInContext(this._getTimelineContext(desiredWindow)) - .dispatchChangeEvent(this); + const currentlySelectedResult = this.viewManagerEpoch?.manager + .getView() + ?.queryResults?.getSelectedResult(); + + await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ + params: { + query: freshMediaQuery, + }, + queryExecutorOptions: { + selectResult: { + id: currentlySelectedResult?.getID() ?? undefined, + }, + }, + modifiers: [ + new MergeContextViewModifier(this._getTimelineContext(desiredWindow)), + ], + }); } } protected _alreadyHasAcceptableMediaQuery(freshMediaQuery: MediaQueries): boolean { - const currentQueries = this.view?.query?.getQueries(); - const currentResultTimestamp = this.view?.queryResults?.getResultsTimestamp(); + const view = this.viewManagerEpoch?.manager.getView(); + + const currentQueries = view?.query?.getQueries(); + const currentResultTimestamp = view?.queryResults?.getResultsTimestamp(); return ( !!this.cameraManager && @@ -1089,10 +1083,11 @@ export class FrigateCardTimelineCore extends LitElement { * @returns The TimelineViewContext object. */ protected _getTimelineContext(window?: TimelineWindow): ViewContext { + const view = this.viewManagerEpoch?.manager.getView(); const newWindow = window ?? this._timeline?.getWindow(); return { timeline: { - ...this.view?.context?.timeline, + ...view?.context?.timeline, ...(newWindow && { window: newWindow }), }, }; @@ -1225,7 +1220,7 @@ export class FrigateCardTimelineCore extends LitElement { // `this._timeline.setwindow()` being entirely ignored. Example case: // Clicking the timeline control on a recording thumbnail. window.requestAnimationFrame(this._updateTimelineFromView.bind(this)); - } else if (changedProperties.has('view')) { + } else if (changedProperties.has('viewManagerEpoch')) { this._updateTimelineFromView(); } } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 08f737e6..4e0c11e9 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,10 +1,10 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { CameraManager } from '../camera-manager/manager'; +import { ViewManagerEpoch } from '../card-controller/view/types'; import { CardWideConfig, TimelineConfig } from '../config/types'; import basicBlockStyle from '../scss/basic-block.scss'; import { ExtendedHomeAssistant } from '../types'; -import { View } from '../view/view'; import './surround.js'; import './timeline-core.js'; @@ -14,7 +14,7 @@ export class FrigateCardTimeline extends LitElement { public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - public view?: Readonly; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public timelineConfig?: TimelineConfig; @@ -33,7 +33,7 @@ export class FrigateCardTimeline extends LitElement { return html` , + view?: Readonly | null, baseConfig?: TitleControlConfig, ): TitleControlConfig | null => { if (!baseConfig && view?.isGrid()) { diff --git a/src/components/viewer.ts b/src/components/viewer.ts index d8deceda..3c79774b 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -11,6 +11,8 @@ import { guard } from 'lit/directives/guard.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../camera-manager/manager.js'; +import { RemoveContextPropertyViewModifier } from '../card-controller/view/modifiers/remove-context-property.js'; +import { ViewManagerEpoch } from '../card-controller/view/types.js'; import { MediaGridSelected } from '../components-lib/media-grid-controller.js'; import { ZoomSettingsObserved } from '../components-lib/zoom/types.js'; import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context.js'; @@ -41,7 +43,6 @@ import { mayHaveAudio } from '../utils/audio.js'; import { aspectRatioToString, contentsChanged, - errorToConsole, setOrRemoveAttribute, } from '../utils/basic.js'; import { CarouselSelected } from '../utils/embla/carousel-controller.js'; @@ -58,10 +59,6 @@ import { dispatchMediaVolumeChangeEvent, } from '../utils/media-info.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; -import { - changeViewToRecentEventsForCameraAndDependents, - changeViewToRecentRecordingForCameraAndDependents, -} from '../utils/media-to-view.js'; import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, @@ -71,9 +68,7 @@ import { import { screenshotMedia } from '../utils/screenshot.js'; import { ViewMediaClassifier } from '../view/media-classifier'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; -import { MediaQueriesResults } from '../view/media-queries-results.js'; import { VideoContentType, ViewMedia } from '../view/media.js'; -import { View } from '../view/view.js'; import type { EmblaCarouselPlugins } from './carousel.js'; import './next-prev-control.js'; import './ptz'; @@ -110,7 +105,7 @@ export class FrigateCardViewer extends LitElement { public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - public view?: Readonly; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public viewerConfig?: ViewerConfig; @@ -127,7 +122,7 @@ export class FrigateCardViewer extends LitElement { protected render(): TemplateResult | void { if ( !this.hass || - !this.view || + !this.viewManagerEpoch || !this.viewerConfig || !this.cameraManager || !this.cardWideConfig @@ -135,55 +130,20 @@ export class FrigateCardViewer extends LitElement { return; } - if (!this.view.queryResults?.hasResults()) { - // If the query is not specified, the view must tell us which mediaType to - // search for. When the query *is* specified, the view is not required to - // indicate the media type (e.g. the mixed 'media' view from the - // timeline). - const mediaType = this.view.getDefaultMediaType(); - if (!mediaType) { - // Directly render an error message (instead of dispatching it upwards) - // to preserve the mini-timeline if the user pans into an area with no - // media. - return renderMessage({ - type: 'info', - message: localize('common.no_media'), - icon: 'mdi:multimedia', - }); - } - - if (mediaType === 'recordings') { - changeViewToRecentRecordingForCameraAndDependents( - this, - this.cameraManager, - this.cardWideConfig, - this.view, - { - allCameras: this.view.isGrid(), - targetView: 'recording', - useCache: false, - }, - ); - } else { - changeViewToRecentEventsForCameraAndDependents( - this, - this.cameraManager, - this.cardWideConfig, - this.view, - { - allCameras: this.view.isGrid(), - targetView: 'media', - eventsMediaType: mediaType, - useCache: false, - }, - ); - } - return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); + if (!this.viewManagerEpoch.manager.getView()?.queryResults?.hasResults()) { + // Directly render an error message (instead of dispatching it upwards) + // to preserve the mini-timeline if the user pans into an area with no + // media. + return renderMessage({ + type: 'info', + message: localize('common.no_media'), + icon: 'mdi:multimedia', + }); } return html` ; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public viewFilterCameraID?: string; @@ -227,6 +187,9 @@ export class FrigateCardViewerCarousel extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public showControls = true; + @state() protected _selected = 0; @@ -241,12 +204,14 @@ export class FrigateCardViewerCarousel extends LitElement { updated(changedProperties: PropertyValues): void { super.updated(changedProperties); - if (changedProperties.has('view')) { - const oldView = changedProperties.get('view') as View | undefined; + if (changedProperties.has('viewManagerEpoch')) { // Seek into the video if the seek time has changed (this is also called // on media load, since the media may or may not have been loaded at // this point). - if (this.view?.context?.mediaViewer !== oldView?.context?.mediaViewer) { + if ( + this.viewManagerEpoch?.manager.getView()?.context?.mediaViewer !== + this.viewManagerEpoch?.oldView?.context?.mediaViewer + ) { this._seekHandler(); } } @@ -324,7 +289,9 @@ export class FrigateCardViewerCarousel extends LitElement { } protected _setViewSelectedIndex(index: number): void { - if (!this._media) { + const view = this.viewManagerEpoch?.manager.getView(); + + if (!this._media || !view) { return; } @@ -335,7 +302,7 @@ export class FrigateCardViewerCarousel extends LitElement { return; } - const newResults = this.view?.queryResults + const newResults = view?.queryResults ?.clone() .selectIndex(index, this.viewFilterCameraID); if (!newResults) { @@ -345,15 +312,14 @@ export class FrigateCardViewerCarousel extends LitElement { .getSelectedResult(this.viewFilterCameraID) ?.getCameraID(); - this.view - ?.evolve({ + this.viewManagerEpoch?.manager.setViewByParameters({ + params: { queryResults: newResults, - // Always change the camera to the owner of the selected media. ...(cameraID && { camera: cameraID }), - }) - .removeContextProperty('mediaViewer', 'seek') - .dispatchChangeEvent(this); + }, + modifiers: [new RemoveContextPropertyViewModifier('mediaViewer', 'seek')], + }); } /** @@ -395,17 +361,13 @@ export class FrigateCardViewerCarousel extends LitElement { return slides; } - /** - * Called when an update will occur. - * @param changedProps The changed properties - */ protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('view')) { - const newMedia = - this.view?.queryResults?.getResults(this.viewFilterCameraID) ?? null; + if (changedProps.has('viewManagerEpoch')) { + const view = this.viewManagerEpoch?.manager.getView(); + const newMedia = view?.queryResults?.getResults(this.viewFilterCameraID) ?? null; const newSelected = - this.view?.queryResults?.getSelectedIndex(this.viewFilterCameraID) ?? 0; - const newSeek = this.view?.context?.mediaViewer?.seek; + view?.queryResults?.getSelectedIndex(this.viewFilterCameraID) ?? 0; + const newSeek = view?.context?.mediaViewer?.seek; if (newMedia !== this._media || newSelected !== this._selected || !newSeek) { setOrRemoveAttribute(this, false, 'unseekable'); @@ -449,8 +411,9 @@ export class FrigateCardViewerCarousel extends LitElement { selectedMedia.getCameraID(), ); + const view = this.viewManagerEpoch?.manager.getView(); const titleConfig = getDefaultTitleConfigForView( - this.view, + view, this.viewerConfig?.controls.title, ); @@ -474,38 +437,42 @@ export class FrigateCardViewerCarousel extends LitElement { this._player = null; }} > - { - scroll('previous'); - stopEventFromActivatingCardWideActions(ev); - }} - > - ${guard([this._media, this.view], () => this._getSlides())} - { - scroll('next'); - stopEventFromActivatingCardWideActions(ev); - }} - > + ${this.showControls + ? html` { + scroll('previous'); + stopEventFromActivatingCardWideActions(ev); + }} + >` + : ''} + ${guard([this._media, view], () => this._getSlides())} + ${this.showControls + ? html` { + scroll('next'); + stopEventFromActivatingCardWideActions(ev); + }} + >` + : ''} - ${this.view + ${view ? html` ` : ''} @@ -530,7 +497,8 @@ export class FrigateCardViewerCarousel extends LitElement { * Fire a media show event when a slide is selected. */ protected async _seekHandler(): Promise { - const seek = this.view?.context?.mediaViewer?.seek; + const view = this.viewManagerEpoch?.manager.getView(); + const seek = view?.context?.mediaViewer?.seek; if (!this.hass || !seek || !this._media || !this._player) { return; } @@ -556,14 +524,15 @@ export class FrigateCardViewerCarousel extends LitElement { } protected _renderMediaItem(media: ViewMedia): TemplateResult | null { - if (!this.hass || !this.view || !this.viewerConfig) { + const view = this.viewManagerEpoch?.manager.getView(); + if (!this.hass || !view || !this.viewerConfig) { return null; } return html`
; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public viewerConfig?: ViewerConfig; @@ -600,66 +569,64 @@ export class FrigateCardViewerGrid extends LitElement { public cameraManager?: CameraManager; protected _renderCarousel(filterCamera?: string): TemplateResult { + const selectedCameraID = this.viewManagerEpoch?.manager.getView()?.camera; return html` `; } - protected _gridSelectCamera(cameraID: string, view?: View): void { - const newView = view ?? this.view; - const promotedQueryResults = newView?.queryResults - ?.clone() - .promoteCameraSelectionToMainSelection(cameraID); - newView - ?.evolve({ - camera: cameraID, - queryResults: promotedQueryResults, - }) - .dispatchChangeEvent(this); - } - protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('view') && this._needsGrid()) { + if (changedProps.has('viewManagerEpoch') && this._needsGrid()) { import('./media-grid.js'); } } protected _needsGrid(): boolean { - const cameraIDs = this.view?.queryResults?.getCameraIDs(); + const view = this.viewManagerEpoch?.manager.getView(); + const cameraIDs = view?.queryResults?.getCameraIDs(); return ( - !!this.view?.isGrid() && - !!this.view?.supportsMultipleDisplayModes() && + !!view?.isGrid() && + !!view?.supportsMultipleDisplayModes() && (cameraIDs?.size ?? 0) > 1 ); } + protected _gridSelectCamera(cameraID: string): void { + const view = this.viewManagerEpoch?.manager.getView(); + this.viewManagerEpoch?.manager.setViewByParameters({ + params: { + camera: cameraID, + queryResults: view?.queryResults + ?.clone() + .promoteCameraSelectionToMainSelection(cameraID), + }, + }); + } + protected render(): TemplateResult { - const cameraIDs = this.view?.queryResults?.getCameraIDs(); + const view = this.viewManagerEpoch?.manager.getView(); + const cameraIDs = view?.queryResults?.getCameraIDs(); if (!cameraIDs || !this._needsGrid()) { return this._renderCarousel(); } return html` ) => this._gridSelectCamera(ev.detail.selected)} - @frigate-card:view:change=${(ev: CustomEvent) => { - ev.stopPropagation(); - const childView = ev.detail; - this._gridSelectCamera(childView.camera, childView); - }} > ${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))} @@ -680,7 +647,7 @@ export class FrigateCardViewerProvider public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - public view?: Readonly; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public media?: ViewMedia; @@ -788,21 +755,22 @@ export class FrigateCardViewerProvider * Dispatch a clip view that matches the current (snapshot) query. */ protected async _dispatchRelatedClipView(): Promise { + const view = this.viewManagerEpoch?.manager.getView(); if ( !this.hass || - !this.view || + !view || !this.cameraManager || !this.media || // If this specific media item has no clip, then do nothing (even if all // the other media items do). !ViewMediaClassifier.isEvent(this.media) || - !MediaQueriesClassifier.areEventQueries(this.view.query) + !MediaQueriesClassifier.areEventQueries(view.query) ) { return; } // Convert the query to a clips equivalent. - const clipQuery = this.view.query.clone(); + const clipQuery = view.query.clone(); clipQuery.convertToClipsQueries(); const queries = clipQuery.getQueries(); @@ -810,32 +778,18 @@ export class FrigateCardViewerProvider return; } - let mediaArray: ViewMedia[] | null; - try { - mediaArray = await this.cameraManager.executeMediaQueries(queries); - } catch (e) { - errorToConsole(e as Error); - return; - } - if (!mediaArray) { - return; - } - - const results = new MediaQueriesResults({ results: mediaArray }); - results.selectResultIfFound( - (clipMedia) => clipMedia.getID() === this.media?.getID(), - ); - if (!results.hasSelectedResult()) { - return; - } - - this.view - .evolve({ + await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ + params: { view: 'media', query: clipQuery, - queryResults: results, - }) - .dispatchChangeEvent(this); + }, + queryExecutorOptions: { + selectResult: { + id: this.media.getID() ?? undefined, + }, + rejectResults: (results) => !results.hasSelectedResult(), + }, + }); } protected willUpdate(changedProps: PropertyValues): void { @@ -881,6 +835,7 @@ export class FrigateCardViewerProvider const cameraID = this.media.getCameraID(); const mediaID = this.media.getID() ?? undefined; const cameraConfig = this.cameraManager?.getStore().getCameraConfig(cameraID); + const view = this.viewManagerEpoch?.manager.getView(); return this.viewerConfig?.zoomable ? html` this.setControls(false)} @frigate-card:zoom:unzoomed=${() => this.setControls()} @frigate-card:zoom:change=${(ev: CustomEvent) => - handleZoomSettingsObservedEvent(this, ev, mediaID)} + handleZoomSettingsObservedEvent(ev, this.viewManagerEpoch?.manager, mediaID)} > ${template} ` @@ -906,7 +859,7 @@ export class FrigateCardViewerProvider } protected render(): TemplateResult | void { - if (!this.load || !this.media || !this.hass || !this.view || !this.viewerConfig) { + if (!this.load || !this.media || !this.hass || !this.viewerConfig) { return; } diff --git a/src/components/views.ts b/src/components/views.ts index 160ffeb0..8d735574 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -11,6 +11,7 @@ import { classMap } from 'lit/directives/class-map.js'; import { CameraManager } from '../camera-manager/manager.js'; import { ConditionsManagerEpoch } from '../card-controller/conditions-manager.js'; import { ReadonlyMicrophoneManager } from '../card-controller/microphone-manager.js'; +import { ViewManagerEpoch } from '../card-controller/view/types.js'; import { CardWideConfig, FrigateCardConfig, @@ -19,8 +20,6 @@ import { import viewsStyle from '../scss/views.scss'; import { ExtendedHomeAssistant } from '../types.js'; import { ResolvedMediaCache } from '../utils/ha/resolved-media.js'; -import { View } from '../view/view.js'; -import './surround.js'; // As a special case: Diagnostics is not dynamically loaded in case something goes wrong. import './diagnostics.js'; @@ -31,7 +30,7 @@ export class FrigateCardViews extends LitElement { public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - public view?: Readonly; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public cameraManager?: CameraManager; @@ -64,17 +63,18 @@ export class FrigateCardViews extends LitElement { public triggeredCameraIDs?: Set; protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('view') || changedProps.has('config')) { - if (this.view?.is('live') || this._shouldLivePreload()) { + if (changedProps.has('viewManagerEpoch') || changedProps.has('config')) { + const view = this.viewManagerEpoch?.manager.getView(); + if (view?.is('live') || this._shouldLivePreload()) { import('./live/live.js'); } - if (this.view?.isGalleryView()) { + if (view?.isGalleryView()) { import('./gallery.js'); - } else if (this.view?.isViewerView()) { + } else if (view?.isViewerView()) { import('./viewer.js'); - } else if (this.view?.is('image')) { + } else if (view?.is('image')) { import('./image.js'); - } else if (this.view?.is('timeline')) { + } else if (view?.is('timeline')) { import('./timeline.js'); } } @@ -108,10 +108,11 @@ export class FrigateCardViews extends LitElement { } protected _shouldLivePreload(): boolean { + const view = this.viewManagerEpoch?.manager.getView(); return ( // Special case: Never preload for diagnostics -- we want that to be as // minimal as possible. - !!this.overriddenConfig?.live.preload && !this.view?.is('diagnostics') + !!this.overriddenConfig?.live.preload && !view?.is('diagnostics') ); } @@ -129,67 +130,69 @@ export class FrigateCardViews extends LitElement { return html``; } + const view = this.viewManagerEpoch?.manager.getView(); + // Render but hide the live view if there's a message, or if it's preload // mode and the view is not live. const liveClasses = { - hidden: this._shouldLivePreload() && !this.view?.is('live'), + hidden: this._shouldLivePreload() && !view?.is('live'), }; const overallClasses = { hidden: !!this.hide, }; - const thumbnailConfig = this.view?.is('live') + const thumbnailConfig = view?.is('live') ? this.overriddenConfig.live.controls.thumbnails - : this.view?.isViewerView() + : view?.isViewerView() ? this.overriddenConfig.media_viewer.controls.thumbnails - : this.view?.is('timeline') + : view?.is('timeline') ? this.overriddenConfig.timeline.controls.thumbnails : undefined; - const miniTimelineConfig = this.view?.is('live') + const miniTimelineConfig = view?.is('live') ? this.overriddenConfig.live.controls.timeline - : this.view?.isViewerView() + : view?.isViewerView() ? this.overriddenConfig.media_viewer.controls.timeline : undefined; - const cameraConfig = this.view - ? this.cameraManager?.getStore().getCameraConfig(this.view.camera) ?? null + const cameraConfig = view + ? this.cameraManager?.getStore().getCameraConfig(view.camera) ?? null : null; return html` - ${!this.hide && this.view?.is('image') && cameraConfig + ${!this.hide && view?.is('image') && cameraConfig ? html` ` : ``} - ${!this.hide && this.view?.isGalleryView() + ${!this.hide && view?.isGalleryView() ? html` ` : ``} - ${!this.hide && this.view?.isViewerView() + ${!this.hide && view?.isViewerView() ? html` ` : ``} - ${!this.hide && this.view?.is('timeline') + ${!this.hide && view?.is('timeline') ? html` ` : ``} - ${!this.hide && this.view?.is('diagnostics') + ${!this.hide && view?.is('diagnostics') ? html` uses nonOverriddenConfig rather than the // overriden config as it does it's own overriding as part of the camera // carousel. - this._shouldLivePreload() || (!this.hide && this.view?.is('live')) + this._shouldLivePreload() || (!this.hide && view?.is('live')) ? html` ` : ''} ${this._renderOptionSetHeader('timeline')} diff --git a/src/utils/find-best-media-index.ts b/src/utils/find-best-media-index.ts new file mode 100644 index 00000000..f59c3c55 --- /dev/null +++ b/src/utils/find-best-media-index.ts @@ -0,0 +1,48 @@ +import { ViewMedia } from '../view/media'; + +/** + * Find the longest matching media object that contains a given targetTime. + * Longest is chosen to give the most stability to the media viewer. + * @param mediaArray The media. + * @param targetTime The target time used to find the relevant child. + * @returns The childindex or null if no matching child is found. + */ +export const findBestMediaIndex = ( + mediaArray: ViewMedia[], + targetTime: Date, + favorCameraID?: string, +): number | null => { + let bestMatch: + | { + index: number; + duration: number; + cameraID: string; + } + | undefined; + + for (const [i, media] of mediaArray.entries()) { + const start = media.getStartTime(); + const end = media.getUsableEndTime(); + + if (media.includesTime(targetTime) && start && end) { + const duration = end.getTime() - start.getTime(); + + if ( + // No best match so far ... + !bestMatch || + // ... or there is a best-match, but it's from a non-favored camera (unlike this one) ... + (favorCameraID && + bestMatch.cameraID !== favorCameraID && + media.getCameraID() === favorCameraID) || + // ... or this match is longer and either there's no favored camera or this is it. + (duration > bestMatch.duration && + (!favorCameraID || + bestMatch.cameraID !== favorCameraID || + media.getCameraID() === favorCameraID)) + ) { + bestMatch = { index: i, duration: duration, cameraID: media.getCameraID() }; + } + } + } + return bestMatch ? bestMatch.index : null; +}; diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts deleted file mode 100644 index 718f2646..00000000 --- a/src/utils/media-to-view.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { ViewContext } from 'view'; -import { CameraManager } from '../camera-manager/manager'; -import { CapabilitySearchOptions, MediaQuery } from '../camera-manager/types'; -import { dispatchFrigateCardErrorEvent } from '../components/message'; -import { CardWideConfig, FrigateCardView } from '../config/types'; -import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const'; -import { ClipsOrSnapshotsOrAll } from '../types'; -import { ViewMedia } from '../view/media'; -import { - EventMediaQueries, - MediaQueries, - RecordingMediaQueries, -} from '../view/media-queries'; -import { MediaQueriesResults } from '../view/media-queries-results'; -import { View } from '../view/view'; -import { errorToConsole } from './basic'; - -type ResultSelectType = 'latest' | 'time' | 'none'; - -export const changeViewToRecentEventsForCameraAndDependents = async ( - element: HTMLElement, - cameraManager: CameraManager, - cardWideConfig: CardWideConfig, - view: View, - options?: { - allCameras?: boolean; - eventsMediaType?: ClipsOrSnapshotsOrAll; - targetView?: FrigateCardView; - select?: ResultSelectType; - useCache?: boolean; - viewContext?: ViewContext; - }, -): Promise => { - const capabilitySearch: CapabilitySearchOptions = - !options?.eventsMediaType || options?.eventsMediaType === 'all' - ? { - anyCapabilities: ['clips', 'snapshots'], - } - : options.eventsMediaType; - - const cameraIDs = options?.allCameras - ? cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch) - : cameraManager.getStore().getAllDependentCameras(view.camera, capabilitySearch); - if (!cameraIDs.size) { - return; - } - - const queries = createQueriesForEventsView(cameraManager, cardWideConfig, cameraIDs, { - eventsMediaType: options?.eventsMediaType, - }); - if (!queries) { - return; - } - - ( - await executeMediaQueryForViewWithErrorDispatching( - element, - cameraManager, - view, - queries, - { - targetView: options?.targetView, - select: options?.select, - useCache: options?.useCache, - viewContext: options?.viewContext, - }, - ) - )?.dispatchChangeEvent(element); -}; - -const createQueriesForEventsView = ( - cameraManager: CameraManager, - cardWideConfig: CardWideConfig, - cameraIDs: Set, - options?: { - eventsMediaType?: ClipsOrSnapshotsOrAll; - }, -): EventMediaQueries | null => { - const limit = - cardWideConfig.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT; - const eventQueries = cameraManager.generateDefaultEventQueries(cameraIDs, { - limit: limit, - ...(options?.eventsMediaType === 'clips' && { hasClip: true }), - ...(options?.eventsMediaType === 'snapshots' && { hasSnapshot: true }), - }); - return eventQueries ? new EventMediaQueries(eventQueries) : null; -}; - -/** - * Change the view to a recent recording. - * @param element The element to dispatch the view change from. - * @param hass The Home Assistant object. - * @param cameraManager The datamanager to use for data access. - * @param cameras The camera configurations. - * @param view The current view. - * @param options A set of cameraIDs to fetch recordings for, and a targetView to dispatch to. - */ -export const changeViewToRecentRecordingForCameraAndDependents = async ( - element: HTMLElement, - cameraManager: CameraManager, - cardWideConfig: CardWideConfig, - view: View, - options?: { - allCameras?: boolean; - targetView?: FrigateCardView; - select?: ResultSelectType; - useCache?: boolean; - viewContext?: ViewContext; - }, -): Promise => { - const cameraIDs = options?.allCameras - ? cameraManager.getStore().getCameraIDsWithCapability('recordings') - : cameraManager.getStore().getAllDependentCameras(view.camera, 'recordings'); - if (!cameraIDs.size) { - return; - } - - const queries = createQueriesForRecordingsView( - cameraManager, - cardWideConfig, - cameraIDs, - ); - if (!queries) { - return; - } - - ( - await executeMediaQueryForViewWithErrorDispatching( - element, - cameraManager, - view, - queries, - { - targetView: options?.targetView, - select: options?.select, - useCache: options?.useCache, - viewContext: options?.viewContext, - }, - ) - )?.dispatchChangeEvent(element); -}; - -const createQueriesForRecordingsView = ( - cameraManager: CameraManager, - cardWideConfig: CardWideConfig, - cameraIDs: Set, -): RecordingMediaQueries | null => { - const limit = - cardWideConfig.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT; - const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, { - limit: limit, - }); - return recordingQueries ? new RecordingMediaQueries(recordingQueries) : null; -}; - -export const executeMediaQueryForView = async ( - cameraManager: CameraManager, - view: View, - query: MediaQueries, - options?: { - targetCameraID?: string; - targetView?: FrigateCardView; - targetTime?: Date; - select?: ResultSelectType; - useCache?: boolean; - viewContext?: ViewContext; - }, -): Promise => { - const queries = query.getQueries(); - if (!queries) { - return null; - } - - const mediaArray = await cameraManager.executeMediaQueries(queries, { - useCache: options?.useCache, - }); - if (!mediaArray) { - return null; - } - - const queryResults = new MediaQueriesResults({ results: mediaArray }); - let viewerContext: ViewContext | undefined = {}; - const cameraID = options?.targetCameraID ?? view.camera; - - if (options?.select === 'time' && options?.targetTime) { - queryResults.selectBestResult((media) => - findBestMediaIndex(media, options.targetTime as Date, cameraID), - ); - viewerContext = { - mediaViewer: { - seek: options.targetTime, - }, - }; - } - - return view - .evolve({ - query: query, - queryResults: queryResults, - view: options?.targetView, - camera: cameraID, - }) - .mergeInContext(options?.viewContext) - .mergeInContext(viewerContext); -}; - -export const executeMediaQueryForViewWithErrorDispatching = async ( - element: HTMLElement, - cameraManager: CameraManager, - view: View, - query: MediaQueries, - options?: { - targetCameraID?: string; - targetView?: FrigateCardView; - targetTime?: Date; - select?: ResultSelectType; - useCache?: boolean; - viewContext?: ViewContext; - }, -): Promise => { - try { - return await executeMediaQueryForView(cameraManager, view, query, { - targetCameraID: options?.targetCameraID, - targetView: options?.targetView, - targetTime: options?.targetTime, - select: options?.select, - useCache: options?.useCache, - viewContext: options?.viewContext, - }); - } catch (e: unknown) { - errorToConsole(e as Error); - dispatchFrigateCardErrorEvent(element, e as Error); - } - return null; -}; - -/** - * Find the longest matching media object that contains a given targetTime. - * Longest is chosen to give the most stability to the media viewer. - * @param mediaArray The media. - * @param targetTime The target time used to find the relevant child. - * @returns The childindex or null if no matching child is found. - */ -export const findBestMediaIndex = ( - mediaArray: ViewMedia[], - targetTime: Date, - favorCameraID?: string, -): number | null => { - let bestMatch: - | { - index: number; - duration: number; - cameraID: string; - } - | undefined; - - for (const [i, media] of mediaArray.entries()) { - const start = media.getStartTime(); - const end = media.getUsableEndTime(); - - if (media.includesTime(targetTime) && start && end) { - const duration = end.getTime() - start.getTime(); - - if ( - // No best match so far ... - !bestMatch || - // ... or there is a best-match, but it's from a non-favored camera (unlike this one) ... - (favorCameraID && - bestMatch.cameraID !== favorCameraID && - media.getCameraID() === favorCameraID) || - // ... or this match is longer and either there's no favored camera or this is it. - (duration > bestMatch.duration && - (!favorCameraID || - bestMatch.cameraID !== favorCameraID || - media.getCameraID() === favorCameraID)) - ) { - bestMatch = { index: i, duration: duration, cameraID: media.getCameraID() }; - } - } - } - return bestMatch ? bestMatch.index : null; -}; diff --git a/src/utils/substream.ts b/src/utils/substream.ts index 938eb218..7f1aafaa 100644 --- a/src/utils/substream.ts +++ b/src/utils/substream.ts @@ -7,3 +7,18 @@ export const getStreamCameraID = (view: View, cameraID?: string): string => { export const hasSubstream = (view: View): boolean => { return getStreamCameraID(view) !== view.camera; }; + +export const setSubstream = (view: View, substreamID: string): void => { + const overrides: Map = view.context?.live?.overrides ?? new Map(); + overrides.set(view.camera, substreamID); + view.mergeInContext({ + live: { overrides: overrides }, + }); +}; + +export const removeSubstream = (view: View): void => { + const overrides: Map | undefined = view.context?.live?.overrides; + if (overrides && overrides.has(view.camera)) { + view.context?.live?.overrides?.delete(view.camera); + } +}; diff --git a/src/view/view.ts b/src/view/view.ts index 918ff818..b2cde44e 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,11 +1,8 @@ +import merge from 'lodash-es/merge'; import { ViewContext } from 'view'; import { FrigateCardView, ViewDisplayMode } from '../config/types.js'; -import { ClipsOrSnapshots } from '../types.js'; -import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { MediaQueries } from './media-queries'; -import { MediaQueriesClassifier } from './media-queries-classifier.js'; import { MediaQueriesResults } from './media-queries-results'; -import merge from 'lodash-es/merge'; interface ViewEvolveParameters { view?: FrigateCardView; @@ -21,6 +18,10 @@ export interface ViewParameters extends ViewEvolveParameters { camera: string; } +export const mergeViewContext = (a?: ViewContext | null, b?: ViewContext | null): ViewContext => { + return merge({}, a, b); +} + export class View { public view: FrigateCardView; public camera: string; @@ -38,109 +39,6 @@ export class View { this.displayMode = params.displayMode ?? null; } - /** - * Detect if a view change represents a major "media change" for the given - * view. - * @param prev The previous view. - * @param curr The current view. - * @returns True if the view change is a real media change. - */ - public static isMajorMediaChange(prev?: View | null, curr?: View): boolean { - return ( - !prev || - !curr || - prev.view !== curr.view || - prev.camera !== curr.camera || - // When in live mode, take overrides (substreams) into account in deciding - // if this is a major media change. - (curr.view === 'live' && - prev.context?.live?.overrides?.get(prev.camera) !== - curr.context?.live?.overrides?.get(curr.camera)) || - // 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. - (curr.view !== 'live' && prev.queryResults !== curr.queryResults) - ); - } - - public static adoptFromViewIfAppropriate(next: View, curr?: View | null): void { - if (!curr) { - return; - } - - // In certain cases it may make sense to adopt parameters from a prior view. - // - // * Case #1: 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 - // - // * Case #2: If the user is looking at media in the `media` view and then - // changes camera to the *current* camera (via the menu) it will cause a - // new view to issue without a query and just the 'media' view, which - // means the viewer cannot know what kind of media to fetch. - // - // * Case #3: Staying within the live view in order to preserve substreams - // turned on. See: - // https://github.com/dermotduffy/frigate-hass-card/issues/1122 - // - - let currentQueriesView: ClipsOrSnapshots | 'recordings' | null = null; - if (MediaQueriesClassifier.areEventQueries(curr.query)) { - const queries = curr.query.getQueries(); - if ( - queries?.every((query) => query.hasClip) || - queries?.every( - (query) => query.hasClip === undefined && query.hasSnapshot === undefined, - ) - ) { - currentQueriesView = 'clips'; - } else if (queries?.every((query) => query.hasSnapshot)) { - currentQueriesView = 'snapshots'; - } - } else if (MediaQueriesClassifier.areRecordingQueries(curr.query)) { - currentQueriesView = 'recordings'; - } - - const hasNoQueryOrResults = !next.query || !next.queryResults; - const switchingToGalleryFromViewer = - curr.isViewerView() && next.isGalleryView() && next.view === currentQueriesView; - const switchingToMediaFromMedia = curr?.is('media') && next.is('media'); - - if (hasNoQueryOrResults) { - if (switchingToGalleryFromViewer && curr.query && curr.queryResults) { - next.query = curr.query; - next.queryResults = curr.queryResults; - } else if (switchingToMediaFromMedia && currentQueriesView) { - next.view = - currentQueriesView === 'clips' - ? 'clip' - : currentQueriesView === 'snapshots' - ? 'snapshot' - : 'recording'; - } - } - - if ( - curr.is('live') && - next.is('live') && - curr.context?.live?.overrides && - !next.context?.live?.overrides - ) { - const nextLiveContext = next.context?.live ?? {}; - nextLiveContext.overrides = curr.context.live.overrides; - next.mergeInContext({ live: nextLiveContext }); - } - } - - /** - * Clone a view. - */ public clone(): View { return new View({ view: this.view, @@ -178,7 +76,7 @@ export class View { * @returns This view. */ public mergeInContext(context?: ViewContext | null): View { - this.context = merge({}, this.context, context); + this.context = mergeViewContext(this.context, context); return this; } @@ -259,48 +157,4 @@ export class View { public isGrid(): boolean { return this.displayMode === 'grid'; } - - /** - * Dispatch an event to request a view change. - * @param target The target dispatching the event. - */ - public dispatchChangeEvent(target: EventTarget): void { - dispatchFrigateCardEvent(target, 'view:change', this); - } } - -// Facilitates correct typing of event handlers. -export interface FrigateCardViewChangeEventTarget extends EventTarget { - addEventListener( - event: 'frigate-card:view:change', - listener: (this: FrigateCardViewChangeEventTarget, ev: CustomEvent) => void, - options?: AddEventListenerOptions | boolean, - ): void; - addEventListener( - type: string, - callback: EventListenerOrEventListenerObject, - options?: AddEventListenerOptions | boolean, - ): void; - removeEventListener( - event: 'frigate-card:view:change', - listener: (this: FrigateCardViewChangeEventTarget, ev: CustomEvent) => void, - options?: boolean | EventListenerOptions, - ): void; - removeEventListener( - type: string, - callback: EventListenerOrEventListenerObject, - options?: boolean | EventListenerOptions, - ): void; -} - -/** - * Dispatch an event to change the view context. - * @param target The EventTarget to send the event from. - * @param context The context to change. - */ -export const dispatchViewContextChangeEvent = ( - target: EventTarget, - context: ViewContext | null, -): void => { - dispatchFrigateCardEvent(target, 'view:change-context', context); -}; diff --git a/tests/card-controller/actions/actions/camera-select.test.ts b/tests/card-controller/actions/actions/camera-select.test.ts index 7c2abc2c..57d30a19 100644 --- a/tests/card-controller/actions/actions/camera-select.test.ts +++ b/tests/card-controller/actions/actions/camera-select.test.ts @@ -19,10 +19,12 @@ describe('should handle camera_select action', () => { await action.execute(api); - expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith( expect.objectContaining({ - viewName: 'live', - cameraID: 'camera', + params: { + view: 'live', + camera: 'camera', + }, failSafe: true, }), ); @@ -49,10 +51,12 @@ describe('should handle camera_select action', () => { await action.execute(api); - expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith( expect.objectContaining({ - viewName: 'timeline', - cameraID: 'camera', + params: { + view: 'timeline', + camera: 'camera', + }, failSafe: true, }), ); @@ -86,10 +90,12 @@ describe('should handle camera_select action', () => { await action.execute(api); - expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith( expect.objectContaining({ - viewName: 'clips', - cameraID: 'camera', + params: { + view: 'clips', + camera: 'camera', + }, failSafe: true, }), ); @@ -114,10 +120,12 @@ describe('should handle camera_select action', () => { await action.execute(api); - expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith( expect.objectContaining({ - viewName: 'live', - cameraID: 'camera', + params: { + view: 'live', + camera: 'camera', + }, failSafe: true, }), ); @@ -141,7 +149,7 @@ describe('should handle camera_select action', () => { await action.execute(api); - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); it('without a current view', async () => { @@ -158,6 +166,6 @@ describe('should handle camera_select action', () => { await action.execute(api); - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); }); diff --git a/tests/card-controller/actions/actions/display-mode-select.test.ts b/tests/card-controller/actions/actions/display-mode-select.test.ts index 57da6936..7c7900e5 100644 --- a/tests/card-controller/actions/actions/display-mode-select.test.ts +++ b/tests/card-controller/actions/actions/display-mode-select.test.ts @@ -15,5 +15,9 @@ it('should handle default action', async () => { await action.execute(api); - expect(api.getViewManager().setViewWithNewDisplayMode).toBeCalledWith('grid'); + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ + params: { + displayMode: 'grid', + }, + }); }); diff --git a/tests/card-controller/actions/actions/substream-off.test.ts b/tests/card-controller/actions/actions/substream-off.test.ts index 149b2d17..03b86187 100644 --- a/tests/card-controller/actions/actions/substream-off.test.ts +++ b/tests/card-controller/actions/actions/substream-off.test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest'; import { SubstreamOffAction } from '../../../../src/card-controller/actions/actions/substream-off'; import { createCardAPI } from '../../../test-utils'; +import { SubstreamOffViewModifier } from '../../../../src/card-controller/view/modifiers/substream-off'; it('should handle live_substream_off action', async () => { const api = createCardAPI(); @@ -14,5 +15,7 @@ it('should handle live_substream_off action', async () => { await action.execute(api); - expect(api.getViewManager().setViewWithoutSubstream).toBeCalled(); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + modifiers: [expect.any(SubstreamOffViewModifier)], + }); }); diff --git a/tests/card-controller/actions/actions/substream-on.test.ts b/tests/card-controller/actions/actions/substream-on.test.ts index 21ede4bf..b37061f3 100644 --- a/tests/card-controller/actions/actions/substream-on.test.ts +++ b/tests/card-controller/actions/actions/substream-on.test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest'; import { SubstreamOnAction } from '../../../../src/card-controller/actions/actions/substream-on'; import { createCardAPI } from '../../../test-utils'; +import { SubstreamOnViewModifier } from '../../../../src/card-controller/view/modifiers/substream-on'; it('should handle live_substream_on action', async () => { const api = createCardAPI(); @@ -14,5 +15,7 @@ it('should handle live_substream_on action', async () => { await action.execute(api); - expect(api.getViewManager().setViewWithSubstream).toBeCalledWith(); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + modifiers: [expect.any(SubstreamOnViewModifier)], + }); }); diff --git a/tests/card-controller/actions/actions/substream-select.test.ts b/tests/card-controller/actions/actions/substream-select.test.ts index 7ab186f8..a0f326f9 100644 --- a/tests/card-controller/actions/actions/substream-select.test.ts +++ b/tests/card-controller/actions/actions/substream-select.test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest'; import { SubstreamSelectAction } from '../../../../src/card-controller/actions/actions/substream-select'; import { createCardAPI } from '../../../test-utils'; +import { SubstreamSelectViewModifier } from '../../../../src/card-controller/view/modifiers/substream-select'; it('should handle live_substream_select action', async () => { const api = createCardAPI(); @@ -15,5 +16,9 @@ it('should handle live_substream_select action', async () => { await action.execute(api); - expect(api.getViewManager().setViewWithSubstream).toBeCalledWith('substream'); + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + modifiers: expect.arrayContaining([expect.any(SubstreamSelectViewModifier)]), + }), + ); }); diff --git a/tests/card-controller/actions/actions/view.test.ts b/tests/card-controller/actions/actions/view.test.ts index 16f17f79..41215a63 100644 --- a/tests/card-controller/actions/actions/view.test.ts +++ b/tests/card-controller/actions/actions/view.test.ts @@ -27,9 +27,11 @@ describe('should handle view action', () => { await action.execute(api); - expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith( expect.objectContaining({ - viewName: viewName, + params: { + view: viewName, + }, }), ); }); diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index f2ae1f97..6148cb7e 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -25,7 +25,7 @@ import { MicrophoneManager } from '../../src/card-controller/microphone-manager' import { QueryStringManager } from '../../src/card-controller/query-string-manager'; import { StyleManager } from '../../src/card-controller/style-manager'; import { TriggersManager } from '../../src/card-controller/triggers-manager'; -import { ViewManager } from '../../src/card-controller/view-manager'; +import { ViewManager } from '../../src/card-controller/view/view-manager'; import { FrigateCardEditor } from '../../src/editor'; import { EntityRegistryManager } from '../../src/utils/ha/entity-registry'; import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media'; @@ -52,7 +52,7 @@ vi.mock('../../src/card-controller/microphone-manager'); vi.mock('../../src/card-controller/query-string-manager'); vi.mock('../../src/card-controller/style-manager'); vi.mock('../../src/card-controller/triggers-manager'); -vi.mock('../../src/card-controller/view-manager'); +vi.mock('../../src/card-controller/view/view-manager'); vi.mock('../../src/utils/ha/entity-registry'); vi.mock('../../src/utils/ha/resolved-media'); diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts index fb8ba160..add474ce 100644 --- a/tests/card-controller/initialization-manager.test.ts +++ b/tests/card-controller/initialization-manager.test.ts @@ -110,7 +110,7 @@ describe('InitializationManager', () => { expect(loadLanguages).toBeCalled(); expect(sideLoadHomeAssistantElements).toBeCalled(); expect(api.getCameraManager().initializeCamerasFromConfig).toBeCalled(); - expect(api.getViewManager().setViewDefault).toBeCalled(); + expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); expect(api.getMicrophoneManager().connect).not.toBeCalled(); }); diff --git a/tests/card-controller/query-string-manager.test.ts b/tests/card-controller/query-string-manager.test.ts index 7d1c6427..6ee8ae42 100644 --- a/tests/card-controller/query-string-manager.test.ts +++ b/tests/card-controller/query-string-manager.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { QueryStringManager } from '../../src/card-controller/query-string-manager'; +import { SubstreamSelectViewModifier } from '../../src/card-controller/view/modifiers/substream-select'; import { createCardAPI } from '../test-utils'; const setQueryString = (qs: string): void => { @@ -51,8 +52,10 @@ describe('QueryStringManager', () => { manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeTruthy(); - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - viewName: viewName, + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ + params: { + view: viewName, + }, }); }); }); @@ -92,7 +95,7 @@ describe('QueryStringManager', () => { manager.executeAll(); - expect(api.getViewManager().setViewDefault).toBeCalled(); + expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); expect(manager.hasViewRelatedActions()).toBeTruthy(); expect(api.getActionsManager().executeActions).not.toBeCalled(); @@ -107,8 +110,10 @@ describe('QueryStringManager', () => { manager.executeAll(); - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - cameraID: 'camera.office', + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ + params: { + camera: 'camera.office', + }, }); expect(manager.hasViewRelatedActions()).toBeTruthy(); @@ -124,8 +129,9 @@ describe('QueryStringManager', () => { manager.executeAll(); - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - substream: 'camera.office_hd', + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ + modifiers: [expect.any(SubstreamSelectViewModifier)], + params: {}, }); expect(manager.hasViewRelatedActions()).toBeTruthy(); @@ -190,8 +196,10 @@ describe('QueryStringManager', () => { manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeTruthy(); - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - viewName: viewName, + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ + params: { + view: viewName, + }, }); }); }); @@ -230,11 +238,13 @@ describe('QueryStringManager', () => { manager.executeAll(); - expect(api.getViewManager().setViewDefault).toBeCalledWith({ - cameraID: 'camera.kitchen', - substream: 'camera.kitchen_hd', + expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledWith({ + params: { + camera: 'camera.kitchen', + }, + modifiers: [expect.any(SubstreamSelectViewModifier)], }); - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); it('multiple cameras specified', () => { @@ -248,8 +258,10 @@ describe('QueryStringManager', () => { manager.executeAll(); - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - cameraID: 'camera.office', + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ + params: { + camera: 'camera.office', + }, }); }); }); diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index e81e8ecb..88b058e6 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -14,6 +14,7 @@ import { createConfig, createStore, createView, + flushPromises, } from '../test-utils'; vi.mock('lodash-es/throttle', () => ({ @@ -109,7 +110,7 @@ describe('TriggersManager', () => { }); describe('trigger actions', () => { - it('update', () => { + it('update', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, @@ -122,16 +123,18 @@ describe('TriggersManager', () => { const manager = new TriggersManager(api); - manager.handleCameraEvent({ + await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); - expect(api.getViewManager().setView).toBeCalled(); + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ + queryExecutorOptions: { useCache: false }, + }); }); - it('default', () => { + it('default', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, @@ -144,11 +147,13 @@ describe('TriggersManager', () => { const manager = new TriggersManager(api); - manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); expect(manager.isTriggered()).toBeTruthy(); - expect(api.getViewManager().setViewDefault).toBeCalledWith({ - cameraID: 'camera_1', + expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledWith({ + params: { + camera: 'camera_1', + }, }); }); @@ -168,9 +173,11 @@ describe('TriggersManager', () => { manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); expect(manager.isTriggered()).toBeTruthy(); - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - viewName: 'live', - cameraID: 'camera_1', + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ + params: { + view: 'live', + camera: 'camera_1', + }, }); }); @@ -207,12 +214,16 @@ describe('TriggersManager', () => { }); if (!viewName) { - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect( + api.getViewManager().setViewByParametersWithNewQuery, + ).not.toBeCalled(); } else { expect(manager.isTriggered()).toBeTruthy(); - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - cameraID: 'camera_1', - viewName: viewName, + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ + params: { + camera: 'camera_1', + view: viewName, + }, }); } }, @@ -235,9 +246,8 @@ describe('TriggersManager', () => { manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); expect(manager.isTriggered()).toBeTruthy(); - expect(api.getViewManager().setView).not.toBeCalled(); - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); + expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); }); @@ -263,12 +273,11 @@ describe('TriggersManager', () => { expect(manager.isTriggered()).toBeFalsy(); - expect(api.getViewManager().setView).not.toBeCalled(); - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); + expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); - it('default', () => { + it('default', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, @@ -281,15 +290,16 @@ describe('TriggersManager', () => { }); const manager = new TriggersManager(api); - manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); - manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' }); + await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' }); vi.setSystemTime(add(start, { seconds: 10 })); vi.runOnlyPendingTimers(); + await flushPromises(); expect(manager.isTriggered()).toBeFalsy(); - expect(api.getViewManager().setViewDefault).toBeCalled(); + expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); }); }); @@ -343,9 +353,8 @@ describe('TriggersManager', () => { manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', fidelity: 'high' }); - expect(api.getViewManager().setView).not.toBeCalled(); - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); + expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); it('with non-live default', () => { @@ -364,9 +373,8 @@ describe('TriggersManager', () => { manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', fidelity: 'high' }); - expect(api.getViewManager().setView).not.toBeCalled(); - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); + expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); }); @@ -384,7 +392,8 @@ describe('TriggersManager', () => { expect(manager.isTriggered()).toBeTruthy(); - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); + expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); manager.handleCameraEvent({ cameraID: 'camera_1', @@ -396,7 +405,8 @@ describe('TriggersManager', () => { expect(manager.isTriggered()).toBeFalsy(); - expect(api.getViewManager().setViewDefault).not.toBeCalled(); + expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); + expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); it('should take no actions when actions are set to none', () => { @@ -415,7 +425,8 @@ describe('TriggersManager', () => { type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); + expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); manager.handleCameraEvent({ cameraID: 'camera_1', @@ -426,10 +437,11 @@ describe('TriggersManager', () => { vi.runOnlyPendingTimers(); expect(manager.isTriggered()).toBeFalsy(); - expect(api.getViewManager().setViewDefault).not.toBeCalled(); + expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); + expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); - it('should take actions with human interactions when interaction mode is active', () => { + it('should take actions with human interactions when interaction mode is active', async () => { const api = createTriggerAPI({ // Interaction present. interaction: true, @@ -443,31 +455,34 @@ describe('TriggersManager', () => { }, }); const manager = new TriggersManager(api); - manager.handleCameraEvent({ + await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - viewName: 'live' as const, - cameraID: 'camera_1' as const, + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ + params: { + view: 'live' as const, + camera: 'camera_1' as const, + } }); - manager.handleCameraEvent({ + await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end', }); vi.setSystemTime(add(start, { seconds: 10 })); vi.runOnlyPendingTimers(); + await flushPromises(); expect(manager.isTriggered()).toBeFalsy(); - expect(api.getViewManager().setViewDefault).toBeCalled(); + expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); }); - it('should report multiple triggered cameras', () => { + it('should report multiple triggered cameras', async () => { const api = createTriggerAPI(); vi.mocked(api.getCameraManager().getStore).mockReturnValue( createStore([ @@ -496,11 +511,11 @@ describe('TriggersManager', () => { expect(manager.getMostRecentlyTriggeredCameraID()).toBeNull(); expect(manager.getTriggeredCameraIDs()).toEqual(new Set()); - manager.handleCameraEvent({ + await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', }); - manager.handleCameraEvent({ + await manager.handleCameraEvent({ cameraID: 'camera_2', type: 'new', }); @@ -513,7 +528,7 @@ describe('TriggersManager', () => { manager.getMostRecentlyTriggeredCameraID(), ); - manager.handleCameraEvent({ + await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end', }); @@ -521,6 +536,8 @@ describe('TriggersManager', () => { vi.setSystemTime(add(start, { seconds: 10 })); vi.runOnlyPendingTimers(); + await flushPromises(); + expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_2'])); expect(manager.getMostRecentlyTriggeredCameraID()).toBe('camera_2'); }); diff --git a/tests/card-controller/view-manager.test.ts b/tests/card-controller/view-manager.test.ts deleted file mode 100644 index ae911bb4..00000000 --- a/tests/card-controller/view-manager.test.ts +++ /dev/null @@ -1,1035 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { QueryType } from '../../src/camera-manager/types'; -import { ViewManager } from '../../src/card-controller/view-manager'; -import { FrigateCardView } from '../../src/config/types'; -import { executeMediaQueryForView } from '../../src/utils/media-to-view'; -import { EventMediaQueries } from '../../src/view/media-queries'; -import { MediaQueriesResults } from '../../src/view/media-queries-results'; -import { View } from '../../src/view/view'; -import { - createCameraConfig, - createCameraManager, - createCapabilities, - createCardAPI, - createConfig, - createHASS, - createStore, - createView, - generateViewMediaArray, -} from '../test-utils'; - -vi.mock('../../src/utils/media-to-view'); - -describe('ViewManager.setView', () => { - it('should set view', () => { - const api = createCardAPI(); - const manager = new ViewManager(api); - - const view = createView({ - view: 'live', - camera: 'camera', - displayMode: 'grid', - }); - manager.setView(view); - - expect(manager.getView()).toBe(view); - expect(manager.hasView()).toBeTruthy(); - expect(api.getMediaLoadedInfoManager().clear).toBeCalled(); - expect(api.getCardElementManager().scrollReset).toBeCalled(); - expect(api.getMessageManager().reset).toBeCalled(); - expect(api.getStyleManager().setExpandedMode).toBeCalled(); - expect(api.getConditionsManager()?.setState).toBeCalledWith({ - view: 'live', - camera: 'camera', - displayMode: 'grid', - }); - expect(api.getCardElementManager().update).toBeCalled(); - }); - - it('should set view with minor changes without media clearing or scroll', () => { - const api = createCardAPI(); - const manager = new ViewManager(api); - - const view_1 = createView({ - view: 'live', - camera: 'camera', - }); - manager.setView(view_1); - - vi.mocked(api.getMediaLoadedInfoManager().clear).mockClear(); - vi.mocked(api.getCardElementManager().scrollReset).mockClear(); - - const view_2 = createView({ - view: 'live', - camera: 'camera', - displayMode: 'single', - }); - - manager.setView(view_2); - - expect(manager.getView()).toBe(view_2); - - // The new view is neither a major media change, nor a different view name, - // so media clearing and scrolling should not happen. - expect(api.getMediaLoadedInfoManager().clear).not.toBeCalled(); - expect(api.getCardElementManager().scrollReset).not.toBeCalled(); - }); - - it('should set view with new context', () => { - const api = createCardAPI(); - const manager = new ViewManager(api); - const context = { live: { fetchThumbnails: false } }; - - // Setting context with no existing view does nothing. - manager.setViewWithMergedContext(context); - expect(manager.getView()).toBeNull(); - - const view = createView({ - view: 'live', - camera: 'camera', - }); - manager.setView(view); - manager.setViewWithMergedContext(context); - - expect(manager.getView()?.camera).toBe('camera'); - expect(manager.getView()?.view).toBe('live'); - expect(manager.getView()?.context).toEqual(context); - }); -}); - -describe('ViewManager.reset', () => { - it('should reset', () => { - const manager = new ViewManager(createCardAPI()); - - const view = createView(); - manager.setView(view); - manager.reset(); - - expect(manager.getView()).toBeNull(); - expect(manager.hasView()).toBeFalsy(); - }); -}); - -describe('ViewManager.setViewDefault', () => { - it('should set default view', () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera', - capabilities: createCapabilities({ live: true }), - }, - ]), - ); - - const manager = new ViewManager(api); - manager.setViewDefault(); - - expect(manager.getView()?.view).toBe('live'); - expect(manager.getView()?.camera).toBe('camera'); - }); - - it('should not set default view without config', () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); - - const manager = new ViewManager(api); - manager.setViewDefault(); - - expect(manager.getView()).toBeNull(); - }); - - it('should cycle camera when configured', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera_1', - capabilities: createCapabilities({ live: true }), - }, - { - cameraID: 'camera_2', - capabilities: createCapabilities({ live: true }), - }, - ]), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - default_cycle_camera: true, - }, - }), - ); - const manager = new ViewManager(api); - - manager.setViewDefault(); - expect(manager.getView()?.camera).toBe('camera_1'); - - manager.setViewDefault(); - expect(manager.getView()?.camera).toBe('camera_2'); - - manager.setViewDefault(); - expect(manager.getView()?.camera).toBe('camera_1'); - - // When a parameter is specified, it will not cycle. - manager.setViewDefault({ cameraID: 'camera_1' }); - expect(manager.getView()?.camera).toBe('camera_1'); - }); - - it('should respect parameters', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ live: true }), - }, - { - cameraID: 'camera.office', - capabilities: createCapabilities({ live: true }), - }, - ]), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - const manager = new ViewManager(api); - - manager.setViewDefault({ - cameraID: 'camera.office', - substream: 'camera.office_hd', - }); - expect(manager.getView()?.view).toBe('live'); - expect(manager.getView()?.camera).toBe('camera.office'); - expect(manager.getView()?.context?.live?.overrides).toEqual( - new Map([['camera.office', 'camera.office_hd']]), - ); - }); -}); - -describe('ViewManager.setViewByParameters', () => { - it('should set view by parameters specifying camera and view', () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ clips: true }), - }, - ]), - ); - vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createCapabilities({ - clips: true, - }), - ); - - const manager = new ViewManager(api); - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - viewName: 'clips', - }); - - expect(manager.getView()?.view).toBe('clips'); - expect(manager.getView()?.camera).toBe('camera.kitchen'); - }); - - it('should set view by parameters using existing view if unspecified', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ clips: true }), - }, - { - cameraID: 'camera.office', - capabilities: createCapabilities({ clips: true }), - }, - ]), - ); - vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createCapabilities({ - clips: true, - }), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - - const manager = new ViewManager(api); - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - viewName: 'clips', - }); - - manager.setViewByParameters({ - cameraID: 'camera.office', - }); - - expect(manager.getView()?.view).toBe('clips'); - expect(manager.getView()?.camera).toBe('camera.office'); - }); - - it('should set view by parameters using config as fallback', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ live: true }), - }, - ]), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - - const manager = new ViewManager(api); - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - // No prior view, and no specified view. This could happen during query - // string based initialization. - }); - - expect(manager.getView()?.view).toBe('live'); - expect(manager.getView()?.camera).toBe('camera.kitchen'); - }); - - it('should not set view by parameters without config', () => { - const manager = new ViewManager(createCardAPI()); - - manager.setViewByParameters({ - viewName: 'live', - }); - - expect(manager.getView()).toBeNull(); - }); - - describe('should handle unsupported view', () => { - it('without camera without failsafe', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ snapshots: false }), - }, - ]), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - - const manager = new ViewManager(api); - manager.setViewByParameters({ - // Since no camera is specified, and no camera supports the capabilities - // necessary for this view, the view will be null. - viewName: 'snapshots', - }); - - expect(manager.getView()).toBeNull(); - }); - - it('without camera with failsafe', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ snapshots: false }), - }, - { - cameraID: 'camera.office', - // No capabilities. - capabilities: null, - }, - ]), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - - const manager = new ViewManager(api); - manager.setViewByParameters({ - // Since no camera is specified, and no camera supports the capabilities - // necessary for this view, and since failSafe is specified, an error - // will be shown. - viewName: 'snapshots', - failSafe: true, - }); - - expect(manager.getView()).toBeNull(); - expect(manager.hasView()).toBeFalsy(); - expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith( - expect.objectContaining({ - type: 'error', - message: 'No cameras support this view', - context: { - view: 'snapshots', - cameras_capabilities: { - 'camera.kitchen': { - 'favorite-events': false, - 'favorite-recordings': false, - clips: false, - live: false, - recordings: false, - seek: false, - snapshots: false, - }, - }, - }, - }), - ); - }); - - it('with camera without failsafe', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ snapshots: false }), - }, - ]), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createCapabilities({ - snapshots: false, - }), - ); - - const manager = new ViewManager(api); - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - viewName: 'snapshots', - }); - - expect(manager.hasView()).toBeFalsy(); - expect(manager.getView()).toBeNull(); - }); - - it('with camera with failsafe', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ snapshots: false, live: true }), - }, - ]), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createCapabilities({ - snapshots: false, - live: true, - }), - ); - - const manager = new ViewManager(api); - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - viewName: 'snapshots', - failSafe: true, - }); - - expect(manager.hasView()).toBeTruthy(); - expect(manager.getView()?.view).toBe('live'); - }); - - it('with camera with failsafe when live unsupported', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ snapshots: false, live: false }), - }, - ]), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createCapabilities({ - snapshots: false, - live: false, - }), - ); - - const manager = new ViewManager(api); - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - viewName: 'snapshots', - failSafe: true, - }); - - expect(manager.hasView()).toBeFalsy(); - expect(manager.getView()).toBeNull(); - expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith( - expect.objectContaining({ - type: 'error', - message: 'The selected camera does not support this view', - context: { - view: 'snapshots', - camera: 'camera.kitchen', - camera_capabilities: { - 'favorite-events': false, - 'favorite-recordings': false, - clips: false, - live: false, - recordings: false, - seek: false, - snapshots: false, - }, - }, - }), - ); - }); - }); - - describe('should set view by parameters and respect display mode in config for view', () => { - it.each([ - ['media' as const], - ['clip' as const], - ['recording' as const], - ['snapshot' as const], - ['live' as const], - ])('%s', (viewName: FrigateCardView) => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - live: true, - clips: true, - recordings: true, - snapshots: true, - }), - }, - ]), - ); - vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createCapabilities({ - live: true, - clips: true, - recordings: true, - snapshots: true, - }), - ); - - vi.mocked(api.getConfigManager()).getConfig.mockReturnValue( - createConfig({ - media_viewer: { - display: { - mode: 'grid', - }, - }, - live: { - display: { - mode: 'grid', - }, - }, - }), - ); - const manager = new ViewManager(api); - - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - viewName: viewName, - }); - - expect(manager.getView()?.displayMode).toBe('grid'); - }); - }); - - describe('should set view by parameters and leave display mode unset for view', () => { - it.each([ - ['media' as const], - ['clip' as const], - ['recording' as const], - ['snapshot' as const], - ['live' as const], - ])('%s', (viewName: FrigateCardView) => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - live: true, - clips: true, - recordings: true, - snapshots: true, - }), - }, - ]), - ); - vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createCapabilities({ - live: true, - clips: true, - recordings: true, - snapshots: true, - }), - ); - - const manager = new ViewManager(api); - - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - viewName: viewName, - }); - - expect(manager.getView()?.displayMode).toBe('single'); - }); - }); -}); - -// @vitest-environment jsdom -describe('ViewManager.setViewWithNewDisplayMode', () => { - it('should set display mode', async () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - - const manager = new ViewManager(api); - manager.setView(createView()); - - await manager.setViewWithNewDisplayMode('grid'); - - expect(manager.getView()?.displayMode).toBe('grid'); - }); - - it('should not set display mode without view', async () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - const manager = new ViewManager(api); - - manager.setViewWithNewDisplayMode('grid'); - - expect(manager.getView()).toBeNull(); - }); - - it('should set display mode to grid and create new query', async () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - { - cameraID: 'camera.office', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - ]), - ); - - const hass = createHASS(); - vi.mocked(api.getHASSManager()).getHASS.mockReturnValue(hass); - - const mediaArray = generateViewMediaArray({ count: 5 }); - vi.mocked(executeMediaQueryForView).mockResolvedValue( - new View({ - view: 'clip', - camera: 'camera.kitchen', - queryResults: new MediaQueriesResults({ results: mediaArray }), - }), - ); - - const manager = new ViewManager(api); - const query = new EventMediaQueries([ - { type: QueryType.Event, cameraIDs: new Set(['camera_1']), hasClip: true }, - ]); - - manager.setView( - createView({ - camera: 'camera_1', - view: 'clip', - query: query, - }), - ); - - await manager.setViewWithNewDisplayMode('grid'); - - expect(manager.getView()?.queryResults?.getResults()).toBe(mediaArray); - }); - - it('should set display mode to single and create new query', async () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - { - cameraID: 'camera.office', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - ]), - ); - - const hass = createHASS(); - vi.mocked(api.getHASSManager()).getHASS.mockReturnValue(hass); - - const mediaArray = generateViewMediaArray({ count: 5 }); - vi.mocked(executeMediaQueryForView).mockResolvedValue( - new View({ - view: 'clip', - camera: 'camera.kitchen', - queryResults: new MediaQueriesResults({ results: mediaArray }), - }), - ); - - const manager = new ViewManager(api); - const query = new EventMediaQueries([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen', 'camera.office']), - hasClip: true, - }, - ]); - - manager.setView( - createView({ - view: 'clip', - camera: 'camera.office', - query: query, - }), - ); - - await manager.setViewWithNewDisplayMode('single'); - - expect(manager.getView()?.queryResults?.getResults()).toBe(mediaArray); - }); - - it('should set display mode to single and handle failed new query', async () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - { - cameraID: 'camera.office', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - ]), - ); - - const manager = new ViewManager(api); - const query = new EventMediaQueries([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen', 'camera.office']), - hasClip: true, - }, - ]); - - const originalView = createView({ - view: 'clip', - camera: 'camera.office', - query: query, - }); - manager.setView(originalView); - - // Query execution fails / returns null. - vi.mocked(executeMediaQueryForView).mockRejectedValue(null); - - await manager.setViewWithNewDisplayMode('single'); - - expect(manager.getView()).toBe(originalView); - }); - - it('should set display mode and handle empty new query results', async () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - { - cameraID: 'camera.office', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - ]), - ); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - - const manager = new ViewManager(api); - - const query = new EventMediaQueries([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - hasClip: true, - }, - ]); - const originalView = createView({ - view: 'clip', - camera: 'camera.office', - query: query, - }); - manager.setView(originalView); - - await manager.setViewWithNewDisplayMode('grid'); - - vi.mocked(executeMediaQueryForView).mockResolvedValue(null); - - // Empty queries will not be executed, so view will not be changed. - expect(manager.getView()?.displayMode).toBeNull(); - }); -}); - -describe('ViewManager.setViewWithSubstream', () => { - it('should set new equal view with no dependencies', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - }, - ]), - ); - const manager = new ViewManager(api); - const view = createView({ - view: 'live', - camera: 'camera', - }); - - manager.setView(view); - manager.setViewWithSubstream(); - - expect(manager.getView()?.camera).toBe(view.camera); - expect(manager.getView()?.view).toBe(view.view); - expect(manager.getView()?.context).toEqual(view.context); - }); - - it('should set new view with next substream', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - config: createCameraConfig({ - dependencies: { - cameras: ['camera.kitchen_hd'], - }, - }), - }, - { - cameraID: 'camera.kitchen_hd', - }, - ]), - ); - const manager = new ViewManager(api); - const view = createView({ - view: 'live', - camera: 'camera.kitchen', - }); - - manager.setView(view); - manager.setViewWithSubstream(); - - expect(manager.getView()?.context?.live?.overrides).toEqual( - new Map([['camera.kitchen', 'camera.kitchen_hd']]), - ); - }); - - it('should set new view with next substream when view has invalid substream', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - config: createCameraConfig({ - dependencies: { - cameras: ['camera.kitchen_hd'], - }, - }), - }, - { - cameraID: 'camera.kitchen_hd', - }, - ]), - ); - const manager = new ViewManager(api); - const view = createView({ - view: 'live', - camera: 'camera.kitchen', - context: { - live: { - overrides: new Map([['camera.kitchen', 'camera-that-does-not-exist']]), - }, - }, - }); - - manager.setView(view); - manager.setViewWithSubstream(); - - expect(manager.getView()?.context?.live?.overrides).toEqual( - new Map([['camera.kitchen', 'camera.kitchen']]), - ); - }); - - it('should set new view with selected substream', () => { - const view = createView({ - view: 'live', - camera: 'camera', - }); - - const manager = new ViewManager(createCardAPI()); - manager.setView(view); - manager.setViewWithSubstream('substream'); - - expect(manager.getView()?.context?.live?.overrides).toEqual( - new Map([['camera', 'substream']]), - ); - }); - - it('should not set view with next substream without an existing view', () => { - const manager = new ViewManager(createCardAPI()); - manager.setViewWithSubstream(); - expect(manager.getView()).toBeNull(); - }); - - it('should not set view with selected substream without an existing view', () => { - const manager = new ViewManager(createCardAPI()); - manager.setViewWithSubstream('substream'); - expect(manager.getView()).toBeNull(); - }); - - it('should not set view without substream without an existing view', () => { - const manager = new ViewManager(createCardAPI()); - manager.setViewWithoutSubstream(); - expect(manager.getView()).toBeNull(); - }); - - it('should set new view without substream', () => { - const view = createView({ - view: 'live', - camera: 'camera', - context: { - live: { - overrides: new Map([['camera', 'camera']]), - }, - }, - }); - - const manager = new ViewManager(createCardAPI()); - manager.setView(view); - manager.setViewWithoutSubstream(); - - expect(manager.getView()?.context?.live?.overrides).toEqual(new Map()); - }); - - it('should set new view without substream', () => { - const view = createView({ - view: 'live', - camera: 'camera', - context: { - live: { - overrides: new Map([['camera-2', 'camera-3']]), - }, - }, - }); - - const manager = new ViewManager(createCardAPI()); - manager.setView(view); - manager.setViewWithoutSubstream(); - - expect(manager.getView()?.context?.live?.overrides).toEqual( - view.context?.live?.overrides, - ); - }); -}); - -describe('ViewManager.isViewSupportedByCamera', () => { - it.each([ - ['live' as const, false], - ['image' as const, true], - ['diagnostics' as const, true], - ['clip' as const, false], - ['clips' as const, false], - ['snapshot' as const, false], - ['snapshots' as const, false], - ['recording' as const, false], - ['recordings' as const, false], - ['timeline' as const, false], - ['media' as const, false], - ])('%s', (viewName: FrigateCardView, expected: boolean) => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - live: false, - 'favorite-events': false, - 'favorite-recordings': false, - seek: false, - clips: false, - recordings: false, - snapshots: false, - }), - }, - ]), - ); - const manager = new ViewManager(api); - - expect(manager.isViewSupportedByCamera('camera', viewName)).toBe(expected); - }); -}); diff --git a/tests/card-controller/view/factory.test.ts b/tests/card-controller/view/factory.test.ts new file mode 100644 index 00000000..7e84115c --- /dev/null +++ b/tests/card-controller/view/factory.test.ts @@ -0,0 +1,751 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { ViewFactory } from '../../../src/card-controller/view/factory'; +import { QueryExecutor } from '../../../src/card-controller/view/query-executor'; +import { ViewModifier } from '../../../src/card-controller/view/types'; +import { FrigateCardView, ViewDisplayMode } from '../../../src/config/types'; +import { + EventMediaQueries, + RecordingMediaQueries, +} from '../../../src/view/media-queries'; +import { MediaQueriesResults } from '../../../src/view/media-queries-results'; +import { View } from '../../../src/view/view'; +import { + createCameraManager, + createCapabilities, + createCardAPI, + createConfig, + createStore, +} from '../../test-utils'; +import { createPopulatedAPI } from './test-utils'; + +describe('getViewDefault', () => { + it('should return null without config', () => { + const factory = new ViewFactory(createCardAPI()); + expect(factory.getViewDefault()).toBeNull(); + }); + + it('should create view', () => { + const factory = new ViewFactory(createPopulatedAPI()); + const view = factory.getViewDefault(); + + expect(view?.is('live')).toBeTruthy(); + expect(view?.camera).toBe('camera.office'); + }); + + it('should cycle camera when configured', () => { + const api = createPopulatedAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + default_cycle_camera: true, + }, + }), + ); + + const factory = new ViewFactory(api); + + let view = factory.getViewDefault(); + expect(view?.camera).toBe('camera.office'); + + view = factory.getViewDefault({ baseView: view }); + expect(view?.camera).toBe('camera.kitchen'); + + view = factory.getViewDefault({ baseView: view }); + expect(view?.camera).toBe('camera.office'); + + // When a parameter is specified, it will not cycle. + view = factory.getViewDefault({ + params: { camera: 'camera.office' }, + baseView: view, + }); + expect(view?.camera).toBe('camera.office'); + }); + + it('should respect parameters', () => { + const factory = new ViewFactory(createPopulatedAPI()); + const view = factory.getViewDefault({ + params: { + camera: 'camera.office', + }, + }); + + expect(view?.is('live')).toBeTruthy(); + expect(view?.camera).toBe('camera.office'); + }); +}); + +describe('getViewByParameters', () => { + it('should get view by parameters specifying camera and view', () => { + const api = createPopulatedAPI(); + vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( + createCapabilities({ + clips: true, + }), + ); + + const factory = new ViewFactory(api); + const view = factory.getViewByParameters({ + params: { + camera: 'camera.kitchen', + view: 'clips', + }, + }); + + expect(view?.is('clips')).toBeTruthy(); + expect(view?.camera).toBe('camera.kitchen'); + }); + + it('should get view by parameters using base view if unspecified', () => { + const api = createPopulatedAPI(); + vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( + createCapabilities({ + clips: true, + }), + ); + + const factory = new ViewFactory(api); + const baseView = new View({ + camera: 'camera.kitchen', + view: 'clips', + }); + + const view = factory.getViewByParameters({ + baseView: baseView, + params: { + camera: 'camera.office', + }, + }); + + expect(view?.view).toBe('clips'); + expect(view?.camera).toBe('camera.office'); + }); + + it('should set view by parameters using config as fallback', () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + capabilities: createCapabilities({ live: true }), + }, + ]), + ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + + const factory = new ViewFactory(api); + const view = factory.getViewByParameters({ + params: { + camera: 'camera.kitchen', + + // No prior view, and no specified view. This could happen during query + // string based initialization. + }, + }); + + expect(view?.is('live')).toBeTruthy(); + expect(view?.camera).toBe('camera.kitchen'); + }); + + it('should not set view by parameters without config', () => { + const factory = new ViewFactory(createCardAPI()); + + const view = factory.getViewByParameters({ + params: { + view: 'live', + }, + }); + + expect(view).toBeNull(); + }); + + it('should throw without camera and without failsafe', () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + capabilities: createCapabilities({ snapshots: false }), + }, + { + cameraID: 'camera.office', + // No capabilities. + capabilities: null, + }, + ]), + ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + + const factory = new ViewFactory(api); + expect(() => + factory.getViewByParameters({ + // Since no camera is specified, and no camera supports the capabilities + // necessary for this view, the view will be null. + params: { + view: 'snapshots', + }, + }), + ).toThrowError(/No cameras support this view/); + }); + + describe('should handle unsupported view', () => { + it('should throw without failsafe', () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + capabilities: createCapabilities({ snapshots: false }), + }, + ]), + ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( + createCapabilities({ + snapshots: false, + }), + ); + + const factory = new ViewFactory(api); + expect(() => + factory.getViewByParameters({ + params: { + camera: 'camera.kitchen', + view: 'snapshots', + }, + }), + ).toThrowError(/The selected camera does not support this view/); + }); + + it('should choose live view with failsafe', () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + capabilities: createCapabilities({ live: true, snapshots: false }), + }, + ]), + ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( + createCapabilities({ + live: true, + snapshots: false, + }), + ); + + const factory = new ViewFactory(api); + const view = factory.getViewByParameters({ + params: { + camera: 'camera.kitchen', + view: 'snapshots', + }, + failSafe: true, + }); + expect(view?.is('live')).toBeTruthy(); + }); + }); + + it('should call modifiers', () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ live: true }), + }, + ]), + ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + + const modifyCallback = vi.fn(); + class TestViewModifier implements ViewModifier { + modify = modifyCallback; + } + + const view = new View({ + view: 'live', + camera: 'camera.office', + }); + const factory = new ViewFactory(api); + const modifiedView = factory.getViewByParameters({ + baseView: view, + modifiers: [new TestViewModifier()], + }); + + expect(modifiedView?.is('live')).toBeTruthy(); + expect(modifiedView?.camera).toBe('camera.office'); + expect(view).not.toBe(modifiedView); + expect(modifyCallback).toHaveBeenCalledWith(modifiedView); + }); + + describe('should get correct default display mode', () => { + describe.each([['single' as const], ['grid' as const]])( + '%s', + (displayMode: ViewDisplayMode) => { + it.each([ + ['media' as const], + ['clip' as const], + ['recording' as const], + ['snapshot' as const], + ['live' as const], + ])('%s', (viewName: FrigateCardView) => { + const api = createPopulatedAPI({ + media_viewer: { + display: { + mode: displayMode, + }, + }, + live: { + display: { + mode: displayMode, + }, + }, + }); + + const factory = new ViewFactory(api); + expect( + factory.getViewByParameters({ + params: { + view: viewName, + }, + })?.displayMode, + ).toBe(displayMode); + }); + }, + ); + }); +}); + +describe('getViewByParametersWithNewQuery', () => { + it('should not execute query without config', async () => { + const factory = new ViewFactory(createCardAPI()); + expect(await factory.getViewByParametersWithNewQuery()).toBeNull(); + }); + + describe('with a live view', () => { + beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-07-21T13:22:06Z')); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should set timeline window', async () => { + const executor = mock(); + const factory = new ViewFactory(createPopulatedAPI(), executor); + const view = await factory.getViewByParametersWithNewQuery({ + params: { + view: 'live', + }, + }); + + expect(view?.context).toEqual({ + timeline: { + window: { + start: new Date('2024-07-21T12:22:06.000Z'), + end: new Date('2024-07-21T13:22:06.000Z'), + }, + }, + }); + }); + + it('should not fetch anything if configured for no thumbnails', async () => { + const executor = mock(); + const factory = new ViewFactory( + createPopulatedAPI({ + live: { + controls: { + thumbnails: { + mode: 'none' as const, + }, + }, + }, + }), + executor, + ); + const view = await factory.getViewByParametersWithNewQuery({ + params: { + view: 'live', + }, + }); + + expect(view?.query).toBeNull(); + expect(view?.queryResults).toBeNull(); + expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled(); + expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); + }); + + it('should fetch events', async () => { + const executor = mock(); + const query = new EventMediaQueries(); + const queryResults = new MediaQueriesResults(); + + executor.executeDefaultEventQuery.mockResolvedValue({ + query: query, + queryResults: queryResults, + }); + + const factory = new ViewFactory(createPopulatedAPI(), executor); + const view = await factory.getViewByParametersWithNewQuery({ + params: { + view: 'live', + }, + }); + + expect(view?.query).toBe(query); + expect(view?.queryResults).toBe(queryResults); + expect(executor.executeDefaultEventQuery).toBeCalledWith({ + cameraID: 'camera.office', + eventsMediaType: 'all', + executorOptions: { + useCache: false, + }, + }); + expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); + }); + + it('should fetch recordings', async () => { + const executor = mock(); + const query = new RecordingMediaQueries(); + const queryResults = new MediaQueriesResults(); + + executor.executeDefaultRecordingQuery.mockResolvedValue({ + query: query, + queryResults: queryResults, + }); + + const factory = new ViewFactory( + createPopulatedAPI({ + live: { + controls: { + thumbnails: { + media_type: 'recordings', + }, + }, + }, + }), + executor, + ); + const view = await factory.getViewByParametersWithNewQuery({ + params: { + view: 'live', + }, + }); + + expect(view?.query).toBe(query); + expect(view?.queryResults).toBe(queryResults); + expect(executor.executeDefaultEventQuery).not.toBeCalled(); + expect(executor.executeDefaultRecordingQuery).toBeCalledWith({ + cameraID: 'camera.office', + executorOptions: { + useCache: false, + }, + }); + }); + }); + + describe('with a media view', () => { + it('should do nothing with same camera', async () => { + const executor = mock(); + const factory = new ViewFactory(createPopulatedAPI(), executor); + const baseView = new View({ + view: 'media', + camera: 'camera.office', + }); + const view = await factory.getViewByParametersWithNewQuery({ + baseView: baseView, + params: { + view: 'media', + }, + }); + + expect(view?.query).toBeNull(); + expect(view?.queryResults).toBeNull(); + expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled(); + expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); + }); + + it('should fetch clips with different camera', async () => { + const executor = mock(); + const query = new EventMediaQueries(); + const queryResults = new MediaQueriesResults(); + + executor.executeDefaultEventQuery.mockResolvedValue({ + query: query, + queryResults: queryResults, + }); + + const factory = new ViewFactory(createPopulatedAPI(), executor); + const baseView = new View({ + view: 'media', + camera: 'camera.office', + }); + const view = await factory.getViewByParametersWithNewQuery({ + baseView: baseView, + params: { + view: 'media', + camera: 'camera.kitchen', + }, + }); + + expect(view?.query).toBe(query); + expect(view?.queryResults).toBe(queryResults); + expect(executor.executeDefaultEventQuery).toBeCalledWith({ + cameraID: 'camera.kitchen', + eventsMediaType: 'clips', + executorOptions: { + useCache: false, + }, + }); + expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); + }); + }); + + describe('with an events-based view', () => { + it.each([ + ['clip' as const, 'clips' as const], + ['clips' as const, 'clips' as const], + ['snapshot' as const, 'snapshots' as const], + ['snapshots' as const, 'snapshots' as const], + ])( + '%s', + async (viewName: FrigateCardView, eventsMediaType: 'clips' | 'snapshots') => { + const executor = mock(); + const query = new EventMediaQueries(); + const queryResults = new MediaQueriesResults(); + + executor.executeDefaultEventQuery.mockResolvedValue({ + query: query, + queryResults: queryResults, + }); + + const factory = new ViewFactory(createPopulatedAPI(), executor); + const view = await factory.getViewByParametersWithNewQuery({ + params: { + view: viewName, + }, + }); + + expect(view?.query).toBe(query); + expect(view?.queryResults).toBe(queryResults); + expect(executor.executeDefaultEventQuery).toBeCalledWith({ + cameraID: 'camera.office', + eventsMediaType: eventsMediaType, + executorOptions: { + useCache: false, + }, + }); + expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); + }, + ); + }); + + describe('with an recordings-based view', () => { + it.each([['recording' as const], ['recordings' as const]])( + '%s', + async (viewName: FrigateCardView) => { + const executor = mock(); + const query = new RecordingMediaQueries(); + const queryResults = new MediaQueriesResults(); + + executor.executeDefaultRecordingQuery.mockResolvedValue({ + query: query, + queryResults: queryResults, + }); + + const factory = new ViewFactory(createPopulatedAPI(), executor); + const view = await factory.getViewByParametersWithNewQuery({ + params: { + view: viewName, + }, + }); + + expect(view?.query).toBe(query); + expect(view?.queryResults).toBe(queryResults); + expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled(); + expect(executor.executeDefaultRecordingQuery).toBeCalledWith({ + cameraID: 'camera.office', + executorOptions: { + useCache: false, + }, + }); + }, + ); + }); + + describe('with an media viewer view', () => { + it('hould not fetch anything if configured for no thumbnails', async () => { + const executor = mock(); + const factory = new ViewFactory( + createPopulatedAPI({ + media_viewer: { + controls: { + thumbnails: { + mode: 'none' as const, + }, + }, + }, + }), + executor, + ); + const view = await factory.getViewByParametersWithNewQuery({ + params: { + view: 'clip', + }, + }); + + expect(view?.query).toBeNull(); + expect(view?.queryResults).toBeNull(); + expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled(); + expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); + }); + }); + + describe('when changing to gallery from the media viewer', () => { + it('should adopt query and results', async () => { + const executor = mock(); + const factory = new ViewFactory(createPopulatedAPI(), executor); + + const baseView = new View({ + view: 'media', + camera: 'camera.office', + query: new EventMediaQueries(), + queryResults: new MediaQueriesResults(), + }); + + const view = await factory.getViewByParametersWithNewQuery({ + baseView: baseView, + params: { + view: 'clips', + }, + }); + + expect(view?.query).toBe(baseView.query); + expect(view?.queryResults).toBe(baseView.queryResults); + expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled(); + expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); + }); + }); + + describe('when set or remove seek time', () => { + it('should set seek time when results are selected based on time', async () => { + const now = new Date(); + const executor = mock(); + const factory = new ViewFactory(createPopulatedAPI(), executor); + + const view = await factory.getViewByParametersWithNewQuery({ + params: { + view: 'clips', + }, + queryExecutorOptions: { + selectResult: { + time: { + time: now, + }, + }, + }, + }); + + expect(view?.context).toEqual({ + mediaViewer: { + seek: now, + }, + }); + }); + + it('should remove seek time when results are not selected based on time', async () => { + const executor = mock(); + const factory = new ViewFactory(createPopulatedAPI(), executor); + + const view = await factory.getViewByParametersWithNewQuery({ + baseView: new View({ + view: 'clips', + camera: 'camera.office', + context: { + mediaViewer: { + seek: new Date(), + }, + }, + }), + params: { + view: 'clips', + }, + }); + + expect(view?.context?.mediaViewer?.seek).toBeUndefined(); + }); + }); +}); + +describe('getViewDefaultWithNewQuery', () => { + it('should fetch events', async () => { + const executor = mock(); + const query = new EventMediaQueries(); + const queryResults = new MediaQueriesResults(); + + executor.executeDefaultEventQuery.mockResolvedValue({ + query: query, + queryResults: queryResults, + }); + + const factory = new ViewFactory(createPopulatedAPI(), executor); + const view = await factory.getViewDefaultWithNewQuery(); + + expect(view?.query).toBe(query); + expect(view?.queryResults).toBe(queryResults); + expect(executor.executeDefaultEventQuery).toBeCalledWith({ + cameraID: 'camera.office', + eventsMediaType: 'all', + executorOptions: { + useCache: false, + }, + }); + expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); + }); +}); + +describe('getViewByParametersWithExistingQuery', () => { + it('should not execute anything when query is absent', async () => { + const executor = mock(); + const factory = new ViewFactory(createPopulatedAPI(), executor); + const view = await factory.getViewByParametersWithExistingQuery({ + params: { + view: 'live', + camera: 'camera.office', + }, + }); + + expect(view?.query).toBeNull(); + expect(view?.queryResults).toBeNull(); + expect(executor.executeDefaultEventQuery).not.toBeCalled(); + expect(executor.executeDefaultRecordingQuery).not.toBeCalled(); + }); + + it('should set query results', async () => { + const executor = mock(); + const queryResults = new MediaQueriesResults(); + executor.execute.mockResolvedValue(queryResults); + + const factory = new ViewFactory(createPopulatedAPI(), executor); + const query = new RecordingMediaQueries(); + const view = await factory.getViewByParametersWithExistingQuery({ + params: { + view: 'live', + camera: 'camera.office', + query: query, + }, + }); + + expect(view?.query).toBe(query); + expect(view?.queryResults).toBe(queryResults); + }); +}); diff --git a/tests/card-controller/view/modifiers/merge-context.test.ts b/tests/card-controller/view/modifiers/merge-context.test.ts new file mode 100644 index 00000000..43e91f41 --- /dev/null +++ b/tests/card-controller/view/modifiers/merge-context.test.ts @@ -0,0 +1,28 @@ +import { ViewContext } from 'view'; +import { expect, it } from 'vitest'; +import { MergeContextViewModifier } from '../../../../src/card-controller/view/modifiers/merge-context'; +import { createView } from '../../../test-utils'; + +it('should merge context', () => { + const context: ViewContext = { + timeline: { + window: { + start: new Date(), + end: new Date(), + }, + }, + }; + const modifier = new MergeContextViewModifier(context); + + const view = createView({ + view: 'live', + camera: 'camera', + displayMode: 'grid', + }); + + expect(view.context).toBeNull(); + + modifier.modify(view); + + expect(view.context).toEqual(context); +}); diff --git a/tests/card-controller/view/modifiers/remove-context-property.test.ts b/tests/card-controller/view/modifiers/remove-context-property.test.ts new file mode 100644 index 00000000..ba4f5241 --- /dev/null +++ b/tests/card-controller/view/modifiers/remove-context-property.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from 'vitest'; +import { createView } from '../../../test-utils'; +import { RemoveContextPropertyViewModifier } from '../../../../src/card-controller/view/modifiers/remove-context-property'; + +it('should remove context property', () => { + const modifier = new RemoveContextPropertyViewModifier('timeline', 'window'); + + const view = createView({ + view: 'live', + camera: 'camera', + displayMode: 'grid', + context: { + timeline: { + window: { + start: new Date(), + end: new Date(), + }, + }, + }, + }); + + modifier.modify(view); + + expect(view.context).toEqual({ timeline: {} }); +}); diff --git a/tests/card-controller/view/modifiers/remove-context.test.ts b/tests/card-controller/view/modifiers/remove-context.test.ts new file mode 100644 index 00000000..28795272 --- /dev/null +++ b/tests/card-controller/view/modifiers/remove-context.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'vitest'; +import { createView } from '../../../test-utils'; +import { RemoveContextViewModifier } from '../../../../src/card-controller/view/modifiers/remove-context'; + +it('should remove context property', () => { + const modifier = new RemoveContextViewModifier(['timeline']); + + const view = createView({ + view: 'live', + camera: 'camera', + displayMode: 'grid', + context: { + timeline: { + window: { + start: new Date(), + end: new Date(), + }, + }, + }, + }); + + modifier.modify(view); + + expect(view.context).toEqual({}); +}); + \ No newline at end of file diff --git a/tests/card-controller/view/modifiers/substream-off.test.ts b/tests/card-controller/view/modifiers/substream-off.test.ts new file mode 100644 index 00000000..d58d0fbf --- /dev/null +++ b/tests/card-controller/view/modifiers/substream-off.test.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'vitest'; +import { createView } from '../../../test-utils'; +import { SubstreamOffViewModifier } from '../../../../src/card-controller/view/modifiers/substream-off'; +import { hasSubstream, setSubstream } from '../../../../src/utils/substream'; + +it('should turn off substream', () => { + const view = createView({ + view: 'live', + camera: 'camera', + displayMode: 'grid', + }); + + setSubstream(view, 'substream'); + expect(hasSubstream(view)).toBe(true); + + const modifier = new SubstreamOffViewModifier(); + modifier.modify(view); + + expect(hasSubstream(view)).toBe(false); +}); diff --git a/tests/card-controller/view/modifiers/substream-on.test.ts b/tests/card-controller/view/modifiers/substream-on.test.ts new file mode 100644 index 00000000..cbba3e27 --- /dev/null +++ b/tests/card-controller/view/modifiers/substream-on.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CardController } from '../../../../src/card-controller/controller'; +import { SubstreamOnViewModifier } from '../../../../src/card-controller/view/modifiers/substream-on'; +import { RawFrigateCardConfig } from '../../../../src/config/types'; +import { getStreamCameraID, hasSubstream, setSubstream } from '../../../../src/utils/substream'; +import { + createCameraConfig, + createCameraManager, + createCapabilities, + createCardAPI, + createConfig, + createStore, + createView, +} from '../../../test-utils'; + +const createAPIWithSubstreams = (config?: RawFrigateCardConfig): CardController => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ + live: true, + substream: true, + }), + config: createCameraConfig({ + dependencies: { + all_cameras: true, + }, + }), + }, + { + cameraID: 'camera.kitchen', + capabilities: createCapabilities({ + substream: true, + }), + }, + ]), + ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config)); + return api; +}; + +describe('should turn on substream', () => { + it('substream available', () => { + const view = createView({ + view: 'live', + camera: 'camera.office', + }); + + expect(hasSubstream(view)).toBe(false); + + const api = createAPIWithSubstreams(); + + const modifier = new SubstreamOnViewModifier(api); + modifier.modify(view); + + expect(hasSubstream(view)).toBe(true); + expect(getStreamCameraID(view)).toBe('camera.kitchen'); + + modifier.modify(view); + + expect(hasSubstream(view)).toBe(false); + expect(getStreamCameraID(view)).toBe('camera.office'); + }); + + it('malformed substream', () => { + const view = createView({ + view: 'live', + camera: 'camera.office', + }); + + const api = createAPIWithSubstreams(); + + setSubstream(view, 'NOT_A_REAL_CAMERA'); + + const modifier = new SubstreamOnViewModifier(api); + modifier.modify(view); + + expect(hasSubstream(view)).toBe(false); + expect(getStreamCameraID(view)).toBe('camera.office'); + }); + + it('substream unavailable', () => { + const view = createView({ + view: 'live', + camera: 'camera.office', + }); + + expect(hasSubstream(view)).toBe(false); + + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + + const modifier = new SubstreamOnViewModifier(api); + modifier.modify(view); + + expect(hasSubstream(view)).toBe(false); + }); +}); diff --git a/tests/card-controller/view/modifiers/substream-select.test.ts b/tests/card-controller/view/modifiers/substream-select.test.ts new file mode 100644 index 00000000..7c1a4455 --- /dev/null +++ b/tests/card-controller/view/modifiers/substream-select.test.ts @@ -0,0 +1,19 @@ +import { expect, it } from 'vitest'; +import { SubstreamSelectViewModifier } from '../../../../src/card-controller/view/modifiers/substream-select'; +import { getStreamCameraID, hasSubstream } from '../../../../src/utils/substream'; +import { createView } from '../../../test-utils'; + +it('should select substream', () => { + const view = createView({ + view: 'live', + camera: 'camera.office', + }); + + expect(hasSubstream(view)).toBe(false); + + const modifier = new SubstreamSelectViewModifier('substream'); + modifier.modify(view); + + expect(hasSubstream(view)).toBe(true); + expect(getStreamCameraID(view)).toBe('substream'); +}); diff --git a/tests/card-controller/view/query-executor.test.ts b/tests/card-controller/view/query-executor.test.ts new file mode 100644 index 00000000..61355da4 --- /dev/null +++ b/tests/card-controller/view/query-executor.test.ts @@ -0,0 +1,360 @@ +import { add } from 'date-fns'; +import { describe, expect, it, vi } from 'vitest'; +import { QueryType } from '../../../src/camera-manager/types'; +import { QueryExecutor } from '../../../src/card-controller/view/query-executor'; +import { ClipsOrSnapshotsOrAll } from '../../../src/types'; +import { EventMediaQueries } from '../../../src/view/media-queries'; +import { + TestViewMedia, + createCameraManager, + createCardAPI, + createStore, + generateViewMediaArray, +} from '../../test-utils'; +import { createPopulatedAPI } from './test-utils'; + +describe('executeDefaultEventQuery', () => { + it('should return null without cameras', async () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue(createStore()); + + const executor = new QueryExecutor(api); + expect( + await executor.executeDefaultEventQuery({ + cameraID: 'camera.office', + }), + ).toBeNull(); + }); + + it('should return null without queries', async () => { + const api = createPopulatedAPI(); + vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue(null); + + const executor = new QueryExecutor(api); + expect( + await executor.executeDefaultEventQuery({ + cameraID: 'camera.office', + }), + ).toBeNull(); + }); + + describe('should return query results for specified camera', async () => { + it.each([['all' as const], ['clips' as const], ['snapshots' as const], [undefined]])( + '%s', + async (mediaType?: ClipsOrSnapshotsOrAll) => { + const api = createPopulatedAPI(); + const media = generateViewMediaArray(); + const rawQueries = [ + { type: QueryType.Event as const, cameraIDs: new Set(['camera.office']) }, + ]; + vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue( + rawQueries, + ); + vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); + + const executor = new QueryExecutor(api); + const results = await executor.executeDefaultEventQuery({ + cameraID: 'camera.office', + eventsMediaType: mediaType, + }); + + expect(results?.query.getQueries()).toEqual(rawQueries); + expect(results?.queryResults.getResults()).toEqual(media); + expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith( + new Set(['camera.office']), + { + limit: 50, + ...(mediaType === 'clips' && { hasClip: true }), + ...(mediaType === 'snapshots' && { hasSnapshot: true }), + }, + ); + }, + ); + }); + + it('should return query results for all cameras', async () => { + const api = createPopulatedAPI(); + const media = generateViewMediaArray(); + const rawQueries = [ + { + type: QueryType.Event as const, + cameraIDs: new Set(['camera.office', 'camera.kitchen']), + }, + ]; + vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue( + rawQueries, + ); + vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); + + const executor = new QueryExecutor(api); + const results = await executor.executeDefaultEventQuery(); + + expect(results?.query.getQueries()).toEqual(rawQueries); + expect(results?.queryResults.getResults()).toEqual(media); + expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith( + new Set(['camera.office', 'camera.kitchen']), + { + limit: 50, + }, + ); + }); + + it('should return null when query returns null', async () => { + const api = createPopulatedAPI(); + const rawQueries = [ + { + type: QueryType.Event as const, + cameraIDs: new Set(['camera.office']), + }, + ]; + vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue( + rawQueries, + ); + vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(null); + + const executor = new QueryExecutor(api); + expect( + await executor.executeDefaultEventQuery({ cameraID: 'camera.office' }), + ).toBeNull(); + expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith( + new Set(['camera.office']), + { + limit: 50, + }, + ); + }); +}); + +describe('executeDefaultRecordingQuery', () => { + it('should return null without cameras', async () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue(createStore()); + + const executor = new QueryExecutor(api); + expect( + await executor.executeDefaultRecordingQuery({ + cameraID: 'camera.office', + }), + ).toBeNull(); + }); + + it('should return null without queries', async () => { + const api = createPopulatedAPI(); + vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue( + null, + ); + + const executor = new QueryExecutor(api); + expect( + await executor.executeDefaultRecordingQuery({ + cameraID: 'camera.office', + }), + ).toBeNull(); + }); + + it('should return query results for specified camera', async () => { + const api = createPopulatedAPI(); + const media = generateViewMediaArray(); + const rawQueries = [ + { type: QueryType.Recording as const, cameraIDs: new Set(['camera.office']) }, + ]; + vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue( + rawQueries, + ); + vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); + + const executor = new QueryExecutor(api); + const results = await executor.executeDefaultRecordingQuery({ + cameraID: 'camera.office', + }); + + expect(results?.query.getQueries()).toEqual(rawQueries); + expect(results?.queryResults.getResults()).toEqual(media); + expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith( + new Set(['camera.office']), + { + limit: 50, + }, + ); + }); + + it('should return query results for all cameras', async () => { + const api = createPopulatedAPI(); + const media = generateViewMediaArray(); + const rawQueries = [ + { + type: QueryType.Recording as const, + cameraIDs: new Set(['camera.office', 'camera.kitchen']), + }, + ]; + vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue( + rawQueries, + ); + vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); + + const executor = new QueryExecutor(api); + const results = await executor.executeDefaultRecordingQuery(); + + expect(results?.query.getQueries()).toEqual(rawQueries); + expect(results?.queryResults.getResults()).toEqual(media); + expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith( + new Set(['camera.office', 'camera.kitchen']), + { + limit: 50, + }, + ); + }); + + it('should return null when query returns null', async () => { + const api = createPopulatedAPI(); + const rawQueries = [ + { + type: QueryType.Recording as const, + cameraIDs: new Set(['camera.office']), + }, + ]; + vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue( + rawQueries, + ); + vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(null); + + const executor = new QueryExecutor(api); + expect( + await executor.executeDefaultRecordingQuery({ cameraID: 'camera.office' }), + ).toBeNull(); + expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith( + new Set(['camera.office']), + { + limit: 50, + }, + ); + }); +}); + +describe('execute', () => { + it('should return null when query is empty', async () => { + const executor = new QueryExecutor(createCardAPI()); + expect(await executor.execute(new EventMediaQueries())).toBeNull(); + }); + + describe('should handle result rejections', () => { + it('rejected', async () => { + const api = createPopulatedAPI(); + const media = generateViewMediaArray(); + vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); + + const query = new EventMediaQueries([ + { + type: QueryType.Event as const, + cameraIDs: new Set(['camera.office']), + }, + ]); + const executor = new QueryExecutor(api); + + expect(await executor.execute(query, { rejectResults: (_) => true })).toBeNull(); + }); + + it('not rejected', async () => { + const api = createPopulatedAPI(); + const media = generateViewMediaArray(); + vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); + + const query = new EventMediaQueries([ + { + type: QueryType.Event as const, + cameraIDs: new Set(['camera.office']), + }, + ]); + const executor = new QueryExecutor(api); + + expect( + await executor.execute(query, { rejectResults: (_) => false }), + ).not.toBeNull(); + }); + }); + + describe('should select', () => { + it('by id', async () => { + const api = createPopulatedAPI(); + const media = generateViewMediaArray({ + cameraIDs: ['camera.office'], + count: 100, + }); + vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); + + const query = new EventMediaQueries([ + { + type: QueryType.Event as const, + cameraIDs: new Set(['camera.office']), + }, + ]); + const executor = new QueryExecutor(api); + + const results = await executor.execute(query, { + selectResult: { id: 'id-camera.office-42' }, + }); + expect(results?.getSelectedResult()?.getID()).toBe('id-camera.office-42'); + }); + }); + + it('by func', async () => { + const api = createPopulatedAPI(); + const media = generateViewMediaArray({ + cameraIDs: ['camera.office'], + count: 100, + }); + vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); + + const query = new EventMediaQueries([ + { + type: QueryType.Event as const, + cameraIDs: new Set(['camera.office']), + }, + ]); + const executor = new QueryExecutor(api); + + const results = await executor.execute(query, { + selectResult: { func: (media) => media.getID() === 'id-camera.office-43' }, + }); + expect(results?.getSelectedResult()?.getID()).toBe('id-camera.office-43'); + }); + + it('by time', async () => { + const now = new Date('2024-07-21T19:09:37Z'); + + const api = createPopulatedAPI(); + const media = [ + new TestViewMedia({ + cameraID: 'camera.office', + id: 'id-camera.office-0', + startTime: now, + }), + new TestViewMedia({ + cameraID: 'camera.office', + id: 'id-camera.office-1', + startTime: add(now, { seconds: 1 }), + }), + new TestViewMedia({ + cameraID: 'camera.office', + id: 'id-camera.office-2', + startTime: add(now, { seconds: 2 }), + }), + ]; + vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); + + const query = new EventMediaQueries([ + { + type: QueryType.Event as const, + cameraIDs: new Set(['camera.office']), + }, + ]); + const executor = new QueryExecutor(api); + + const results = await executor.execute(query, { + selectResult: { time: { time: add(now, { seconds: 1 }) } }, + }); + expect(results?.getSelectedResult()?.getID()).toBe('id-camera.office-1'); + }); +}); diff --git a/tests/card-controller/view/test-utils.ts b/tests/card-controller/view/test-utils.ts new file mode 100644 index 00000000..926208a5 --- /dev/null +++ b/tests/card-controller/view/test-utils.ts @@ -0,0 +1,41 @@ +import { vi } from 'vitest'; +import { CardController } from '../../../src/card-controller/controller'; +import { RawFrigateCardConfig } from '../../../src/config/types'; +import { + createCameraManager, + createCapabilities, + createCardAPI, + createConfig, + createStore, +} from '../../test-utils'; + +export const createPopulatedAPI = (config?: RawFrigateCardConfig): CardController => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ + live: true, + snapshots: true, + clips: true, + recordings: true, + substream: true, + }), + }, + { + cameraID: 'camera.kitchen', + capabilities: createCapabilities({ + live: true, + snapshots: true, + clips: true, + recordings: true, + substream: true, + }), + }, + ]), + ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config)); + return api; +}; diff --git a/tests/card-controller/view/view-manager.test.ts b/tests/card-controller/view/view-manager.test.ts new file mode 100644 index 00000000..0f4094a6 --- /dev/null +++ b/tests/card-controller/view/view-manager.test.ts @@ -0,0 +1,363 @@ +import { ViewContext } from 'view'; +import { describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { ViewFactory } from '../../../src/card-controller/view/factory'; +import { ViewManager } from '../../../src/card-controller/view/view-manager'; +import { FrigateCardView } from '../../../src/config/types'; +import { + createCameraManager, + createCapabilities, + createCardAPI, + createStore, + createView, +} from '../../test-utils'; +import { ViewMedia } from '../../../src/view/media'; +import { MediaQueriesResults } from '../../../src/view/media-queries-results'; + +describe('should act correctly when view is set', () => { + it('basic view', () => { + const view = createView({ + view: 'live', + camera: 'camera', + displayMode: 'grid', + }); + + const factory = mock(); + factory.getViewDefault.mockReturnValue(view); + + const api = createCardAPI(); + const manager = new ViewManager(api, factory); + + manager.setViewDefault(); + + expect(manager.getView()).toBe(view); + expect(manager.hasView()).toBeTruthy(); + expect(api.getMediaLoadedInfoManager().clear).toBeCalled(); + expect(api.getCardElementManager().scrollReset).toBeCalled(); + expect(api.getMessageManager().reset).toBeCalled(); + expect(api.getStyleManager().setExpandedMode).toBeCalled(); + expect(api.getConditionsManager()?.setState).toBeCalledWith({ + view: 'live', + camera: 'camera', + displayMode: 'grid', + }); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('view with minor changes without media clearing or scroll', () => { + const view_1 = createView({ + view: 'live', + camera: 'camera', + }); + const factory = mock(); + factory.getViewDefault.mockReturnValue(view_1); + + const api = createCardAPI(); + const manager = new ViewManager(api, factory); + + manager.setViewDefault(); + + vi.mocked(api.getMediaLoadedInfoManager().clear).mockClear(); + vi.mocked(api.getCardElementManager().scrollReset).mockClear(); + + const view_2 = createView({ + view: 'live', + camera: 'camera', + displayMode: 'single', + }); + factory.getViewDefault.mockReturnValue(view_2); + + manager.setViewDefault(); + + expect(manager.getView()).toBe(view_2); + + // The new view is neither a major media change, nor a different view name, + // so media clearing and scrolling should not happen. + expect(api.getMediaLoadedInfoManager().clear).not.toBeCalled(); + expect(api.getCardElementManager().scrollReset).not.toBeCalled(); + }); +}); + +it('setViewWithMergedContext', () => { + const api = createCardAPI(); + const factory = mock(); + + const manager = new ViewManager(api, factory); + const context: ViewContext = { timeline: {} }; + + // Setting context with no existing view does nothing. + manager.setViewWithMergedContext(context); + expect(manager.getView()).toBeNull(); + + const view = createView({ + view: 'live', + camera: 'camera', + }); + factory.getViewDefault.mockReturnValue(view); + manager.setViewDefault(); + manager.setViewWithMergedContext(context); + + expect(manager.getView()?.camera).toBe('camera'); + expect(manager.getView()?.view).toBe('live'); + expect(manager.getView()?.context).toEqual(context); +}); + +it('getEpoch', () => { + const factory = mock(); + const manager = new ViewManager(createCardAPI(), factory); + expect(manager.getEpoch()).toBeTruthy(); + expect(manager.getEpoch().manager).toBe(manager); +}); + +it('reset', () => { + const factory = mock(); + const manager = new ViewManager(createCardAPI(), factory); + + manager.reset(); + expect(manager.getView()).toBeNull(); + expect(manager.hasView()).toBeFalsy(); + + factory.getViewDefault.mockReturnValue(createView()); + manager.setViewDefault(); + + expect(manager.getView()).not.toBeNull(); + expect(manager.hasView()).toBeTruthy(); + + manager.reset(); + + expect(manager.getView()).toBeNull(); + expect(manager.hasView()).toBeFalsy(); +}); + +it('setViewDefault', () => { + const factory = mock(); + factory.getViewDefault.mockReturnValue(createView()); + + const manager = new ViewManager(createCardAPI(), factory); + manager.setViewDefault(); + + expect(manager.getView()?.view).toBe('live'); + expect(manager.getView()?.camera).toBe('camera'); +}); + +it('setViewByParameters', () => { + const factory = mock(); + factory.getViewByParameters.mockReturnValue(createView()); + + const manager = new ViewManager(createCardAPI(), factory); + manager.setViewByParameters(); + + expect(manager.getView()?.view).toBe('live'); + expect(manager.getView()?.camera).toBe('camera'); +}); + +it('setViewDefaultWithNewQuery', async () => { + const factory = mock(); + factory.getViewDefaultWithNewQuery.mockResolvedValue(createView()); + + const manager = new ViewManager(createCardAPI(), factory); + await manager.setViewDefaultWithNewQuery(); + + expect(manager.getView()?.view).toBe('live'); + expect(manager.getView()?.camera).toBe('camera'); +}); + +it('setViewByParametersWithNewQuery', async () => { + const factory = mock(); + factory.getViewByParametersWithNewQuery.mockResolvedValue(createView()); + + const manager = new ViewManager(createCardAPI(), factory); + await manager.setViewByParametersWithNewQuery(); + + expect(manager.getView()?.view).toBe('live'); + expect(manager.getView()?.camera).toBe('camera'); +}); + +it('setViewByParametersWithExistingQuery', async () => { + const factory = mock(); + factory.getViewByParametersWithExistingQuery.mockResolvedValue(createView()); + + const manager = new ViewManager(createCardAPI(), factory); + await manager.setViewByParametersWithExistingQuery(); + + expect(manager.getView()?.view).toBe('live'); + expect(manager.getView()?.camera).toBe('camera'); +}); + +describe('should handle exceptions', () => { + it('non-async', () => { + const factory = mock(); + const error = new Error(); + factory.getViewDefault.mockImplementation(() => { + throw error; + }); + + const api = createCardAPI(); + const manager = new ViewManager(api, factory); + manager.setViewDefault(); + + expect(manager.hasView()).toBeFalsy(); + expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error); + }); + + it('async', async () => { + const factory = mock(); + const error = new Error(); + factory.getViewByParametersWithNewQuery.mockRejectedValue(error); + + const api = createCardAPI(); + const manager = new ViewManager(api, factory); + await manager.setViewByParametersWithNewQuery(); + + expect(manager.hasView()).toBeFalsy(); + expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error); + }); +}); + +describe('isViewSupportedByCamera', () => { + it.each([ + ['live' as const, false], + ['image' as const, true], + ['diagnostics' as const, true], + ['clip' as const, false], + ['clips' as const, false], + ['snapshot' as const, false], + ['snapshots' as const, false], + ['recording' as const, false], + ['recordings' as const, false], + ['timeline' as const, false], + ['media' as const, false], + ])('%s', (viewName: FrigateCardView, expected: boolean) => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + capabilities: createCapabilities({ + live: false, + 'favorite-events': false, + 'favorite-recordings': false, + seek: false, + clips: false, + recordings: false, + snapshots: false, + }), + }, + ]), + ); + const manager = new ViewManager(api); + + expect(manager.isViewSupportedByCamera('camera', viewName)).toBe(expected); + }); +}); + +describe('hasMajorMediaChange', () => { + it('should consider undefined views as major', () => { + const manager = new ViewManager(createCardAPI()); + + expect(manager.hasMajorMediaChange(undefined)).toBeFalsy(); + expect(manager.hasMajorMediaChange(createView())).toBeTruthy(); + expect(manager.hasMajorMediaChange()).toBeFalsy(); + }); + + it('should consider view change as major', () => { + const factory = mock(); + factory.getViewDefault.mockReturnValue(createView({ view: 'live' })); + + const manager = new ViewManager(createCardAPI(), factory); + manager.setViewDefault(); + + expect(manager.hasMajorMediaChange(createView({ view: 'clips' }))).toBeTruthy(); + }); + + it('should consider camera change as major', () => { + const factory = mock(); + factory.getViewDefault.mockReturnValue(createView({ camera: 'camera-1' })); + + const manager = new ViewManager(createCardAPI(), factory); + manager.setViewDefault(); + + expect(manager.hasMajorMediaChange(createView({ camera: 'camera-2' }))).toBeTruthy(); + }); + + it('should consider live substream change as major in live view', () => { + const overrides_1: Map = new Map(); + overrides_1.set('camera', 'camera-2'); + + const overrides_2: Map = new Map(); + overrides_2.set('camera', 'camera-3'); + + const factory = mock(); + factory.getViewDefault.mockReturnValue( + createView({ context: { live: { overrides: overrides_1 } } }), + ); + + const manager = new ViewManager(createCardAPI(), factory); + manager.setViewDefault(); + + expect( + manager.hasMajorMediaChange( + createView({ context: { live: { overrides: overrides_2 } } }), + ), + ).toBeTruthy(); + }); + + it('should not consider live substream change as major in other view', () => { + const overrides_1: Map = new Map(); + overrides_1.set('camera', 'camera-2'); + + const overrides_2: Map = new Map(); + overrides_2.set('camera', 'camera-3'); + + const factory = mock(); + factory.getViewDefault.mockReturnValue( + createView({ view: 'clips', context: { live: { overrides: overrides_1 } } }), + ); + + const manager = new ViewManager(createCardAPI(), factory); + manager.setViewDefault(); + + expect( + manager.hasMajorMediaChange( + createView({ view: 'clips', context: { live: { overrides: overrides_2 } } }), + ), + ).toBeFalsy(); + }); + + it('should consider result change as major in other view', () => { + const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')]; + const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 }); + const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 }); + + const factory = mock(); + factory.getViewDefault.mockReturnValue( + createView({ view: 'media', queryResults: queryResults_1 }), + ); + + const manager = new ViewManager(createCardAPI(), factory); + manager.setViewDefault(); + + expect( + manager.hasMajorMediaChange( + createView({ view: 'media', queryResults: queryResults_2 }), + ), + ).toBeTruthy(); + }); + + it('should not consider selected result change as major in live view', () => { + const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')]; + const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 }); + const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 }); + + const factory = mock(); + factory.getViewDefault.mockReturnValue(createView({ queryResults: queryResults_1 })); + + const manager = new ViewManager(createCardAPI(), factory); + manager.setViewDefault(); + + expect( + manager.hasMajorMediaChange(createView({ queryResults: queryResults_2 })), + ).toBeFalsy(); + }); +}); diff --git a/tests/components-lib/live/live-controller.test.ts b/tests/components-lib/live/live-controller.test.ts index b3dea6c9..85fd3b31 100644 --- a/tests/components-lib/live/live-controller.test.ts +++ b/tests/components-lib/live/live-controller.test.ts @@ -1,26 +1,15 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { LiveController } from '../../../src/components-lib/live/live-controller'; import { dispatchMessageEvent } from '../../../src/components/message'; -import { - changeViewToRecentEventsForCameraAndDependents, - changeViewToRecentRecordingForCameraAndDependents, -} from '../../../src/utils/media-to-view'; -import { EventMediaQueries } from '../../../src/view/media-queries'; import { IntersectionObserverMock, callIntersectionHandler, - createCameraManager, - createConfig, createLitElement, createMediaLoadedInfo, createMediaLoadedInfoEvent, createParent, - createView, - createViewChangeEvent, } from '../../test-utils'; -vi.mock('../../../src/utils/media-to-view'); - // @vitest-environment jsdom describe('LiveController', () => { beforeEach(() => { @@ -168,210 +157,4 @@ describe('LiveController', () => { expect(eventListener).toBeCalledTimes(1); expect(controller.getRenderEpoch()).toBe(secondRenderEpoch); }); - - it('should handle view change', () => { - const host = createLitElement(); - const parent = createParent({ children: [host] }); - const eventListener = vi.fn(); - parent.addEventListener('frigate-card:view:change', eventListener); - - const controller = new LiveController(host); - controller.hostConnected(); - const view = createView(); - - callIntersectionHandler(false); - expect(controller.isInBackground()).toBeTruthy(); - host.dispatchEvent(createViewChangeEvent(view)); - - expect(eventListener).toBeCalledTimes(0); - - callIntersectionHandler(true); - expect(controller.isInBackground()).toBeFalsy(); - - host.dispatchEvent(createViewChangeEvent(view)); - - expect(eventListener).toBeCalledTimes(1); - }); - - describe('should fetch media', () => { - it('when in background', async () => { - const controller = new LiveController(createLitElement()); - - callIntersectionHandler(false); - - await controller.fetchMediaInBackgroundIfNecessary( - createView(), - createCameraManager(), - {}, - createConfig().live, - ); - - expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled(); - expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled(); - }); - - it('when has existing query', async () => { - const controller = new LiveController(createLitElement()); - - await controller.fetchMediaInBackgroundIfNecessary( - createView({ query: new EventMediaQueries() }), - createCameraManager(), - {}, - createConfig().live, - ); - - expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled(); - expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled(); - }); - - it('when has no thumbnails', async () => { - const controller = new LiveController(createLitElement()); - - await controller.fetchMediaInBackgroundIfNecessary( - createView(), - createCameraManager(), - {}, - createConfig({ - live: { - controls: { - thumbnails: { - mode: 'none', - }, - }, - }, - }).live, - ); - - expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled(); - expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled(); - }); - - it('when fetch disabled in context', async () => { - const controller = new LiveController(createLitElement()); - - await controller.fetchMediaInBackgroundIfNecessary( - createView({ - context: { - live: { - fetchThumbnails: false, - }, - }, - }), - createCameraManager(), - {}, - createConfig().live, - ); - - expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled(); - expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled(); - }); - - describe('with fetch', () => { - const now = new Date('2024-04-07T19:43'); - beforeAll(() => { - vi.useFakeTimers(); - vi.setSystemTime(now); - }); - - afterAll(() => { - vi.useRealTimers(); - }); - - it('events', async () => { - const host = createLitElement(); - const controller = new LiveController(host); - const view = createView(); - const cameraManager = createCameraManager(); - const cardWideConfig = {}; - - await controller.fetchMediaInBackgroundIfNecessary( - view, - cameraManager, - cardWideConfig, - createConfig({ - live: { - controls: { - thumbnails: { - media_type: 'events', - events_media_type: 'all', - }, - timeline: { - window_seconds: 3600, - }, - }, - }, - }).live, - ); - - expect(changeViewToRecentEventsForCameraAndDependents).toBeCalledWith( - host, - cameraManager, - cardWideConfig, - view, - expect.objectContaining({ - allCameras: false, - targetView: 'live', - eventsMediaType: 'all', - select: 'latest', - viewContext: expect.objectContaining({ - timeline: { - window: { - start: new Date('2024-04-07T18:43'), - end: now, - }, - }, - }), - }), - ); - expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled(); - }); - - it('recordings', async () => { - const host = createLitElement(); - const controller = new LiveController(host); - const view = createView(); - const cameraManager = createCameraManager(); - const cardWideConfig = {}; - - await controller.fetchMediaInBackgroundIfNecessary( - view, - cameraManager, - cardWideConfig, - createConfig({ - live: { - controls: { - thumbnails: { - media_type: 'recordings', - }, - timeline: { - window_seconds: 3600, - }, - }, - }, - }).live, - ); - - expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled(); - expect(changeViewToRecentRecordingForCameraAndDependents).toBeCalledWith( - host, - cameraManager, - cardWideConfig, - view, - expect.objectContaining({ - allCameras: false, - targetView: 'live', - select: 'latest', - viewContext: expect.objectContaining({ - timeline: { - window: { - start: new Date('2024-04-07T18:43'), - end: now, - }, - }, - }), - }), - ); - }); - }); - }); }); diff --git a/tests/components-lib/media-filter-controller.test.ts b/tests/components-lib/media-filter-controller.test.ts index cf08281d..da45ef7f 100644 --- a/tests/components-lib/media-filter-controller.test.ts +++ b/tests/components-lib/media-filter-controller.test.ts @@ -1,8 +1,10 @@ import { endOfDay, startOfDay, sub } from 'date-fns'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; import { Capabilities } from '../../src/camera-manager/capabilities'; import { CameraManagerStore } from '../../src/camera-manager/store'; import { QueryType } from '../../src/camera-manager/types'; +import { ViewManager } from '../../src/card-controller/view/view-manager'; import { MediaFilterController, MediaFilterCoreDefaults, @@ -10,7 +12,6 @@ import { MediaFilterCoreWhen, MediaFilterMediaType, } from '../../src/components-lib/media-filter-controller'; -import { executeMediaQueryForViewWithErrorDispatching } from '../../src/utils/media-to-view'; import { EventMediaQueries, MediaQueries, @@ -26,8 +27,6 @@ import { createView, } from '../test-utils'; -vi.mock('../../src/utils/media-to-view'); - const createCameraStore = (options?: { capabilities: Capabilities; }): CameraManagerStore => { @@ -295,59 +294,82 @@ describe('MediaFilterController', () => { describe('should get correct controls to show', () => { it('view with events', () => { - const view = createView({ query: new EventMediaQueries() }); + const viewManager = mock(); + viewManager.getView.mockReturnValue( + createView({ query: new EventMediaQueries() }), + ); const cameraManager = createCameraManager(); const controller = new MediaFilterController(createLitElement()); - expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ + controller.setViewManager(viewManager); + expect(controller.getControlsToShow(cameraManager)).toMatchObject({ events: true, recordings: false, }); }); it('view with recordings', () => { - const view = createView({ query: new RecordingMediaQueries() }); + const viewManager = mock(); + viewManager.getView.mockReturnValue( + createView({ query: new RecordingMediaQueries() }), + ); const cameraManager = createCameraManager(); const controller = new MediaFilterController(createLitElement()); - expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ + controller.setViewManager(viewManager); + expect(controller.getControlsToShow(cameraManager)).toMatchObject({ events: false, recordings: true, }); }); it('can favorite events', () => { - const view = createView({ query: new EventMediaQueries() }); + const viewManager = mock(); + viewManager.getView.mockReturnValue( + createView({ query: new EventMediaQueries() }), + ); const cameraManager = createCameraManager(); vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( createCapabilities({ 'favorite-events': true }), ); const controller = new MediaFilterController(createLitElement()); - expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ + controller.setViewManager(viewManager); + + expect(controller.getControlsToShow(cameraManager)).toMatchObject({ favorites: true, }); }); it('can favorite recordings', () => { - const view = createView({ query: new RecordingMediaQueries() }); + const viewManager = mock(); + viewManager.getView.mockReturnValue( + createView({ query: new RecordingMediaQueries() }), + ); + const cameraManager = createCameraManager(); vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( createCapabilities({ 'favorite-recordings': true }), ); const controller = new MediaFilterController(createLitElement()); - expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ + controller.setViewManager(viewManager); + + expect(controller.getControlsToShow(cameraManager)).toMatchObject({ favorites: true, }); }); it('can not favorite without a query', () => { - const view = createView(); + const viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); + const cameraManager = createCameraManager(); const controller = new MediaFilterController(createLitElement()); - expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ + controller.setViewManager(viewManager); + + expect(controller.getControlsToShow(cameraManager)).toMatchObject({ favorites: false, }); }); @@ -355,40 +377,34 @@ describe('MediaFilterController', () => { describe('should handle value change', () => { it('must have visible cameras', async () => { - const host = createLitElement(); - const controller = new MediaFilterController(host); - await controller.valueChangeHandler( - createCameraManager(), - createView(), - {}, - { when: {} }, - ); + const viewManager = mock(); - expect(host.requestUpdate).not.toBeCalled(); + const controller = new MediaFilterController(createLitElement()); + controller.setViewManager(viewManager); + + await controller.valueChangeHandler(createCameraManager(), {}, { when: {} }); + + expect(viewManager.setViewByParametersWithExistingQuery).not.toBeCalled(); }); describe('with events media type', () => { it.each([['clips' as const], ['snapshots' as const]])( '%s', async (viewName: 'clips' | 'snapshots') => { - const eventListener = vi.fn(); const host = createLitElement(); - host.addEventListener('frigate-card:view:change', eventListener); + const viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); const controller = new MediaFilterController(host); - const cameraManager = createCameraManager(); - const view = createView(); - vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); - vi.mocked(executeMediaQueryForViewWithErrorDispatching).mockResolvedValueOnce( - view, - ); + controller.setViewManager(viewManager); + + const cameraManager = createCameraManager(createCameraStore()); const from = new Date('2024-02-06T21:59'); const to = new Date('2024-02-06T22:00'); await controller.valueChangeHandler( cameraManager, - view, { performance: createPerformanceConfig({ features: { @@ -397,6 +413,7 @@ describe('MediaFilterController', () => { }), }, { + camera: 'camera.kitchen', mediaType: viewName === 'clips' ? MediaFilterMediaType.Clips @@ -412,20 +429,14 @@ describe('MediaFilterController', () => { }, ); - expect(vi.mocked(executeMediaQueryForViewWithErrorDispatching)).toBeCalledWith( - host, - cameraManager, - view, - expect.anything(), - { - targetCameraID: 'camera.kitchen', - targetView: viewName, - }, - ); + expect(viewManager.setViewByParametersWithExistingQuery).toBeCalledWith({ + params: expect.objectContaining({ + camera: 'camera.kitchen', + view: viewName, + }), + }); expect( - vi - .mocked(executeMediaQueryForViewWithErrorDispatching) - .mock.calls[0][3].getQueries(), + viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(), ).toEqual([ { cameraIDs: new Set(['camera.kitchen']), @@ -442,31 +453,29 @@ describe('MediaFilterController', () => { }, ]); - expect(eventListener).toBeCalled(); expect(host.requestUpdate).toBeCalled(); }, ); }); it('with recordings media type', async () => { - const eventListener = vi.fn(); const host = createLitElement(); - host.addEventListener('frigate-card:view:change', eventListener); + + const cameraManager = createCameraManager(createCameraStore()); + + const viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); + + vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); const controller = new MediaFilterController(host); - const cameraManager = createCameraManager(); - const view = createView(); - vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); - vi.mocked(executeMediaQueryForViewWithErrorDispatching).mockResolvedValueOnce( - view, - ); + controller.setViewManager(viewManager); const from = new Date('2024-02-06T21:59'); const to = new Date('2024-02-06T22:00'); await controller.valueChangeHandler( cameraManager, - view, { performance: createPerformanceConfig({ features: { @@ -484,20 +493,15 @@ describe('MediaFilterController', () => { }, ); - expect(vi.mocked(executeMediaQueryForViewWithErrorDispatching)).toBeCalledWith( - host, - cameraManager, - view, - expect.anything(), - { - targetCameraID: 'camera.kitchen', - targetView: 'recordings', - }, - ); + expect(viewManager.setViewByParametersWithExistingQuery).toBeCalledWith({ + params: expect.objectContaining({ + camera: 'camera.kitchen', + view: 'recordings', + }), + }); + expect( - vi - .mocked(executeMediaQueryForViewWithErrorDispatching) - .mock.calls[0][3].getQueries(), + viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(), ).toEqual([ { cameraIDs: new Set(['camera.kitchen']), @@ -509,18 +513,21 @@ describe('MediaFilterController', () => { }, ]); - expect(eventListener).toBeCalled(); expect(host.requestUpdate).toBeCalled(); }); it('without favorites', async () => { - const controller = new MediaFilterController(createLitElement()); const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); + const viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); + + const controller = new MediaFilterController(createLitElement()); + controller.setViewManager(viewManager); + await controller.valueChangeHandler( cameraManager, - createView(), {}, { mediaType: MediaFilterMediaType.Recordings, @@ -528,10 +535,15 @@ describe('MediaFilterController', () => { }, ); + expect(viewManager.setViewByParametersWithExistingQuery).toBeCalledWith({ + params: expect.objectContaining({ + camera: 'camera.kitchen', + view: 'recordings', + }), + }); + expect( - vi - .mocked(executeMediaQueryForViewWithErrorDispatching) - .mock.calls[0][3].getQueries(), + viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(), ).toEqual([ { cameraIDs: new Set(['camera.kitchen']), @@ -575,13 +587,17 @@ describe('MediaFilterController', () => { new Date('2024-02-29T23:59:59.999'), ], ])('%s', async (value: MediaFilterCoreWhen | string, from: Date, to: Date) => { - const controller = new MediaFilterController(createLitElement()); const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); + const viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); + + const controller = new MediaFilterController(createLitElement()); + controller.setViewManager(viewManager); + await controller.valueChangeHandler( cameraManager, - createView(), {}, { mediaType: MediaFilterMediaType.Recordings, @@ -590,11 +606,8 @@ describe('MediaFilterController', () => { }, }, ); - expect( - vi - .mocked(executeMediaQueryForViewWithErrorDispatching) - .mock.calls[0][3].getQueries(), + viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(), ).toEqual([ { cameraIDs: new Set(['camera.kitchen']), @@ -606,13 +619,17 @@ describe('MediaFilterController', () => { }); it('custom without values', async () => { - const controller = new MediaFilterController(createLitElement()); const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); + const viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); + + const controller = new MediaFilterController(createLitElement()); + controller.setViewManager(viewManager); + await controller.valueChangeHandler( cameraManager, - createView(), {}, { mediaType: MediaFilterMediaType.Recordings, @@ -623,9 +640,7 @@ describe('MediaFilterController', () => { ); expect( - vi - .mocked(executeMediaQueryForViewWithErrorDispatching) - .mock.calls[0][3].getQueries(), + viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(), ).toEqual([ { cameraIDs: new Set(['camera.kitchen']), @@ -638,24 +653,25 @@ describe('MediaFilterController', () => { describe('should calculate correct defaults', () => { it('with no queries', () => { - const controller = new MediaFilterController(createLitElement()); + const viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); - controller.computeInitialDefaultsFromView(createCameraManager(), createView()); + const controller = new MediaFilterController(createLitElement()); + controller.setViewManager(viewManager); + + controller.computeInitialDefaultsFromView(createCameraManager()); expect(controller.getDefaults()).toBeNull(); }); it('with no cameras', () => { - const controller = new MediaFilterController(createLitElement()); + const viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); - controller.computeInitialDefaultsFromView( - createCameraManager(), - createView({ - query: new EventMediaQueries([ - { type: QueryType.Event, cameraIDs: new Set(['camera.kitchen']) }, - ]), - }), - ); + const controller = new MediaFilterController(createLitElement()); + controller.setViewManager(viewManager); + + controller.computeInitialDefaultsFromView(createCameraManager()); expect(controller.getDefaults()).toBeNull(); }); @@ -948,17 +964,21 @@ describe('MediaFilterController', () => { mediaQueries: MediaQueries, defaults: MediaFilterCoreDefaults | null, ) => { - const controller = new MediaFilterController(createLitElement()); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); - - controller.computeInitialDefaultsFromView( - cameraManager, + const viewManager = mock(); + viewManager.getView.mockReturnValue( createView({ query: mediaQueries, }), ); + const controller = new MediaFilterController(createLitElement()); + controller.setViewManager(viewManager); + + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); + + controller.computeInitialDefaultsFromView(cameraManager); + expect(controller.getDefaults()).toEqual(defaults); }, ); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index c55b9c0c..5e7d5c30 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -6,7 +6,7 @@ import { CameraManager } from '../../src/camera-manager/manager'; import { CameraManagerCameraMetadata } from '../../src/camera-manager/types'; import { MediaPlayerManager } from '../../src/card-controller/media-player-manager'; import { MicrophoneManager } from '../../src/card-controller/microphone-manager'; -import { ViewManager } from '../../src/card-controller/view-manager'; +import { ViewManager } from '../../src/card-controller/view/view-manager'; import { MenuButtonController, MenuButtonControllerOptions, diff --git a/tests/components-lib/zoom/zoom-view-context.test.ts b/tests/components-lib/zoom/zoom-view-context.test.ts index 25508b5a..cf2320ba 100644 --- a/tests/components-lib/zoom/zoom-view-context.test.ts +++ b/tests/components-lib/zoom/zoom-view-context.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { MergeContextViewModifier } from '../../../src/card-controller/view/modifiers/merge-context'; +import { ViewManager } from '../../../src/card-controller/view/view-manager'; import { generateViewContextForZoom, handleZoomSettingsObservedEvent, } from '../../../src/components-lib/zoom/zoom-view-context'; +vi.mock('../../../src/card-controller/view/modifiers/merge-context'); + describe('generateViewContextForZoom', () => { it('with observed', () => { expect( @@ -53,11 +58,9 @@ describe('generateViewContextForZoom', () => { // @vitest-environment jsdom it('handleZoomSettingsObservedEvent', () => { - const element = document.createElement('div'); - const callback = vi.fn(); - element.addEventListener('frigate-card:view:change-context', callback); + const viewManager = mock(); + handleZoomSettingsObservedEvent( - element, new CustomEvent('frigate-card:zoom:change', { detail: { pan: { x: 1, y: 2 }, @@ -66,18 +69,21 @@ it('handleZoomSettingsObservedEvent', () => { unzoomed: true, }, }), + viewManager, 'target', ); - expect(callback).toBeCalledWith( + expect(viewManager.setViewByParameters).toBeCalledWith( expect.objectContaining({ - detail: { - zoom: { - target: { - observed: { pan: { x: 1, y: 2 }, zoom: 3, isDefault: true, unzoomed: true }, - requested: null, - }, - }, - }, + modifiers: [expect.any(MergeContextViewModifier)], }), ); + + expect(MergeContextViewModifier).toBeCalledWith({ + zoom: { + target: { + observed: { pan: { x: 1, y: 2 }, zoom: 3, isDefault: true, unzoomed: true }, + requested: null, + }, + }, + }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 2e5defb3..e066e719 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -35,7 +35,7 @@ import { MicrophoneManager } from '../src/card-controller/microphone-manager'; import { QueryStringManager } from '../src/card-controller/query-string-manager'; import { StyleManager } from '../src/card-controller/style-manager'; import { TriggersManager } from '../src/card-controller/triggers-manager'; -import { ViewManager } from '../src/card-controller/view-manager'; +import { ViewManager } from '../src/card-controller/view/view-manager'; import { CameraConfig, FrigateCardCondition, @@ -278,14 +278,6 @@ export const createMediaLoadedInfoEvent = ( }); }; -export const createViewChangeEvent = (view?: View): CustomEvent => { - return new CustomEvent('frigate-card:view:change', { - detail: view ?? createView(), - composed: true, - bubbles: true, - }); -}; - export const createPerformanceConfig = (config: unknown): PerformanceConfig => { return performanceConfigSchema.parse(config); }; @@ -297,7 +289,12 @@ export const generateViewMediaArray = (options?: { const media: ViewMedia[] = []; for (let i = 0; i < (options?.count ?? 100); ++i) { for (const cameraID of options?.cameraIDs ?? ['kitchen', 'office']) { - media.push(new TestViewMedia({ cameraID: cameraID, id: `id-${cameraID}-${i}` })); + media.push( + new TestViewMedia({ + cameraID: cameraID, + id: `id-${cameraID}-${i}`, + }), + ); } } return media; @@ -469,3 +466,10 @@ export const callHASubscribeMessageHandler = ( expect(mock.calls.length).greaterThan(n); mock.calls[n][0](ev); }; + +/** + * Flush resolved promises. + */ +export const flushPromises = async (): Promise => { + await new Promise(process.nextTick); +}; diff --git a/tests/utils/media-to-view.test.ts b/tests/utils/media-to-view.test.ts deleted file mode 100644 index 05493cb3..00000000 --- a/tests/utils/media-to-view.test.ts +++ /dev/null @@ -1,600 +0,0 @@ -import { add, sub } from 'date-fns'; -import { beforeEach, describe, expect, it, Mock, MockedFunction, vi } from 'vitest'; -import { QueryType } from '../../src/camera-manager/types'; -import { - changeViewToRecentEventsForCameraAndDependents, - changeViewToRecentRecordingForCameraAndDependents, - executeMediaQueryForView, - executeMediaQueryForViewWithErrorDispatching, - findBestMediaIndex, -} from '../../src/utils/media-to-view'; -import { ViewMedia } from '../../src/view/media'; -import { EventMediaQueries } from '../../src/view/media-queries'; -import { - createCameraManager, - createCapabilities, - createPerformanceConfig, - createStore, - createView, - TestViewMedia, -} from '../test-utils'; - -const createElementListenForView = (): { - element: HTMLElement; - viewHandler: EventListener; - messageHandler: EventListener; -} => { - const element = document.createElement('div'); - - const viewHandler = vi.fn(); - element.addEventListener('frigate-card:view:change', viewHandler); - - const messageHandler = vi.fn(); - element.addEventListener('frigate-card:message', messageHandler); - - return { - element: element, - viewHandler: viewHandler, - messageHandler: messageHandler, - }; -}; - -const getMediaFromHandlerCall = (handler: Mock): ViewMedia[] | null => { - return handler.mock.calls[0][0].detail.queryResults.getResults(); -}; - -const generateViewMedia = ( - index: number, - base: Date, - durationSeconds: number, - cameraID?: string, -): ViewMedia => { - return new TestViewMedia({ - id: `id-${index}`, - startTime: base, - endTime: add(base, { seconds: durationSeconds }), - ...(cameraID && { cameraID: cameraID }), - }); -}; - -// @vitest-environment jsdom -describe('changeViewToRecentEventsForCameraAndDependents', () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - it('should do nothing without camera config for selected camera', async () => { - const elementHandler = createElementListenForView(); - - await changeViewToRecentEventsForCameraAndDependents( - elementHandler.element, - createCameraManager(), - {}, - createView(), - ); - expect(elementHandler.viewHandler).not.toBeCalled(); - }); - - it('should do nothing without camera configs for all cameras', async () => { - const elementHandler = createElementListenForView(); - - await changeViewToRecentEventsForCameraAndDependents( - elementHandler.element, - createCameraManager(), - {}, - createView(), - { - allCameras: true, - }, - ); - expect(elementHandler.viewHandler).not.toBeCalled(); - }); - - it('should do nothing unless queries can be created', async () => { - const elementHandler = createElementListenForView(); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue(null); - - await changeViewToRecentEventsForCameraAndDependents( - elementHandler.element, - cameraManager, - {}, - createView(), - { - eventsMediaType: 'clips', - }, - ); - expect(elementHandler.viewHandler).not.toBeCalled(); - }); - - it('should dispatch new view on success', async () => { - const elementHandler = createElementListenForView(); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera', - capabilities: createCapabilities({ clips: true }), - }, - ]), - ); - vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera']), - }, - ]); - - const mediaArray = [new ViewMedia('clip', 'camera')]; - vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray); - - await changeViewToRecentEventsForCameraAndDependents( - elementHandler.element, - cameraManager, - {}, - createView(), - { - targetView: 'clips', - select: 'latest', - }, - ); - expect(elementHandler.viewHandler).toBeCalled(); - expect(getMediaFromHandlerCall(vi.mocked(elementHandler.viewHandler))).toBe( - mediaArray, - ); - }); - - it('should dispatch error message on fail', async () => { - vi.spyOn(global.console, 'warn').mockImplementation(() => true); - - const elementHandler = createElementListenForView(); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera', - capabilities: createCapabilities({ clips: true }), - }, - ]), - ); - vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera']), - }, - ]); - vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue(new Error()); - - await changeViewToRecentEventsForCameraAndDependents( - elementHandler.element, - cameraManager, - {}, - createView(), - ); - expect(elementHandler.viewHandler).not.toBeCalled(); - expect(elementHandler.messageHandler).toBeCalled(); - }); - - it('should respect media chunk size', async () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera', - capabilities: createCapabilities({ clips: true }), - }, - ]), - ); - - await changeViewToRecentEventsForCameraAndDependents( - createElementListenForView().element, - cameraManager, - { - performance: createPerformanceConfig({ - features: { - media_chunk_size: 1000, - }, - }), - }, - createView(), - ); - - expect(cameraManager.generateDefaultEventQueries).toBeCalledWith( - expect.anything(), - expect.objectContaining({ - limit: 1000, - }), - ); - }); - - it('should respect useCache', async () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera', - capabilities: createCapabilities({ clips: true }), - }, - ]), - ); - vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera']), - }, - ]); - - await changeViewToRecentEventsForCameraAndDependents( - createElementListenForView().element, - cameraManager, - {}, - createView(), - { - useCache: false, - }, - ); - - expect(vi.mocked(cameraManager.executeMediaQueries)).toBeCalledWith( - expect.anything(), - expect.objectContaining({ - useCache: false, - }), - ); - }); - - describe('should respect request for media type', () => { - it.each([ - ['snapshots' as const, 'hasSnapshot'], - ['clips' as const, 'hasClip'], - ])('%s', async (mediaType, queryParameter) => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera', - capabilities: createCapabilities({ [mediaType]: true }), - }, - ]), - ); - - await changeViewToRecentEventsForCameraAndDependents( - createElementListenForView().element, - cameraManager, - {}, - createView(), - { - eventsMediaType: mediaType, - }, - ); - - expect(cameraManager.generateDefaultEventQueries).toBeCalledWith( - expect.anything(), - expect.objectContaining({ - [queryParameter]: true, - }), - ); - }); - }); -}); - -// @vitest-environment jsdom -describe('executeMediaQueryForView', () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - it('should not execute empty queries', async () => { - expect( - await executeMediaQueryForView( - createCameraManager(), - createView(), - new EventMediaQueries(), - ), - ).toBeNull(); - }); - - it('should throw on failure', async () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue(new Error()); - - await expect( - executeMediaQueryForView( - cameraManager, - createView(), - new EventMediaQueries([ - { - type: QueryType.Event, - cameraIDs: new Set('camera'), - }, - ]), - ), - ).rejects.toThrowError(); - }); - - it('should select time-based result', async () => { - const cameraManager = createCameraManager(); - - const now = new Date(); - const mediaArray = [ - generateViewMedia(0, now, 60), - generateViewMedia(1, now, 120), - generateViewMedia(2, now, 10), - ]; - vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray); - - const view = await executeMediaQueryForView( - cameraManager, - createView(), - new EventMediaQueries([ - { - type: QueryType.Event, - cameraIDs: new Set('camera'), - }, - ]), - { - select: 'time', - targetTime: add(now, { seconds: 30 }), - }, - ); - - // Should select the longest event. - expect(view?.queryResults?.getSelectedIndex()).toBe(1); - expect(view?.queryResults?.getResults()).toBe(mediaArray); - }); - - it('should select nothing when time-based selection does not match', async () => { - const cameraManager = createCameraManager(); - - const now = new Date(); - const mediaArray = [ - generateViewMedia(0, now, 60), - generateViewMedia(1, now, 120), - generateViewMedia(2, now, 10), - ]; - - vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray); - - const view = await executeMediaQueryForView( - cameraManager, - createView(), - new EventMediaQueries([ - { - type: QueryType.Event, - cameraIDs: new Set('camera'), - }, - ]), - { - select: 'time', - targetTime: sub(now, { seconds: 30 }), - }, - ); - - // Should leave selection untouched (last item will remain selected). - expect(view?.queryResults?.getSelectedIndex()).toBe(2); - expect(view?.queryResults?.getResults()).toBe(mediaArray); - }); - - it('should select nothing when query returns null', async () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(null); - - expect( - await executeMediaQueryForView( - cameraManager, - createView(), - new EventMediaQueries([ - { - type: QueryType.Event, - cameraIDs: new Set('camera'), - }, - ]), - ), - ).toBeNull(); - }); -}); - -// @vitest-environment jsdom -describe('changeViewToRecentRecordingForCameraAndDependents', () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - it('should do nothing without camera config for selected camera', async () => { - const elementHandler = createElementListenForView(); - - await changeViewToRecentRecordingForCameraAndDependents( - elementHandler.element, - createCameraManager(), - {}, - createView(), - ); - expect(elementHandler.viewHandler).not.toBeCalled(); - }); - - it('should do nothing without camera configs for all cameras', async () => { - const elementHandler = createElementListenForView(); - - await changeViewToRecentRecordingForCameraAndDependents( - elementHandler.element, - createCameraManager(), - {}, - createView(), - { - allCameras: true, - }, - ); - expect(elementHandler.viewHandler).not.toBeCalled(); - }); - - it('should do nothing unless queries can be created', async () => { - const elementHandler = createElementListenForView(); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue(null); - - await changeViewToRecentRecordingForCameraAndDependents( - elementHandler.element, - cameraManager, - {}, - createView(), - ); - expect(elementHandler.viewHandler).not.toBeCalled(); - }); - - it('should dispatch new view on success', async () => { - const elementHandler = createElementListenForView(); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera', - capabilities: createCapabilities({ recordings: true }), - }, - ]), - ); - - const mediaArray = [new ViewMedia('recording', 'camera')]; - vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray); - vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue([ - { - type: QueryType.Recording, - cameraIDs: new Set(['camera']), - }, - ]); - - await changeViewToRecentRecordingForCameraAndDependents( - elementHandler.element, - cameraManager, - {}, - createView(), - { - targetView: 'recordings', - select: 'latest', - }, - ); - expect(elementHandler.viewHandler).toBeCalled(); - expect(getMediaFromHandlerCall(vi.mocked(elementHandler.viewHandler))).toBe( - mediaArray, - ); - }); - - it('should respect media chunk size', async () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera', - capabilities: createCapabilities({ recordings: true }), - }, - ]), - ); - - await changeViewToRecentRecordingForCameraAndDependents( - createElementListenForView().element, - cameraManager, - { - performance: createPerformanceConfig({ - features: { - media_chunk_size: 1000, - }, - }), - }, - createView(), - ); - - expect(cameraManager.generateDefaultRecordingQueries).toBeCalledWith( - expect.anything(), - expect.objectContaining({ - limit: 1000, - }), - ); - }); - - it('should respect useCache', async () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera', - capabilities: createCapabilities({ recordings: true }), - }, - ]), - ); - vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue([ - { - type: QueryType.Recording, - cameraIDs: new Set(['camera']), - }, - ]); - - await changeViewToRecentRecordingForCameraAndDependents( - createElementListenForView().element, - cameraManager, - {}, - createView(), - { - targetView: 'recordings', - select: 'latest', - useCache: false, - }, - ); - - expect(vi.mocked(cameraManager.executeMediaQueries)).toBeCalledWith( - expect.anything(), - expect.objectContaining({ - useCache: false, - }), - ); - }); -}); - -// @vitest-environment jsdom -describe('executeMediaQueryForViewWithErrorDispatching', () => { - it('should dispatch error message on fail', async () => { - vi.spyOn(global.console, 'warn').mockImplementation(() => true); - - const elementHandler = createElementListenForView(); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue(new Error()); - - await executeMediaQueryForViewWithErrorDispatching( - elementHandler.element, - cameraManager, - createView(), - new EventMediaQueries([{ type: QueryType.Event, cameraIDs: new Set(['camera']) }]), - ); - - expect(elementHandler.viewHandler).not.toBeCalled(); - expect(elementHandler.messageHandler).toBeCalled(); - }); -}); - -// @vitest-environment jsdom -describe('findBestMediaIndex', () => { - it('should find best media index', async () => { - const now = new Date(); - const mediaArray = [ - generateViewMedia(0, now, 60), - generateViewMedia(1, now, 120), - generateViewMedia(2, now, 10), - ]; - - expect(findBestMediaIndex(mediaArray, add(now, { seconds: 30 }))).toBe(1); - }); - - it('should find best media index respecting favored cameraID', async () => { - const now = new Date(); - const mediaArray = [ - generateViewMedia(0, now, 60, 'less-good-camera'), - generateViewMedia(1, now, 120, 'less-good-camera'), - generateViewMedia(2, now, 10, 'favored-camera'), - generateViewMedia(3, now, 35, 'favored-camera'), - generateViewMedia(4, now, 40, 'favored-camera'), - generateViewMedia(5, now, 30, 'favored-camera'), - generateViewMedia(6, now, 300, 'less-good-camera'), - ]; - - expect( - findBestMediaIndex(mediaArray, add(now, { seconds: 30 }), 'favored-camera'), - ).toBe(4); - }); -}); diff --git a/tests/utils/substream.test.ts b/tests/utils/substream.test.ts index a9d1ea88..c75330b3 100644 --- a/tests/utils/substream.test.ts +++ b/tests/utils/substream.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { getStreamCameraID, hasSubstream } from '../../src/utils/substream'; +import { + getStreamCameraID, + hasSubstream, + removeSubstream, +} from '../../src/utils/substream'; import { View } from '../../src/view/view'; describe('hasSubstream/getStreamCameraID', () => { @@ -54,3 +58,41 @@ describe('hasSubstream/getStreamCameraID', () => { expect(getStreamCameraID(view, 'camera3')).toBe('camera4'); }); }); + +describe('removeSubstream', () => { + it('should remove substream that exists', () => { + const view = new View({ + view: 'live', + camera: 'camera', + context: { + live: { + overrides: new Map([['camera', 'camera2']]), + }, + }, + }); + removeSubstream(view); + expect(view.context).toEqual({ + live: { + overrides: new Map(), + }, + }); + }); + + it('should not remove substream that does not exists', () => { + const view = new View({ + view: 'live', + camera: 'camera-has-no-overrides', + context: { + live: { + overrides: new Map([['camera', 'camera2']]), + }, + }, + }); + removeSubstream(view); + expect(view.context).toEqual({ + live: { + overrides: new Map([['camera', 'camera2']]), + }, + }); + }); +}); diff --git a/tests/view/view-to-cameras.test.ts b/tests/view/view-to-cameras.test.ts index 0d90ad73..723ce232 100644 --- a/tests/view/view-to-cameras.test.ts +++ b/tests/view/view-to-cameras.test.ts @@ -50,6 +50,9 @@ describe('getCameraIDsForViewName', () => { ['media' as const, 'clips' as const], ['media' as const, 'snapshots' as const], ['media' as const, 'recordings' as const], + ['timeline' as const, 'clips' as const], + ['timeline' as const, 'snapshots' as const], + ['timeline' as const, 'recordings' as const], ])('%s', (viewName: FrigateCardView, capabilityKey: CapabilityKey) => { const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue( diff --git a/tests/view/view.test.ts b/tests/view/view.test.ts index bea28a03..ab2ffa29 100644 --- a/tests/view/view.test.ts +++ b/tests/view/view.test.ts @@ -1,12 +1,8 @@ -import { describe, expect, it, vi } from 'vitest'; -import { QueryType } from '../../src/camera-manager/types'; -import { ViewMedia } from '../../src/view/media'; -import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries'; +import { describe, expect, it } from 'vitest'; +import { EventMediaQueries } from '../../src/view/media-queries'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; -import { View, dispatchViewContextChangeEvent } from '../../src/view/view'; import { createView } from '../test-utils'; -// @vitest-environment jsdom describe('View Basics', () => { it('should construct from parameters', () => { const query = new EventMediaQueries(); @@ -28,21 +24,28 @@ describe('View Basics', () => { expect(view.context).toBe(context); }); - it('should clone', () => { - const query = new EventMediaQueries(); - const queryResults = new MediaQueriesResults(); - const context = {}; + describe('should clone', () => { + it('with query and queryResults', () => { + const view = createView({ + view: 'live', + camera: 'camera', + query: new EventMediaQueries(), + queryResults: new MediaQueriesResults(), + context: {}, + }); - const view = createView({ - view: 'live', - camera: 'camera', - query: query, - queryResults: queryResults, - context: context, + expect(view.clone()).toEqual(view); }); - const clone = view.clone(); - expect(clone).toEqual(view); + it('without query and queryResults', () => { + const view = createView({ + view: 'live', + camera: 'camera', + context: {}, + }); + + expect(view.clone()).toEqual(view); + }); }); it('should evolve with everything set', () => { @@ -201,316 +204,6 @@ describe('View Basics', () => { expect(createView({ view: 'timeline' }).getDefaultMediaType()).toBeNull(); }); - it('should dispatch view', () => { - const element = document.createElement('div'); - const view = createView(); - const handler = vi.fn((ev) => { - expect(ev.detail).toBe(view); - }); - - element.addEventListener('frigate-card:view:change', handler); - view.dispatchChangeEvent(element); - expect(handler).toBeCalled(); - }); -}); - -describe('View.isMajorMediaChange', () => { - it('should consider undefined views as major', () => { - expect(View.isMajorMediaChange(createView(), undefined)).toBeTruthy(); - expect(View.isMajorMediaChange(undefined, createView())).toBeTruthy(); - expect(View.isMajorMediaChange()).toBeTruthy(); - }); - - it('should consider view change as major', () => { - expect( - View.isMajorMediaChange( - createView({ view: 'live' }), - createView({ view: 'snapshots' }), - ), - ).toBeTruthy(); - }); - - it('should consider camera change as major', () => { - expect( - View.isMajorMediaChange( - createView({ camera: 'camera-1' }), - createView({ camera: 'camera-2' }), - ), - ).toBeTruthy(); - }); - - it('should consider live substream change as major in live view', () => { - const overrides_1: Map = new Map(); - overrides_1.set('camera', 'camera-2'); - - const overrides_2: Map = new Map(); - overrides_2.set('camera', 'camera-3'); - - expect( - View.isMajorMediaChange( - createView({ context: { live: { overrides: overrides_1 } } }), - createView({ context: { live: { overrides: overrides_2 } } }), - ), - ).toBeTruthy(); - }); - - it('should not consider live substream change as major in other view', () => { - const overrides_1: Map = new Map(); - overrides_1.set('camera', 'camera-2'); - - const overrides_2: Map = new Map(); - overrides_2.set('camera', 'camera-3'); - - expect( - View.isMajorMediaChange( - createView({ view: 'clips', context: { live: { overrides: overrides_1 } } }), - createView({ view: 'clips', context: { live: { overrides: overrides_2 } } }), - ), - ).toBeFalsy(); - }); - - it('should consider result change as major in other view', () => { - const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')]; - const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 }); - const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 }); - expect( - View.isMajorMediaChange( - createView({ view: 'media', queryResults: queryResults_1 }), - createView({ view: 'media', queryResults: queryResults_2 }), - ), - ).toBeTruthy(); - }); - - it('should not consider selected result change as major in live view', () => { - const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')]; - const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 }); - const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 }); - expect( - View.isMajorMediaChange( - createView({ queryResults: queryResults_1 }), - createView({ queryResults: queryResults_2 }), - ), - ).toBeFalsy(); - }); -}); - -describe('View.adoptFromViewIfAppropriate', () => { - it('should adopt for gallery case', () => { - const query = new EventMediaQueries([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, - ]); - const queryResults = new MediaQueriesResults(); - - const current = createView({ - view: 'clip', - query: query, - queryResults: queryResults, - }); - const next = createView({ view: 'clips' }); - View.adoptFromViewIfAppropriate(next, current); - expect(next.view).toBe('clips'); - expect(next.query).toBe(query); - expect(next.queryResults).toBe(queryResults); - }); - - it('should not adopt for gallery case if neither query nor results in current view', () => { - const current = createView({ - view: 'clip', - query: null, - queryResults: null, - }); - - const nextQuery = new EventMediaQueries([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, - ]); - const nextResults = new MediaQueriesResults(); - const next = createView({ - view: 'clips', - query: nextQuery, - queryResults: nextResults, - }); - View.adoptFromViewIfAppropriate(next, current); - expect(next.view).toBe('clips'); - expect(next.query).toBe(nextQuery); - expect(next.queryResults).toBe(nextResults); - }); - - it.each([ - [ - new EventMediaQueries([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, - ]), - 'clip', - ], - [ - new EventMediaQueries([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true }, - ]), - 'snapshot', - ], - [ - new RecordingMediaQueries([ - { type: QueryType.Recording, cameraIDs: new Set(['camera']) }, - ]), - 'recording', - ], - ])('should adopt in media case', (mediaQueries, expectedView) => { - const current = createView({ - view: 'media', - query: mediaQueries, - queryResults: new MediaQueriesResults(), - }); - const next = createView({ view: 'media' }); - View.adoptFromViewIfAppropriate(next, current); - expect(next.view).toBe(expectedView); - expect(next.query).toBeFalsy(); - expect(next.queryResults).toBeFalsy(); - }); - - it('should not adopt for mixed queries in media case', () => { - const query = new EventMediaQueries([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true }, - ]); - const results = new MediaQueriesResults(); - const current = createView({ - view: 'media', - query: query, - queryResults: results, - }); - const next = createView({ view: 'media' }); - View.adoptFromViewIfAppropriate(next, current); - - expect(next.view).toBe('media'); - expect(next.query).toBeNull(); - expect(next.queryResults).toBeNull(); - }); - - it('should not adopt when queries and results present in next view in media case', () => { - const currentQuery = new EventMediaQueries([ - { type: QueryType.Event, cameraIDs: new Set(['camera-1']), hasClip: true }, - { type: QueryType.Event, cameraIDs: new Set(['camera-1']), hasSnapshot: true }, - ]); - const currentResults = new MediaQueriesResults(); - const current = createView({ - view: 'media', - query: currentQuery, - queryResults: currentResults, - }); - - const nextQuery = new EventMediaQueries([ - { type: QueryType.Event, cameraIDs: new Set(['camera-2']), hasClip: true }, - { type: QueryType.Event, cameraIDs: new Set(['camera-2']), hasSnapshot: true }, - ]); - const nextResults = new MediaQueriesResults(); - const next = createView({ - view: 'media', - query: nextQuery, - queryResults: nextResults, - }); - View.adoptFromViewIfAppropriate(next, current); - - expect(next.view).toBe('media'); - expect(next.query).toBe(nextQuery); - expect(next.queryResults).toBe(nextResults); - }); - - it('should not adopt for other case', () => { - const query = new EventMediaQueries([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, - ]); - const queryResults = new MediaQueriesResults(); - - const current = createView({ - view: 'media', - query: query, - queryResults: queryResults, - }); - const next = createView({ view: 'live' }); - View.adoptFromViewIfAppropriate(next, current); - expect(next.view).toBe('live'); - expect(next.query).toBeFalsy(); - expect(next.queryResults).toBeFalsy(); - }); - - it('should do nothing with undefined next view', () => { - const view_1 = createView(); - const view_2 = view_1.clone(); - View.adoptFromViewIfAppropriate(view_1); - expect(view_1).toEqual(view_2); - }); - - it('should adopt with query but no queryResults', () => { - const query = new EventMediaQueries([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, - ]); - - const current = createView({ - view: 'media', - query: query, - }); - const next = createView({ - view: 'media', - query: query, - }); - View.adoptFromViewIfAppropriate(next, current); - expect(next.view).toBe('clip'); - }); - - it('should adopt live context overrides for substreams', () => { - const current = createView({ - view: 'live', - context: { - live: { - overrides: new Map([['camera', 'camera2']]), - }, - }, - }); - const next = createView({ - view: 'live', - }); - View.adoptFromViewIfAppropriate(next, current); - expect(next.context?.live).toEqual(current.context?.live); - }); - - it('should not adopt live context overrides if there are new overrides', () => { - const current = createView({ - view: 'live', - context: { - live: { - overrides: new Map([['camera', 'camera2']]), - }, - }, - }); - const next = createView({ - view: 'live', - context: { - live: { - overrides: new Map([['camera', 'camera3']]), - }, - }, - }); - View.adoptFromViewIfAppropriate(next, current); - expect(next.context?.live?.overrides).toEqual(new Map([['camera', 'camera3']])); - }); - - it('should adopt live context overrides even if there is new context', () => { - const current = createView({ - view: 'live', - context: { - live: { - overrides: new Map([['camera', 'camera2']]), - }, - }, - }); - const next = createView({ - view: 'live', - context: {}, - }); - View.adoptFromViewIfAppropriate(next, current); - expect(next.context?.live).toEqual(current.context?.live); - }); - it('should determine if display mode is grid', () => { expect(createView({ displayMode: 'grid' }).isGrid()).toBeTruthy(); expect(createView({ displayMode: 'single' }).isGrid()).toBeFalsy(); @@ -535,19 +228,3 @@ describe('View.adoptFromViewIfAppropriate', () => { expect(createView({ view: 'timeline' }).supportsMultipleDisplayModes()).toBeFalsy(); }); }); - -// @vitest-environment jsdom -describe('dispatchViewContextChangeEvent', () => { - it('should dispatch event', () => { - const context = {}; - const handler = vi.fn((ev) => { - expect(ev.detail).toBe(context); - }); - - const element = document.createElement('div'); - element.addEventListener('frigate-card:view:change-context', handler); - - dispatchViewContextChangeEvent(element, context); - expect(handler).toBeCalled(); - }); -}); diff --git a/vite.config.ts b/vite.config.ts index a35a2154..0808f0ab 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -37,7 +37,6 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'utils/interaction-mode.ts', 'utils/media-info.ts', 'utils/media-layout.ts', - 'utils/media-to-view.ts', 'utils/media.ts', 'utils/ptz.ts', 'utils/screenshot.ts', diff --git a/yarn.lock b/yarn.lock index 4d2adda6..7de17cfc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1833,14 +1833,23 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.3.2": +"acorn-walk@npm:^8.0.2": version: 8.3.2 resolution: "acorn-walk@npm:8.3.2" checksum: 10c0/7e2a8dad5480df7f872569b9dccff2f3da7e65f5353686b1d6032ab9f4ddf6e3a2cb83a9b52cf50b1497fd522154dda92f0abf7153290cc79cd14721ff121e52 languageName: node linkType: hard -"acorn@npm:^8.1.0, acorn@npm:^8.11.3, acorn@npm:^8.8.2, acorn@npm:^8.9.0": +"acorn-walk@npm:^8.3.2": + version: 8.3.3 + resolution: "acorn-walk@npm:8.3.3" + dependencies: + acorn: "npm:^8.11.0" + checksum: 10c0/4a9e24313e6a0a7b389e712ba69b66b455b4cb25988903506a8d247e7b126f02060b05a8a5b738a9284214e4ca95f383dd93443a4ba84f1af9b528305c7f243b + languageName: node + linkType: hard + +"acorn@npm:^8.1.0, acorn@npm:^8.8.2, acorn@npm:^8.9.0": version: 8.11.3 resolution: "acorn@npm:8.11.3" bin: @@ -1849,6 +1858,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^8.11.0, acorn@npm:^8.11.3": + version: 8.12.1 + resolution: "acorn@npm:8.12.1" + bin: + acorn: bin/acorn + checksum: 10c0/51fb26cd678f914e13287e886da2d7021f8c2bc0ccc95e03d3e0447ee278dd3b40b9c57dc222acd5881adcf26f3edc40901a4953403232129e3876793cd17386 + languageName: node + linkType: hard + "agent-base@npm:6": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -2621,6 +2639,13 @@ __metadata: languageName: node linkType: hard +"confbox@npm:^0.1.7": + version: 0.1.7 + resolution: "confbox@npm:0.1.7" + checksum: 10c0/18b40c2f652196a833f3f1a5db2326a8a579cd14eacabfe637e4fc8cb9b68d7cf296139a38c5e7c688ce5041bf46f9adce05932d43fde44cf7e012840b5da111 + languageName: node + linkType: hard + "configstore@npm:^5.0.1": version: 5.0.1 resolution: "configstore@npm:5.0.1" @@ -2981,11 +3006,11 @@ __metadata: linkType: hard "deep-eql@npm:^4.1.3": - version: 4.1.3 - resolution: "deep-eql@npm:4.1.3" + version: 4.1.4 + resolution: "deep-eql@npm:4.1.4" dependencies: type-detect: "npm:^4.0.0" - checksum: 10c0/ff34e8605d8253e1bf9fe48056e02c6f347b81d9b5df1c6650a1b0f6f847b4a86453b16dc226b34f853ef14b626e85d04e081b022e20b00cd7d54f079ce9bbdd + checksum: 10c0/264e0613493b43552fc908f4ff87b8b445c0e6e075656649600e1b8a17a57ee03e960156fce7177646e4d2ddaf8e5ee616d76bd79929ff593e5c79e4e5e6c517 languageName: node linkType: hard @@ -5209,10 +5234,10 @@ __metadata: languageName: node linkType: hard -"js-tokens@npm:^8.0.2": - version: 8.0.3 - resolution: "js-tokens@npm:8.0.3" - checksum: 10c0/b50ba7d926b087ad31949d8155c7bc84374e0785019b17bdddeb2c4f98f5dea04ba464651fe23a8be4f7d15f50d06ce8bb536087b24ce3ebfbaea4a1dc5869f0 +"js-tokens@npm:^9.0.0": + version: 9.0.0 + resolution: "js-tokens@npm:9.0.0" + checksum: 10c0/4ad1c12f47b8c8b2a3a99e29ef338c1385c7b7442198a425f3463f3537384dab6032012791bfc2f056ea5ecdb06b1ed4f70e11a3ab3f388d3dcebfe16a52b27d languageName: node linkType: hard @@ -5337,13 +5362,6 @@ __metadata: languageName: node linkType: hard -"jsonc-parser@npm:^3.2.0": - version: 3.2.1 - resolution: "jsonc-parser@npm:3.2.1" - checksum: 10c0/ada66dec143d7f9cb0e2d0d29c69e9ce40d20f3a4cb96b0c6efb745025ac7f9ba647d7ac0990d0adfc37a2d2ae084a12009a9c833dbdbeadf648879a99b9df89 - languageName: node - linkType: hard - "jsonfile@npm:^4.0.0": version: 4.0.0 resolution: "jsonfile@npm:4.0.0" @@ -5659,7 +5677,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.3": +"magic-string@npm:^0.30.3, magic-string@npm:^0.30.5": version: 0.30.10 resolution: "magic-string@npm:0.30.10" dependencies: @@ -5668,15 +5686,6 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.5": - version: 0.30.7 - resolution: "magic-string@npm:0.30.7" - dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.4.15" - checksum: 10c0/d1d949f7a53c37c6e685f4ea7b2b151c2fe0cc5af8f1f979ecba916f7d60d58f35309aaf4c8b09ce1aef7c160b957be39a38b52b478a91650750931e4ddd5daf - languageName: node - linkType: hard - "magicast@npm:^0.3.3": version: 0.3.3 resolution: "magicast@npm:0.3.3" @@ -6025,15 +6034,15 @@ __metadata: languageName: node linkType: hard -"mlly@npm:^1.2.0, mlly@npm:^1.4.2": - version: 1.6.1 - resolution: "mlly@npm:1.6.1" +"mlly@npm:^1.4.2, mlly@npm:^1.7.1": + version: 1.7.1 + resolution: "mlly@npm:1.7.1" dependencies: acorn: "npm:^8.11.3" pathe: "npm:^1.1.2" - pkg-types: "npm:^1.0.3" - ufo: "npm:^1.3.2" - checksum: 10c0/a7bf26b3d4f83b0f5a5232caa3af44be08b464f562f31c11d885d1bc2d43b7d717137d47b0c06fdc69e1b33ffc09f902b6d2b18de02c577849d40914e8785092 + pkg-types: "npm:^1.1.1" + ufo: "npm:^1.5.3" + checksum: 10c0/d836a7b0adff4d118af41fb93ad4d9e57f80e694a681185280ba220a4607603c19e86c80f9a6c57512b04280567f2599e3386081705c5b5fd74c9ddfd571d0fa languageName: node linkType: hard @@ -6671,7 +6680,7 @@ __metadata: languageName: node linkType: hard -"pathe@npm:^1.1.0, pathe@npm:^1.1.1, pathe@npm:^1.1.2": +"pathe@npm:^1.1.1, pathe@npm:^1.1.2": version: 1.1.2 resolution: "pathe@npm:1.1.2" checksum: 10c0/64ee0a4e587fb0f208d9777a6c56e4f9050039268faaaaecd50e959ef01bf847b7872785c36483fa5cdcdbdfdb31fef2ff222684d4fc21c330ab60395c681897 @@ -6706,14 +6715,14 @@ __metadata: languageName: node linkType: hard -"pkg-types@npm:^1.0.3": - version: 1.0.3 - resolution: "pkg-types@npm:1.0.3" +"pkg-types@npm:^1.0.3, pkg-types@npm:^1.1.1": + version: 1.1.3 + resolution: "pkg-types@npm:1.1.3" dependencies: - jsonc-parser: "npm:^3.2.0" - mlly: "npm:^1.2.0" - pathe: "npm:^1.1.0" - checksum: 10c0/7f692ff2005f51b8721381caf9bdbc7f5461506ba19c34f8631660a215c8de5e6dca268f23a319dd180b8f7c47a0dc6efea14b376c485ff99e98d810b8f786c4 + confbox: "npm:^0.1.7" + mlly: "npm:^1.7.1" + pathe: "npm:^1.1.2" + checksum: 10c0/4cd2c9442dd5e4ae0c61cbd8fdaa92a273939749b081f78150ce9a3f4e625cca0375607386f49f103f0720b239d02369bf181c3ea6c80cf1028a633df03706ad languageName: node linkType: hard @@ -7314,9 +7323,9 @@ __metadata: linkType: hard "react-is@npm:^18.0.0": - version: 18.2.0 - resolution: "react-is@npm:18.2.0" - checksum: 10c0/6eb5e4b28028c23e2bfcf73371e72cd4162e4ac7ab445ddae2afe24e347a37d6dc22fae6e1748632cd43c6d4f9b8f86dcf26bf9275e1874f436d129952528ae0 + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 languageName: node linkType: hard @@ -8136,11 +8145,11 @@ __metadata: linkType: hard "strip-literal@npm:^2.0.0": - version: 2.0.0 - resolution: "strip-literal@npm:2.0.0" + version: 2.1.0 + resolution: "strip-literal@npm:2.1.0" dependencies: - js-tokens: "npm:^8.0.2" - checksum: 10c0/63a6e4224ac7088ff93fd19fc0f6882705020da2f0767dbbecb929cbf9d49022e72350420f47be635866823608da9b9a5caf34f518004721895b6031199fc3c8 + js-tokens: "npm:^9.0.0" + checksum: 10c0/bc8b8c8346125ae3c20fcdaf12e10a498ff85baf6f69597b4ab2b5fbf2e58cfd2827f1a44f83606b852da99a5f6c8279770046ddea974c510c17c98934c9cc24 languageName: node linkType: hard @@ -8273,9 +8282,9 @@ __metadata: linkType: hard "tinybench@npm:^2.5.1": - version: 2.6.0 - resolution: "tinybench@npm:2.6.0" - checksum: 10c0/60ea35699bf8bac9bc8cf279fa5877ab5b335b4673dcd07bf0fbbab9d7953a02c0ccded374677213eaa13aa147f54eb75d3230139ddbeec3875829ebe73db310 + version: 2.8.0 + resolution: "tinybench@npm:2.8.0" + checksum: 10c0/5a9a642351fa3e4955e0cbf38f5674be5f3ba6730fd872fd23a5c953ad6c914234d5aba6ea41ef88820180a81829ceece5bd8d3967c490c5171bca1141c2f24d languageName: node linkType: hard @@ -8287,9 +8296,9 @@ __metadata: linkType: hard "tinypool@npm:^0.8.3": - version: 0.8.3 - resolution: "tinypool@npm:0.8.3" - checksum: 10c0/c219d0cfb69de8e3cf17403034a508d773f2fccaad79a13cdbad68600c4fb10186ad814d2320bcaa8f6e774fff5666d2a3d3b241dc8a7ad9d970ee63fe620a32 + version: 0.8.4 + resolution: "tinypool@npm:0.8.4" + checksum: 10c0/779c790adcb0316a45359652f4b025958c1dff5a82460fe49f553c864309b12ad732c8288be52f852973bc76317f5e7b3598878aee0beb8a33322c0e72c4a66c languageName: node linkType: hard @@ -8583,10 +8592,10 @@ __metadata: languageName: node linkType: hard -"ufo@npm:^1.3.2": - version: 1.4.0 - resolution: "ufo@npm:1.4.0" - checksum: 10c0/d9a3cb8c5fd13356e0af661362244fd0a901edcdd08996f42553271007cae01e85dcec29a3303a87ddab6aa705cbd630332aaa8c268d037483536b198fa67a7c +"ufo@npm:^1.5.3": + version: 1.5.4 + resolution: "ufo@npm:1.5.4" + checksum: 10c0/b5dc4dc435c49c9ef8890f1b280a19ee4d0954d1d6f9ab66ce62ce64dd04c7be476781531f952a07c678d51638d02ad4b98e16237be29149295b0f7c09cda765 languageName: node linkType: hard @@ -9049,14 +9058,14 @@ __metadata: linkType: hard "why-is-node-running@npm:^2.2.2": - version: 2.2.2 - resolution: "why-is-node-running@npm:2.2.2" + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" dependencies: siginfo: "npm:^2.0.0" stackback: "npm:0.0.2" bin: why-is-node-running: cli.js - checksum: 10c0/805d57eb5d33f0fb4e36bae5dceda7fd8c6932c2aeb705e30003970488f1a2bc70029ee64be1a0e1531e2268b11e65606e88e5b71d667ea745e6dc48fc9014bd + checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054 languageName: node linkType: hard @@ -9289,9 +9298,9 @@ __metadata: linkType: hard "yocto-queue@npm:^1.0.0": - version: 1.0.0 - resolution: "yocto-queue@npm:1.0.0" - checksum: 10c0/856117aa15cf5103d2a2fb173f0ab4acb12b4b4d0ed3ab249fdbbf612e55d1cadfd27a6110940e24746fb0a78cf640b522cc8bca76f30a3b00b66e90cf82abe0 + version: 1.1.1 + resolution: "yocto-queue@npm:1.1.1" + checksum: 10c0/cb287fe5e6acfa82690acb43c283de34e945c571a78a939774f6eaba7c285bacdf6c90fbc16ce530060863984c906d2b4c6ceb069c94d1e0a06d5f2b458e2a92 languageName: node linkType: hard