Refactor views to support dynamic updates.

This commit is contained in:
Dermot Duffy
2024-07-26 19:15:38 -07:00
parent 33dee47205
commit d5c4e56c45
80 changed files with 3714 additions and 4021 deletions
+1
View File
@@ -0,0 +1 @@
cache
+1
View File
@@ -51,6 +51,7 @@ const plugins = [
typescript({
sourceMap: dev,
inlineSources: dev,
exclude: ["tests/**/*.test.ts"],
}),
json({ exclude: 'package.json' }),
replace({
@@ -16,9 +16,11 @@ export class CameraSelectAction extends FrigateCardAction<CameraSelectActionConf
const viewOnCameraSelect = config?.view.camera_select ?? 'current';
const targetViewName =
viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect;
api.getViewManager().setViewByParameters({
viewName: targetViewName,
cameraID: selectCameraID,
await api.getViewManager().setViewByParametersWithNewQuery({
params:{
view: targetViewName,
camera: selectCameraID,
},
failSafe: true,
});
}
@@ -4,6 +4,10 @@ import { FrigateCardAction } from './base';
export class DisplayModeSelectAction extends FrigateCardAction<DisplayModeActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getViewManager().setViewWithNewDisplayMode(this._action.display_mode);
await api.getViewManager().setViewByParametersWithNewQuery({
params: {
displayMode: this._action.display_mode,
},
});
}
}
@@ -1,9 +1,12 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { SubstreamOffViewModifier } from '../../view/modifiers/substream-off';
import { FrigateCardAction } from './base';
export class SubstreamOffAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithoutSubstream();
api.getViewManager().setViewByParameters({
modifiers: [new SubstreamOffViewModifier()],
});
}
}
@@ -1,9 +1,12 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { SubstreamOnViewModifier } from '../../view/modifiers/substream-on';
import { FrigateCardAction } from './base';
export class SubstreamOnAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithSubstream();
api.getViewManager().setViewByParameters({
modifiers: [new SubstreamOnViewModifier(api)],
});
}
}
@@ -1,9 +1,12 @@
import { SubstreamSelectActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { SubstreamSelectViewModifier } from '../../view/modifiers/substream-select';
import { FrigateCardAction } from './base';
export class SubstreamSelectAction extends FrigateCardAction<SubstreamSelectActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithSubstream(this._action.camera);
api.getViewManager().setViewByParameters({
modifiers: [new SubstreamSelectViewModifier(this._action.camera)],
});
}
}
+4 -7
View File
@@ -4,13 +4,10 @@ import { FrigateCardAction } from './base';
export class ViewAction extends FrigateCardAction<ViewActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewByParameters({
viewName: this._action.frigate_card_action,
// Note: This function needs to process (view-related) commands even when
// _view has not yet been initialized (since it may be used to set a view
// via the querystring).
cameraID: api.getViewManager().getView()?.camera,
api.getViewManager().setViewByParametersWithNewQuery({
params: {
view: this._action.frigate_card_action,
},
});
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ import {
CardTriggersAPI,
CardViewAPI,
} from './types';
import { ViewManager } from './view-manager';
import { ViewManager } from './view/view-manager';
import { KeyboardStateManager } from './keyboard-state-manager';
export class CardController
@@ -100,10 +100,10 @@ export class InitializationManager {
if (hasViewRelatedActions) {
this._api.getQueryStringManager().executeViewRelated();
} else {
this._api.getViewManager().setViewDefault({ failSafe: true });
this._api.getViewManager().setViewDefaultWithNewQuery({ failSafe: true });
}
} else {
// If we already have a view something (e.g. cameras) may have been
// If we already have a view, something (e.g. cameras) may have been
// reinitialized, be sure to ask for an update.
this._api.getCardElementManager().update();
}
+23 -13
View File
@@ -1,11 +1,13 @@
import { FrigateCardCustomAction, ViewActionConfig } from '../config/types';
import { createCameraAction, createGeneralAction } from '../utils/action.js';
import { ViewParameters } from '../view/view';
import { CardQueryStringAPI } from './types';
import { ViewManagerSetViewParameters } from './view-manager';
import { SubstreamSelectViewModifier } from './view/modifiers/substream-select';
interface QueryStringViewIntent {
view?: ViewManagerSetViewParameters & {
view?: Partial<ViewParameters> & {
default?: boolean;
substream?: string;
};
other?: FrigateCardCustomAction[];
}
@@ -35,18 +37,26 @@ export class QueryStringManager {
this._executeNonViewRelated(intent);
};
protected _executeViewRelated(intent: QueryStringViewIntent): void {
protected async _executeViewRelated(intent: QueryStringViewIntent): Promise<void> {
if (intent.view) {
if (intent.view.default) {
this._api.getViewManager().setViewDefault({
...(intent.view.cameraID && { cameraID: intent.view.cameraID }),
...(intent.view.substream && { substream: intent.view.substream }),
await this._api.getViewManager().setViewDefaultWithNewQuery({
params: {
camera: intent.view.camera,
},
...(intent.view.substream && {
modifiers: [new SubstreamSelectViewModifier(intent.view.substream)],
}),
});
} else {
this._api.getViewManager().setViewByParameters({
...(intent.view.viewName && { viewName: intent.view.viewName }),
...(intent.view.cameraID && { cameraID: intent.view.cameraID }),
...(intent.view.substream && { substream: intent.view.substream }),
await this._api.getViewManager().setViewByParametersWithNewQuery({
params: {
...(intent.view.view && { view: intent.view.view }),
...(intent.view.camera && { camera: intent.view.camera }),
},
...(intent.view.substream && {
modifiers: [new SubstreamSelectViewModifier(intent.view.substream)],
}),
});
}
}
@@ -68,13 +78,13 @@ export class QueryStringManager {
const result: QueryStringViewIntent = {};
for (const action of this._getActions()) {
if (this._isViewAction(action)) {
(result.view ??= {}).viewName = action.frigate_card_action;
(result.view ??= {}).view = action.frigate_card_action;
(result.view ??= {}).default = undefined;
} else if (action.frigate_card_action === 'default') {
(result.view ??= {}).default = true;
(result.view ??= {}).viewName = undefined;
(result.view ??= {}).view = undefined;
} else if (action.frigate_card_action === 'camera_select') {
(result.view ??= {}).cameraID = action.camera;
(result.view ??= {}).camera = action.camera;
} else if (action.frigate_card_action === 'live_substream_select') {
(result.view ??= {}).substream = action.camera;
} else {
+26 -26
View File
@@ -36,7 +36,7 @@ export class TriggersManager {
return sorted.length ? sorted[0][0] : null;
}
public handleCameraEvent(ev: CameraEvent): void {
public async handleCameraEvent(ev: CameraEvent): Promise<void> {
const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers;
const selectedCameraID = this._api.getViewManager().getView()?.camera;
@@ -60,7 +60,7 @@ export class TriggersManager {
this._triggeredCameras.set(ev.cameraID, new Date());
this._setConditionStateIfNecessary();
this._throttledTriggerAction(ev);
await this._throttledTriggerAction(ev);
}
protected _hasAllowableInteractionStateForAction(): boolean {
@@ -75,7 +75,7 @@ export class TriggersManager {
);
}
protected _triggerAction(ev: CameraEvent): void {
protected async _triggerAction(ev: CameraEvent): Promise<void> {
const triggerAction = this._api.getConfigManager().getConfig()?.view.triggers
.actions.trigger;
const defaultView = this._api.getConfigManager().getConfig()?.view.default;
@@ -98,30 +98,30 @@ export class TriggersManager {
if (this._hasAllowableInteractionStateForAction()) {
if (triggerAction === 'update') {
const view = this._api.getViewManager().getView()?.evolve({
// Reset the media queries to catch media to be refetched in the
// current view.
query: null,
queryResults: null,
});
/* istanbul ignore else: the else path cannot be reached, as the camera
cannot be triggered without a view -- @preserve */
if (view) {
this._api.getViewManager().setView(view);
}
await this._api
.getViewManager()
.setViewByParametersWithNewQuery({
queryExecutorOptions: { useCache: false },
});
} else if (triggerAction === 'live') {
this._api.getViewManager().setViewByParameters({
viewName: 'live',
cameraID: ev.cameraID,
await this._api.getViewManager().setViewByParametersWithNewQuery({
params: {
view: 'live',
camera: ev.cameraID,
},
});
} else if (triggerAction === 'default') {
this._api.getViewManager().setViewDefault({
cameraID: ev.cameraID,
await this._api.getViewManager().setViewDefaultWithNewQuery({
params: {
camera: ev.cameraID,
},
});
} else if (ev.fidelity === 'high' && triggerAction === 'media') {
this._api.getViewManager().setViewByParameters({
viewName: ev.clip ? 'clip' : 'snapshot',
cameraID: ev.cameraID,
await this._api.getViewManager().setViewByParametersWithNewQuery({
params: {
view: ev.clip ? 'clip' : 'snapshot',
camera: ev.cameraID,
},
});
}
}
@@ -143,12 +143,12 @@ export class TriggersManager {
}
}
protected _untriggerAction(cameraID: string): void {
protected async _untriggerAction(cameraID: string): Promise<void> {
const action = this._api.getConfigManager().getConfig()?.view.triggers
.actions.untrigger;
if (action === 'default' && this._hasAllowableInteractionStateForAction()) {
this._api.getViewManager().setViewDefault();
await this._api.getViewManager().setViewDefaultWithNewQuery();
}
this._triggeredCameras.delete(cameraID);
this._deleteTimer(cameraID);
@@ -168,8 +168,8 @@ export class TriggersManager {
reached, as there's no way to have the untrigger call happen without
a config. -- @preserve */
this._api.getConfigManager().getConfig()?.view.triggers.untrigger_seconds ?? 0,
() => {
this._untriggerAction(cameraID);
async () => {
await this._untriggerAction(cameraID);
},
);
}
+1 -1
View File
@@ -20,7 +20,7 @@ import type { MessageManager } from './message-manager';
import type { MicrophoneManager } from './microphone-manager';
import type { StyleManager } from './style-manager';
import type { TriggersManager } from './triggers-manager';
import type { ViewManager } from './view-manager';
import type { ViewManager } from './view/view-manager';
import type { QueryStringManager } from './query-string-manager';
import { KeyboardStateManager } from './keyboard-state-manager';
import { Automation } from '../config/types';
-323
View File
@@ -1,323 +0,0 @@
import isEqual from 'lodash-es/isEqual';
import { ViewContext } from 'view';
import {
FRIGATE_CARD_VIEW_DEFAULT,
FrigateCardConfig,
FrigateCardView,
ViewDisplayMode,
} from '../config/types';
import { localize } from '../localize/localize';
import { log } from '../utils/debug';
import { executeMediaQueryForView } from '../utils/media-to-view';
import { View } from '../view/view';
import { getCameraIDsForViewName } from '../view/view-to-cameras';
import { CardViewAPI } from './types';
interface ViewManagerSetViewDefaultParameters {
cameraID?: string;
substream?: string;
// When failSafe is true, the view will be changed to the default view, or the
// `live` view if the default view is not supported, or failing that an error
// message is shown. Without `failSafe` the view will just not be changed if
// unsupported.
failSafe?: boolean;
}
export interface ViewManagerSetViewParameters
extends ViewManagerSetViewDefaultParameters {
viewName?: FrigateCardView;
}
export class ViewManager {
protected _view: View | null = null;
protected _api: CardViewAPI;
constructor(api: CardViewAPI) {
this._api = api;
}
public getView(): View | null {
return this._view;
}
public hasView(): boolean {
return !!this.getView();
}
public setView(view: View): void {
this._setView(view);
}
public setViewDefault(params?: ViewManagerSetViewDefaultParameters): void {
const config = this._api.getConfigManager().getConfig();
if (config) {
let forceCameraID: string | null = params?.cameraID ?? null;
const viewName = config.view.default;
if (!forceCameraID && this._view?.camera && config.view.default_cycle_camera) {
const cameraIDs = [
...getCameraIDsForViewName(this._api.getCameraManager(), viewName),
];
const currentIndex = cameraIDs.indexOf(this._view.camera);
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
forceCameraID = cameraIDs[targetIndex];
}
this.setViewByParameters({
...params,
viewName: viewName,
...(forceCameraID && { cameraID: forceCameraID }),
});
}
}
public setViewByParameters(params: ViewManagerSetViewParameters): void {
const config = this._api.getConfigManager().getConfig();
if (config) {
let cameraID: string | null = null;
let viewName = params?.viewName ?? this._view?.view ?? config.view.default;
const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs();
if (params?.cameraID && allCameraIDs.has(params.cameraID)) {
cameraID = params.cameraID;
} else {
const viewCameraIDs = getCameraIDsForViewName(
this._api.getCameraManager(),
viewName,
);
// Reset to the default camera.
cameraID = viewCameraIDs.keys().next().value;
}
if (!cameraID) {
if (params.failSafe) {
const camerasToCapabilities = [
...this._api.getCameraManager().getStore().getCameras(),
].reduce((acc, [cameraID, camera]) => {
const capabilities = camera.getCapabilities()?.getRawCapabilities();
if (capabilities) {
acc[cameraID] = capabilities;
}
return acc;
}, {});
this._api.getMessageManager().setMessageIfHigherPriority({
type: 'error',
message: localize('error.no_supported_cameras'),
context: {
view: viewName,
cameras_capabilities: camerasToCapabilities,
},
});
}
return;
}
if (!this.isViewSupportedByCamera(cameraID, viewName)) {
if (params.failSafe) {
if (this.isViewSupportedByCamera(cameraID, FRIGATE_CARD_VIEW_DEFAULT)) {
viewName = FRIGATE_CARD_VIEW_DEFAULT;
} else {
const capabilities = this._api
.getCameraManager()
.getStore()
.getCamera(cameraID)
?.getCapabilities()
?.getRawCapabilities();
this._api.getMessageManager().setMessageIfHigherPriority({
type: 'error',
message: localize('error.no_supported_camera'),
context: {
view: viewName,
camera: cameraID,
...(capabilities && { camera_capabilities: capabilities }),
},
});
return;
}
} else {
return;
}
}
const displayMode =
this._view?.displayMode ?? this._getDefaultDisplayModeForView(viewName, config);
let view: View = new View({
view: viewName,
camera: cameraID,
displayMode: displayMode,
});
if (params.substream) {
view = this._createViewWithSelectedSubstream(view, params.substream);
}
this._setView(view);
}
}
public setViewWithMergedContext(context: ViewContext | null): void {
if (this._view) {
return this._setView(this._view?.clone().mergeInContext(context));
}
}
public reset(): void {
this._view = null;
}
protected _getCameraIDsInvolvedInView(view: View): Set<string> {
return view.supportsMultipleDisplayModes() && view.isGrid()
? getCameraIDsForViewName(this._api.getCameraManager(), view.view)
: getCameraIDsForViewName(this._api.getCameraManager(), view.view, view.camera);
}
public async setViewWithNewDisplayMode(displayMode: ViewDisplayMode): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
if (this._view && hass) {
const view = this._view.evolve({
displayMode: displayMode,
});
const expectedCameraIDs = this._getCameraIDsInvolvedInView(view);
const queryCameraIDs = view.query?.getQueryCameraIDs();
if (!isEqual(expectedCameraIDs, queryCameraIDs) && view && view.query) {
// If the user requests a grid but the current query does not have a
// query for more than one camera, reset the query results, change the
// existing query to refer to all cameras and execute it to fetch new
// results.
let viewWithNewQuery: View | null = null;
try {
viewWithNewQuery = await executeMediaQueryForView(
this._api.getCameraManager(),
view,
view.query.clone().setQueryCameraIDs(expectedCameraIDs),
);
} catch (e: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
}
if (viewWithNewQuery) {
return this._setView(viewWithNewQuery);
}
} else {
return this._setView(view);
}
}
}
public setViewWithSubstream(substream?: string): void {
if (!this._view) {
return;
}
this._setView(
substream
? this._createViewWithSelectedSubstream(this._view, substream)
: this._createViewWithNextStream(this._view),
);
}
public setViewWithoutSubstream(): void {
const view = this._createViewWithoutSubstream();
if (view) {
return this._setView(view);
}
}
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
}
protected _getDefaultDisplayModeForView(
viewName: FrigateCardView,
config?: FrigateCardConfig,
): ViewDisplayMode {
let mode: ViewDisplayMode | null = null;
switch (viewName) {
case 'media':
case 'clip':
case 'recording':
case 'snapshot':
mode = config?.media_viewer.display?.mode ?? null;
break;
case 'live':
mode = config?.live.display?.mode ?? null;
break;
}
return mode ?? 'single';
}
protected _setView(view: View): void {
const oldView = this._view;
View.adoptFromViewIfAppropriate(view, oldView);
log(
this._api.getConfigManager().getCardWideConfig(),
`Frigate Card view change: `,
view,
);
this._view = view;
if (View.isMajorMediaChange(oldView, view)) {
this._api.getMediaLoadedInfoManager().clear();
}
if (oldView?.view !== view.view) {
this._api.getCardElementManager().scrollReset();
}
this._api.getMessageManager().reset();
this._api.getStyleManager().setExpandedMode();
this._api.getConditionsManager()?.setState({
view: view.view,
camera: view.camera,
displayMode: view.displayMode ?? undefined,
});
this._api.getCardElementManager().update();
}
protected _createViewWithSelectedSubstream(baseView: View, substreamID: string): View {
const overrides: Map<string, string> =
baseView?.context?.live?.overrides ?? new Map();
overrides.set(baseView.camera, substreamID);
return baseView.clone().mergeInContext({
live: { overrides: overrides },
});
}
protected _createViewWithNextStream(baseView: View): View {
const dependencies = [
...this._api.getCameraManager().getStore().getAllDependentCameras(baseView.camera),
];
if (dependencies.length <= 1) {
return baseView.clone();
}
const view = baseView.clone();
const overrides: Map<string, string> = view.context?.live?.overrides ?? new Map();
const currentOverride = overrides.get(view.camera) ?? view.camera;
const currentIndex = dependencies.indexOf(currentOverride);
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
overrides.set(view.camera, dependencies[newIndex]);
view.mergeInContext({ live: { overrides: overrides } });
return view;
}
protected _createViewWithoutSubstream(): View | null {
if (!this._view) {
return null;
}
const view = this._view.clone();
const overrides: Map<string, string> | undefined = view.context?.live?.overrides;
if (overrides && overrides.has(view.camera)) {
view.context?.live?.overrides?.delete(view.camera);
}
return view;
}
}
+373
View File
@@ -0,0 +1,373 @@
import { sub } from 'date-fns';
import {
FRIGATE_CARD_VIEW_DEFAULT,
FrigateCardConfig,
FrigateCardView,
ViewDisplayMode,
} from '../../config/types';
import { localize } from '../../localize/localize';
import { ClipsOrSnapshotsOrAll } from '../../types';
import { View, ViewParameters } from '../../view/view';
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
import { CardViewAPI } from '../types';
import { QueryExecutor } from './query-executor';
import {
QueryExecutorOptions,
QueryWithResults,
ViewFactoryOptions,
ViewIncompatible,
ViewNoCameraError,
} from './types';
export class ViewFactory {
protected _api: CardViewAPI;
protected _executor: QueryExecutor;
constructor(api: CardViewAPI, executor?: QueryExecutor) {
this._api = api;
this._executor = executor ?? new QueryExecutor(api);
}
public getViewDefault(options?: ViewFactoryOptions): View | null {
const config = this._api.getConfigManager().getConfig();
if (!config) {
return null;
}
let forceCameraID: string | null = options?.params?.camera ?? null;
const viewName = options?.params?.view ?? config.view.default;
if (
!forceCameraID &&
options?.baseView?.camera &&
config.view.default_cycle_camera
) {
const cameraIDs = [
...getCameraIDsForViewName(this._api.getCameraManager(), viewName),
];
const currentIndex = cameraIDs.indexOf(options?.baseView?.camera);
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
forceCameraID = cameraIDs[targetIndex];
}
return this.getViewByParameters({
params: {
...options?.params,
view: viewName,
...(forceCameraID && { camera: forceCameraID }),
},
baseView: options?.baseView,
});
}
public getViewByParameters(options?: ViewFactoryOptions): View | null {
const config = this._api.getConfigManager().getConfig();
if (!config) {
return null;
}
let cameraID: string | null =
options?.params?.camera ?? options?.baseView?.camera ?? null;
let viewName =
options?.params?.view ?? options?.baseView?.view ?? config.view.default;
const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs();
if (!cameraID || !allCameraIDs.has(cameraID)) {
const viewCameraIDs = getCameraIDsForViewName(
this._api.getCameraManager(),
viewName,
);
// Reset to the default camera.
cameraID = viewCameraIDs.keys().next().value;
}
if (!cameraID) {
const camerasToCapabilities = [
...this._api.getCameraManager().getStore().getCameras(),
].reduce((acc, [cameraID, camera]) => {
const capabilities = camera.getCapabilities()?.getRawCapabilities();
if (capabilities) {
acc[cameraID] = capabilities;
}
return acc;
}, {});
throw new ViewNoCameraError(localize('error.no_supported_cameras'), {
view: viewName,
cameras_capabilities: camerasToCapabilities,
});
}
if (!this.isViewSupportedByCamera(cameraID, viewName)) {
if (
options?.failSafe &&
this.isViewSupportedByCamera(cameraID, FRIGATE_CARD_VIEW_DEFAULT)
) {
viewName = FRIGATE_CARD_VIEW_DEFAULT;
} else {
const capabilities = this._api
.getCameraManager()
.getStore()
.getCamera(cameraID)
?.getCapabilities()
?.getRawCapabilities();
throw new ViewIncompatible(localize('error.no_supported_camera'), {
view: viewName,
camera: cameraID,
...(capabilities && { camera_capabilities: capabilities }),
});
}
}
const displayMode =
options?.params?.displayMode ??
options?.baseView?.displayMode ??
this._getDefaultDisplayModeForView(viewName, config);
const viewParameters: ViewParameters = {
...options?.params,
view: viewName,
camera: cameraID,
displayMode: displayMode,
};
const view = options?.baseView
? options.baseView.evolve(viewParameters)
: new View(viewParameters);
if (options?.modifiers) {
options.modifiers.forEach((modifier) => modifier.modify(view));
}
return view;
}
public async getViewDefaultWithNewQuery(
options?: ViewFactoryOptions,
): Promise<View | null> {
return this._executeNewQuery(this.getViewDefault(options), {
...options,
queryExecutorOptions: {
useCache: false,
...options?.queryExecutorOptions,
},
});
}
public async getViewByParametersWithNewQuery(
options?: ViewFactoryOptions,
): Promise<View | null> {
return this._executeNewQuery(this.getViewByParameters(options), {
...options,
queryExecutorOptions: {
useCache: false,
...options?.queryExecutorOptions,
},
});
}
public async getViewByParametersWithExistingQuery(
options?: ViewFactoryOptions,
): Promise<View | null> {
const view = this.getViewByParameters(options);
if (view?.query) {
view.queryResults = await this._executor.execute(
view.query,
options?.queryExecutorOptions,
);
}
return view;
}
protected async _executeNewQuery(
view: View | null,
options?: ViewFactoryOptions,
): Promise<View | null> {
const config = this._api.getConfigManager().getConfig();
if (
!config ||
/* istanbul ignore next: this path cannot be reached as the only way for
view to be null here, is if the config is also null -- @preserve */
!view
) {
return null;
}
const executeMediaQuery = async (
mediaType: ClipsOrSnapshotsOrAll | 'recordings' | null,
): Promise<boolean> => {
/* istanbul ignore if: this path cannot be reached -- @preserve */
if (!mediaType) {
return false;
}
return await this._executeMediaQuery(
view,
mediaType === 'recordings' ? 'recordings' : 'events',
{
eventsMediaType: mediaType === 'recordings' ? undefined : mediaType,
executorOptions: options?.queryExecutorOptions,
},
);
};
// Implementation note: For new queries, if the query itself fails that is
// just ignored and the view is returned anyway (e.g. if the user changes to
// live but the thumbnail fetch fails, it is better to change to live and
// show no thumbnails than not change to live).
const mediaType = view.getDefaultMediaType();
const baseView = options?.baseView;
const switchingToGalleryFromViewer =
baseView?.isViewerView() && view.isGalleryView();
if (switchingToGalleryFromViewer && baseView?.query && baseView?.queryResults) {
// If the user is currently using the viewer, and then switches to the
// gallery we make an attempt to keep the query/queryResults the same so
// the gallery can be used to click back and forth to the viewer, and the
// selected media can be centered in the gallery. See the matching code in
// `updated()` in `gallery.ts`. We specifically must ensure that the new
// target media of the gallery (e.g. clips, snapshots or recordings) is
// equal to the queries that are currently used in the viewer.
//
// See: https://github.com/dermotduffy/frigate-hass-card/issues/885
view.query = baseView.query;
view.queryResults = baseView.queryResults;
} else {
switch (view.view) {
case 'live':
this._setTimelineWindowToLive(view);
if (config.live.controls.thumbnails.mode !== 'none') {
await executeMediaQuery(
config.live.controls.thumbnails.media_type === 'recordings'
? 'recordings'
: config.live.controls.thumbnails.events_media_type,
);
}
break;
case 'media':
// If the user is looking at media in the `media` view and then
// changes camera (via the menu) it should default to showing clips
// for the new camera.
if (baseView && view.camera !== baseView.camera) {
await executeMediaQuery('clips');
}
break;
// Gallery views:
case 'clips':
case 'snapshots':
case 'recordings':
await executeMediaQuery(mediaType);
break;
// Viewer views:
case 'clip':
case 'snapshot':
case 'recording':
if (config.media_viewer.controls.thumbnails.mode !== 'none') {
await executeMediaQuery(mediaType);
}
break;
}
}
this._setOrRemoveSeekTime(
view,
options?.queryExecutorOptions?.selectResult?.time?.time,
);
return view;
}
protected _setTimelineWindowToLive(view: View): void {
const now = new Date();
const liveConfig = this._api.getConfigManager().getConfig()?.live;
/* istanbul ignore if: this if branch cannot be reached as if the config is
empty this function is never called -- @preserve */
if (!liveConfig) {
return;
}
view.mergeInContext({
// Force the window to start at the most recent time, not
// necessarily when the most recent event/recording was:
// https://github.com/dermotduffy/frigate-hass-card/issues/1301
timeline: {
window: {
start: sub(now, {
seconds: liveConfig.controls.timeline.window_seconds,
}),
end: now,
},
},
});
}
protected _setOrRemoveSeekTime(view: View, time?: Date): void {
if (time) {
view.mergeInContext({
mediaViewer: {
seek: time,
},
});
} else {
view.removeContextProperty('mediaViewer', 'seek');
}
}
protected async _executeMediaQuery(
view: View,
mediaType: 'events' | 'recordings',
options?: {
eventsMediaType?: ClipsOrSnapshotsOrAll;
executorOptions?: QueryExecutorOptions;
},
): Promise<boolean> {
const queryWithResults: QueryWithResults | null =
mediaType === 'events'
? await this._executor.executeDefaultEventQuery({
...(!view.isGrid() && { cameraID: view.camera }),
eventsMediaType: options?.eventsMediaType,
executorOptions: options?.executorOptions,
})
: mediaType === 'recordings'
? await this._executor.executeDefaultRecordingQuery({
...(!view.isGrid() && { cameraID: view.camera }),
executorOptions: options?.executorOptions,
})
: /* istanbul ignore next -- @preserve */
null;
if (!queryWithResults) {
return false;
}
view.query = queryWithResults.query;
view.queryResults = queryWithResults.queryResults;
return true;
}
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
}
protected _getDefaultDisplayModeForView(
viewName: FrigateCardView,
config?: FrigateCardConfig,
): ViewDisplayMode {
let mode: ViewDisplayMode | null = null;
switch (viewName) {
case 'media':
case 'clip':
case 'recording':
case 'snapshot':
mode = config?.media_viewer.display?.mode ?? null;
break;
case 'live':
mode = config?.live.display?.mode ?? null;
break;
}
return mode ?? 'single';
}
}
@@ -0,0 +1,15 @@
import { ViewContext } from "view";
import { View } from "../../../view/view";
import { ViewModifier } from "../types";
export class MergeContextViewModifier implements ViewModifier {
protected _context?: ViewContext | null;
constructor(context?: ViewContext | null) {
this._context = context;
}
public modify(view: View): void {
view.mergeInContext(this._context);
}
}
@@ -0,0 +1,17 @@
import { ViewContext } from 'view';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class RemoveContextPropertyViewModifier implements ViewModifier {
protected _key: keyof ViewContext;
protected _property: PropertyKey;
constructor(key: keyof ViewContext, property: PropertyKey) {
this._key = key;
this._property = property;
}
public modify(view: View): void {
view.removeContextProperty(this._key, this._property);
}
}
@@ -0,0 +1,15 @@
import { ViewContext } from "view";
import { View } from "../../../view/view";
import { ViewModifier } from "../types";
export class RemoveContextViewModifier implements ViewModifier {
protected _keys: (keyof ViewContext)[];
constructor(keys: (keyof ViewContext)[]) {
this._keys = keys;
}
public modify(view: View): void {
this._keys.forEach((key) => view.removeContext(key));
}
}
@@ -0,0 +1,10 @@
import { removeSubstream } from '../../../utils/substream';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class SubstreamOffViewModifier implements ViewModifier {
public modify(view: View): void {
removeSubstream(view);
}
}
@@ -0,0 +1,35 @@
import { CameraManager } from '../../../camera-manager/manager';
import { getStreamCameraID, setSubstream } from '../../../utils/substream';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
interface SubstreamOnViewModifierAPI {
getCameraManager(): CameraManager;
}
export class SubstreamOnViewModifier implements ViewModifier {
protected _api: SubstreamOnViewModifierAPI;
constructor(api: SubstreamOnViewModifierAPI) {
this._api = api;
}
public modify(view: View): void {
const dependencies = [
...this._api
.getCameraManager()
.getStore()
.getAllDependentCameras(view.camera, 'substream'),
];
if (dependencies.length <= 1) {
return;
}
const currentOverride = getStreamCameraID(view);
const currentIndex = dependencies.indexOf(currentOverride);
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
setSubstream(view, dependencies[newIndex]);
}
}
@@ -0,0 +1,15 @@
import { setSubstream } from '../../../utils/substream';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class SubstreamSelectViewModifier implements ViewModifier {
protected _substreamID: string;
constructor(substreamID: string) {
this._substreamID = substreamID;
}
public modify(view: View): void {
setSubstream(view, this._substreamID);
}
}
+131
View File
@@ -0,0 +1,131 @@
import { CapabilitySearchOptions, MediaQuery } from '../../camera-manager/types';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
import { ClipsOrSnapshotsOrAll } from '../../types';
import { findBestMediaIndex } from '../../utils/find-best-media-index';
import {
EventMediaQueries,
MediaQueries,
RecordingMediaQueries,
} from '../../view/media-queries';
import { MediaQueriesResults } from '../../view/media-queries-results';
import { CardViewAPI } from '../types';
import { QueryExecutorOptions, QueryWithResults } from './types';
export class QueryExecutor {
protected _api: CardViewAPI;
constructor(api: CardViewAPI) {
this._api = api;
}
public async executeDefaultEventQuery(options?: {
cameraID?: string;
eventsMediaType?: ClipsOrSnapshotsOrAll;
executorOptions?: QueryExecutorOptions;
}): Promise<QueryWithResults | null> {
const capabilitySearch: CapabilitySearchOptions =
!options?.eventsMediaType || options?.eventsMediaType === 'all'
? {
anyCapabilities: ['clips', 'snapshots'],
}
: options.eventsMediaType;
const cameraManager = this._api.getCameraManager();
const cameraIDs = options?.cameraID
? cameraManager
.getStore()
.getAllDependentCameras(options.cameraID, capabilitySearch)
: cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch);
if (!cameraIDs.size) {
return null;
}
const rawQueries = cameraManager.generateDefaultEventQueries(cameraIDs, {
limit: this._getChunkLimit(),
...(options?.eventsMediaType === 'clips' && { hasClip: true }),
...(options?.eventsMediaType === 'snapshots' && { hasSnapshot: true }),
});
if (!rawQueries) {
return null;
}
const queries = new EventMediaQueries(rawQueries);
const results = await this.execute(queries, options?.executorOptions);
return results
? {
query: queries,
queryResults: results,
}
: null;
}
public async executeDefaultRecordingQuery(options?: {
cameraID?: string;
executorOptions?: QueryExecutorOptions;
}): Promise<QueryWithResults | null> {
const cameraManager = this._api.getCameraManager();
const cameraIDs = options?.cameraID
? cameraManager.getStore().getAllDependentCameras(options.cameraID, 'recordings')
: cameraManager.getStore().getCameraIDsWithCapability('recordings');
if (!cameraIDs.size) {
return null;
}
const rawQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, {
limit: this._getChunkLimit(),
});
if (!rawQueries) {
return null;
}
const queries = new RecordingMediaQueries(rawQueries);
const results = await this.execute(queries, options?.executorOptions);
return results ? { query: queries, queryResults: results } : null;
}
public async execute(
query: MediaQueries,
executorOptions?: QueryExecutorOptions,
): Promise<MediaQueriesResults | null> {
const queries = query.getQueries();
if (!queries) {
return null;
}
const mediaArray = await this._api
.getCameraManager()
.executeMediaQueries<MediaQuery>(queries, {
useCache: executorOptions?.useCache,
});
if (!mediaArray) {
return null;
}
const queryResults = new MediaQueriesResults({ results: mediaArray });
if (executorOptions?.rejectResults?.(queryResults)) {
return null;
}
if (executorOptions?.selectResult?.id) {
queryResults.selectBestResult((media) =>
media.findIndex((m) => m.getID() === executorOptions.selectResult?.id),
);
} else if (executorOptions?.selectResult?.func) {
queryResults.selectResultIfFound(executorOptions.selectResult.func);
} else if (executorOptions?.selectResult?.time) {
queryResults.selectBestResult((media) =>
findBestMediaIndex(
media,
executorOptions.selectResult?.time?.time as Date,
executorOptions.selectResult?.time?.favorCameraID,
),
);
}
return queryResults;
}
protected _getChunkLimit(): number {
const cardWideConfig = this._api.getConfigManager().getCardWideConfig();
return (
cardWideConfig?.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT
);
}
}
+80
View File
@@ -0,0 +1,80 @@
import { ViewContext } from 'view';
import { FrigateCardView } from '../../config/types.js';
import { FrigateCardError } from '../../types.js';
import { MediaQueriesResults } from '../../view/media-queries-results.js';
import { MediaQueries } from '../../view/media-queries.js';
import { ViewMedia } from '../../view/media.js';
import { View, ViewParameters } from '../../view/view.js';
export interface ViewModifier {
modify(view: View): void;
}
export interface QueryExecutorOptions {
// Select the result of a query, based on time, an id match or an arbitrary
// function. If no parameter is specified, the latest media will be selected
// by default.
selectResult?: {
time?: {
time: Date;
favorCameraID?: string;
};
id?: string;
func?: (media: ViewMedia) => boolean;
};
rejectResults?: (results: MediaQueriesResults) => boolean;
useCache?: boolean;
}
export interface QueryWithResults {
query: MediaQueries;
queryResults: MediaQueriesResults;
}
export interface ViewFactoryOptions {
// An existing view to evolve from.
baseView?: View | null;
// View parameters to set/evolve.
params?: Partial<ViewParameters>;
// Modifiers to the view once created.
modifiers?: ViewModifier[];
// When failSafe is true the view will be changed to the default view, or the
// `live` view if the configured default view is not supported.
failSafe?: boolean;
// Options for the query executor that control how a query is executed and the
// result selected.
queryExecutorOptions?: QueryExecutorOptions;
}
export interface ViewManagerEpoch {
manager: ViewManagerInterface;
oldView?: View;
}
export interface ViewManagerInterface {
getEpoch(): ViewManagerEpoch;
getView(): View | null;
hasView(): boolean;
reset(): void;
setViewDefault(options?: ViewFactoryOptions): void;
setViewByParameters(options?: ViewFactoryOptions): void;
setViewDefaultWithNewQuery(options?: ViewFactoryOptions): Promise<void>;
setViewByParametersWithNewQuery(options?: ViewFactoryOptions): Promise<void>;
setViewByParametersWithExistingQuery(options?: ViewFactoryOptions): Promise<void>;
setViewWithMergedContext(context: ViewContext | null): void;
isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean;
hasMajorMediaChange(oldView?: View | null): boolean;
}
export class ViewNoCameraError extends FrigateCardError {}
export class ViewIncompatible extends FrigateCardError {}
+164
View File
@@ -0,0 +1,164 @@
import { ViewContext } from 'view';
import { FrigateCardView } from '../../config/types';
import { log } from '../../utils/debug';
import { getStreamCameraID } from '../../utils/substream';
import { View } from '../../view/view';
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
import { CardViewAPI } from '../types';
import { ViewFactory } from './factory';
import { ViewFactoryOptions, ViewManagerEpoch, ViewManagerInterface } from './types';
export class ViewManager implements ViewManagerInterface {
protected _view: View | null = null;
protected _factory: ViewFactory;
protected _api: CardViewAPI;
protected _epoch: ViewManagerEpoch = this._createEpoch();
constructor(api: CardViewAPI, factory?: ViewFactory) {
this._api = api;
this._factory = factory ?? new ViewFactory(api);
}
public getEpoch(): ViewManagerEpoch {
return this._epoch;
}
protected _createEpoch(oldView?: View | null): ViewManagerEpoch {
return {
manager: this,
...(oldView && { oldView }),
};
}
public getView(): View | null {
return this._view;
}
public hasView(): boolean {
return !!this.getView();
}
public reset(): void {
if (this._view) {
this._setView(null);
}
}
setViewDefault = (options?: ViewFactoryOptions): void =>
this._setViewGeneric(this._factory.getViewDefault, options);
setViewByParameters = (options?: ViewFactoryOptions): void =>
this._setViewGeneric(this._factory.getViewByParameters, options);
setViewDefaultWithNewQuery = async (options?: ViewFactoryOptions): Promise<void> =>
await this._setViewGenericAsync(this._factory.getViewDefaultWithNewQuery, options);
setViewByParametersWithNewQuery = async (
options?: ViewFactoryOptions,
): Promise<void> =>
await this._setViewGenericAsync(
this._factory.getViewByParametersWithNewQuery,
options,
);
setViewByParametersWithExistingQuery = async (
options?: ViewFactoryOptions,
): Promise<void> =>
await this._setViewGenericAsync(
this._factory.getViewByParametersWithExistingQuery,
options,
);
protected _setViewGeneric(
factoryFunc: (options?: ViewFactoryOptions) => View | null,
options?: ViewFactoryOptions,
): void {
let view: View | null = null;
try {
view = factoryFunc({
baseView: this._view,
...options,
});
} catch (e) {
return this._api.getMessageManager().setErrorIfHigherPriority(e);
}
view && this._setView(view);
}
protected async _setViewGenericAsync(
factoryFunc: (options?: ViewFactoryOptions) => Promise<View | null>,
options?: ViewFactoryOptions,
): Promise<void> {
let view: View | null = null;
try {
view = await factoryFunc({
baseView: this._view,
...options,
});
} catch (e) {
return this._api.getMessageManager().setErrorIfHigherPriority(e);
}
view && this._setView(view);
}
public setViewWithMergedContext(context: ViewContext | null): void {
if (this._view) {
return this._setView(this._view?.clone().mergeInContext(context));
}
}
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
}
/**
* Detect if the current view has a major "media change" for the given previous view.
* @param oldView The previous view.
* @returns True if the view change is a real media change.
*/
public hasMajorMediaChange(oldView?: View | null): boolean {
return (
!!oldView !== !!this._view ||
oldView?.view !== this._view?.view ||
oldView?.camera !== this._view?.camera ||
// When in live mode, take overrides (substreams) into account in deciding
// if this is a major media change.
(this._view?.view === 'live' &&
oldView &&
getStreamCameraID(oldView) !== getStreamCameraID(this._view)) ||
// When in the live view, the queryResults contain the events that
// happened in the past -- not reflective of the actual live media viewer
// the user is seeing.
(this._view?.view !== 'live' && oldView?.queryResults !== this._view?.queryResults)
);
}
protected _setView(view: View | null): void {
const oldView = this._view;
log(
this._api.getConfigManager().getCardWideConfig(),
`Frigate Card view change: `,
view,
);
this._view = view;
this._epoch = this._createEpoch(oldView);
if (this.hasMajorMediaChange(oldView)) {
this._api.getMediaLoadedInfoManager().clear();
}
if (oldView?.view !== view?.view) {
this._api.getCardElementManager().scrollReset();
}
this._api.getMessageManager().reset();
this._api.getStyleManager().setExpandedMode();
this._api.getConditionsManager()?.setState({
view: view?.view,
camera: view?.camera,
displayMode: view?.displayMode ?? undefined,
});
this._api.getCardElementManager().update();
}
}
+1 -7
View File
@@ -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<Message>) =>
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
@frigate-card:view:change=${(ev: CustomEvent<View>) =>
this._controller.getViewManager().setView(ev.detail)}
@frigate-card:view:change-context=${(ev: CustomEvent<ViewContext | null>) =>
this._controller.getViewManager().setViewWithMergedContext(ev.detail)}
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
@frigate-card:media:unloaded=${() =>
@@ -299,7 +293,7 @@ class FrigateCard extends LitElement {
html`<frigate-card-views
${ref(this._refViews)}
.hass=${this._hass}
.view=${this._controller.getViewManager().getView()}
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
.cameraManager=${cameraManager}
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
.nonOverriddenConfig=${this._controller
+2 -92
View File
@@ -1,26 +1,15 @@
import { sub } from 'date-fns';
import { LitElement, ReactiveController } from 'lit';
import { ViewContext } from 'view';
import { CameraManager } from '../../camera-manager/manager.js';
import { FrigateCardMessageEventTarget } from '../../components/message.js';
import { CardWideConfig, LiveConfig } from '../../config/types.js';
import { MediaLoadedInfo, Message } from '../../types.js';
import {
FrigateCardMediaLoadedEventTarget,
dispatchExistingMediaLoadedInfoAsEvent,
} from '../../utils/media-info.js';
import {
changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents,
} from '../../utils/media-to-view.js';
import { FrigateCardViewChangeEventTarget, View } from '../../view/view.js';
interface LiveViewContext {
// A cameraID override (used for dependencies/substreams to force a different
// camera to be live rather than the camera selected in the view).
overrides?: Map<string, string>;
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<View>): 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<void> {
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,
},
);
}
}
}
+31 -41
View File
@@ -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 {
+1 -1
View File
@@ -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,
+16 -12
View File
@@ -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<ZoomSettingsObserved>,
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,
}),
),
],
});
};
+36 -64
View File
@@ -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<View>;
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`
<frigate-card-surround-basic
.drawerIcons=${{
@@ -118,7 +87,7 @@ export class FrigateCardGallery extends LitElement {
? html` <frigate-card-media-filter
.hass=${this.hass}
.cameraManager=${this.cameraManager}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.cardWideConfig=${this.cardWideConfig}
slot=${this.galleryConfig.controls.filter.mode}
>
@@ -126,7 +95,7 @@ export class FrigateCardGallery extends LitElement {
: ''}
<frigate-card-gallery-core
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.galleryConfig=${this.galleryConfig}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
@@ -147,7 +116,7 @@ export class FrigateCardGalleryCore extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public galleryConfig?: GalleryConfig;
@@ -328,13 +297,15 @@ export class FrigateCardGalleryCore extends LitElement {
direction: 'earlier' | 'later',
useCache = true,
): Promise<void> {
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` <div class="grid">
${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
) {
+63 -87
View File
@@ -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<View>;
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`
<frigate-card-live-grid
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig}
.inBackground=${this._controller.isInBackground()}
@@ -180,7 +159,7 @@ export class FrigateCardLiveGrid extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public nonOverriddenLiveConfig?: LiveConfig;
@@ -207,13 +186,14 @@ export class FrigateCardLiveGrid extends LitElement {
public triggeredCameraIDs?: Set<string>;
protected _renderCarousel(cameraID?: string): TemplateResult {
const triggeredCameraID = cameraID ?? this.view?.camera;
const view = this.viewManagerEpoch?.manager.getView();
const triggeredCameraID = cameraID ?? view?.camera;
return html`
<frigate-card-live-carousel
grid-id=${ifDefined(cameraID)}
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewFilterCameraID=${cameraID}
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig}
@@ -229,26 +209,27 @@ export class FrigateCardLiveGrid extends LitElement {
`;
}
protected _gridSelectCamera(cameraID: string, view?: View): void {
(view ?? this.view)
?.evolve({
protected _gridSelectCamera(cameraID: string): void {
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
camera: cameraID,
})
.dispatchChangeEvent(this);
},
});
}
protected _needsGrid(): boolean {
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
const view = this.viewManagerEpoch?.manager.getView();
return (
!!this.view?.isGrid() &&
!!this.view?.supportsMultipleDisplayModes() &&
!!view?.isGrid() &&
!!view?.supportsMultipleDisplayModes() &&
!!cameraIDs &&
cameraIDs.size > 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`
<frigate-card-media-grid
.selected=${this.view?.camera}
.selected=${this.viewManagerEpoch?.manager.getView()?.camera}
.displayConfig=${this.overriddenLiveConfig?.display}
@frigate-card:media-grid:selected=${(ev: CustomEvent<MediaGridSelected>) =>
this._gridSelectCamera(ev.detail.selected)}
@frigate-card:view:change=${(ev: CustomEvent<View>) => {
ev.stopPropagation();
this._gridSelectCamera(ev.detail.camera, ev.detail);
}}
>
${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))}
</frigate-card-media-grid>
@@ -288,7 +266,7 @@ export class FrigateCardLiveCarousel extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
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`
<div class="embla__slide">
<frigate-card-live-provider
?load=${!liveConfig.lazy_load}
.microphoneStream=${this.view?.camera === cameraID
.microphoneStream=${view?.camera === cameraID
? this.microphoneManager?.getStream()
: undefined}
.cameraConfig=${cameraConfig}
@@ -507,9 +481,13 @@ export class FrigateCardLiveCarousel extends LitElement {
.liveConfig=${liveConfig}
.hass=${this.hass}
.cardWideConfig=${this.cardWideConfig}
.zoomSettings=${this.view?.context?.zoom?.[cameraID]?.requested}
.zoomSettings=${view?.context?.zoom?.[cameraID]?.requested}
@frigate-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
handleZoomSettingsObservedEvent(this, ev, cameraID)}
handleZoomSettingsObservedEvent(
ev,
this.viewManagerEpoch?.manager,
cameraID,
)}
>
</frigate-card-live-provider>
</div>
@@ -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 {
<frigate-card-ptz
.config=${this.overriddenLiveConfig.controls.ptz}
.cameraManager=${this.cameraManager}
.cameraID=${getStreamCameraID(this.view, cameraID)}
.forceVisibility=${this._mediaHasLoaded &&
this.view.context?.ptzControls?.enabled}
.cameraID=${getStreamCameraID(view, cameraID)}
.forceVisibility=${this._mediaHasLoaded && view.context?.ptzControls?.enabled}
>
</frigate-card-ptz>
${cameraMetadataCurrent && titleConfig
+23 -23
View File
@@ -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<FrigateCardSelect> = 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();
+10 -3
View File
@@ -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);
}
}
}
+32 -28
View File
@@ -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<View>;
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<string> | 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<ThumbnailCarouselTap>,
) => {
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` <frigate-card-timeline-core
slot=${this.timelineConfig.mode}
.hass=${this.hass}
.view=${this.view}
.itemClickAction=${this.view.isViewerView() ||
.viewManagerEpoch=${this.viewManagerEpoch}
.itemClickAction=${view.isViewerView() ||
!this.thumbnailConfig ||
this.thumbnailConfig?.mode === 'none'
? 'play'
+15 -11
View File
@@ -17,9 +17,9 @@ import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { CarouselDirection } from '../utils/embla/carousel-controller.js';
import AutoSize from '../utils/embla/plugins/auto-size/auto-size.js';
import { MediaQueriesResults } from '../view/media-queries-results';
import { View } from '../view/view.js';
import './carousel.js';
import './thumbnail.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
export interface ThumbnailCarouselTap {
queryResults: MediaQueriesResults;
@@ -31,7 +31,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
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<ThumbnailCarouselTap>(
this,
'thumbnail-carousel:tap',
{
queryResults: this.view.queryResults.clone().selectIndex(index),
queryResults: view.queryResults.clone().selectIndex(index),
},
);
}
+23 -22
View File
@@ -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<View>;
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'])],
});
}}
></ha-icon>`
: ''}
+133 -138
View File
@@ -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<ThumbnailDataRequest> {}
@@ -115,7 +114,7 @@ class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
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}
>
</frigate-card-thumbnail>`;
@@ -182,12 +181,12 @@ export class FrigateCardTimelineCore extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
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<void> {
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<void> {
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<View | null> {
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<void> {
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();
}
}
+3 -3
View File
@@ -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<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public timelineConfig?: TimelineConfig;
@@ -33,7 +33,7 @@ export class FrigateCardTimeline extends LitElement {
return html`
<frigate-card-timeline-core
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.timelineConfig=${this.timelineConfig}
.thumbnailConfig=${this.timelineConfig.controls.thumbnails}
.cameraManager=${this.cameraManager}
+1 -1
View File
@@ -10,7 +10,7 @@ type PaperToast = HTMLElement & {
};
export const getDefaultTitleConfigForView = (
view?: Readonly<View>,
view?: Readonly<View> | null,
baseConfig?: TitleControlConfig,
): TitleControlConfig | null => {
if (!baseConfig && view?.isGrid()) {
+120 -167
View File
@@ -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<View>;
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` <frigate-card-viewer-grid
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
@@ -205,7 +165,7 @@ export class FrigateCardViewerCarousel extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
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;
}}
>
<frigate-card-next-previous-control
slot="previous"
.hass=${this.hass}
.direction=${'previous'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${neighbors?.previous?.media.getThumbnail() ?? undefined}
.label=${neighbors?.previous?.media.getTitle() ?? ''}
?disabled=${!neighbors?.previous}
@click=${(ev: Event) => {
scroll('previous');
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>
${guard([this._media, this.view], () => this._getSlides())}
<frigate-card-next-previous-control
slot="next"
.hass=${this.hass}
.direction=${'next'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${neighbors?.next?.media.getThumbnail() ?? undefined}
.label=${neighbors?.next?.media.getTitle() ?? ''}
?disabled=${!neighbors?.next}
@click=${(ev: Event) => {
scroll('next');
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>
${this.showControls
? html` <frigate-card-next-previous-control
slot="previous"
.hass=${this.hass}
.direction=${'previous'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${neighbors?.previous?.media.getThumbnail() ?? undefined}
.label=${neighbors?.previous?.media.getTitle() ?? ''}
?disabled=${!neighbors?.previous}
@click=${(ev: Event) => {
scroll('previous');
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>`
: ''}
${guard([this._media, view], () => this._getSlides())}
${this.showControls
? html` <frigate-card-next-previous-control
slot="next"
.hass=${this.hass}
.direction=${'next'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${neighbors?.next?.media.getThumbnail() ?? undefined}
.label=${neighbors?.next?.media.getTitle() ?? ''}
?disabled=${!neighbors?.next}
@click=${(ev: Event) => {
scroll('next');
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>`
: ''}
</frigate-card-carousel>
${this.view
${view
? html` <frigate-card-ptz
.config=${this.viewerConfig?.controls.ptz}
.forceVisibility=${this.view?.context?.ptzControls?.enabled}
.forceVisibility=${view?.context?.ptzControls?.enabled}
>
</frigate-card-ptz>`
: ''}
@@ -530,7 +497,8 @@ export class FrigateCardViewerCarousel extends LitElement {
* Fire a media show event when a slide is selected.
*/
protected async _seekHandler(): Promise<void> {
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` <div class="embla__slide">
<frigate-card-viewer-provider
.hass=${this.hass}
.view=${this.view}
.view=${view}
.media=${media}
.viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache}
@@ -585,7 +554,7 @@ export class FrigateCardViewerGrid extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
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`
<frigate-card-viewer-carousel
grid-id=${ifDefined(filterCamera)}
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewFilterCameraID=${filterCamera}
.viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.showControls=${!filterCamera || selectedCameraID === filterCamera}
>
</frigate-card-viewer-carousel>
`;
}
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`
<frigate-card-media-grid
.selected=${this.view?.camera}
.selected=${view?.camera}
.displayConfig=${this.viewerConfig?.display}
@frigate-card:media-grid:selected=${(ev: CustomEvent<MediaGridSelected>) =>
this._gridSelectCamera(ev.detail.selected)}
@frigate-card:view:change=${(ev: CustomEvent<View>) => {
ev.stopPropagation();
const childView = ev.detail;
this._gridSelectCamera(childView.camera, childView);
}}
>
${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))}
</frigate-card-media-grid>
@@ -680,7 +647,7 @@ export class FrigateCardViewerProvider
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
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<void> {
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` <frigate-card-zoomer
@@ -892,13 +847,11 @@ export class FrigateCardViewerProvider
}
: undefined,
)}
.settings=${mediaID
? this.view?.context?.zoom?.[mediaID]?.requested
: undefined}
.settings=${mediaID ? view?.context?.zoom?.[mediaID]?.requested : undefined}
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
@frigate-card:zoom:unzoomed=${() => this.setControls()}
@frigate-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
handleZoomSettingsObservedEvent(this, ev, mediaID)}
handleZoomSettingsObservedEvent(ev, this.viewManagerEpoch?.manager, mediaID)}
>
${template}
</frigate-card-zoomer>`
@@ -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;
}
+33 -30
View File
@@ -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<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public cameraManager?: CameraManager;
@@ -64,17 +63,18 @@ export class FrigateCardViews extends LitElement {
public triggeredCameraIDs?: Set<string>;
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` <frigate-card-surround
class="${classMap(overallClasses)}"
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.thumbnailConfig=${!this.hide ? thumbnailConfig : undefined}
.timelineConfig=${!this.hide ? miniTimelineConfig : undefined}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
${!this.hide && this.view?.is('image') && cameraConfig
${!this.hide && view?.is('image') && cameraConfig
? html` <frigate-card-image
.imageConfig=${this.overriddenConfig.image}
.view=${this.view}
.view=${view}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
.cameraManager=${this.cameraManager}
>
</frigate-card-image>`
: ``}
${!this.hide && this.view?.isGalleryView()
${!this.hide && view?.isGalleryView()
? html` <frigate-card-gallery
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.galleryConfig=${this.overriddenConfig.media_gallery}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-gallery>`
: ``}
${!this.hide && this.view?.isViewerView()
${!this.hide && view?.isViewerView()
? html`
<frigate-card-viewer
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewerConfig=${this.overriddenConfig.media_viewer}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
@@ -198,17 +201,17 @@ export class FrigateCardViews extends LitElement {
</frigate-card-viewer>
`
: ``}
${!this.hide && this.view?.is('timeline')
${!this.hide && view?.is('timeline')
? html` <frigate-card-timeline
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.timelineConfig=${this.overriddenConfig.timeline}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-timeline>`
: ``}
${!this.hide && this.view?.is('diagnostics')
${!this.hide && view?.is('diagnostics')
? html` <frigate-card-diagnostics
.hass=${this.hass}
.rawConfig=${this.rawConfig}
@@ -222,11 +225,11 @@ export class FrigateCardViews extends LitElement {
// Note: <frigate-card-live> 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`
<frigate-card-live
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.nonOverriddenLiveConfig=${this.nonOverriddenConfig.live}
.overriddenLiveConfig=${this.overriddenConfig.live}
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
-4
View File
@@ -863,10 +863,6 @@ const imageBaseConfigSchema = z.object({
const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const;
const imageConfigDefault = {
mode: 'url' as const,
zoomable: true,
controls: {
ptz: ptzControlsDefaults,
},
...imageBaseConfigDefault,
};
-1
View File
@@ -301,7 +301,6 @@ const CONF_IMAGE = 'image' as const;
export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const;
export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const;
export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const;
export const CONF_IMAGE_ZOOMABLE = `${CONF_IMAGE}.zoomable` as const;
const CONF_TIMELINE = 'timeline' as const;
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
-2
View File
@@ -84,7 +84,6 @@ import {
CONF_IMAGE_MODE,
CONF_IMAGE_REFRESH_SECONDS,
CONF_IMAGE_URL,
CONF_IMAGE_ZOOMABLE,
CONF_LIVE_AUTO_MUTE,
CONF_LIVE_AUTO_PAUSE,
CONF_LIVE_AUTO_PLAY,
@@ -2735,7 +2734,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderOptionSelector(CONF_IMAGE_MODE, this._imageModes)}
${this._renderStringInput(CONF_IMAGE_URL)}
${this._renderNumberInput(CONF_IMAGE_REFRESH_SECONDS)}
${this._renderSwitch(CONF_IMAGE_ZOOMABLE, this._defaults.image.zoomable)}
</div>`
: ''}
${this._renderOptionSetHeader('timeline')}
+48
View File
@@ -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;
};
-282
View File
@@ -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<void> => {
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<string>,
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<void> => {
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<string>,
): 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<View | null> => {
const queries = query.getQueries();
if (!queries) {
return null;
}
const mediaArray = await cameraManager.executeMediaQueries<MediaQuery>(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<View | null> => {
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;
};
+15
View File
@@ -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<string, string> = 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<string, string> | undefined = view.context?.live?.overrides;
if (overrides && overrides.has(view.camera)) {
view.context?.live?.overrides?.delete(view.camera);
}
};
+6 -152
View File
@@ -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<View>) => 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<View>) => 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);
};
@@ -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();
});
});
@@ -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',
},
});
});
@@ -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)],
});
});
@@ -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)],
});
});
@@ -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)]),
}),
);
});
@@ -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,
},
}),
);
});
+2 -2
View File
@@ -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');
@@ -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();
});
@@ -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',
},
});
});
});
+62 -45
View File
@@ -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');
});
File diff suppressed because it is too large Load Diff
+751
View File
@@ -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<QueryExecutor>();
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<QueryExecutor>();
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<QueryExecutor>();
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<QueryExecutor>();
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<QueryExecutor>();
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<QueryExecutor>();
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<QueryExecutor>();
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<QueryExecutor>();
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<QueryExecutor>();
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<QueryExecutor>();
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<QueryExecutor>();
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<QueryExecutor>();
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<QueryExecutor>();
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<QueryExecutor>();
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<QueryExecutor>();
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);
});
});
@@ -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);
});
@@ -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: {} });
});
@@ -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({});
});
@@ -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);
});
@@ -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);
});
});
@@ -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');
});
@@ -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');
});
});
+41
View File
@@ -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;
};
@@ -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<ViewFactory>();
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<ViewFactory>();
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<ViewFactory>();
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<ViewFactory>();
const manager = new ViewManager(createCardAPI(), factory);
expect(manager.getEpoch()).toBeTruthy();
expect(manager.getEpoch().manager).toBe(manager);
});
it('reset', () => {
const factory = mock<ViewFactory>();
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<ViewFactory>();
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<ViewFactory>();
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<ViewFactory>();
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<ViewFactory>();
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<ViewFactory>();
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<ViewFactory>();
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<ViewFactory>();
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<ViewFactory>();
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<ViewFactory>();
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<string, string> = new Map();
overrides_1.set('camera', 'camera-2');
const overrides_2: Map<string, string> = new Map();
overrides_2.set('camera', 'camera-3');
const factory = mock<ViewFactory>();
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<string, string> = new Map();
overrides_1.set('camera', 'camera-2');
const overrides_2: Map<string, string> = new Map();
overrides_2.set('camera', 'camera-3');
const factory = mock<ViewFactory>();
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<ViewFactory>();
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<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView({ queryResults: queryResults_1 }));
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewDefault();
expect(
manager.hasMajorMediaChange(createView({ queryResults: queryResults_2 })),
).toBeFalsy();
});
});
@@ -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,
},
},
}),
}),
);
});
});
});
});
@@ -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>();
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>();
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>();
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>();
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>();
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<ViewManager>();
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>();
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>();
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>();
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>();
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>();
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>();
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>();
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>();
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);
},
);
@@ -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,
@@ -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<ViewManager>();
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,
},
},
});
});
+14 -10
View File
@@ -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<View> => {
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<void> => {
await new Promise(process.nextTick);
};
-600
View File
@@ -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<any, any>): 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);
});
});
+43 -1
View File
@@ -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']]),
},
});
});
});
+3
View File
@@ -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(
+21 -344
View File
@@ -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<string, string> = new Map();
overrides_1.set('camera', 'camera-2');
const overrides_2: Map<string, string> = 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<string, string> = new Map();
overrides_1.set('camera', 'camera-2');
const overrides_2: Map<string, string> = 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();
});
});
-1
View File
@@ -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',
+72 -63
View File
@@ -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