Refactor views to support dynamic updates.
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
cache
|
||||||
@@ -51,6 +51,7 @@ const plugins = [
|
|||||||
typescript({
|
typescript({
|
||||||
sourceMap: dev,
|
sourceMap: dev,
|
||||||
inlineSources: dev,
|
inlineSources: dev,
|
||||||
|
exclude: ["tests/**/*.test.ts"],
|
||||||
}),
|
}),
|
||||||
json({ exclude: 'package.json' }),
|
json({ exclude: 'package.json' }),
|
||||||
replace({
|
replace({
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ export class CameraSelectAction extends FrigateCardAction<CameraSelectActionConf
|
|||||||
const viewOnCameraSelect = config?.view.camera_select ?? 'current';
|
const viewOnCameraSelect = config?.view.camera_select ?? 'current';
|
||||||
const targetViewName =
|
const targetViewName =
|
||||||
viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect;
|
viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect;
|
||||||
api.getViewManager().setViewByParameters({
|
await api.getViewManager().setViewByParametersWithNewQuery({
|
||||||
viewName: targetViewName,
|
params:{
|
||||||
cameraID: selectCameraID,
|
view: targetViewName,
|
||||||
|
camera: selectCameraID,
|
||||||
|
},
|
||||||
failSafe: true,
|
failSafe: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { FrigateCardAction } from './base';
|
|||||||
|
|
||||||
export class DisplayModeSelectAction extends FrigateCardAction<DisplayModeActionConfig> {
|
export class DisplayModeSelectAction extends FrigateCardAction<DisplayModeActionConfig> {
|
||||||
public async execute(api: CardActionsAPI): Promise<void> {
|
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 { GeneralActionConfig } from '../../../config/types';
|
||||||
import { CardActionsAPI } from '../../types';
|
import { CardActionsAPI } from '../../types';
|
||||||
|
import { SubstreamOffViewModifier } from '../../view/modifiers/substream-off';
|
||||||
import { FrigateCardAction } from './base';
|
import { FrigateCardAction } from './base';
|
||||||
|
|
||||||
export class SubstreamOffAction extends FrigateCardAction<GeneralActionConfig> {
|
export class SubstreamOffAction extends FrigateCardAction<GeneralActionConfig> {
|
||||||
public async execute(api: CardActionsAPI): Promise<void> {
|
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 { GeneralActionConfig } from '../../../config/types';
|
||||||
import { CardActionsAPI } from '../../types';
|
import { CardActionsAPI } from '../../types';
|
||||||
|
import { SubstreamOnViewModifier } from '../../view/modifiers/substream-on';
|
||||||
import { FrigateCardAction } from './base';
|
import { FrigateCardAction } from './base';
|
||||||
|
|
||||||
export class SubstreamOnAction extends FrigateCardAction<GeneralActionConfig> {
|
export class SubstreamOnAction extends FrigateCardAction<GeneralActionConfig> {
|
||||||
public async execute(api: CardActionsAPI): Promise<void> {
|
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 { SubstreamSelectActionConfig } from '../../../config/types';
|
||||||
import { CardActionsAPI } from '../../types';
|
import { CardActionsAPI } from '../../types';
|
||||||
|
import { SubstreamSelectViewModifier } from '../../view/modifiers/substream-select';
|
||||||
import { FrigateCardAction } from './base';
|
import { FrigateCardAction } from './base';
|
||||||
|
|
||||||
export class SubstreamSelectAction extends FrigateCardAction<SubstreamSelectActionConfig> {
|
export class SubstreamSelectAction extends FrigateCardAction<SubstreamSelectActionConfig> {
|
||||||
public async execute(api: CardActionsAPI): Promise<void> {
|
public async execute(api: CardActionsAPI): Promise<void> {
|
||||||
api.getViewManager().setViewWithSubstream(this._action.camera);
|
api.getViewManager().setViewByParameters({
|
||||||
|
modifiers: [new SubstreamSelectViewModifier(this._action.camera)],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,10 @@ import { FrigateCardAction } from './base';
|
|||||||
|
|
||||||
export class ViewAction extends FrigateCardAction<ViewActionConfig> {
|
export class ViewAction extends FrigateCardAction<ViewActionConfig> {
|
||||||
public async execute(api: CardActionsAPI): Promise<void> {
|
public async execute(api: CardActionsAPI): Promise<void> {
|
||||||
api.getViewManager().setViewByParameters({
|
api.getViewManager().setViewByParametersWithNewQuery({
|
||||||
viewName: this._action.frigate_card_action,
|
params: {
|
||||||
|
view: 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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import {
|
|||||||
CardTriggersAPI,
|
CardTriggersAPI,
|
||||||
CardViewAPI,
|
CardViewAPI,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { ViewManager } from './view-manager';
|
import { ViewManager } from './view/view-manager';
|
||||||
import { KeyboardStateManager } from './keyboard-state-manager';
|
import { KeyboardStateManager } from './keyboard-state-manager';
|
||||||
|
|
||||||
export class CardController
|
export class CardController
|
||||||
|
|||||||
@@ -100,10 +100,10 @@ export class InitializationManager {
|
|||||||
if (hasViewRelatedActions) {
|
if (hasViewRelatedActions) {
|
||||||
this._api.getQueryStringManager().executeViewRelated();
|
this._api.getQueryStringManager().executeViewRelated();
|
||||||
} else {
|
} else {
|
||||||
this._api.getViewManager().setViewDefault({ failSafe: true });
|
this._api.getViewManager().setViewDefaultWithNewQuery({ failSafe: true });
|
||||||
}
|
}
|
||||||
} else {
|
} 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.
|
// reinitialized, be sure to ask for an update.
|
||||||
this._api.getCardElementManager().update();
|
this._api.getCardElementManager().update();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { FrigateCardCustomAction, ViewActionConfig } from '../config/types';
|
import { FrigateCardCustomAction, ViewActionConfig } from '../config/types';
|
||||||
import { createCameraAction, createGeneralAction } from '../utils/action.js';
|
import { createCameraAction, createGeneralAction } from '../utils/action.js';
|
||||||
|
import { ViewParameters } from '../view/view';
|
||||||
import { CardQueryStringAPI } from './types';
|
import { CardQueryStringAPI } from './types';
|
||||||
import { ViewManagerSetViewParameters } from './view-manager';
|
import { SubstreamSelectViewModifier } from './view/modifiers/substream-select';
|
||||||
|
|
||||||
interface QueryStringViewIntent {
|
interface QueryStringViewIntent {
|
||||||
view?: ViewManagerSetViewParameters & {
|
view?: Partial<ViewParameters> & {
|
||||||
default?: boolean;
|
default?: boolean;
|
||||||
|
substream?: string;
|
||||||
};
|
};
|
||||||
other?: FrigateCardCustomAction[];
|
other?: FrigateCardCustomAction[];
|
||||||
}
|
}
|
||||||
@@ -35,18 +37,26 @@ export class QueryStringManager {
|
|||||||
this._executeNonViewRelated(intent);
|
this._executeNonViewRelated(intent);
|
||||||
};
|
};
|
||||||
|
|
||||||
protected _executeViewRelated(intent: QueryStringViewIntent): void {
|
protected async _executeViewRelated(intent: QueryStringViewIntent): Promise<void> {
|
||||||
if (intent.view) {
|
if (intent.view) {
|
||||||
if (intent.view.default) {
|
if (intent.view.default) {
|
||||||
this._api.getViewManager().setViewDefault({
|
await this._api.getViewManager().setViewDefaultWithNewQuery({
|
||||||
...(intent.view.cameraID && { cameraID: intent.view.cameraID }),
|
params: {
|
||||||
...(intent.view.substream && { substream: intent.view.substream }),
|
camera: intent.view.camera,
|
||||||
|
},
|
||||||
|
...(intent.view.substream && {
|
||||||
|
modifiers: [new SubstreamSelectViewModifier(intent.view.substream)],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this._api.getViewManager().setViewByParameters({
|
await this._api.getViewManager().setViewByParametersWithNewQuery({
|
||||||
...(intent.view.viewName && { viewName: intent.view.viewName }),
|
params: {
|
||||||
...(intent.view.cameraID && { cameraID: intent.view.cameraID }),
|
...(intent.view.view && { view: intent.view.view }),
|
||||||
...(intent.view.substream && { substream: intent.view.substream }),
|
...(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 = {};
|
const result: QueryStringViewIntent = {};
|
||||||
for (const action of this._getActions()) {
|
for (const action of this._getActions()) {
|
||||||
if (this._isViewAction(action)) {
|
if (this._isViewAction(action)) {
|
||||||
(result.view ??= {}).viewName = action.frigate_card_action;
|
(result.view ??= {}).view = action.frigate_card_action;
|
||||||
(result.view ??= {}).default = undefined;
|
(result.view ??= {}).default = undefined;
|
||||||
} else if (action.frigate_card_action === 'default') {
|
} else if (action.frigate_card_action === 'default') {
|
||||||
(result.view ??= {}).default = true;
|
(result.view ??= {}).default = true;
|
||||||
(result.view ??= {}).viewName = undefined;
|
(result.view ??= {}).view = undefined;
|
||||||
} else if (action.frigate_card_action === 'camera_select') {
|
} 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') {
|
} else if (action.frigate_card_action === 'live_substream_select') {
|
||||||
(result.view ??= {}).substream = action.camera;
|
(result.view ??= {}).substream = action.camera;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export class TriggersManager {
|
|||||||
return sorted.length ? sorted[0][0] : null;
|
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 triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers;
|
||||||
const selectedCameraID = this._api.getViewManager().getView()?.camera;
|
const selectedCameraID = this._api.getViewManager().getView()?.camera;
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ export class TriggersManager {
|
|||||||
|
|
||||||
this._triggeredCameras.set(ev.cameraID, new Date());
|
this._triggeredCameras.set(ev.cameraID, new Date());
|
||||||
this._setConditionStateIfNecessary();
|
this._setConditionStateIfNecessary();
|
||||||
this._throttledTriggerAction(ev);
|
await this._throttledTriggerAction(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _hasAllowableInteractionStateForAction(): boolean {
|
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
|
const triggerAction = this._api.getConfigManager().getConfig()?.view.triggers
|
||||||
.actions.trigger;
|
.actions.trigger;
|
||||||
const defaultView = this._api.getConfigManager().getConfig()?.view.default;
|
const defaultView = this._api.getConfigManager().getConfig()?.view.default;
|
||||||
@@ -98,30 +98,30 @@ export class TriggersManager {
|
|||||||
|
|
||||||
if (this._hasAllowableInteractionStateForAction()) {
|
if (this._hasAllowableInteractionStateForAction()) {
|
||||||
if (triggerAction === 'update') {
|
if (triggerAction === 'update') {
|
||||||
const view = this._api.getViewManager().getView()?.evolve({
|
await this._api
|
||||||
// Reset the media queries to catch media to be refetched in the
|
.getViewManager()
|
||||||
// current view.
|
.setViewByParametersWithNewQuery({
|
||||||
query: null,
|
queryExecutorOptions: { useCache: false },
|
||||||
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);
|
|
||||||
}
|
|
||||||
} else if (triggerAction === 'live') {
|
} else if (triggerAction === 'live') {
|
||||||
this._api.getViewManager().setViewByParameters({
|
await this._api.getViewManager().setViewByParametersWithNewQuery({
|
||||||
viewName: 'live',
|
params: {
|
||||||
cameraID: ev.cameraID,
|
view: 'live',
|
||||||
|
camera: ev.cameraID,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else if (triggerAction === 'default') {
|
} else if (triggerAction === 'default') {
|
||||||
this._api.getViewManager().setViewDefault({
|
await this._api.getViewManager().setViewDefaultWithNewQuery({
|
||||||
cameraID: ev.cameraID,
|
params: {
|
||||||
|
camera: ev.cameraID,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else if (ev.fidelity === 'high' && triggerAction === 'media') {
|
} else if (ev.fidelity === 'high' && triggerAction === 'media') {
|
||||||
this._api.getViewManager().setViewByParameters({
|
await this._api.getViewManager().setViewByParametersWithNewQuery({
|
||||||
viewName: ev.clip ? 'clip' : 'snapshot',
|
params: {
|
||||||
cameraID: ev.cameraID,
|
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
|
const action = this._api.getConfigManager().getConfig()?.view.triggers
|
||||||
.actions.untrigger;
|
.actions.untrigger;
|
||||||
|
|
||||||
if (action === 'default' && this._hasAllowableInteractionStateForAction()) {
|
if (action === 'default' && this._hasAllowableInteractionStateForAction()) {
|
||||||
this._api.getViewManager().setViewDefault();
|
await this._api.getViewManager().setViewDefaultWithNewQuery();
|
||||||
}
|
}
|
||||||
this._triggeredCameras.delete(cameraID);
|
this._triggeredCameras.delete(cameraID);
|
||||||
this._deleteTimer(cameraID);
|
this._deleteTimer(cameraID);
|
||||||
@@ -168,8 +168,8 @@ export class TriggersManager {
|
|||||||
reached, as there's no way to have the untrigger call happen without
|
reached, as there's no way to have the untrigger call happen without
|
||||||
a config. -- @preserve */
|
a config. -- @preserve */
|
||||||
this._api.getConfigManager().getConfig()?.view.triggers.untrigger_seconds ?? 0,
|
this._api.getConfigManager().getConfig()?.view.triggers.untrigger_seconds ?? 0,
|
||||||
() => {
|
async () => {
|
||||||
this._untriggerAction(cameraID);
|
await this._untriggerAction(cameraID);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type { MessageManager } from './message-manager';
|
|||||||
import type { MicrophoneManager } from './microphone-manager';
|
import type { MicrophoneManager } from './microphone-manager';
|
||||||
import type { StyleManager } from './style-manager';
|
import type { StyleManager } from './style-manager';
|
||||||
import type { TriggersManager } from './triggers-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 type { QueryStringManager } from './query-string-manager';
|
||||||
import { KeyboardStateManager } from './keyboard-state-manager';
|
import { KeyboardStateManager } from './keyboard-state-manager';
|
||||||
import { Automation } from '../config/types';
|
import { Automation } from '../config/types';
|
||||||
|
|||||||
@@ -1,323 +0,0 @@
|
|||||||
import isEqual from 'lodash-es/isEqual';
|
|
||||||
import { ViewContext } from 'view';
|
|
||||||
import {
|
|
||||||
FRIGATE_CARD_VIEW_DEFAULT,
|
|
||||||
FrigateCardConfig,
|
|
||||||
FrigateCardView,
|
|
||||||
ViewDisplayMode,
|
|
||||||
} from '../config/types';
|
|
||||||
import { localize } from '../localize/localize';
|
|
||||||
import { log } from '../utils/debug';
|
|
||||||
import { executeMediaQueryForView } from '../utils/media-to-view';
|
|
||||||
import { View } from '../view/view';
|
|
||||||
import { getCameraIDsForViewName } from '../view/view-to-cameras';
|
|
||||||
import { CardViewAPI } from './types';
|
|
||||||
|
|
||||||
interface ViewManagerSetViewDefaultParameters {
|
|
||||||
cameraID?: string;
|
|
||||||
substream?: string;
|
|
||||||
|
|
||||||
// When failSafe is true, the view will be changed to the default view, or the
|
|
||||||
// `live` view if the default view is not supported, or failing that an error
|
|
||||||
// message is shown. Without `failSafe` the view will just not be changed if
|
|
||||||
// unsupported.
|
|
||||||
failSafe?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ViewManagerSetViewParameters
|
|
||||||
extends ViewManagerSetViewDefaultParameters {
|
|
||||||
viewName?: FrigateCardView;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ViewManager {
|
|
||||||
protected _view: View | null = null;
|
|
||||||
protected _api: CardViewAPI;
|
|
||||||
|
|
||||||
constructor(api: CardViewAPI) {
|
|
||||||
this._api = api;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getView(): View | null {
|
|
||||||
return this._view;
|
|
||||||
}
|
|
||||||
|
|
||||||
public hasView(): boolean {
|
|
||||||
return !!this.getView();
|
|
||||||
}
|
|
||||||
|
|
||||||
public setView(view: View): void {
|
|
||||||
this._setView(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
public setViewDefault(params?: ViewManagerSetViewDefaultParameters): void {
|
|
||||||
const config = this._api.getConfigManager().getConfig();
|
|
||||||
if (config) {
|
|
||||||
let forceCameraID: string | null = params?.cameraID ?? null;
|
|
||||||
const viewName = config.view.default;
|
|
||||||
|
|
||||||
if (!forceCameraID && this._view?.camera && config.view.default_cycle_camera) {
|
|
||||||
const cameraIDs = [
|
|
||||||
...getCameraIDsForViewName(this._api.getCameraManager(), viewName),
|
|
||||||
];
|
|
||||||
const currentIndex = cameraIDs.indexOf(this._view.camera);
|
|
||||||
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
|
|
||||||
forceCameraID = cameraIDs[targetIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setViewByParameters({
|
|
||||||
...params,
|
|
||||||
viewName: viewName,
|
|
||||||
...(forceCameraID && { cameraID: forceCameraID }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public setViewByParameters(params: ViewManagerSetViewParameters): void {
|
|
||||||
const config = this._api.getConfigManager().getConfig();
|
|
||||||
|
|
||||||
if (config) {
|
|
||||||
let cameraID: string | null = null;
|
|
||||||
|
|
||||||
let viewName = params?.viewName ?? this._view?.view ?? config.view.default;
|
|
||||||
const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs();
|
|
||||||
if (params?.cameraID && allCameraIDs.has(params.cameraID)) {
|
|
||||||
cameraID = params.cameraID;
|
|
||||||
} else {
|
|
||||||
const viewCameraIDs = getCameraIDsForViewName(
|
|
||||||
this._api.getCameraManager(),
|
|
||||||
viewName,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reset to the default camera.
|
|
||||||
cameraID = viewCameraIDs.keys().next().value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cameraID) {
|
|
||||||
if (params.failSafe) {
|
|
||||||
const camerasToCapabilities = [
|
|
||||||
...this._api.getCameraManager().getStore().getCameras(),
|
|
||||||
].reduce((acc, [cameraID, camera]) => {
|
|
||||||
const capabilities = camera.getCapabilities()?.getRawCapabilities();
|
|
||||||
if (capabilities) {
|
|
||||||
acc[cameraID] = capabilities;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
this._api.getMessageManager().setMessageIfHigherPriority({
|
|
||||||
type: 'error',
|
|
||||||
message: localize('error.no_supported_cameras'),
|
|
||||||
context: {
|
|
||||||
view: viewName,
|
|
||||||
cameras_capabilities: camerasToCapabilities,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.isViewSupportedByCamera(cameraID, viewName)) {
|
|
||||||
if (params.failSafe) {
|
|
||||||
if (this.isViewSupportedByCamera(cameraID, FRIGATE_CARD_VIEW_DEFAULT)) {
|
|
||||||
viewName = FRIGATE_CARD_VIEW_DEFAULT;
|
|
||||||
} else {
|
|
||||||
const capabilities = this._api
|
|
||||||
.getCameraManager()
|
|
||||||
.getStore()
|
|
||||||
.getCamera(cameraID)
|
|
||||||
?.getCapabilities()
|
|
||||||
?.getRawCapabilities();
|
|
||||||
this._api.getMessageManager().setMessageIfHigherPriority({
|
|
||||||
type: 'error',
|
|
||||||
message: localize('error.no_supported_camera'),
|
|
||||||
context: {
|
|
||||||
view: viewName,
|
|
||||||
camera: cameraID,
|
|
||||||
...(capabilities && { camera_capabilities: capabilities }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayMode =
|
|
||||||
this._view?.displayMode ?? this._getDefaultDisplayModeForView(viewName, config);
|
|
||||||
let view: View = new View({
|
|
||||||
view: viewName,
|
|
||||||
camera: cameraID,
|
|
||||||
displayMode: displayMode,
|
|
||||||
});
|
|
||||||
if (params.substream) {
|
|
||||||
view = this._createViewWithSelectedSubstream(view, params.substream);
|
|
||||||
}
|
|
||||||
this._setView(view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public setViewWithMergedContext(context: ViewContext | null): void {
|
|
||||||
if (this._view) {
|
|
||||||
return this._setView(this._view?.clone().mergeInContext(context));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public reset(): void {
|
|
||||||
this._view = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _getCameraIDsInvolvedInView(view: View): Set<string> {
|
|
||||||
return view.supportsMultipleDisplayModes() && view.isGrid()
|
|
||||||
? getCameraIDsForViewName(this._api.getCameraManager(), view.view)
|
|
||||||
: getCameraIDsForViewName(this._api.getCameraManager(), view.view, view.camera);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async setViewWithNewDisplayMode(displayMode: ViewDisplayMode): Promise<void> {
|
|
||||||
const hass = this._api.getHASSManager().getHASS();
|
|
||||||
|
|
||||||
if (this._view && hass) {
|
|
||||||
const view = this._view.evolve({
|
|
||||||
displayMode: displayMode,
|
|
||||||
});
|
|
||||||
|
|
||||||
const expectedCameraIDs = this._getCameraIDsInvolvedInView(view);
|
|
||||||
const queryCameraIDs = view.query?.getQueryCameraIDs();
|
|
||||||
|
|
||||||
if (!isEqual(expectedCameraIDs, queryCameraIDs) && view && view.query) {
|
|
||||||
// If the user requests a grid but the current query does not have a
|
|
||||||
// query for more than one camera, reset the query results, change the
|
|
||||||
// existing query to refer to all cameras and execute it to fetch new
|
|
||||||
// results.
|
|
||||||
let viewWithNewQuery: View | null = null;
|
|
||||||
try {
|
|
||||||
viewWithNewQuery = await executeMediaQueryForView(
|
|
||||||
this._api.getCameraManager(),
|
|
||||||
view,
|
|
||||||
view.query.clone().setQueryCameraIDs(expectedCameraIDs),
|
|
||||||
);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
this._api.getMessageManager().setErrorIfHigherPriority(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viewWithNewQuery) {
|
|
||||||
return this._setView(viewWithNewQuery);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return this._setView(view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public setViewWithSubstream(substream?: string): void {
|
|
||||||
if (!this._view) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._setView(
|
|
||||||
substream
|
|
||||||
? this._createViewWithSelectedSubstream(this._view, substream)
|
|
||||||
: this._createViewWithNextStream(this._view),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public setViewWithoutSubstream(): void {
|
|
||||||
const view = this._createViewWithoutSubstream();
|
|
||||||
if (view) {
|
|
||||||
return this._setView(view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
|
|
||||||
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _getDefaultDisplayModeForView(
|
|
||||||
viewName: FrigateCardView,
|
|
||||||
config?: FrigateCardConfig,
|
|
||||||
): ViewDisplayMode {
|
|
||||||
let mode: ViewDisplayMode | null = null;
|
|
||||||
switch (viewName) {
|
|
||||||
case 'media':
|
|
||||||
case 'clip':
|
|
||||||
case 'recording':
|
|
||||||
case 'snapshot':
|
|
||||||
mode = config?.media_viewer.display?.mode ?? null;
|
|
||||||
break;
|
|
||||||
case 'live':
|
|
||||||
mode = config?.live.display?.mode ?? null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return mode ?? 'single';
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _setView(view: View): void {
|
|
||||||
const oldView = this._view;
|
|
||||||
View.adoptFromViewIfAppropriate(view, oldView);
|
|
||||||
|
|
||||||
log(
|
|
||||||
this._api.getConfigManager().getCardWideConfig(),
|
|
||||||
`Frigate Card view change: `,
|
|
||||||
view,
|
|
||||||
);
|
|
||||||
this._view = view;
|
|
||||||
|
|
||||||
if (View.isMajorMediaChange(oldView, view)) {
|
|
||||||
this._api.getMediaLoadedInfoManager().clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oldView?.view !== view.view) {
|
|
||||||
this._api.getCardElementManager().scrollReset();
|
|
||||||
}
|
|
||||||
|
|
||||||
this._api.getMessageManager().reset();
|
|
||||||
this._api.getStyleManager().setExpandedMode();
|
|
||||||
|
|
||||||
this._api.getConditionsManager()?.setState({
|
|
||||||
view: view.view,
|
|
||||||
camera: view.camera,
|
|
||||||
displayMode: view.displayMode ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
this._api.getCardElementManager().update();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _createViewWithSelectedSubstream(baseView: View, substreamID: string): View {
|
|
||||||
const overrides: Map<string, string> =
|
|
||||||
baseView?.context?.live?.overrides ?? new Map();
|
|
||||||
overrides.set(baseView.camera, substreamID);
|
|
||||||
return baseView.clone().mergeInContext({
|
|
||||||
live: { overrides: overrides },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _createViewWithNextStream(baseView: View): View {
|
|
||||||
const dependencies = [
|
|
||||||
...this._api.getCameraManager().getStore().getAllDependentCameras(baseView.camera),
|
|
||||||
];
|
|
||||||
if (dependencies.length <= 1) {
|
|
||||||
return baseView.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
const view = baseView.clone();
|
|
||||||
const overrides: Map<string, string> = view.context?.live?.overrides ?? new Map();
|
|
||||||
const currentOverride = overrides.get(view.camera) ?? view.camera;
|
|
||||||
const currentIndex = dependencies.indexOf(currentOverride);
|
|
||||||
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
|
|
||||||
overrides.set(view.camera, dependencies[newIndex]);
|
|
||||||
view.mergeInContext({ live: { overrides: overrides } });
|
|
||||||
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _createViewWithoutSubstream(): View | null {
|
|
||||||
if (!this._view) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const view = this._view.clone();
|
|
||||||
const overrides: Map<string, string> | undefined = view.context?.live?.overrides;
|
|
||||||
if (overrides && overrides.has(view.camera)) {
|
|
||||||
view.context?.live?.overrides?.delete(view.camera);
|
|
||||||
}
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { sub } from 'date-fns';
|
||||||
|
import {
|
||||||
|
FRIGATE_CARD_VIEW_DEFAULT,
|
||||||
|
FrigateCardConfig,
|
||||||
|
FrigateCardView,
|
||||||
|
ViewDisplayMode,
|
||||||
|
} from '../../config/types';
|
||||||
|
import { localize } from '../../localize/localize';
|
||||||
|
import { ClipsOrSnapshotsOrAll } from '../../types';
|
||||||
|
import { View, ViewParameters } from '../../view/view';
|
||||||
|
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
|
||||||
|
import { CardViewAPI } from '../types';
|
||||||
|
import { QueryExecutor } from './query-executor';
|
||||||
|
import {
|
||||||
|
QueryExecutorOptions,
|
||||||
|
QueryWithResults,
|
||||||
|
ViewFactoryOptions,
|
||||||
|
ViewIncompatible,
|
||||||
|
ViewNoCameraError,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export class ViewFactory {
|
||||||
|
protected _api: CardViewAPI;
|
||||||
|
protected _executor: QueryExecutor;
|
||||||
|
|
||||||
|
constructor(api: CardViewAPI, executor?: QueryExecutor) {
|
||||||
|
this._api = api;
|
||||||
|
this._executor = executor ?? new QueryExecutor(api);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getViewDefault(options?: ViewFactoryOptions): View | null {
|
||||||
|
const config = this._api.getConfigManager().getConfig();
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let forceCameraID: string | null = options?.params?.camera ?? null;
|
||||||
|
const viewName = options?.params?.view ?? config.view.default;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!forceCameraID &&
|
||||||
|
options?.baseView?.camera &&
|
||||||
|
config.view.default_cycle_camera
|
||||||
|
) {
|
||||||
|
const cameraIDs = [
|
||||||
|
...getCameraIDsForViewName(this._api.getCameraManager(), viewName),
|
||||||
|
];
|
||||||
|
const currentIndex = cameraIDs.indexOf(options?.baseView?.camera);
|
||||||
|
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
|
||||||
|
forceCameraID = cameraIDs[targetIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getViewByParameters({
|
||||||
|
params: {
|
||||||
|
...options?.params,
|
||||||
|
view: viewName,
|
||||||
|
...(forceCameraID && { camera: forceCameraID }),
|
||||||
|
},
|
||||||
|
baseView: options?.baseView,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getViewByParameters(options?: ViewFactoryOptions): View | null {
|
||||||
|
const config = this._api.getConfigManager().getConfig();
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cameraID: string | null =
|
||||||
|
options?.params?.camera ?? options?.baseView?.camera ?? null;
|
||||||
|
let viewName =
|
||||||
|
options?.params?.view ?? options?.baseView?.view ?? config.view.default;
|
||||||
|
|
||||||
|
const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs();
|
||||||
|
|
||||||
|
if (!cameraID || !allCameraIDs.has(cameraID)) {
|
||||||
|
const viewCameraIDs = getCameraIDsForViewName(
|
||||||
|
this._api.getCameraManager(),
|
||||||
|
viewName,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset to the default camera.
|
||||||
|
cameraID = viewCameraIDs.keys().next().value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cameraID) {
|
||||||
|
const camerasToCapabilities = [
|
||||||
|
...this._api.getCameraManager().getStore().getCameras(),
|
||||||
|
].reduce((acc, [cameraID, camera]) => {
|
||||||
|
const capabilities = camera.getCapabilities()?.getRawCapabilities();
|
||||||
|
if (capabilities) {
|
||||||
|
acc[cameraID] = capabilities;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
throw new ViewNoCameraError(localize('error.no_supported_cameras'), {
|
||||||
|
view: viewName,
|
||||||
|
cameras_capabilities: camerasToCapabilities,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isViewSupportedByCamera(cameraID, viewName)) {
|
||||||
|
if (
|
||||||
|
options?.failSafe &&
|
||||||
|
this.isViewSupportedByCamera(cameraID, FRIGATE_CARD_VIEW_DEFAULT)
|
||||||
|
) {
|
||||||
|
viewName = FRIGATE_CARD_VIEW_DEFAULT;
|
||||||
|
} else {
|
||||||
|
const capabilities = this._api
|
||||||
|
.getCameraManager()
|
||||||
|
.getStore()
|
||||||
|
.getCamera(cameraID)
|
||||||
|
?.getCapabilities()
|
||||||
|
?.getRawCapabilities();
|
||||||
|
|
||||||
|
throw new ViewIncompatible(localize('error.no_supported_camera'), {
|
||||||
|
view: viewName,
|
||||||
|
camera: cameraID,
|
||||||
|
...(capabilities && { camera_capabilities: capabilities }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayMode =
|
||||||
|
options?.params?.displayMode ??
|
||||||
|
options?.baseView?.displayMode ??
|
||||||
|
this._getDefaultDisplayModeForView(viewName, config);
|
||||||
|
|
||||||
|
const viewParameters: ViewParameters = {
|
||||||
|
...options?.params,
|
||||||
|
view: viewName,
|
||||||
|
camera: cameraID,
|
||||||
|
displayMode: displayMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const view = options?.baseView
|
||||||
|
? options.baseView.evolve(viewParameters)
|
||||||
|
: new View(viewParameters);
|
||||||
|
|
||||||
|
if (options?.modifiers) {
|
||||||
|
options.modifiers.forEach((modifier) => modifier.modify(view));
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getViewDefaultWithNewQuery(
|
||||||
|
options?: ViewFactoryOptions,
|
||||||
|
): Promise<View | null> {
|
||||||
|
return this._executeNewQuery(this.getViewDefault(options), {
|
||||||
|
...options,
|
||||||
|
queryExecutorOptions: {
|
||||||
|
useCache: false,
|
||||||
|
...options?.queryExecutorOptions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getViewByParametersWithNewQuery(
|
||||||
|
options?: ViewFactoryOptions,
|
||||||
|
): Promise<View | null> {
|
||||||
|
return this._executeNewQuery(this.getViewByParameters(options), {
|
||||||
|
...options,
|
||||||
|
queryExecutorOptions: {
|
||||||
|
useCache: false,
|
||||||
|
...options?.queryExecutorOptions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getViewByParametersWithExistingQuery(
|
||||||
|
options?: ViewFactoryOptions,
|
||||||
|
): Promise<View | null> {
|
||||||
|
const view = this.getViewByParameters(options);
|
||||||
|
if (view?.query) {
|
||||||
|
view.queryResults = await this._executor.execute(
|
||||||
|
view.query,
|
||||||
|
options?.queryExecutorOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _executeNewQuery(
|
||||||
|
view: View | null,
|
||||||
|
options?: ViewFactoryOptions,
|
||||||
|
): Promise<View | null> {
|
||||||
|
const config = this._api.getConfigManager().getConfig();
|
||||||
|
if (
|
||||||
|
!config ||
|
||||||
|
/* istanbul ignore next: this path cannot be reached as the only way for
|
||||||
|
view to be null here, is if the config is also null -- @preserve */
|
||||||
|
!view
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeMediaQuery = async (
|
||||||
|
mediaType: ClipsOrSnapshotsOrAll | 'recordings' | null,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
/* istanbul ignore if: this path cannot be reached -- @preserve */
|
||||||
|
if (!mediaType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await this._executeMediaQuery(
|
||||||
|
view,
|
||||||
|
mediaType === 'recordings' ? 'recordings' : 'events',
|
||||||
|
{
|
||||||
|
eventsMediaType: mediaType === 'recordings' ? undefined : mediaType,
|
||||||
|
executorOptions: options?.queryExecutorOptions,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Implementation note: For new queries, if the query itself fails that is
|
||||||
|
// just ignored and the view is returned anyway (e.g. if the user changes to
|
||||||
|
// live but the thumbnail fetch fails, it is better to change to live and
|
||||||
|
// show no thumbnails than not change to live).
|
||||||
|
const mediaType = view.getDefaultMediaType();
|
||||||
|
const baseView = options?.baseView;
|
||||||
|
const switchingToGalleryFromViewer =
|
||||||
|
baseView?.isViewerView() && view.isGalleryView();
|
||||||
|
|
||||||
|
if (switchingToGalleryFromViewer && baseView?.query && baseView?.queryResults) {
|
||||||
|
// If the user is currently using the viewer, and then switches to the
|
||||||
|
// gallery we make an attempt to keep the query/queryResults the same so
|
||||||
|
// the gallery can be used to click back and forth to the viewer, and the
|
||||||
|
// selected media can be centered in the gallery. See the matching code in
|
||||||
|
// `updated()` in `gallery.ts`. We specifically must ensure that the new
|
||||||
|
// target media of the gallery (e.g. clips, snapshots or recordings) is
|
||||||
|
// equal to the queries that are currently used in the viewer.
|
||||||
|
//
|
||||||
|
// See: https://github.com/dermotduffy/frigate-hass-card/issues/885
|
||||||
|
view.query = baseView.query;
|
||||||
|
view.queryResults = baseView.queryResults;
|
||||||
|
} else {
|
||||||
|
switch (view.view) {
|
||||||
|
case 'live':
|
||||||
|
this._setTimelineWindowToLive(view);
|
||||||
|
if (config.live.controls.thumbnails.mode !== 'none') {
|
||||||
|
await executeMediaQuery(
|
||||||
|
config.live.controls.thumbnails.media_type === 'recordings'
|
||||||
|
? 'recordings'
|
||||||
|
: config.live.controls.thumbnails.events_media_type,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'media':
|
||||||
|
// If the user is looking at media in the `media` view and then
|
||||||
|
// changes camera (via the menu) it should default to showing clips
|
||||||
|
// for the new camera.
|
||||||
|
if (baseView && view.camera !== baseView.camera) {
|
||||||
|
await executeMediaQuery('clips');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Gallery views:
|
||||||
|
case 'clips':
|
||||||
|
case 'snapshots':
|
||||||
|
case 'recordings':
|
||||||
|
await executeMediaQuery(mediaType);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Viewer views:
|
||||||
|
case 'clip':
|
||||||
|
case 'snapshot':
|
||||||
|
case 'recording':
|
||||||
|
if (config.media_viewer.controls.thumbnails.mode !== 'none') {
|
||||||
|
await executeMediaQuery(mediaType);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._setOrRemoveSeekTime(
|
||||||
|
view,
|
||||||
|
options?.queryExecutorOptions?.selectResult?.time?.time,
|
||||||
|
);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _setTimelineWindowToLive(view: View): void {
|
||||||
|
const now = new Date();
|
||||||
|
const liveConfig = this._api.getConfigManager().getConfig()?.live;
|
||||||
|
|
||||||
|
/* istanbul ignore if: this if branch cannot be reached as if the config is
|
||||||
|
empty this function is never called -- @preserve */
|
||||||
|
if (!liveConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.mergeInContext({
|
||||||
|
// Force the window to start at the most recent time, not
|
||||||
|
// necessarily when the most recent event/recording was:
|
||||||
|
// https://github.com/dermotduffy/frigate-hass-card/issues/1301
|
||||||
|
timeline: {
|
||||||
|
window: {
|
||||||
|
start: sub(now, {
|
||||||
|
seconds: liveConfig.controls.timeline.window_seconds,
|
||||||
|
}),
|
||||||
|
end: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _setOrRemoveSeekTime(view: View, time?: Date): void {
|
||||||
|
if (time) {
|
||||||
|
view.mergeInContext({
|
||||||
|
mediaViewer: {
|
||||||
|
seek: time,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
view.removeContextProperty('mediaViewer', 'seek');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _executeMediaQuery(
|
||||||
|
view: View,
|
||||||
|
mediaType: 'events' | 'recordings',
|
||||||
|
options?: {
|
||||||
|
eventsMediaType?: ClipsOrSnapshotsOrAll;
|
||||||
|
executorOptions?: QueryExecutorOptions;
|
||||||
|
},
|
||||||
|
): Promise<boolean> {
|
||||||
|
const queryWithResults: QueryWithResults | null =
|
||||||
|
mediaType === 'events'
|
||||||
|
? await this._executor.executeDefaultEventQuery({
|
||||||
|
...(!view.isGrid() && { cameraID: view.camera }),
|
||||||
|
eventsMediaType: options?.eventsMediaType,
|
||||||
|
executorOptions: options?.executorOptions,
|
||||||
|
})
|
||||||
|
: mediaType === 'recordings'
|
||||||
|
? await this._executor.executeDefaultRecordingQuery({
|
||||||
|
...(!view.isGrid() && { cameraID: view.camera }),
|
||||||
|
executorOptions: options?.executorOptions,
|
||||||
|
})
|
||||||
|
: /* istanbul ignore next -- @preserve */
|
||||||
|
null;
|
||||||
|
if (!queryWithResults) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.query = queryWithResults.query;
|
||||||
|
view.queryResults = queryWithResults.queryResults;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
|
||||||
|
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _getDefaultDisplayModeForView(
|
||||||
|
viewName: FrigateCardView,
|
||||||
|
config?: FrigateCardConfig,
|
||||||
|
): ViewDisplayMode {
|
||||||
|
let mode: ViewDisplayMode | null = null;
|
||||||
|
switch (viewName) {
|
||||||
|
case 'media':
|
||||||
|
case 'clip':
|
||||||
|
case 'recording':
|
||||||
|
case 'snapshot':
|
||||||
|
mode = config?.media_viewer.display?.mode ?? null;
|
||||||
|
break;
|
||||||
|
case 'live':
|
||||||
|
mode = config?.live.display?.mode ?? null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return mode ?? 'single';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ViewContext } from "view";
|
||||||
|
import { View } from "../../../view/view";
|
||||||
|
import { ViewModifier } from "../types";
|
||||||
|
|
||||||
|
export class MergeContextViewModifier implements ViewModifier {
|
||||||
|
protected _context?: ViewContext | null;
|
||||||
|
|
||||||
|
constructor(context?: ViewContext | null) {
|
||||||
|
this._context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public modify(view: View): void {
|
||||||
|
view.mergeInContext(this._context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ViewContext } from 'view';
|
||||||
|
import { View } from '../../../view/view';
|
||||||
|
import { ViewModifier } from '../types';
|
||||||
|
|
||||||
|
export class RemoveContextPropertyViewModifier implements ViewModifier {
|
||||||
|
protected _key: keyof ViewContext;
|
||||||
|
protected _property: PropertyKey;
|
||||||
|
|
||||||
|
constructor(key: keyof ViewContext, property: PropertyKey) {
|
||||||
|
this._key = key;
|
||||||
|
this._property = property;
|
||||||
|
}
|
||||||
|
|
||||||
|
public modify(view: View): void {
|
||||||
|
view.removeContextProperty(this._key, this._property);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ViewContext } from "view";
|
||||||
|
import { View } from "../../../view/view";
|
||||||
|
import { ViewModifier } from "../types";
|
||||||
|
|
||||||
|
export class RemoveContextViewModifier implements ViewModifier {
|
||||||
|
protected _keys: (keyof ViewContext)[];
|
||||||
|
|
||||||
|
constructor(keys: (keyof ViewContext)[]) {
|
||||||
|
this._keys = keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
public modify(view: View): void {
|
||||||
|
this._keys.forEach((key) => view.removeContext(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { removeSubstream } from '../../../utils/substream';
|
||||||
|
import { View } from '../../../view/view';
|
||||||
|
import { ViewModifier } from '../types';
|
||||||
|
|
||||||
|
export class SubstreamOffViewModifier implements ViewModifier {
|
||||||
|
public modify(view: View): void {
|
||||||
|
removeSubstream(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { CameraManager } from '../../../camera-manager/manager';
|
||||||
|
import { getStreamCameraID, setSubstream } from '../../../utils/substream';
|
||||||
|
import { View } from '../../../view/view';
|
||||||
|
import { ViewModifier } from '../types';
|
||||||
|
|
||||||
|
interface SubstreamOnViewModifierAPI {
|
||||||
|
getCameraManager(): CameraManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubstreamOnViewModifier implements ViewModifier {
|
||||||
|
protected _api: SubstreamOnViewModifierAPI;
|
||||||
|
|
||||||
|
constructor(api: SubstreamOnViewModifierAPI) {
|
||||||
|
this._api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
public modify(view: View): void {
|
||||||
|
const dependencies = [
|
||||||
|
...this._api
|
||||||
|
.getCameraManager()
|
||||||
|
.getStore()
|
||||||
|
.getAllDependentCameras(view.camera, 'substream'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (dependencies.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentOverride = getStreamCameraID(view);
|
||||||
|
const currentIndex = dependencies.indexOf(currentOverride);
|
||||||
|
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
|
||||||
|
|
||||||
|
setSubstream(view, dependencies[newIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { setSubstream } from '../../../utils/substream';
|
||||||
|
import { View } from '../../../view/view';
|
||||||
|
import { ViewModifier } from '../types';
|
||||||
|
|
||||||
|
export class SubstreamSelectViewModifier implements ViewModifier {
|
||||||
|
protected _substreamID: string;
|
||||||
|
|
||||||
|
constructor(substreamID: string) {
|
||||||
|
this._substreamID = substreamID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public modify(view: View): void {
|
||||||
|
setSubstream(view, this._substreamID);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { CapabilitySearchOptions, MediaQuery } from '../../camera-manager/types';
|
||||||
|
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
|
||||||
|
import { ClipsOrSnapshotsOrAll } from '../../types';
|
||||||
|
import { findBestMediaIndex } from '../../utils/find-best-media-index';
|
||||||
|
import {
|
||||||
|
EventMediaQueries,
|
||||||
|
MediaQueries,
|
||||||
|
RecordingMediaQueries,
|
||||||
|
} from '../../view/media-queries';
|
||||||
|
import { MediaQueriesResults } from '../../view/media-queries-results';
|
||||||
|
import { CardViewAPI } from '../types';
|
||||||
|
import { QueryExecutorOptions, QueryWithResults } from './types';
|
||||||
|
|
||||||
|
export class QueryExecutor {
|
||||||
|
protected _api: CardViewAPI;
|
||||||
|
|
||||||
|
constructor(api: CardViewAPI) {
|
||||||
|
this._api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async executeDefaultEventQuery(options?: {
|
||||||
|
cameraID?: string;
|
||||||
|
eventsMediaType?: ClipsOrSnapshotsOrAll;
|
||||||
|
executorOptions?: QueryExecutorOptions;
|
||||||
|
}): Promise<QueryWithResults | null> {
|
||||||
|
const capabilitySearch: CapabilitySearchOptions =
|
||||||
|
!options?.eventsMediaType || options?.eventsMediaType === 'all'
|
||||||
|
? {
|
||||||
|
anyCapabilities: ['clips', 'snapshots'],
|
||||||
|
}
|
||||||
|
: options.eventsMediaType;
|
||||||
|
|
||||||
|
const cameraManager = this._api.getCameraManager();
|
||||||
|
const cameraIDs = options?.cameraID
|
||||||
|
? cameraManager
|
||||||
|
.getStore()
|
||||||
|
.getAllDependentCameras(options.cameraID, capabilitySearch)
|
||||||
|
: cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch);
|
||||||
|
if (!cameraIDs.size) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawQueries = cameraManager.generateDefaultEventQueries(cameraIDs, {
|
||||||
|
limit: this._getChunkLimit(),
|
||||||
|
...(options?.eventsMediaType === 'clips' && { hasClip: true }),
|
||||||
|
...(options?.eventsMediaType === 'snapshots' && { hasSnapshot: true }),
|
||||||
|
});
|
||||||
|
if (!rawQueries) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const queries = new EventMediaQueries(rawQueries);
|
||||||
|
const results = await this.execute(queries, options?.executorOptions);
|
||||||
|
return results
|
||||||
|
? {
|
||||||
|
query: queries,
|
||||||
|
queryResults: results,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async executeDefaultRecordingQuery(options?: {
|
||||||
|
cameraID?: string;
|
||||||
|
executorOptions?: QueryExecutorOptions;
|
||||||
|
}): Promise<QueryWithResults | null> {
|
||||||
|
const cameraManager = this._api.getCameraManager();
|
||||||
|
const cameraIDs = options?.cameraID
|
||||||
|
? cameraManager.getStore().getAllDependentCameras(options.cameraID, 'recordings')
|
||||||
|
: cameraManager.getStore().getCameraIDsWithCapability('recordings');
|
||||||
|
if (!cameraIDs.size) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, {
|
||||||
|
limit: this._getChunkLimit(),
|
||||||
|
});
|
||||||
|
if (!rawQueries) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const queries = new RecordingMediaQueries(rawQueries);
|
||||||
|
const results = await this.execute(queries, options?.executorOptions);
|
||||||
|
return results ? { query: queries, queryResults: results } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async execute(
|
||||||
|
query: MediaQueries,
|
||||||
|
executorOptions?: QueryExecutorOptions,
|
||||||
|
): Promise<MediaQueriesResults | null> {
|
||||||
|
const queries = query.getQueries();
|
||||||
|
if (!queries) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaArray = await this._api
|
||||||
|
.getCameraManager()
|
||||||
|
.executeMediaQueries<MediaQuery>(queries, {
|
||||||
|
useCache: executorOptions?.useCache,
|
||||||
|
});
|
||||||
|
if (!mediaArray) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryResults = new MediaQueriesResults({ results: mediaArray });
|
||||||
|
if (executorOptions?.rejectResults?.(queryResults)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (executorOptions?.selectResult?.id) {
|
||||||
|
queryResults.selectBestResult((media) =>
|
||||||
|
media.findIndex((m) => m.getID() === executorOptions.selectResult?.id),
|
||||||
|
);
|
||||||
|
} else if (executorOptions?.selectResult?.func) {
|
||||||
|
queryResults.selectResultIfFound(executorOptions.selectResult.func);
|
||||||
|
} else if (executorOptions?.selectResult?.time) {
|
||||||
|
queryResults.selectBestResult((media) =>
|
||||||
|
findBestMediaIndex(
|
||||||
|
media,
|
||||||
|
executorOptions.selectResult?.time?.time as Date,
|
||||||
|
executorOptions.selectResult?.time?.favorCameraID,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return queryResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _getChunkLimit(): number {
|
||||||
|
const cardWideConfig = this._api.getConfigManager().getCardWideConfig();
|
||||||
|
return (
|
||||||
|
cardWideConfig?.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { ViewContext } from 'view';
|
||||||
|
import { FrigateCardView } from '../../config/types.js';
|
||||||
|
import { FrigateCardError } from '../../types.js';
|
||||||
|
import { MediaQueriesResults } from '../../view/media-queries-results.js';
|
||||||
|
import { MediaQueries } from '../../view/media-queries.js';
|
||||||
|
import { ViewMedia } from '../../view/media.js';
|
||||||
|
import { View, ViewParameters } from '../../view/view.js';
|
||||||
|
|
||||||
|
export interface ViewModifier {
|
||||||
|
modify(view: View): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryExecutorOptions {
|
||||||
|
// Select the result of a query, based on time, an id match or an arbitrary
|
||||||
|
// function. If no parameter is specified, the latest media will be selected
|
||||||
|
// by default.
|
||||||
|
selectResult?: {
|
||||||
|
time?: {
|
||||||
|
time: Date;
|
||||||
|
favorCameraID?: string;
|
||||||
|
};
|
||||||
|
id?: string;
|
||||||
|
func?: (media: ViewMedia) => boolean;
|
||||||
|
};
|
||||||
|
rejectResults?: (results: MediaQueriesResults) => boolean;
|
||||||
|
useCache?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryWithResults {
|
||||||
|
query: MediaQueries;
|
||||||
|
queryResults: MediaQueriesResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewFactoryOptions {
|
||||||
|
// An existing view to evolve from.
|
||||||
|
baseView?: View | null;
|
||||||
|
|
||||||
|
// View parameters to set/evolve.
|
||||||
|
params?: Partial<ViewParameters>;
|
||||||
|
|
||||||
|
// Modifiers to the view once created.
|
||||||
|
modifiers?: ViewModifier[];
|
||||||
|
|
||||||
|
// When failSafe is true the view will be changed to the default view, or the
|
||||||
|
// `live` view if the configured default view is not supported.
|
||||||
|
failSafe?: boolean;
|
||||||
|
|
||||||
|
// Options for the query executor that control how a query is executed and the
|
||||||
|
// result selected.
|
||||||
|
queryExecutorOptions?: QueryExecutorOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewManagerEpoch {
|
||||||
|
manager: ViewManagerInterface;
|
||||||
|
|
||||||
|
oldView?: View;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewManagerInterface {
|
||||||
|
getEpoch(): ViewManagerEpoch;
|
||||||
|
|
||||||
|
getView(): View | null;
|
||||||
|
hasView(): boolean;
|
||||||
|
reset(): void;
|
||||||
|
|
||||||
|
setViewDefault(options?: ViewFactoryOptions): void;
|
||||||
|
setViewByParameters(options?: ViewFactoryOptions): void;
|
||||||
|
|
||||||
|
setViewDefaultWithNewQuery(options?: ViewFactoryOptions): Promise<void>;
|
||||||
|
setViewByParametersWithNewQuery(options?: ViewFactoryOptions): Promise<void>;
|
||||||
|
setViewByParametersWithExistingQuery(options?: ViewFactoryOptions): Promise<void>;
|
||||||
|
|
||||||
|
setViewWithMergedContext(context: ViewContext | null): void;
|
||||||
|
|
||||||
|
isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean;
|
||||||
|
hasMajorMediaChange(oldView?: View | null): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ViewNoCameraError extends FrigateCardError {}
|
||||||
|
export class ViewIncompatible extends FrigateCardError {}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { ViewContext } from 'view';
|
||||||
|
import { FrigateCardView } from '../../config/types';
|
||||||
|
import { log } from '../../utils/debug';
|
||||||
|
import { getStreamCameraID } from '../../utils/substream';
|
||||||
|
import { View } from '../../view/view';
|
||||||
|
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
|
||||||
|
import { CardViewAPI } from '../types';
|
||||||
|
import { ViewFactory } from './factory';
|
||||||
|
import { ViewFactoryOptions, ViewManagerEpoch, ViewManagerInterface } from './types';
|
||||||
|
|
||||||
|
export class ViewManager implements ViewManagerInterface {
|
||||||
|
protected _view: View | null = null;
|
||||||
|
protected _factory: ViewFactory;
|
||||||
|
protected _api: CardViewAPI;
|
||||||
|
protected _epoch: ViewManagerEpoch = this._createEpoch();
|
||||||
|
|
||||||
|
constructor(api: CardViewAPI, factory?: ViewFactory) {
|
||||||
|
this._api = api;
|
||||||
|
this._factory = factory ?? new ViewFactory(api);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEpoch(): ViewManagerEpoch {
|
||||||
|
return this._epoch;
|
||||||
|
}
|
||||||
|
protected _createEpoch(oldView?: View | null): ViewManagerEpoch {
|
||||||
|
return {
|
||||||
|
manager: this,
|
||||||
|
...(oldView && { oldView }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getView(): View | null {
|
||||||
|
return this._view;
|
||||||
|
}
|
||||||
|
public hasView(): boolean {
|
||||||
|
return !!this.getView();
|
||||||
|
}
|
||||||
|
public reset(): void {
|
||||||
|
if (this._view) {
|
||||||
|
this._setView(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewDefault = (options?: ViewFactoryOptions): void =>
|
||||||
|
this._setViewGeneric(this._factory.getViewDefault, options);
|
||||||
|
|
||||||
|
setViewByParameters = (options?: ViewFactoryOptions): void =>
|
||||||
|
this._setViewGeneric(this._factory.getViewByParameters, options);
|
||||||
|
|
||||||
|
setViewDefaultWithNewQuery = async (options?: ViewFactoryOptions): Promise<void> =>
|
||||||
|
await this._setViewGenericAsync(this._factory.getViewDefaultWithNewQuery, options);
|
||||||
|
|
||||||
|
setViewByParametersWithNewQuery = async (
|
||||||
|
options?: ViewFactoryOptions,
|
||||||
|
): Promise<void> =>
|
||||||
|
await this._setViewGenericAsync(
|
||||||
|
this._factory.getViewByParametersWithNewQuery,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
setViewByParametersWithExistingQuery = async (
|
||||||
|
options?: ViewFactoryOptions,
|
||||||
|
): Promise<void> =>
|
||||||
|
await this._setViewGenericAsync(
|
||||||
|
this._factory.getViewByParametersWithExistingQuery,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
protected _setViewGeneric(
|
||||||
|
factoryFunc: (options?: ViewFactoryOptions) => View | null,
|
||||||
|
options?: ViewFactoryOptions,
|
||||||
|
): void {
|
||||||
|
let view: View | null = null;
|
||||||
|
try {
|
||||||
|
view = factoryFunc({
|
||||||
|
baseView: this._view,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return this._api.getMessageManager().setErrorIfHigherPriority(e);
|
||||||
|
}
|
||||||
|
view && this._setView(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _setViewGenericAsync(
|
||||||
|
factoryFunc: (options?: ViewFactoryOptions) => Promise<View | null>,
|
||||||
|
options?: ViewFactoryOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
let view: View | null = null;
|
||||||
|
try {
|
||||||
|
view = await factoryFunc({
|
||||||
|
baseView: this._view,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return this._api.getMessageManager().setErrorIfHigherPriority(e);
|
||||||
|
}
|
||||||
|
view && this._setView(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setViewWithMergedContext(context: ViewContext | null): void {
|
||||||
|
if (this._view) {
|
||||||
|
return this._setView(this._view?.clone().mergeInContext(context));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
|
||||||
|
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if the current view has a major "media change" for the given previous view.
|
||||||
|
* @param oldView The previous view.
|
||||||
|
* @returns True if the view change is a real media change.
|
||||||
|
*/
|
||||||
|
public hasMajorMediaChange(oldView?: View | null): boolean {
|
||||||
|
return (
|
||||||
|
!!oldView !== !!this._view ||
|
||||||
|
oldView?.view !== this._view?.view ||
|
||||||
|
oldView?.camera !== this._view?.camera ||
|
||||||
|
// When in live mode, take overrides (substreams) into account in deciding
|
||||||
|
// if this is a major media change.
|
||||||
|
(this._view?.view === 'live' &&
|
||||||
|
oldView &&
|
||||||
|
getStreamCameraID(oldView) !== getStreamCameraID(this._view)) ||
|
||||||
|
// When in the live view, the queryResults contain the events that
|
||||||
|
// happened in the past -- not reflective of the actual live media viewer
|
||||||
|
// the user is seeing.
|
||||||
|
(this._view?.view !== 'live' && oldView?.queryResults !== this._view?.queryResults)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _setView(view: View | null): void {
|
||||||
|
const oldView = this._view;
|
||||||
|
|
||||||
|
log(
|
||||||
|
this._api.getConfigManager().getCardWideConfig(),
|
||||||
|
`Frigate Card view change: `,
|
||||||
|
view,
|
||||||
|
);
|
||||||
|
|
||||||
|
this._view = view;
|
||||||
|
this._epoch = this._createEpoch(oldView);
|
||||||
|
|
||||||
|
if (this.hasMajorMediaChange(oldView)) {
|
||||||
|
this._api.getMediaLoadedInfoManager().clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldView?.view !== view?.view) {
|
||||||
|
this._api.getCardElementManager().scrollReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._api.getMessageManager().reset();
|
||||||
|
this._api.getStyleManager().setExpandedMode();
|
||||||
|
|
||||||
|
this._api.getConditionsManager()?.setState({
|
||||||
|
view: view?.view,
|
||||||
|
camera: view?.camera,
|
||||||
|
displayMode: view?.displayMode ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
this._api.getCardElementManager().update();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-7
@@ -4,7 +4,6 @@ import { customElement } from 'lit/decorators.js';
|
|||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import { Ref, createRef, ref } from 'lit/directives/ref.js';
|
import { Ref, createRef, ref } from 'lit/directives/ref.js';
|
||||||
import { styleMap } from 'lit/directives/style-map.js';
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
import { ViewContext } from 'view';
|
|
||||||
import 'web-dialog';
|
import 'web-dialog';
|
||||||
import pkg from '../package.json';
|
import pkg from '../package.json';
|
||||||
import { actionHandler } from './action-handler-directive.js';
|
import { actionHandler } from './action-handler-directive.js';
|
||||||
@@ -26,7 +25,6 @@ import { localize } from './localize/localize.js';
|
|||||||
import cardStyle from './scss/card.scss';
|
import cardStyle from './scss/card.scss';
|
||||||
import { ExtendedHomeAssistant, MediaLoadedInfo, Message } from './types.js';
|
import { ExtendedHomeAssistant, MediaLoadedInfo, Message } from './types.js';
|
||||||
import { frigateCardHasAction } from './utils/action.js';
|
import { frigateCardHasAction } from './utils/action.js';
|
||||||
import { View } from './view/view.js';
|
|
||||||
|
|
||||||
// ***************************************************************************
|
// ***************************************************************************
|
||||||
// General Card-Wide Notes
|
// General Card-Wide Notes
|
||||||
@@ -268,10 +266,6 @@ class FrigateCard extends LitElement {
|
|||||||
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
|
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
|
||||||
@frigate-card:message=${(ev: CustomEvent<Message>) =>
|
@frigate-card:message=${(ev: CustomEvent<Message>) =>
|
||||||
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
|
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>) =>
|
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
|
||||||
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
|
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
|
||||||
@frigate-card:media:unloaded=${() =>
|
@frigate-card:media:unloaded=${() =>
|
||||||
@@ -299,7 +293,7 @@ class FrigateCard extends LitElement {
|
|||||||
html`<frigate-card-views
|
html`<frigate-card-views
|
||||||
${ref(this._refViews)}
|
${ref(this._refViews)}
|
||||||
.hass=${this._hass}
|
.hass=${this._hass}
|
||||||
.view=${this._controller.getViewManager().getView()}
|
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
|
||||||
.cameraManager=${cameraManager}
|
.cameraManager=${cameraManager}
|
||||||
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
|
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
|
||||||
.nonOverriddenConfig=${this._controller
|
.nonOverriddenConfig=${this._controller
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
import { sub } from 'date-fns';
|
|
||||||
import { LitElement, ReactiveController } from 'lit';
|
import { LitElement, ReactiveController } from 'lit';
|
||||||
import { ViewContext } from 'view';
|
|
||||||
import { CameraManager } from '../../camera-manager/manager.js';
|
|
||||||
import { FrigateCardMessageEventTarget } from '../../components/message.js';
|
import { FrigateCardMessageEventTarget } from '../../components/message.js';
|
||||||
import { CardWideConfig, LiveConfig } from '../../config/types.js';
|
|
||||||
import { MediaLoadedInfo, Message } from '../../types.js';
|
import { MediaLoadedInfo, Message } from '../../types.js';
|
||||||
import {
|
import {
|
||||||
FrigateCardMediaLoadedEventTarget,
|
FrigateCardMediaLoadedEventTarget,
|
||||||
dispatchExistingMediaLoadedInfoAsEvent,
|
dispatchExistingMediaLoadedInfoAsEvent,
|
||||||
} from '../../utils/media-info.js';
|
} from '../../utils/media-info.js';
|
||||||
import {
|
|
||||||
changeViewToRecentEventsForCameraAndDependents,
|
|
||||||
changeViewToRecentRecordingForCameraAndDependents,
|
|
||||||
} from '../../utils/media-to-view.js';
|
|
||||||
import { FrigateCardViewChangeEventTarget, View } from '../../view/view.js';
|
|
||||||
|
|
||||||
interface LiveViewContext {
|
interface LiveViewContext {
|
||||||
// A cameraID override (used for dependencies/substreams to force a different
|
// A cameraID override (used for dependencies/substreams to force a different
|
||||||
// camera to be live rather than the camera selected in the view).
|
// camera to be live rather than the camera selected in the view).
|
||||||
overrides?: Map<string, string>;
|
overrides?: Map<string, string>;
|
||||||
|
|
||||||
fetchThumbnails?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'view' {
|
declare module 'view' {
|
||||||
@@ -36,8 +25,7 @@ interface LastMediaLoadedInfo {
|
|||||||
|
|
||||||
type LiveControllerHost = LitElement &
|
type LiveControllerHost = LitElement &
|
||||||
FrigateCardMediaLoadedEventTarget &
|
FrigateCardMediaLoadedEventTarget &
|
||||||
FrigateCardMessageEventTarget &
|
FrigateCardMessageEventTarget;
|
||||||
FrigateCardViewChangeEventTarget;
|
|
||||||
|
|
||||||
export class LiveController implements ReactiveController {
|
export class LiveController implements ReactiveController {
|
||||||
protected _host: LiveControllerHost;
|
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
|
// Don't process updates if it's in the background and a message was
|
||||||
// received (otherwise an error message thrown by the background live
|
// received (otherwise an error message thrown by the background live
|
||||||
// component may continually be re-spammed hitting performance).
|
// component may continually be re-spammed hitting performance).
|
||||||
return !this._inBackground || !this._messageReceived;
|
return !(this._inBackground && this._messageReceived);
|
||||||
}
|
}
|
||||||
|
|
||||||
public hostConnected(): void {
|
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:media:loaded', this._handleMediaLoaded);
|
||||||
this._host.addEventListener('frigate-card:message', this._handleMessage);
|
this._host.addEventListener('frigate-card:message', this._handleMessage);
|
||||||
this._host.addEventListener('frigate-card:view:change', this._handleViewChange);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public hostDisconnected(): void {
|
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:media:loaded', this._handleMediaLoaded);
|
||||||
this._host.removeEventListener('frigate-card:message', this._handleMessage);
|
this._host.removeEventListener('frigate-card:message', this._handleMessage);
|
||||||
this._host.removeEventListener('frigate-card:view:change', this._handleViewChange);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public clearMessageReceived(): void {
|
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 {
|
protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
|
||||||
const wasInBackground = this._inBackground;
|
const wasInBackground = this._inBackground;
|
||||||
this._inBackground = !entries.some((entry) => entry.isIntersecting);
|
this._inBackground = !entries.some((entry) => entry.isIntersecting);
|
||||||
@@ -150,74 +130,4 @@ export class LiveController implements ReactiveController {
|
|||||||
this._host.requestUpdate();
|
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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,9 @@ import { SelectOption, SelectValues } from '../components/select';
|
|||||||
import { CardWideConfig } from '../config/types';
|
import { CardWideConfig } from '../config/types';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
|
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
|
||||||
import { executeMediaQueryForViewWithErrorDispatching } from '../utils/media-to-view';
|
|
||||||
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
|
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
|
||||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||||
import { View } from '../view/view';
|
import { ViewManagerInterface } from '../card-controller/view/types';
|
||||||
|
|
||||||
interface MediaFilterControls {
|
interface MediaFilterControls {
|
||||||
events: boolean;
|
events: boolean;
|
||||||
@@ -77,6 +76,7 @@ export class MediaFilterController {
|
|||||||
protected _favoriteOptions: SelectOption[];
|
protected _favoriteOptions: SelectOption[];
|
||||||
|
|
||||||
protected _defaults: MediaFilterCoreDefaults | null = null;
|
protected _defaults: MediaFilterCoreDefaults | null = null;
|
||||||
|
protected _viewManager: ViewManagerInterface | null = null;
|
||||||
|
|
||||||
constructor(host: LitElement) {
|
constructor(host: LitElement) {
|
||||||
this._host = host;
|
this._host = host;
|
||||||
@@ -154,10 +154,12 @@ export class MediaFilterController {
|
|||||||
public getDefaults(): MediaFilterCoreDefaults | null {
|
public getDefaults(): MediaFilterCoreDefaults | null {
|
||||||
return this._defaults;
|
return this._defaults;
|
||||||
}
|
}
|
||||||
|
public setViewManager(viewManager: ViewManagerInterface | null): void {
|
||||||
|
this._viewManager = viewManager;
|
||||||
|
}
|
||||||
|
|
||||||
public async valueChangeHandler(
|
public async valueChangeHandler(
|
||||||
cameraManager: CameraManager,
|
cameraManager: CameraManager,
|
||||||
view: View,
|
|
||||||
cardWideConfig: CardWideConfig,
|
cardWideConfig: CardWideConfig,
|
||||||
values: {
|
values: {
|
||||||
camera?: string | string[];
|
camera?: string | string[];
|
||||||
@@ -234,20 +236,15 @@ export class MediaFilterController {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
(
|
this._viewManager?.setViewByParametersWithExistingQuery({
|
||||||
await executeMediaQueryForViewWithErrorDispatching(
|
params: {
|
||||||
this._host,
|
query: queries,
|
||||||
cameraManager,
|
|
||||||
view,
|
// See 'A note on views' above for these two arguments
|
||||||
queries,
|
...(cameraIDs.size === 1 && { camera: [...cameraIDs][0] }),
|
||||||
{
|
view: values.mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots',
|
||||||
// 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);
|
|
||||||
} else {
|
} else {
|
||||||
const queries = new RecordingMediaQueries([
|
const queries = new RecordingMediaQueries([
|
||||||
{
|
{
|
||||||
@@ -262,19 +259,15 @@ export class MediaFilterController {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
(
|
this._viewManager?.setViewByParametersWithExistingQuery({
|
||||||
await executeMediaQueryForViewWithErrorDispatching(
|
params: {
|
||||||
this._host,
|
query: queries,
|
||||||
cameraManager,
|
|
||||||
view,
|
// See 'A note on views' above for these two arguments
|
||||||
queries,
|
...(cameraIDs.size === 1 && { camera: [...cameraIDs][0] }),
|
||||||
{
|
view: 'recordings',
|
||||||
// See 'A note on views' above for these two arguments.
|
|
||||||
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
|
|
||||||
targetView: 'recordings',
|
|
||||||
},
|
},
|
||||||
)
|
});
|
||||||
)?.dispatchChangeEvent(this._host);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Need to ensure we update the element as the date-picker selections may
|
// 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 {
|
public computeInitialDefaultsFromView(cameraManager: CameraManager): void {
|
||||||
const queries = view.query?.getQueries();
|
const view = this._viewManager?.getView();
|
||||||
|
const queries = view?.query?.getQueries();
|
||||||
const allCameraIDs = this._getAllCameraIDs(cameraManager);
|
const allCameraIDs = this._getAllCameraIDs(cameraManager);
|
||||||
if (!queries || !allCameraIDs.size) {
|
if (!view || !queries || !allCameraIDs.size) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,14 +438,10 @@ export class MediaFilterController {
|
|||||||
this._host.requestUpdate();
|
this._host.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getControlsToShow(
|
public getControlsToShow(cameraManager: CameraManager): MediaFilterControls {
|
||||||
cameraManager: CameraManager,
|
const view = this._viewManager?.getView();
|
||||||
view: View,
|
const events = MediaQueriesClassifier.areEventQueries(view?.query);
|
||||||
): MediaFilterControls {
|
const recordings = MediaQueriesClassifier.areRecordingQueries(view?.query);
|
||||||
const events = !!(view.query && MediaQueriesClassifier.areEventQueries(view.query));
|
|
||||||
const recordings = !!(
|
|
||||||
view.query && MediaQueriesClassifier.areRecordingQueries(view.query)
|
|
||||||
);
|
|
||||||
const managerCapabilities = cameraManager.getAggregateCameraCapabilities();
|
const managerCapabilities = cameraManager.getAggregateCameraCapabilities();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { StyleInfo } from 'lit/directives/style-map';
|
|||||||
import { CameraManager } from '../camera-manager/manager';
|
import { CameraManager } from '../camera-manager/manager';
|
||||||
import { MediaPlayerManager } from '../card-controller/media-player-manager';
|
import { MediaPlayerManager } from '../card-controller/media-player-manager';
|
||||||
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
||||||
import { ViewManager } from '../card-controller/view-manager';
|
import { ViewManager } from '../card-controller/view/view-manager';
|
||||||
import {
|
import {
|
||||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||||
FrigateCardConfig,
|
FrigateCardConfig,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ViewContext } from 'view';
|
import { ViewContext } from 'view';
|
||||||
import { dispatchViewContextChangeEvent } from '../../view/view.js';
|
import { MergeContextViewModifier } from '../../card-controller/view/modifiers/merge-context.js';
|
||||||
import { ZoomSettingsObserved, PartialZoomSettings } from './types.js';
|
import { ViewManagerInterface } from '../../card-controller/view/types.js';
|
||||||
|
import { PartialZoomSettings, ZoomSettingsObserved } from './types.js';
|
||||||
|
|
||||||
interface ZoomViewContext {
|
interface ZoomViewContext {
|
||||||
observed?: ZoomSettingsObserved;
|
observed?: ZoomSettingsObserved;
|
||||||
@@ -38,19 +39,22 @@ export const generateViewContextForZoom = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience wrapper to convert zoom settings into a dispatched view context
|
* Convenience wrapper to convert zoom settings into a view change
|
||||||
* change.
|
|
||||||
*/
|
*/
|
||||||
export const handleZoomSettingsObservedEvent = (
|
export const handleZoomSettingsObservedEvent = (
|
||||||
element: EventTarget,
|
|
||||||
ev: CustomEvent<ZoomSettingsObserved>,
|
ev: CustomEvent<ZoomSettingsObserved>,
|
||||||
|
viewManager?: ViewManagerInterface,
|
||||||
targetID?: string,
|
targetID?: string,
|
||||||
): void => {
|
): void => {
|
||||||
|
viewManager &&
|
||||||
targetID &&
|
targetID &&
|
||||||
dispatchViewContextChangeEvent(
|
viewManager.setViewByParameters({
|
||||||
element,
|
modifiers: [
|
||||||
|
new MergeContextViewModifier(
|
||||||
generateViewContextForZoom(targetID, {
|
generateViewContextForZoom(targetID, {
|
||||||
observed: ev.detail,
|
observed: ev.detail,
|
||||||
}),
|
}),
|
||||||
);
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
+36
-64
@@ -24,20 +24,16 @@ import galleryStyle from '../scss/gallery.scss';
|
|||||||
import { ExtendedHomeAssistant } from '../types.js';
|
import { ExtendedHomeAssistant } from '../types.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||||
import { errorToConsole, sleep } from '../utils/basic';
|
import { errorToConsole, sleep } from '../utils/basic';
|
||||||
import {
|
|
||||||
changeViewToRecentEventsForCameraAndDependents,
|
|
||||||
changeViewToRecentRecordingForCameraAndDependents,
|
|
||||||
} from '../utils/media-to-view.js';
|
|
||||||
import { ViewMedia } from '../view/media';
|
import { ViewMedia } from '../view/media';
|
||||||
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
|
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
|
||||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||||
import { MediaQueriesResults } from '../view/media-queries-results';
|
import { MediaQueriesResults } from '../view/media-queries-results';
|
||||||
import { View } from '../view/view.js';
|
|
||||||
import './media-filter';
|
import './media-filter';
|
||||||
import { renderMessage, renderProgressIndicator } from './message.js';
|
import { renderMessage, renderProgressIndicator } from './message.js';
|
||||||
import './surround-basic';
|
import './surround-basic';
|
||||||
import './thumbnail.js';
|
import './thumbnail.js';
|
||||||
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
|
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
|
||||||
|
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
||||||
|
|
||||||
const GALLERY_MEDIA_FILTER_MENU_ICONS = {
|
const GALLERY_MEDIA_FILTER_MENU_ICONS = {
|
||||||
closed: 'mdi:filter-cog-outline',
|
closed: 'mdi:filter-cog-outline',
|
||||||
@@ -52,7 +48,7 @@ export class FrigateCardGallery extends LitElement {
|
|||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public galleryConfig?: GalleryConfig;
|
public galleryConfig?: GalleryConfig;
|
||||||
@@ -68,43 +64,16 @@ export class FrigateCardGallery extends LitElement {
|
|||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
*/
|
*/
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
if (
|
if (
|
||||||
!this.hass ||
|
!this.hass ||
|
||||||
!this.view ||
|
!view?.isGalleryView() ||
|
||||||
!this.view.isGalleryView() ||
|
|
||||||
!this.cameraManager ||
|
!this.cameraManager ||
|
||||||
!this.cardWideConfig
|
!this.cardWideConfig
|
||||||
) {
|
) {
|
||||||
return;
|
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`
|
return html`
|
||||||
<frigate-card-surround-basic
|
<frigate-card-surround-basic
|
||||||
.drawerIcons=${{
|
.drawerIcons=${{
|
||||||
@@ -118,7 +87,7 @@ export class FrigateCardGallery extends LitElement {
|
|||||||
? html` <frigate-card-media-filter
|
? html` <frigate-card-media-filter
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
slot=${this.galleryConfig.controls.filter.mode}
|
slot=${this.galleryConfig.controls.filter.mode}
|
||||||
>
|
>
|
||||||
@@ -126,7 +95,7 @@ export class FrigateCardGallery extends LitElement {
|
|||||||
: ''}
|
: ''}
|
||||||
<frigate-card-gallery-core
|
<frigate-card-gallery-core
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.galleryConfig=${this.galleryConfig}
|
.galleryConfig=${this.galleryConfig}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
@@ -147,7 +116,7 @@ export class FrigateCardGalleryCore extends LitElement {
|
|||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public galleryConfig?: GalleryConfig;
|
public galleryConfig?: GalleryConfig;
|
||||||
@@ -328,13 +297,15 @@ export class FrigateCardGalleryCore extends LitElement {
|
|||||||
direction: 'earlier' | 'later',
|
direction: 'earlier' | 'later',
|
||||||
useCache = true,
|
useCache = true,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this.cameraManager || !this.hass || !this.view) {
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
|
|
||||||
|
if (!this.cameraManager || !this.hass || !view) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = this.view?.query;
|
const query = view.query;
|
||||||
const rawQueries = query?.getQueries() ?? null;
|
const rawQueries = query?.getQueries() ?? null;
|
||||||
const existingMedia = this.view.queryResults?.getResults();
|
const existingMedia = view.queryResults?.getResults();
|
||||||
if (!query || !rawQueries || !existingMedia) {
|
if (!query || !rawQueries || !existingMedia) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -362,16 +333,17 @@ export class FrigateCardGalleryCore extends LitElement {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (newMediaQueries) {
|
if (newMediaQueries) {
|
||||||
this.view
|
this.viewManagerEpoch?.manager.setViewByParameters({
|
||||||
?.evolve({
|
baseView: view,
|
||||||
|
params: {
|
||||||
query: newMediaQueries,
|
query: newMediaQueries,
|
||||||
queryResults: new MediaQueriesResults({
|
queryResults: new MediaQueriesResults({
|
||||||
results: extension.results,
|
results: extension.results,
|
||||||
}).selectResultIfFound(
|
}).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
|
// If the view changes, always render the bottom loader to allow for the
|
||||||
// view to be extended once the bottom loader becomes visible.
|
// view to be extended once the bottom loader becomes visible.
|
||||||
this._showLoaderBottom = true;
|
this._showLoaderBottom = true;
|
||||||
const oldView: View | undefined = changedProps.get('view');
|
|
||||||
|
|
||||||
if (
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
oldView?.queryResults?.getResults() !== this.view?.queryResults?.getResults()
|
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
|
// 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
|
// 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.
|
// 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.
|
* @returns A rendered template.
|
||||||
*/
|
*/
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this._media || !this.hass || !this.view || !this.view.isGalleryView()) {
|
if (!this._media || !this.hass) {
|
||||||
return html``;
|
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
|
// 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
|
// 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
|
// 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">
|
return html` <div class="grid">
|
||||||
${this._showLoaderTop
|
${this._showLoaderTop
|
||||||
? html`${renderProgressIndicator({
|
? html`${renderProgressIndicator({
|
||||||
@@ -454,7 +426,7 @@ export class FrigateCardGalleryCore extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.media=${media}
|
.media=${media}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
|
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
|
||||||
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
|
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
|
||||||
.show_favorite_control}
|
.show_favorite_control}
|
||||||
@@ -463,17 +435,17 @@ export class FrigateCardGalleryCore extends LitElement {
|
|||||||
?show_download_control=${!!this.galleryConfig?.controls.thumbnails
|
?show_download_control=${!!this.galleryConfig?.controls.thumbnails
|
||||||
.show_download_control}
|
.show_download_control}
|
||||||
@click=${(ev: Event) => {
|
@click=${(ev: Event) => {
|
||||||
if (this.view && this._media) {
|
if (this._media) {
|
||||||
this.view
|
this.viewManagerEpoch?.manager.setViewByParameters({
|
||||||
.evolve({
|
params: {
|
||||||
view: 'media',
|
view: 'media',
|
||||||
queryResults: this.view.queryResults?.clone().selectIndex(
|
queryResults: view.queryResults?.clone().selectIndex(
|
||||||
// Media in the gallery is reversed vs the queryResults (see
|
// Media in the gallery is reversed vs the queryResults (see
|
||||||
// note above).
|
// note above).
|
||||||
this._media.length - index - 1,
|
this._media.length - index - 1,
|
||||||
),
|
),
|
||||||
})
|
},
|
||||||
.dispatchChangeEvent(this);
|
});
|
||||||
}
|
}
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
}}
|
}}
|
||||||
@@ -504,9 +476,9 @@ export class FrigateCardGalleryCore extends LitElement {
|
|||||||
// See: https://github.com/dermotduffy/frigate-hass-card/issues/885
|
// See: https://github.com/dermotduffy/frigate-hass-card/issues/885
|
||||||
if (
|
if (
|
||||||
// If this update cycle updated the view ...
|
// If this update cycle updated the view ...
|
||||||
changedProps.has('view') &&
|
changedProps.has('viewManagerEpoch') &&
|
||||||
// ... and it wasn't set at all prior ...
|
// ... and it wasn't set at all prior ...
|
||||||
!changedProps.get('view') &&
|
!changedProps.get('viewManagerEpoch') &&
|
||||||
// ... and there is a thumbnail rendered that is selected.
|
// ... and there is a thumbnail rendered that is selected.
|
||||||
this._refSelected.value
|
this._refSelected.value
|
||||||
) {
|
) {
|
||||||
|
|||||||
+63
-87
@@ -19,8 +19,14 @@ import {
|
|||||||
getOverriddenConfig,
|
getOverriddenConfig,
|
||||||
} from '../../card-controller/conditions-manager.js';
|
} from '../../card-controller/conditions-manager.js';
|
||||||
import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-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 { LiveController } from '../../components-lib/live/live-controller.js';
|
||||||
import { MediaGridSelected } from '../../components-lib/media-grid-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 {
|
import {
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
CardWideConfig,
|
CardWideConfig,
|
||||||
@@ -48,7 +54,8 @@ import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js';
|
|||||||
import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
|
import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
|
||||||
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
|
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
|
||||||
import { playMediaMutingIfNecessary } from '../../utils/media.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 { EmblaCarouselPlugins } from '../carousel.js';
|
||||||
import { renderMessage } from '../message.js';
|
import { renderMessage } from '../message.js';
|
||||||
import '../next-prev-control.js';
|
import '../next-prev-control.js';
|
||||||
@@ -60,12 +67,6 @@ import {
|
|||||||
FrigateCardTitleControl,
|
FrigateCardTitleControl,
|
||||||
getDefaultTitleConfigForView,
|
getDefaultTitleConfigForView,
|
||||||
} from '../title-control.js';
|
} 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';
|
const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider';
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ export class FrigateCardLive extends LitElement {
|
|||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public nonOverriddenLiveConfig?: LiveConfig;
|
public nonOverriddenLiveConfig?: LiveConfig;
|
||||||
@@ -108,38 +109,16 @@ export class FrigateCardLive extends LitElement {
|
|||||||
return this._controller.shouldUpdate();
|
return this._controller.shouldUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProperties: PropertyValues): void {
|
protected willUpdate(): 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._controller.clearMessageReceived();
|
this._controller.clearMessageReceived();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (
|
if (!this.hass || !this.nonOverriddenLiveConfig || !this.cameraManager) {
|
||||||
!this.hass ||
|
|
||||||
!this.nonOverriddenLiveConfig ||
|
|
||||||
!this.cameraManager ||
|
|
||||||
!this.view
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notes:
|
// Implementation notes:
|
||||||
// - See use of liveConfig and not config below -- the underlying carousel
|
// - See use of liveConfig and not config below -- the underlying carousel
|
||||||
// will independently override the liveConfig to reflect the camera in the
|
// will independently override the liveConfig to reflect the camera in the
|
||||||
// carousel (not necessarily the selected camera).
|
// carousel (not necessarily the selected camera).
|
||||||
@@ -153,7 +132,7 @@ export class FrigateCardLive extends LitElement {
|
|||||||
html`
|
html`
|
||||||
<frigate-card-live-grid
|
<frigate-card-live-grid
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
|
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
|
||||||
.overriddenLiveConfig=${this.overriddenLiveConfig}
|
.overriddenLiveConfig=${this.overriddenLiveConfig}
|
||||||
.inBackground=${this._controller.isInBackground()}
|
.inBackground=${this._controller.isInBackground()}
|
||||||
@@ -180,7 +159,7 @@ export class FrigateCardLiveGrid extends LitElement {
|
|||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public nonOverriddenLiveConfig?: LiveConfig;
|
public nonOverriddenLiveConfig?: LiveConfig;
|
||||||
@@ -207,13 +186,14 @@ export class FrigateCardLiveGrid extends LitElement {
|
|||||||
public triggeredCameraIDs?: Set<string>;
|
public triggeredCameraIDs?: Set<string>;
|
||||||
|
|
||||||
protected _renderCarousel(cameraID?: string): TemplateResult {
|
protected _renderCarousel(cameraID?: string): TemplateResult {
|
||||||
const triggeredCameraID = cameraID ?? this.view?.camera;
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
|
const triggeredCameraID = cameraID ?? view?.camera;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<frigate-card-live-carousel
|
<frigate-card-live-carousel
|
||||||
grid-id=${ifDefined(cameraID)}
|
grid-id=${ifDefined(cameraID)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.viewFilterCameraID=${cameraID}
|
.viewFilterCameraID=${cameraID}
|
||||||
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
|
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
|
||||||
.overriddenLiveConfig=${this.overriddenLiveConfig}
|
.overriddenLiveConfig=${this.overriddenLiveConfig}
|
||||||
@@ -229,26 +209,27 @@ export class FrigateCardLiveGrid extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _gridSelectCamera(cameraID: string, view?: View): void {
|
protected _gridSelectCamera(cameraID: string): void {
|
||||||
(view ?? this.view)
|
this.viewManagerEpoch?.manager.setViewByParameters({
|
||||||
?.evolve({
|
params: {
|
||||||
camera: cameraID,
|
camera: cameraID,
|
||||||
})
|
},
|
||||||
.dispatchChangeEvent(this);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _needsGrid(): boolean {
|
protected _needsGrid(): boolean {
|
||||||
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
||||||
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
return (
|
return (
|
||||||
!!this.view?.isGrid() &&
|
!!view?.isGrid() &&
|
||||||
!!this.view?.supportsMultipleDisplayModes() &&
|
!!view?.supportsMultipleDisplayModes() &&
|
||||||
!!cameraIDs &&
|
!!cameraIDs &&
|
||||||
cameraIDs.size > 1
|
cameraIDs.size > 1
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('view') && this._needsGrid()) {
|
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) {
|
||||||
import('../media-grid.js');
|
import('../media-grid.js');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,16 +242,13 @@ export class FrigateCardLiveGrid extends LitElement {
|
|||||||
if (!cameraIDs?.size || !this._needsGrid()) {
|
if (!cameraIDs?.size || !this._needsGrid()) {
|
||||||
return this._renderCarousel();
|
return this._renderCarousel();
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<frigate-card-media-grid
|
<frigate-card-media-grid
|
||||||
.selected=${this.view?.camera}
|
.selected=${this.viewManagerEpoch?.manager.getView()?.camera}
|
||||||
.displayConfig=${this.overriddenLiveConfig?.display}
|
.displayConfig=${this.overriddenLiveConfig?.display}
|
||||||
@frigate-card:media-grid:selected=${(ev: CustomEvent<MediaGridSelected>) =>
|
@frigate-card:media-grid:selected=${(ev: CustomEvent<MediaGridSelected>) =>
|
||||||
this._gridSelectCamera(ev.detail.selected)}
|
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))}
|
${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))}
|
||||||
</frigate-card-media-grid>
|
</frigate-card-media-grid>
|
||||||
@@ -288,7 +266,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public nonOverriddenLiveConfig?: LiveConfig;
|
public nonOverriddenLiveConfig?: LiveConfig;
|
||||||
@@ -331,12 +309,13 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
|
|
||||||
protected _getSelectedCameraIndex(): number {
|
protected _getSelectedCameraIndex(): number {
|
||||||
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
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)
|
// If the carousel is limited to a single cameraID, the first (only)
|
||||||
// element is always the selected one.
|
// element is always the selected one.
|
||||||
return 0;
|
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 {
|
protected _getPlugins(): EmblaCarouselPlugins {
|
||||||
@@ -393,6 +372,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
return [[], {}];
|
return [[], {}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
const cameraIDs = this.viewFilterCameraID
|
const cameraIDs = this.viewFilterCameraID
|
||||||
? new Set([this.viewFilterCameraID])
|
? new Set([this.viewFilterCameraID])
|
||||||
: this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
: this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
||||||
@@ -403,8 +383,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
for (const [cameraID, cameraConfig] of this.cameraManager
|
for (const [cameraID, cameraConfig] of this.cameraManager
|
||||||
.getStore()
|
.getStore()
|
||||||
.getCameraConfigEntries(cameraIDs)) {
|
.getCameraConfigEntries(cameraIDs)) {
|
||||||
const liveCameraID =
|
const liveCameraID = this._getSubstreamCameraID(cameraID, view);
|
||||||
this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
|
|
||||||
const liveCameraConfig =
|
const liveCameraConfig =
|
||||||
cameraID === liveCameraID
|
cameraID === liveCameraID
|
||||||
? cameraConfig
|
? cameraConfig
|
||||||
@@ -430,17 +409,11 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
|
|
||||||
protected _setViewCameraID(cameraID?: string | null): void {
|
protected _setViewCameraID(cameraID?: string | null): void {
|
||||||
if (cameraID) {
|
if (cameraID) {
|
||||||
this.view
|
this.viewManagerEpoch?.manager.setViewByParametersWithNewQuery({
|
||||||
?.evolve({
|
params: {
|
||||||
camera: cameraID,
|
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;
|
).live as LiveConfig;
|
||||||
|
|
||||||
const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
|
const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
|
||||||
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="embla__slide">
|
<div class="embla__slide">
|
||||||
<frigate-card-live-provider
|
<frigate-card-live-provider
|
||||||
?load=${!liveConfig.lazy_load}
|
?load=${!liveConfig.lazy_load}
|
||||||
.microphoneStream=${this.view?.camera === cameraID
|
.microphoneStream=${view?.camera === cameraID
|
||||||
? this.microphoneManager?.getStream()
|
? this.microphoneManager?.getStream()
|
||||||
: undefined}
|
: undefined}
|
||||||
.cameraConfig=${cameraConfig}
|
.cameraConfig=${cameraConfig}
|
||||||
@@ -507,9 +481,13 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
.liveConfig=${liveConfig}
|
.liveConfig=${liveConfig}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
.zoomSettings=${this.view?.context?.zoom?.[cameraID]?.requested}
|
.zoomSettings=${view?.context?.zoom?.[cameraID]?.requested}
|
||||||
@frigate-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
|
@frigate-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
|
||||||
handleZoomSettingsObservedEvent(this, ev, cameraID)}
|
handleZoomSettingsObservedEvent(
|
||||||
|
ev,
|
||||||
|
this.viewManagerEpoch?.manager,
|
||||||
|
cameraID,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
</frigate-card-live-provider>
|
</frigate-card-live-provider>
|
||||||
</div>
|
</div>
|
||||||
@@ -520,11 +498,13 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
const cameraIDs = this.cameraManager
|
const cameraIDs = this.cameraManager
|
||||||
? [...this.cameraManager?.getStore().getCameraIDsWithCapability('live')]
|
? [...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];
|
return [null, null];
|
||||||
}
|
}
|
||||||
|
|
||||||
const cameraID = this.viewFilterCameraID ?? this.view.camera;
|
const cameraID = this.viewFilterCameraID ?? view.camera;
|
||||||
const currentIndex = cameraIDs.indexOf(cameraID);
|
const currentIndex = cameraIDs.indexOf(cameraID);
|
||||||
|
|
||||||
if (currentIndex < 0) {
|
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 {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,23 +536,19 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
const hasMultipleCameras = slides.length > 1;
|
const hasMultipleCameras = slides.length > 1;
|
||||||
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
|
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
|
||||||
|
|
||||||
const getOverrideCameraID = (cameraID: string): string => {
|
|
||||||
return this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cameraMetadataPrevious = prevID
|
const cameraMetadataPrevious = prevID
|
||||||
? this.cameraManager.getCameraMetadata(getOverrideCameraID(prevID))
|
? this.cameraManager.getCameraMetadata(this._getSubstreamCameraID(prevID, view))
|
||||||
: null;
|
: null;
|
||||||
const cameraID = this.viewFilterCameraID ?? this.view.camera;
|
const cameraID = this.viewFilterCameraID ?? view.camera;
|
||||||
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
|
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
|
||||||
getOverrideCameraID(cameraID),
|
this._getSubstreamCameraID(cameraID, view),
|
||||||
);
|
);
|
||||||
const cameraMetadataNext = nextID
|
const cameraMetadataNext = nextID
|
||||||
? this.cameraManager.getCameraMetadata(getOverrideCameraID(nextID))
|
? this.cameraManager.getCameraMetadata(this._getSubstreamCameraID(nextID, view))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const titleConfig = getDefaultTitleConfigForView(
|
const titleConfig = getDefaultTitleConfigForView(
|
||||||
this.view,
|
view,
|
||||||
this.overriddenLiveConfig?.controls.title,
|
this.overriddenLiveConfig?.controls.title,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -592,10 +573,6 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
.selected=${this._getSelectedCameraIndex()}
|
.selected=${this._getSelectedCameraIndex()}
|
||||||
transitionEffect=${this._getTransitionEffect()}
|
transitionEffect=${this._getTransitionEffect()}
|
||||||
@frigate-card:carousel:select=${this._setViewHandler.bind(this)}
|
@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=${() => {
|
@frigate-card:media:loaded=${() => {
|
||||||
if (this._refTitleControl.value) {
|
if (this._refTitleControl.value) {
|
||||||
this._refTitleControl.value.show();
|
this._refTitleControl.value.show();
|
||||||
@@ -639,9 +616,8 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
<frigate-card-ptz
|
<frigate-card-ptz
|
||||||
.config=${this.overriddenLiveConfig.controls.ptz}
|
.config=${this.overriddenLiveConfig.controls.ptz}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.cameraID=${getStreamCameraID(this.view, cameraID)}
|
.cameraID=${getStreamCameraID(view, cameraID)}
|
||||||
.forceVisibility=${this._mediaHasLoaded &&
|
.forceVisibility=${this._mediaHasLoaded && view.context?.ptzControls?.enabled}
|
||||||
this.view.context?.ptzControls?.enabled}
|
|
||||||
>
|
>
|
||||||
</frigate-card-ptz>
|
</frigate-card-ptz>
|
||||||
${cameraMetadataCurrent && titleConfig
|
${cameraMetadataCurrent && titleConfig
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ import {
|
|||||||
import { CardWideConfig } from '../config/types';
|
import { CardWideConfig } from '../config/types';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import mediaFilterStyle from '../scss/media-filter.scss';
|
import mediaFilterStyle from '../scss/media-filter.scss';
|
||||||
import { View } from '../view/view';
|
|
||||||
import { FrigateCardDatePicker } from './date-picker';
|
import { FrigateCardDatePicker } from './date-picker';
|
||||||
import './date-picker.js';
|
import './date-picker.js';
|
||||||
import { FrigateCardSelect } from './select';
|
import { FrigateCardSelect } from './select';
|
||||||
import './select.js';
|
import './select.js';
|
||||||
|
import { ViewManagerEpoch } from '../card-controller/view/types';
|
||||||
|
|
||||||
@customElement('frigate-card-media-filter')
|
@customElement('frigate-card-media-filter')
|
||||||
class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||||
@@ -36,7 +36,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
|||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: View;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
@@ -59,46 +59,49 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
|||||||
protected _refTags: Ref<FrigateCardSelect> = createRef();
|
protected _refTags: Ref<FrigateCardSelect> = createRef();
|
||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
|
if (changedProps.has('viewManagerEpoch')) {
|
||||||
|
this._mediaFilterController.setViewManager(this.viewManagerEpoch?.manager ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
if (changedProps.has('cameraManager') && this.cameraManager) {
|
if (changedProps.has('cameraManager') && this.cameraManager) {
|
||||||
this._mediaFilterController.computeCameraOptions(this.cameraManager);
|
this._mediaFilterController.computeCameraOptions(this.cameraManager);
|
||||||
this._mediaFilterController.computeMetadataOptions(this.cameraManager);
|
this._mediaFilterController.computeMetadataOptions(this.cameraManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The first time the viewManager is set, compute the initial default selections.
|
||||||
if (
|
if (
|
||||||
changedProps.has('view') &&
|
!changedProps.get('viewManager') &&
|
||||||
!changedProps.get('view') &&
|
this.viewManagerEpoch &&
|
||||||
this.view &&
|
|
||||||
this.cameraManager
|
this.cameraManager
|
||||||
) {
|
) {
|
||||||
this._mediaFilterController.computeInitialDefaultsFromView(
|
this._mediaFilterController.computeInitialDefaultsFromView(this.cameraManager);
|
||||||
this.cameraManager,
|
|
||||||
this.view,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
const valueChange = async () => {
|
const valueChange = async () => {
|
||||||
if (!this.cameraManager || !this.view || !this.cardWideConfig) {
|
if (!this.cameraManager || !this.viewManagerEpoch || !this.cardWideConfig) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this._mediaFilterController.valueChangeHandler(
|
await this._mediaFilterController.valueChangeHandler(
|
||||||
this.cameraManager,
|
this.cameraManager,
|
||||||
this.view,
|
|
||||||
this.cardWideConfig,
|
this.cardWideConfig,
|
||||||
{
|
{
|
||||||
camera: this._refCamera.value?.value,
|
camera: this._refCamera.value?.value ?? undefined,
|
||||||
mediaType: this._refMediaType.value?.value as MediaFilterMediaType | undefined,
|
mediaType: (this._refMediaType.value?.value ?? undefined) as
|
||||||
|
| MediaFilterMediaType
|
||||||
|
| undefined,
|
||||||
when: {
|
when: {
|
||||||
selected: this._refWhen.value?.value,
|
selected: this._refWhen.value?.value ?? undefined,
|
||||||
from: this._refWhenFrom.value?.value,
|
from: this._refWhenFrom.value?.value,
|
||||||
to: this._refWhenTo.value?.value,
|
to: this._refWhenTo.value?.value,
|
||||||
},
|
},
|
||||||
favorite: this._refFavorite.value?.value as
|
favorite: (this._refFavorite.value?.value ?? undefined) as
|
||||||
| MediaFilterCoreFavoriteSelection
|
| MediaFilterCoreFavoriteSelection
|
||||||
| undefined,
|
| undefined,
|
||||||
where: this._refWhere.value?.value,
|
where: this._refWhere.value?.value ?? undefined,
|
||||||
what: this._refWhat.value?.value,
|
what: this._refWhat.value?.value ?? undefined,
|
||||||
tags: this._refTags.value?.value,
|
tags: this._refTags.value?.value ?? undefined,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -119,14 +122,11 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
|||||||
await valueChange();
|
await valueChange();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!this.cameraManager || !this.view) {
|
if (!this.cameraManager || !this.viewManagerEpoch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const controls = this._mediaFilterController.getControlsToShow(
|
const controls = this._mediaFilterController.getControlsToShow(this.cameraManager);
|
||||||
this.cameraManager,
|
|
||||||
this.view,
|
|
||||||
);
|
|
||||||
const defaults = this._mediaFilterController.getDefaults();
|
const defaults = this._mediaFilterController.getDefaults();
|
||||||
const whatOptions = this._mediaFilterController.getWhatOptions();
|
const whatOptions = this._mediaFilterController.getWhatOptions();
|
||||||
const tagsOptions = this._mediaFilterController.getTagsOptions();
|
const tagsOptions = this._mediaFilterController.getTagsOptions();
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) {
|
|||||||
public options?: SelectOption[];
|
public options?: SelectOption[];
|
||||||
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public value?: SelectValues;
|
public value: SelectValues | null = null;
|
||||||
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public initialValue?: SelectValues;
|
public initialValue?: SelectValues;
|
||||||
@@ -56,7 +56,7 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.value = undefined;
|
this.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
@@ -66,10 +66,17 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) {
|
|||||||
// the change event even if the value has not actually changed. Prevent that
|
// the change event even if the value has not actually changed. Prevent that
|
||||||
// from propagating upwards.
|
// from propagating upwards.
|
||||||
if (value !== undefined && !isEqual(this.value, value)) {
|
if (value !== undefined && !isEqual(this.value, value)) {
|
||||||
|
const initialValueSet = this.value === null;
|
||||||
this.value = value;
|
this.value = 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);
|
dispatchFrigateCardEvent(this, 'select:change', value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('initialValue') && this.initialValue && !this.value) {
|
if (changedProps.has('initialValue') && this.initialValue && !this.value) {
|
||||||
|
|||||||
+32
-28
@@ -8,6 +8,8 @@ import {
|
|||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { CameraManager } from '../camera-manager/manager.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 {
|
import {
|
||||||
CardWideConfig,
|
CardWideConfig,
|
||||||
MiniTimelineControlConfig,
|
MiniTimelineControlConfig,
|
||||||
@@ -16,7 +18,6 @@ import {
|
|||||||
import basicBlockStyle from '../scss/basic-block.scss';
|
import basicBlockStyle from '../scss/basic-block.scss';
|
||||||
import { ExtendedHomeAssistant } from '../types.js';
|
import { ExtendedHomeAssistant } from '../types.js';
|
||||||
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
|
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||||
import { View } from '../view/view.js';
|
|
||||||
import './surround-basic.js';
|
import './surround-basic.js';
|
||||||
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public thumbnailConfig?: ThumbnailsControlConfig;
|
public thumbnailConfig?: ThumbnailsControlConfig;
|
||||||
@@ -60,42 +61,47 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
// Only reset the timeline cameraIDs when the media or display mode
|
// Only reset the timeline cameraIDs when the media or display mode
|
||||||
// materially changes (and not on every view change, since the view will
|
// materially changes (and not on every view change, since the view will
|
||||||
// change frequently when the user is scrubbing video).
|
// change frequently when the user is scrubbing video).
|
||||||
const oldView = changedProperties.get('view');
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
if (
|
if (
|
||||||
changedProperties.has('view') &&
|
changedProperties.has('viewManagerEpoch') &&
|
||||||
(View.isMajorMediaChange(oldView, this.view) ||
|
(this.viewManagerEpoch?.manager.hasMajorMediaChange(
|
||||||
oldView.displayMode !== this.view?.displayMode)
|
this.viewManagerEpoch?.oldView,
|
||||||
|
) ||
|
||||||
|
this.viewManagerEpoch?.oldView?.displayMode !== view?.displayMode)
|
||||||
) {
|
) {
|
||||||
this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined;
|
this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _getCameraIDsForTimeline(): Set<string> | null {
|
protected _getCameraIDsForTimeline(): Set<string> | null {
|
||||||
if (!this.view || !this.cameraManager) {
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
|
if (!view || !this.cameraManager) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (this.view.is('live')) {
|
|
||||||
|
if (view.is('live')) {
|
||||||
const capabilitySearch = {
|
const capabilitySearch = {
|
||||||
anyCapabilities: ['clips' as const, 'snapshots' as const, 'recordings' as const],
|
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
|
return this.cameraManager
|
||||||
.getStore()
|
.getStore()
|
||||||
.getCameraIDsWithCapability(capabilitySearch);
|
.getCameraIDsWithCapability(capabilitySearch);
|
||||||
} else {
|
} else {
|
||||||
return this.cameraManager
|
return this.cameraManager
|
||||||
.getStore()
|
.getStore()
|
||||||
.getAllDependentCameras(this.view.camera, capabilitySearch);
|
.getAllDependentCameras(view.camera, capabilitySearch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.view.isViewerView()) {
|
if (view.isViewerView()) {
|
||||||
return this.view.query?.getQueryCameraIDs() ?? null;
|
return view.query?.getQueryCameraIDs() ?? null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.hass || !this.view) {
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
|
if (!this.hass || !view) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,27 +128,25 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.config=${this.thumbnailConfig}
|
.config=${this.thumbnailConfig}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.fadeThumbnails=${this.view.isViewerView()}
|
.fadeThumbnails=${view.isViewerView()}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.selected=${this.view.queryResults?.getSelectedIndex() ?? undefined}
|
.selected=${view.queryResults?.getSelectedIndex() ?? undefined}
|
||||||
@frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
|
|
||||||
@frigate-card:thumbnail-carousel:tap=${(
|
@frigate-card:thumbnail-carousel:tap=${(
|
||||||
ev: CustomEvent<ThumbnailCarouselTap>,
|
ev: CustomEvent<ThumbnailCarouselTap>,
|
||||||
) => {
|
) => {
|
||||||
const media = ev.detail.queryResults.getSelectedResult();
|
const media = ev.detail.queryResults.getSelectedResult();
|
||||||
if (media) {
|
if (media) {
|
||||||
this.view
|
this.viewManagerEpoch?.manager.setViewByParameters({
|
||||||
?.evolve({
|
params: {
|
||||||
view: 'media',
|
view: 'media',
|
||||||
queryResults: ev.detail.queryResults,
|
queryResults: ev.detail.queryResults,
|
||||||
...(media.getCameraID() && { camera: media.getCameraID() }),
|
...(media.getCameraID() && { camera: media.getCameraID() }),
|
||||||
})
|
},
|
||||||
.removeContext('timeline')
|
modifiers: [
|
||||||
.removeContext('mediaViewer')
|
new RemoveContextViewModifier(['timeline', '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).
|
changeDrawer(ev, 'close');
|
||||||
.dispatchChangeEvent(ev.composedPath()[0]);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -152,8 +156,8 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
? html` <frigate-card-timeline-core
|
? html` <frigate-card-timeline-core
|
||||||
slot=${this.timelineConfig.mode}
|
slot=${this.timelineConfig.mode}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.itemClickAction=${this.view.isViewerView() ||
|
.itemClickAction=${view.isViewerView() ||
|
||||||
!this.thumbnailConfig ||
|
!this.thumbnailConfig ||
|
||||||
this.thumbnailConfig?.mode === 'none'
|
this.thumbnailConfig?.mode === 'none'
|
||||||
? 'play'
|
? 'play'
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
|||||||
import { CarouselDirection } from '../utils/embla/carousel-controller.js';
|
import { CarouselDirection } from '../utils/embla/carousel-controller.js';
|
||||||
import AutoSize from '../utils/embla/plugins/auto-size/auto-size.js';
|
import AutoSize from '../utils/embla/plugins/auto-size/auto-size.js';
|
||||||
import { MediaQueriesResults } from '../view/media-queries-results';
|
import { MediaQueriesResults } from '../view/media-queries-results';
|
||||||
import { View } from '../view/view.js';
|
|
||||||
import './carousel.js';
|
import './carousel.js';
|
||||||
import './thumbnail.js';
|
import './thumbnail.js';
|
||||||
|
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
||||||
|
|
||||||
export interface ThumbnailCarouselTap {
|
export interface ThumbnailCarouselTap {
|
||||||
queryResults: MediaQueriesResults;
|
queryResults: MediaQueriesResults;
|
||||||
@@ -31,7 +31,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
@@ -62,13 +62,13 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
'cameraManager',
|
'cameraManager',
|
||||||
'config',
|
'config',
|
||||||
'transitionEffect',
|
'transitionEffect',
|
||||||
'view',
|
'viewManagerEpoch',
|
||||||
] as const;
|
] as const;
|
||||||
if (renderProperties.some((prop) => changedProps.has(prop))) {
|
if (renderProperties.some((prop) => changedProps.has(prop))) {
|
||||||
this._thumbnailSlides = this._renderSlides();
|
this._thumbnailSlides = this._renderSlides();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedProps.has('view')) {
|
if (changedProps.has('viewManagerEpoch')) {
|
||||||
this.style.setProperty(
|
this.style.setProperty(
|
||||||
'--frigate-card-carousel-thumbnail-opacity',
|
'--frigate-card-carousel-thumbnail-opacity',
|
||||||
!this.fadeThumbnails || this._getSelectedSlide() === null ? '1.0' : '0.4',
|
!this.fadeThumbnails || this._getSelectedSlide() === null ? '1.0' : '0.4',
|
||||||
@@ -76,16 +76,19 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _getSelectedSlide(view?: View): number | null {
|
protected _getSelectedSlide(): number | null {
|
||||||
return (view ?? this.view)?.queryResults?.getSelectedIndex() ?? null;
|
return (
|
||||||
|
this.viewManagerEpoch?.manager.getView()?.queryResults?.getSelectedIndex() ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _renderSlides(): TemplateResult[] {
|
protected _renderSlides(): TemplateResult[] {
|
||||||
const slides: 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();
|
const selectedIndex = this._getSelectedSlide();
|
||||||
|
|
||||||
for (const media of this.view?.queryResults?.getResults() ?? []) {
|
for (const media of view?.queryResults?.getResults() ?? []) {
|
||||||
const index = slides.length;
|
const index = slides.length;
|
||||||
const classes = {
|
const classes = {
|
||||||
embla__slide: true,
|
embla__slide: true,
|
||||||
@@ -98,19 +101,20 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.media=${media}
|
.media=${media}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.seek=${seekTarget && media.includesTime(seekTarget) ? seekTarget : undefined}
|
.seek=${seekTarget && media.includesTime(seekTarget) ? seekTarget : undefined}
|
||||||
?details=${!!this.config?.show_details}
|
?details=${!!this.config?.show_details}
|
||||||
?show_favorite_control=${this.config?.show_favorite_control}
|
?show_favorite_control=${this.config?.show_favorite_control}
|
||||||
?show_timeline_control=${this.config?.show_timeline_control}
|
?show_timeline_control=${this.config?.show_timeline_control}
|
||||||
?show_download_control=${this.config?.show_download_control}
|
?show_download_control=${this.config?.show_download_control}
|
||||||
@click=${(ev: Event) => {
|
@click=${(ev: Event) => {
|
||||||
if (this.view && this.view.queryResults) {
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
|
if (view && view.queryResults) {
|
||||||
dispatchFrigateCardEvent<ThumbnailCarouselTap>(
|
dispatchFrigateCardEvent<ThumbnailCarouselTap>(
|
||||||
this,
|
this,
|
||||||
'thumbnail-carousel:tap',
|
'thumbnail-carousel:tap',
|
||||||
{
|
{
|
||||||
queryResults: this.view.queryResults.clone().selectIndex(index),
|
queryResults: view.queryResults.clone().selectIndex(index),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-22
@@ -1,3 +1,4 @@
|
|||||||
|
import { Task, TaskStatus } from '@lit-labs/task';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
CSSResult,
|
CSSResult,
|
||||||
@@ -9,11 +10,14 @@ import {
|
|||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { classMap } from 'lit/directives/class-map.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 { localize } from '../localize/localize.js';
|
||||||
import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
|
import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
|
||||||
import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
|
import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
|
||||||
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
|
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
|
||||||
import thumbnailStyle from '../scss/thumbnail.scss';
|
import thumbnailStyle from '../scss/thumbnail.scss';
|
||||||
|
import type { ExtendedHomeAssistant } from '../types.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||||
import {
|
import {
|
||||||
errorToConsole,
|
errorToConsole,
|
||||||
@@ -21,17 +25,13 @@ import {
|
|||||||
getDurationString,
|
getDurationString,
|
||||||
prettifyTitle,
|
prettifyTitle,
|
||||||
} from '../utils/basic.js';
|
} from '../utils/basic.js';
|
||||||
|
import { downloadMedia } from '../utils/download.js';
|
||||||
import { renderTask } from '../utils/task.js';
|
import { renderTask } from '../utils/task.js';
|
||||||
import { createFetchThumbnailTask, FetchThumbnailTaskArgs } from '../utils/thumbnail.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 { 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 { dispatchFrigateCardErrorEvent } from './message.js';
|
||||||
|
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js';
|
||||||
|
|
||||||
// The minimum width of a thumbnail with details enabled.
|
// The minimum width of a thumbnail with details enabled.
|
||||||
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
||||||
@@ -335,22 +335,22 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
|||||||
|
|
||||||
@customElement('frigate-card-thumbnail')
|
@customElement('frigate-card-thumbnail')
|
||||||
export class FrigateCardThumbnail extends LitElement {
|
export class FrigateCardThumbnail extends LitElement {
|
||||||
// Performance: During timeline scrubbing, hass may be updated
|
// Performance: During timeline scrubbing, hass may be updated continuously.
|
||||||
// continuously. As it is not needed for the thumbnail rendering itself, it
|
// As it is not needed for the thumbnail rendering itself, it does not trigger
|
||||||
// does not trigger a re-render. The HomeAssistant object may be required for
|
// a re-render. The HomeAssistant object may be required for thumbnail signing
|
||||||
// thumbnail signing (after initial signing the thumbnail is stored in a data
|
// (after initial signing the thumbnail is stored in a data URL, so the
|
||||||
// URL, so the signing will not expire).
|
// signing will not expire).
|
||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
// Performance: During timeline scrubbing, the view will be updated
|
// Performance: During timeline scrubbing, the view will be updated
|
||||||
// continuously. As it is not needed for the thumbnail rendering itself, it
|
// continuously. As it is not needed for the thumbnail rendering itself, it
|
||||||
// does not trigger a re-render.
|
// does not trigger a re-render.
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
@property({ attribute: true })
|
@property({ attribute: false })
|
||||||
public media?: ViewMedia;
|
public media?: ViewMedia;
|
||||||
|
|
||||||
@property({ attribute: true, type: Boolean })
|
@property({ attribute: true, type: Boolean })
|
||||||
@@ -467,18 +467,19 @@ export class FrigateCardThumbnail extends LitElement {
|
|||||||
title=${localize('thumbnail.timeline')}
|
title=${localize('thumbnail.timeline')}
|
||||||
@click=${(ev: Event) => {
|
@click=${(ev: Event) => {
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
if (!this.view || !this.media) {
|
if (!this.viewManagerEpoch || !this.media) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.view
|
this.viewManagerEpoch.manager.setViewByParameters({
|
||||||
.evolve({
|
params: {
|
||||||
view: 'timeline',
|
view: 'timeline',
|
||||||
queryResults: this.view.queryResults
|
queryResults: this.viewManagerEpoch?.manager
|
||||||
?.clone()
|
.getView()
|
||||||
|
?.queryResults?.clone()
|
||||||
.selectResultIfFound((media) => media === this.media),
|
.selectResultIfFound((media) => media === this.media),
|
||||||
})
|
},
|
||||||
.removeContext('timeline')
|
modifiers: [new RemoveContextViewModifier(['timeline'])],
|
||||||
.dispatchChangeEvent(this);
|
});
|
||||||
}}
|
}}
|
||||||
></ha-icon>`
|
></ha-icon>`
|
||||||
: ''}
|
: ''}
|
||||||
|
|||||||
+128
-133
@@ -1,14 +1,14 @@
|
|||||||
import { add, differenceInSeconds, sub } from 'date-fns';
|
import { add, differenceInSeconds, sub } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
CSSResultGroup,
|
CSSResultGroup,
|
||||||
html,
|
|
||||||
LitElement,
|
LitElement,
|
||||||
PropertyValues,
|
PropertyValues,
|
||||||
TemplateResult,
|
TemplateResult,
|
||||||
|
html,
|
||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
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 isEqual from 'lodash-es/isEqual';
|
||||||
import throttle from 'lodash-es/throttle';
|
import throttle from 'lodash-es/throttle';
|
||||||
import { ViewContext } from 'view';
|
import { ViewContext } from 'view';
|
||||||
@@ -26,6 +26,8 @@ import { CameraManager } from '../camera-manager/manager';
|
|||||||
import { rangesOverlap } from '../camera-manager/range';
|
import { rangesOverlap } from '../camera-manager/range';
|
||||||
import { MediaQuery } from '../camera-manager/types';
|
import { MediaQuery } from '../camera-manager/types';
|
||||||
import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly';
|
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 {
|
import {
|
||||||
FrigateCardTimelineItem,
|
FrigateCardTimelineItem,
|
||||||
TimelineDataSource,
|
TimelineDataSource,
|
||||||
@@ -33,11 +35,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
CardWideConfig,
|
CardWideConfig,
|
||||||
frigateCardConfigDefaults,
|
|
||||||
FrigateCardView,
|
FrigateCardView,
|
||||||
ThumbnailsControlBaseConfig,
|
ThumbnailsControlBaseConfig,
|
||||||
TimelineCoreConfig,
|
TimelineCoreConfig,
|
||||||
TimelinePanMode,
|
TimelinePanMode,
|
||||||
|
frigateCardConfigDefaults,
|
||||||
} from '../config/types';
|
} from '../config/types';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import timelineCoreStyle from '../scss/timeline-core.scss';
|
import timelineCoreStyle from '../scss/timeline-core.scss';
|
||||||
@@ -51,10 +53,7 @@ import {
|
|||||||
isTruthy,
|
isTruthy,
|
||||||
setOrRemoveAttribute,
|
setOrRemoveAttribute,
|
||||||
} from '../utils/basic';
|
} from '../utils/basic';
|
||||||
import {
|
import { findBestMediaIndex } from '../utils/find-best-media-index';
|
||||||
executeMediaQueryForViewWithErrorDispatching,
|
|
||||||
findBestMediaIndex,
|
|
||||||
} from '../utils/media-to-view';
|
|
||||||
import { ViewMedia } from '../view/media';
|
import { ViewMedia } from '../view/media';
|
||||||
import { ViewMediaClassifier } from '../view/media-classifier';
|
import { ViewMediaClassifier } from '../view/media-classifier';
|
||||||
import {
|
import {
|
||||||
@@ -67,7 +66,7 @@ import {
|
|||||||
MediaQueriesType,
|
MediaQueriesType,
|
||||||
} from '../view/media-queries-classifier';
|
} from '../view/media-queries-classifier';
|
||||||
import { MediaQueriesResults } from '../view/media-queries-results';
|
import { MediaQueriesResults } from '../view/media-queries-results';
|
||||||
import { View } from '../view/view';
|
import { mergeViewContext } from '../view/view';
|
||||||
import './date-picker.js';
|
import './date-picker.js';
|
||||||
import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js';
|
import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js';
|
||||||
import './thumbnail.js';
|
import './thumbnail.js';
|
||||||
@@ -107,7 +106,7 @@ interface ThumbnailDataRequest {
|
|||||||
cameraManager?: CameraManager;
|
cameraManager?: CameraManager;
|
||||||
cameraConfig?: CameraConfig;
|
cameraConfig?: CameraConfig;
|
||||||
media?: ViewMedia;
|
media?: ViewMedia;
|
||||||
view?: View;
|
viewManagerEpoch?: ViewManagerEpoch;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
|
class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
|
||||||
@@ -115,7 +114,7 @@ class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
|
|||||||
const TIMELINE_TARGET_BAR_ID = 'target_bar';
|
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.
|
* bindings are not available.
|
||||||
*/
|
*/
|
||||||
@customElement('frigate-card-timeline-thumbnail')
|
@customElement('frigate-card-timeline-thumbnail')
|
||||||
@@ -160,7 +159,7 @@ export class FrigateCardTimelineThumbnail extends LitElement {
|
|||||||
!dataRequest.cameraManager ||
|
!dataRequest.cameraManager ||
|
||||||
!dataRequest.cameraConfig ||
|
!dataRequest.cameraConfig ||
|
||||||
!dataRequest.media ||
|
!dataRequest.media ||
|
||||||
!dataRequest.view
|
!dataRequest.viewManagerEpoch
|
||||||
) {
|
) {
|
||||||
return html``;
|
return html``;
|
||||||
}
|
}
|
||||||
@@ -169,7 +168,7 @@ export class FrigateCardTimelineThumbnail extends LitElement {
|
|||||||
.hass=${dataRequest.hass}
|
.hass=${dataRequest.hass}
|
||||||
.cameraManager=${dataRequest.cameraManager}
|
.cameraManager=${dataRequest.cameraManager}
|
||||||
.media=${dataRequest.media}
|
.media=${dataRequest.media}
|
||||||
.view=${dataRequest.view}
|
.viewManagerEpoch=${dataRequest.viewManagerEpoch}
|
||||||
?details=${this.details}
|
?details=${this.details}
|
||||||
>
|
>
|
||||||
</frigate-card-thumbnail>`;
|
</frigate-card-thumbnail>`;
|
||||||
@@ -182,12 +181,12 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public timelineConfig?: TimelineCoreConfig;
|
public timelineConfig?: TimelineCoreConfig;
|
||||||
|
|
||||||
@property({ attribute: true, type: Boolean })
|
@property({ attribute: false })
|
||||||
public thumbnailConfig?: ThumbnailsControlBaseConfig;
|
public thumbnailConfig?: ThumbnailsControlBaseConfig;
|
||||||
|
|
||||||
// Whether or not this is a mini-timeline (in mini-mode the component takes a
|
// 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.cameraConfig = cameraConfig;
|
||||||
request.detail.cameraManager = this.cameraManager;
|
request.detail.cameraManager = this.cameraManager;
|
||||||
request.detail.media = media;
|
request.detail.media = media;
|
||||||
request.detail.view = this.view;
|
request.detail.viewManagerEpoch = this.viewManagerEpoch;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.hass || !this.view || !this.timelineConfig || !this.cameraIDs?.size) {
|
if (!this.hass || !this.timelineConfig || !this.cameraIDs?.size) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +382,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
const panMode = this._getEffectivePanMode();
|
const panMode = this._getEffectivePanMode();
|
||||||
const targetBarOn =
|
const targetBarOn =
|
||||||
this._shouldSupportSeeking() &&
|
this._shouldSupportSeeking() &&
|
||||||
@@ -392,7 +392,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
const item = this._timelineSource?.dataset?.get(id);
|
const item = this._timelineSource?.dataset?.get(id);
|
||||||
return (
|
return (
|
||||||
panMode !== 'seek-in-camera' ||
|
panMode !== 'seek-in-camera' ||
|
||||||
item?.media?.getCameraID() === this.view?.camera,
|
item?.media?.getCameraID() === view?.camera,
|
||||||
item &&
|
item &&
|
||||||
item.start &&
|
item.start &&
|
||||||
item.end &&
|
item.end &&
|
||||||
@@ -450,14 +450,15 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
targetTime: Date,
|
targetTime: Date,
|
||||||
properties: TimelineRangeChange,
|
properties: TimelineRangeChange,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const results = this.view?.queryResults;
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
|
const results = view?.queryResults;
|
||||||
const media = results?.getResults();
|
const media = results?.getResults();
|
||||||
const panMode = this._getEffectivePanMode();
|
const panMode = this._getEffectivePanMode();
|
||||||
if (
|
if (
|
||||||
!media ||
|
!media ||
|
||||||
!results ||
|
!results ||
|
||||||
!this._timeline ||
|
!this._timeline ||
|
||||||
!this.view ||
|
!view ||
|
||||||
!this.hass ||
|
!this.hass ||
|
||||||
!this.cameraManager ||
|
!this.cameraManager ||
|
||||||
panMode === 'pan'
|
panMode === 'pan'
|
||||||
@@ -473,7 +474,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
.clone()
|
.clone()
|
||||||
.resetSelectedResult()
|
.resetSelectedResult()
|
||||||
.selectBestResult(
|
.selectBestResult(
|
||||||
(mediaArray) => findBestMediaIndex(mediaArray, targetTime, this.view?.camera),
|
(mediaArray) => findBestMediaIndex(mediaArray, targetTime, view?.camera),
|
||||||
{
|
{
|
||||||
allCameras: true,
|
allCameras: true,
|
||||||
main: true,
|
main: true,
|
||||||
@@ -484,9 +485,9 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
.clone()
|
.clone()
|
||||||
.resetSelectedResult()
|
.resetSelectedResult()
|
||||||
.selectBestResult((mediaArray) => findBestMediaIndex(mediaArray, targetTime), {
|
.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') {
|
} else if (panMode === 'seek-in-media') {
|
||||||
newResults = results;
|
newResults = results;
|
||||||
}
|
}
|
||||||
@@ -495,20 +496,23 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
? targetTime >= new Date()
|
? targetTime >= new Date()
|
||||||
? 'live'
|
? 'live'
|
||||||
: 'media'
|
: 'media'
|
||||||
: this.view.view;
|
: view.view;
|
||||||
|
|
||||||
const selectedCamera = newResults?.getSelectedResult()?.getCameraID();
|
const selectedCamera = newResults?.getSelectedResult()?.getCameraID();
|
||||||
this.view
|
|
||||||
.evolve({
|
this.viewManagerEpoch?.manager.setViewByParameters({
|
||||||
|
params: {
|
||||||
...(selectedCamera && { camera: selectedCamera }),
|
...(selectedCamera && { camera: selectedCamera }),
|
||||||
view: desiredView,
|
view: desiredView,
|
||||||
queryResults: newResults,
|
queryResults: newResults,
|
||||||
}) // Whether or not to set the timeline window.
|
},
|
||||||
.mergeInContext({
|
modifiers: [
|
||||||
|
new MergeContextViewModifier({
|
||||||
...(canSeek && { mediaViewer: { seek: targetTime } }),
|
...(canSeek && { mediaViewer: { seek: targetTime } }),
|
||||||
...this._getTimelineContext({ start: properties.start, end: properties.end }),
|
...this._getTimelineContext({ start: properties.start, end: properties.end }),
|
||||||
})
|
}),
|
||||||
.dispatchChangeEvent(this);
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _getEffectivePanMode(): TimelinePanMode {
|
protected _getEffectivePanMode(): TimelinePanMode {
|
||||||
@@ -533,22 +537,18 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
stopEventFromActivatingCardWideActions(properties.event);
|
stopEventFromActivatingCardWideActions(properties.event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this._ignoreClick ||
|
this._ignoreClick ||
|
||||||
!this.hass ||
|
!view ||
|
||||||
!this._timeline ||
|
!this.viewManagerEpoch ||
|
||||||
!this.view ||
|
|
||||||
!this.cameraManager ||
|
|
||||||
!this.cardWideConfig ||
|
|
||||||
!this.cameraIDs ||
|
|
||||||
!this.cameraIDs.size ||
|
|
||||||
!this._timelineSource ||
|
!this._timelineSource ||
|
||||||
!properties.what
|
!properties.what
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let view: View | null = null;
|
|
||||||
let drawerAction: 'open' | 'close' = 'close';
|
let drawerAction: 'open' | 'close' = 'close';
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -558,29 +558,35 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
) {
|
) {
|
||||||
const query = this._createMediaQueries('recording');
|
const query = this._createMediaQueries('recording');
|
||||||
if (query) {
|
if (query) {
|
||||||
view = await executeMediaQueryForViewWithErrorDispatching(
|
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||||
this,
|
baseView: view,
|
||||||
this.cameraManager,
|
params: { view: 'recording', query: query },
|
||||||
this.view,
|
queryExecutorOptions: {
|
||||||
query,
|
selectResult: {
|
||||||
{
|
time: {
|
||||||
targetView: 'recording',
|
time: properties.time,
|
||||||
targetTime: properties.time,
|
|
||||||
select: 'time',
|
|
||||||
},
|
},
|
||||||
);
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else if (properties.item && properties.what === 'item') {
|
} else if (properties.item && properties.what === 'item') {
|
||||||
const cameraID = String(properties.group);
|
const cameraID = String(properties.group);
|
||||||
|
const id = String(properties.item);
|
||||||
|
|
||||||
const criteria = {
|
const criteria = {
|
||||||
main: true,
|
main: true,
|
||||||
...(cameraID && this.view.isGrid() && { cameraID: cameraID }),
|
...(cameraID && view.isGrid() && { cameraID: cameraID }),
|
||||||
};
|
};
|
||||||
const newResults = this.view.queryResults
|
const newResults = view.queryResults
|
||||||
?.clone()
|
?.clone()
|
||||||
.resetSelectedResult()
|
.resetSelectedResult()
|
||||||
.selectResultIfFound((media) => media.getID() === properties.item, criteria);
|
.selectResultIfFound((media) => media.getID() === properties.item, criteria);
|
||||||
|
|
||||||
|
const context: ViewContext = mergeViewContext(this._getTimelineContext(), {
|
||||||
|
mediaViewer: { seek: properties.time },
|
||||||
|
});
|
||||||
|
|
||||||
if (!newResults || !newResults.hasSelectedResult()) {
|
if (!newResults || !newResults.hasSelectedResult()) {
|
||||||
// This can happen in a few situations:
|
// This can happen in a few situations:
|
||||||
// - If this is a recording query (with recorded hours) and an event is
|
// - 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
|
// gallery (i.e. any case where the thumbnails may not be match the
|
||||||
// events on the timeline, e.g. in the snapshots viewer but
|
// events on the timeline, e.g. in the snapshots viewer but
|
||||||
// mini-timeline showing all media).
|
// mini-timeline showing all media).
|
||||||
const fullEventView = await this._createViewWithMediaQueries(
|
const query = this._createMediaQueries('event');
|
||||||
this._createMediaQueries('event'),
|
if (query) {
|
||||||
{
|
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||||
selectedItem: properties.item,
|
params: { view: 'media', query: query },
|
||||||
targetView: 'media',
|
queryExecutorOptions: {
|
||||||
|
selectResult: {
|
||||||
|
id: id,
|
||||||
},
|
},
|
||||||
);
|
rejectResults: (results) => !results.hasResults(),
|
||||||
if (fullEventView?.queryResults?.hasResults()) {
|
},
|
||||||
view = fullEventView;
|
modifiers: [new MergeContextViewModifier(context)],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
view = this.view.evolve({
|
this.viewManagerEpoch.manager.setViewByParameters({
|
||||||
|
params: {
|
||||||
queryResults: newResults,
|
queryResults: newResults,
|
||||||
view: this.itemClickAction === 'play' ? 'media' : this.view.view,
|
view: this.itemClickAction === 'play' ? 'media' : view.view,
|
||||||
|
},
|
||||||
|
modifiers: [new MergeContextViewModifier(context)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (view?.queryResults?.hasResults()) {
|
if (this.itemClickAction === 'select') {
|
||||||
view.mergeInContext({ mediaViewer: { seek: properties.time } });
|
|
||||||
}
|
|
||||||
view?.mergeInContext(this._getTimelineContext());
|
|
||||||
|
|
||||||
if (this.itemClickAction === 'select' && view) {
|
|
||||||
drawerAction = 'open';
|
drawerAction = 'open';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (view) {
|
|
||||||
view.dispatchChangeEvent(this);
|
|
||||||
}
|
|
||||||
dispatchFrigateCardEvent(this, `thumbnails:${drawerAction}`);
|
dispatchFrigateCardEvent(this, `thumbnails:${drawerAction}`);
|
||||||
|
|
||||||
this._ignoreClick = false;
|
this._ignoreClick = false;
|
||||||
@@ -648,10 +652,11 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
event: Event & { additionalEvent: string };
|
event: Event & { additionalEvent: string };
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
this._removeTargetBar();
|
this._removeTargetBar();
|
||||||
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!this._timeline ||
|
!this._timeline ||
|
||||||
!this.view ||
|
!view ||
|
||||||
// When in mini mode, something else is in charge of the primary media
|
// 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
|
// population (e.g. the live view), in this case only act when the user
|
||||||
// themselves are interacting with the timeline.
|
// themselves are interacting with the timeline.
|
||||||
@@ -662,23 +667,34 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
|
|
||||||
await this._timelineSource?.refresh(this._getPrefetchWindow(properties));
|
await this._timelineSource?.refresh(this._getPrefetchWindow(properties));
|
||||||
|
|
||||||
const queryType = MediaQueriesClassifier.getQueriesType(this.view.query);
|
const queryType = MediaQueriesClassifier.getQueriesType(view.query);
|
||||||
if (!queryType) {
|
if (!queryType) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const mediaQuery = this._createMediaQueries(queryType);
|
const mediaQuery = this._createMediaQueries(queryType);
|
||||||
const newView = await this._createViewWithMediaQueries(mediaQuery);
|
|
||||||
|
|
||||||
// Specifically avoid dispatching new results on range change unless there
|
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||||
// is something to be gained by doing so. Example usecase: On initial view
|
params: {
|
||||||
// load in mini timeline mode, the first 50 events are fetched -- the
|
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
|
// first drag of the timeline should not dispatch new results unless
|
||||||
// something is actually useful (as otherwise it creates a visible
|
// something is actually useful (as otherwise it creates a visible 'flicker'
|
||||||
// 'flicker' for the user as the viewer reloads all the media).
|
// for the user as the viewer reloads all the media).
|
||||||
const newResults = newView?.queryResults;
|
rejectResults: (results) => !!view.queryResults?.isSupersetOf(results),
|
||||||
if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) {
|
selectResult: {
|
||||||
newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this);
|
id:
|
||||||
}
|
this.viewManagerEpoch?.manager
|
||||||
|
.getView()
|
||||||
|
?.queryResults?.getSelectedResult()
|
||||||
|
?.getID() ?? undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modifiers: [new MergeContextViewModifier(this._getTimelineContext())],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _createMediaQueries(
|
protected _createMediaQueries(
|
||||||
@@ -706,46 +722,6 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
return null;
|
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.
|
* Build the visjs dataset to render on the timeline.
|
||||||
* @returns The dataset.
|
* @returns The dataset.
|
||||||
@@ -937,10 +913,11 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected _getAllSelectedMediaIDsFromView(): IdType[] {
|
protected _getAllSelectedMediaIDsFromView(): IdType[] {
|
||||||
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
return (
|
return (
|
||||||
this.view?.queryResults?.getMultipleSelectedResults({
|
view?.queryResults?.getMultipleSelectedResults({
|
||||||
main: true,
|
main: true,
|
||||||
...(this.view.isGrid() && { allCameras: true }),
|
...(view.isGrid() && { allCameras: true }),
|
||||||
}) ?? []
|
}) ?? []
|
||||||
)
|
)
|
||||||
.filter((media) => ViewMediaClassifier.isEvent(media))
|
.filter((media) => ViewMediaClassifier.isEvent(media))
|
||||||
@@ -952,7 +929,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
* Update the timeline from the view object.
|
* Update the timeline from the view object.
|
||||||
*/
|
*/
|
||||||
protected async _updateTimelineFromView(): Promise<void> {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,7 +943,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
// perfectly center on the media.
|
// perfectly center on the media.
|
||||||
|
|
||||||
let desiredWindow = timelineWindow;
|
let desiredWindow = timelineWindow;
|
||||||
const media = this.view.queryResults?.getSelectedResult();
|
const media = view.queryResults?.getSelectedResult();
|
||||||
const mediaStartTime = media?.getStartTime() ?? null;
|
const mediaStartTime = media?.getStartTime() ?? null;
|
||||||
const mediaEndTime = media?.getEndTime() ?? null;
|
const mediaEndTime = media?.getEndTime() ?? null;
|
||||||
const mediaIsEvent = media ? ViewMediaClassifier.isEvent(media) : false;
|
const mediaIsEvent = media ? ViewMediaClassifier.isEvent(media) : false;
|
||||||
@@ -976,7 +954,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
// range effectively starts/ends at the same time.
|
// range effectively starts/ends at the same time.
|
||||||
{ start: mediaStartTime, end: mediaEndTime ?? mediaStartTime }
|
{ start: mediaStartTime, end: mediaEndTime ?? mediaStartTime }
|
||||||
: null;
|
: null;
|
||||||
const context = this.view.context?.timeline;
|
const context = view.context?.timeline;
|
||||||
|
|
||||||
if (context && context.window) {
|
if (context && context.window) {
|
||||||
desiredWindow = 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
|
// Also don't generate thumbnails in mini-timelines (they will already have
|
||||||
// been generated).
|
// been generated).
|
||||||
|
|
||||||
const queryType = MediaQueriesClassifier.getQueriesType(this.view.query);
|
const queryType = MediaQueriesClassifier.getQueriesType(view.query);
|
||||||
if (!queryType) {
|
if (!queryType) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1062,15 +1040,31 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
freshMediaQuery &&
|
freshMediaQuery &&
|
||||||
!this._alreadyHasAcceptableMediaQuery(freshMediaQuery)
|
!this._alreadyHasAcceptableMediaQuery(freshMediaQuery)
|
||||||
) {
|
) {
|
||||||
(await this._createViewWithMediaQueries(freshMediaQuery))
|
const currentlySelectedResult = this.viewManagerEpoch?.manager
|
||||||
?.mergeInContext(this._getTimelineContext(desiredWindow))
|
.getView()
|
||||||
.dispatchChangeEvent(this);
|
?.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 {
|
protected _alreadyHasAcceptableMediaQuery(freshMediaQuery: MediaQueries): boolean {
|
||||||
const currentQueries = this.view?.query?.getQueries();
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
const currentResultTimestamp = this.view?.queryResults?.getResultsTimestamp();
|
|
||||||
|
const currentQueries = view?.query?.getQueries();
|
||||||
|
const currentResultTimestamp = view?.queryResults?.getResultsTimestamp();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
!!this.cameraManager &&
|
!!this.cameraManager &&
|
||||||
@@ -1089,10 +1083,11 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
* @returns The TimelineViewContext object.
|
* @returns The TimelineViewContext object.
|
||||||
*/
|
*/
|
||||||
protected _getTimelineContext(window?: TimelineWindow): ViewContext {
|
protected _getTimelineContext(window?: TimelineWindow): ViewContext {
|
||||||
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
const newWindow = window ?? this._timeline?.getWindow();
|
const newWindow = window ?? this._timeline?.getWindow();
|
||||||
return {
|
return {
|
||||||
timeline: {
|
timeline: {
|
||||||
...this.view?.context?.timeline,
|
...view?.context?.timeline,
|
||||||
...(newWindow && { window: newWindow }),
|
...(newWindow && { window: newWindow }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1225,7 +1220,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
// `this._timeline.setwindow()` being entirely ignored. Example case:
|
// `this._timeline.setwindow()` being entirely ignored. Example case:
|
||||||
// Clicking the timeline control on a recording thumbnail.
|
// Clicking the timeline control on a recording thumbnail.
|
||||||
window.requestAnimationFrame(this._updateTimelineFromView.bind(this));
|
window.requestAnimationFrame(this._updateTimelineFromView.bind(this));
|
||||||
} else if (changedProperties.has('view')) {
|
} else if (changedProperties.has('viewManagerEpoch')) {
|
||||||
this._updateTimelineFromView();
|
this._updateTimelineFromView();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { CameraManager } from '../camera-manager/manager';
|
import { CameraManager } from '../camera-manager/manager';
|
||||||
|
import { ViewManagerEpoch } from '../card-controller/view/types';
|
||||||
import { CardWideConfig, TimelineConfig } from '../config/types';
|
import { CardWideConfig, TimelineConfig } from '../config/types';
|
||||||
import basicBlockStyle from '../scss/basic-block.scss';
|
import basicBlockStyle from '../scss/basic-block.scss';
|
||||||
import { ExtendedHomeAssistant } from '../types';
|
import { ExtendedHomeAssistant } from '../types';
|
||||||
import { View } from '../view/view';
|
|
||||||
import './surround.js';
|
import './surround.js';
|
||||||
import './timeline-core.js';
|
import './timeline-core.js';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export class FrigateCardTimeline extends LitElement {
|
|||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public timelineConfig?: TimelineConfig;
|
public timelineConfig?: TimelineConfig;
|
||||||
@@ -33,7 +33,7 @@ export class FrigateCardTimeline extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<frigate-card-timeline-core
|
<frigate-card-timeline-core
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.timelineConfig=${this.timelineConfig}
|
.timelineConfig=${this.timelineConfig}
|
||||||
.thumbnailConfig=${this.timelineConfig.controls.thumbnails}
|
.thumbnailConfig=${this.timelineConfig.controls.thumbnails}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ type PaperToast = HTMLElement & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getDefaultTitleConfigForView = (
|
export const getDefaultTitleConfigForView = (
|
||||||
view?: Readonly<View>,
|
view?: Readonly<View> | null,
|
||||||
baseConfig?: TitleControlConfig,
|
baseConfig?: TitleControlConfig,
|
||||||
): TitleControlConfig | null => {
|
): TitleControlConfig | null => {
|
||||||
if (!baseConfig && view?.isGrid()) {
|
if (!baseConfig && view?.isGrid()) {
|
||||||
|
|||||||
+90
-137
@@ -11,6 +11,8 @@ import { guard } from 'lit/directives/guard.js';
|
|||||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
import { CameraManager } from '../camera-manager/manager.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 { MediaGridSelected } from '../components-lib/media-grid-controller.js';
|
||||||
import { ZoomSettingsObserved } from '../components-lib/zoom/types.js';
|
import { ZoomSettingsObserved } from '../components-lib/zoom/types.js';
|
||||||
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context.js';
|
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context.js';
|
||||||
@@ -41,7 +43,6 @@ import { mayHaveAudio } from '../utils/audio.js';
|
|||||||
import {
|
import {
|
||||||
aspectRatioToString,
|
aspectRatioToString,
|
||||||
contentsChanged,
|
contentsChanged,
|
||||||
errorToConsole,
|
|
||||||
setOrRemoveAttribute,
|
setOrRemoveAttribute,
|
||||||
} from '../utils/basic.js';
|
} from '../utils/basic.js';
|
||||||
import { CarouselSelected } from '../utils/embla/carousel-controller.js';
|
import { CarouselSelected } from '../utils/embla/carousel-controller.js';
|
||||||
@@ -58,10 +59,6 @@ import {
|
|||||||
dispatchMediaVolumeChangeEvent,
|
dispatchMediaVolumeChangeEvent,
|
||||||
} from '../utils/media-info.js';
|
} from '../utils/media-info.js';
|
||||||
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
||||||
import {
|
|
||||||
changeViewToRecentEventsForCameraAndDependents,
|
|
||||||
changeViewToRecentRecordingForCameraAndDependents,
|
|
||||||
} from '../utils/media-to-view.js';
|
|
||||||
import {
|
import {
|
||||||
hideMediaControlsTemporarily,
|
hideMediaControlsTemporarily,
|
||||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
@@ -71,9 +68,7 @@ import {
|
|||||||
import { screenshotMedia } from '../utils/screenshot.js';
|
import { screenshotMedia } from '../utils/screenshot.js';
|
||||||
import { ViewMediaClassifier } from '../view/media-classifier';
|
import { ViewMediaClassifier } from '../view/media-classifier';
|
||||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||||
import { MediaQueriesResults } from '../view/media-queries-results.js';
|
|
||||||
import { VideoContentType, ViewMedia } from '../view/media.js';
|
import { VideoContentType, ViewMedia } from '../view/media.js';
|
||||||
import { View } from '../view/view.js';
|
|
||||||
import type { EmblaCarouselPlugins } from './carousel.js';
|
import type { EmblaCarouselPlugins } from './carousel.js';
|
||||||
import './next-prev-control.js';
|
import './next-prev-control.js';
|
||||||
import './ptz';
|
import './ptz';
|
||||||
@@ -110,7 +105,7 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public viewerConfig?: ViewerConfig;
|
public viewerConfig?: ViewerConfig;
|
||||||
@@ -127,7 +122,7 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (
|
if (
|
||||||
!this.hass ||
|
!this.hass ||
|
||||||
!this.view ||
|
!this.viewManagerEpoch ||
|
||||||
!this.viewerConfig ||
|
!this.viewerConfig ||
|
||||||
!this.cameraManager ||
|
!this.cameraManager ||
|
||||||
!this.cardWideConfig
|
!this.cardWideConfig
|
||||||
@@ -135,13 +130,7 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.view.queryResults?.hasResults()) {
|
if (!this.viewManagerEpoch.manager.getView()?.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)
|
// Directly render an error message (instead of dispatching it upwards)
|
||||||
// to preserve the mini-timeline if the user pans into an area with no
|
// to preserve the mini-timeline if the user pans into an area with no
|
||||||
// media.
|
// media.
|
||||||
@@ -152,38 +141,9 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return html` <frigate-card-viewer-grid
|
return html` <frigate-card-viewer-grid
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.viewerConfig=${this.viewerConfig}
|
.viewerConfig=${this.viewerConfig}
|
||||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
@@ -205,7 +165,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public viewFilterCameraID?: string;
|
public viewFilterCameraID?: string;
|
||||||
@@ -227,6 +187,9 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public showControls = true;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
protected _selected = 0;
|
protected _selected = 0;
|
||||||
|
|
||||||
@@ -241,12 +204,14 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
updated(changedProperties: PropertyValues): void {
|
updated(changedProperties: PropertyValues): void {
|
||||||
super.updated(changedProperties);
|
super.updated(changedProperties);
|
||||||
|
|
||||||
if (changedProperties.has('view')) {
|
if (changedProperties.has('viewManagerEpoch')) {
|
||||||
const oldView = changedProperties.get('view') as View | undefined;
|
|
||||||
// Seek into the video if the seek time has changed (this is also called
|
// 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
|
// on media load, since the media may or may not have been loaded at
|
||||||
// this point).
|
// this point).
|
||||||
if (this.view?.context?.mediaViewer !== oldView?.context?.mediaViewer) {
|
if (
|
||||||
|
this.viewManagerEpoch?.manager.getView()?.context?.mediaViewer !==
|
||||||
|
this.viewManagerEpoch?.oldView?.context?.mediaViewer
|
||||||
|
) {
|
||||||
this._seekHandler();
|
this._seekHandler();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,7 +289,9 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected _setViewSelectedIndex(index: number): void {
|
protected _setViewSelectedIndex(index: number): void {
|
||||||
if (!this._media) {
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
|
|
||||||
|
if (!this._media || !view) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +302,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newResults = this.view?.queryResults
|
const newResults = view?.queryResults
|
||||||
?.clone()
|
?.clone()
|
||||||
.selectIndex(index, this.viewFilterCameraID);
|
.selectIndex(index, this.viewFilterCameraID);
|
||||||
if (!newResults) {
|
if (!newResults) {
|
||||||
@@ -345,15 +312,14 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
.getSelectedResult(this.viewFilterCameraID)
|
.getSelectedResult(this.viewFilterCameraID)
|
||||||
?.getCameraID();
|
?.getCameraID();
|
||||||
|
|
||||||
this.view
|
this.viewManagerEpoch?.manager.setViewByParameters({
|
||||||
?.evolve({
|
params: {
|
||||||
queryResults: newResults,
|
queryResults: newResults,
|
||||||
|
|
||||||
// Always change the camera to the owner of the selected media.
|
// Always change the camera to the owner of the selected media.
|
||||||
...(cameraID && { camera: cameraID }),
|
...(cameraID && { camera: cameraID }),
|
||||||
})
|
},
|
||||||
.removeContextProperty('mediaViewer', 'seek')
|
modifiers: [new RemoveContextPropertyViewModifier('mediaViewer', 'seek')],
|
||||||
.dispatchChangeEvent(this);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -395,17 +361,13 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
return slides;
|
return slides;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an update will occur.
|
|
||||||
* @param changedProps The changed properties
|
|
||||||
*/
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('view')) {
|
if (changedProps.has('viewManagerEpoch')) {
|
||||||
const newMedia =
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
this.view?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
|
const newMedia = view?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
|
||||||
const newSelected =
|
const newSelected =
|
||||||
this.view?.queryResults?.getSelectedIndex(this.viewFilterCameraID) ?? 0;
|
view?.queryResults?.getSelectedIndex(this.viewFilterCameraID) ?? 0;
|
||||||
const newSeek = this.view?.context?.mediaViewer?.seek;
|
const newSeek = view?.context?.mediaViewer?.seek;
|
||||||
|
|
||||||
if (newMedia !== this._media || newSelected !== this._selected || !newSeek) {
|
if (newMedia !== this._media || newSelected !== this._selected || !newSeek) {
|
||||||
setOrRemoveAttribute(this, false, 'unseekable');
|
setOrRemoveAttribute(this, false, 'unseekable');
|
||||||
@@ -449,8 +411,9 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
selectedMedia.getCameraID(),
|
selectedMedia.getCameraID(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
const titleConfig = getDefaultTitleConfigForView(
|
const titleConfig = getDefaultTitleConfigForView(
|
||||||
this.view,
|
view,
|
||||||
this.viewerConfig?.controls.title,
|
this.viewerConfig?.controls.title,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -474,7 +437,8 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
this._player = null;
|
this._player = null;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<frigate-card-next-previous-control
|
${this.showControls
|
||||||
|
? html` <frigate-card-next-previous-control
|
||||||
slot="previous"
|
slot="previous"
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.direction=${'previous'}
|
.direction=${'previous'}
|
||||||
@@ -486,9 +450,11 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
scroll('previous');
|
scroll('previous');
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
}}
|
}}
|
||||||
></frigate-card-next-previous-control>
|
></frigate-card-next-previous-control>`
|
||||||
${guard([this._media, this.view], () => this._getSlides())}
|
: ''}
|
||||||
<frigate-card-next-previous-control
|
${guard([this._media, view], () => this._getSlides())}
|
||||||
|
${this.showControls
|
||||||
|
? html` <frigate-card-next-previous-control
|
||||||
slot="next"
|
slot="next"
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.direction=${'next'}
|
.direction=${'next'}
|
||||||
@@ -500,12 +466,13 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
scroll('next');
|
scroll('next');
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
}}
|
}}
|
||||||
></frigate-card-next-previous-control>
|
></frigate-card-next-previous-control>`
|
||||||
|
: ''}
|
||||||
</frigate-card-carousel>
|
</frigate-card-carousel>
|
||||||
${this.view
|
${view
|
||||||
? html` <frigate-card-ptz
|
? html` <frigate-card-ptz
|
||||||
.config=${this.viewerConfig?.controls.ptz}
|
.config=${this.viewerConfig?.controls.ptz}
|
||||||
.forceVisibility=${this.view?.context?.ptzControls?.enabled}
|
.forceVisibility=${view?.context?.ptzControls?.enabled}
|
||||||
>
|
>
|
||||||
</frigate-card-ptz>`
|
</frigate-card-ptz>`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -530,7 +497,8 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
* Fire a media show event when a slide is selected.
|
* Fire a media show event when a slide is selected.
|
||||||
*/
|
*/
|
||||||
protected async _seekHandler(): Promise<void> {
|
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) {
|
if (!this.hass || !seek || !this._media || !this._player) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -556,14 +524,15 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected _renderMediaItem(media: ViewMedia): TemplateResult | null {
|
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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html` <div class="embla__slide">
|
return html` <div class="embla__slide">
|
||||||
<frigate-card-viewer-provider
|
<frigate-card-viewer-provider
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.view=${view}
|
||||||
.media=${media}
|
.media=${media}
|
||||||
.viewerConfig=${this.viewerConfig}
|
.viewerConfig=${this.viewerConfig}
|
||||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||||
@@ -585,7 +554,7 @@ export class FrigateCardViewerGrid extends LitElement {
|
|||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public viewerConfig?: ViewerConfig;
|
public viewerConfig?: ViewerConfig;
|
||||||
@@ -600,66 +569,64 @@ export class FrigateCardViewerGrid extends LitElement {
|
|||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
protected _renderCarousel(filterCamera?: string): TemplateResult {
|
protected _renderCarousel(filterCamera?: string): TemplateResult {
|
||||||
|
const selectedCameraID = this.viewManagerEpoch?.manager.getView()?.camera;
|
||||||
return html`
|
return html`
|
||||||
<frigate-card-viewer-carousel
|
<frigate-card-viewer-carousel
|
||||||
grid-id=${ifDefined(filterCamera)}
|
grid-id=${ifDefined(filterCamera)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.viewFilterCameraID=${filterCamera}
|
.viewFilterCameraID=${filterCamera}
|
||||||
.viewerConfig=${this.viewerConfig}
|
.viewerConfig=${this.viewerConfig}
|
||||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
|
.showControls=${!filterCamera || selectedCameraID === filterCamera}
|
||||||
>
|
>
|
||||||
</frigate-card-viewer-carousel>
|
</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 {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('view') && this._needsGrid()) {
|
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) {
|
||||||
import('./media-grid.js');
|
import('./media-grid.js');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _needsGrid(): boolean {
|
protected _needsGrid(): boolean {
|
||||||
const cameraIDs = this.view?.queryResults?.getCameraIDs();
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
|
const cameraIDs = view?.queryResults?.getCameraIDs();
|
||||||
return (
|
return (
|
||||||
!!this.view?.isGrid() &&
|
!!view?.isGrid() &&
|
||||||
!!this.view?.supportsMultipleDisplayModes() &&
|
!!view?.supportsMultipleDisplayModes() &&
|
||||||
(cameraIDs?.size ?? 0) > 1
|
(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 {
|
protected render(): TemplateResult {
|
||||||
const cameraIDs = this.view?.queryResults?.getCameraIDs();
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
|
const cameraIDs = view?.queryResults?.getCameraIDs();
|
||||||
if (!cameraIDs || !this._needsGrid()) {
|
if (!cameraIDs || !this._needsGrid()) {
|
||||||
return this._renderCarousel();
|
return this._renderCarousel();
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<frigate-card-media-grid
|
<frigate-card-media-grid
|
||||||
.selected=${this.view?.camera}
|
.selected=${view?.camera}
|
||||||
.displayConfig=${this.viewerConfig?.display}
|
.displayConfig=${this.viewerConfig?.display}
|
||||||
@frigate-card:media-grid:selected=${(ev: CustomEvent<MediaGridSelected>) =>
|
@frigate-card:media-grid:selected=${(ev: CustomEvent<MediaGridSelected>) =>
|
||||||
this._gridSelectCamera(ev.detail.selected)}
|
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))}
|
${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))}
|
||||||
</frigate-card-media-grid>
|
</frigate-card-media-grid>
|
||||||
@@ -680,7 +647,7 @@ export class FrigateCardViewerProvider
|
|||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public media?: ViewMedia;
|
public media?: ViewMedia;
|
||||||
@@ -788,21 +755,22 @@ export class FrigateCardViewerProvider
|
|||||||
* Dispatch a clip view that matches the current (snapshot) query.
|
* Dispatch a clip view that matches the current (snapshot) query.
|
||||||
*/
|
*/
|
||||||
protected async _dispatchRelatedClipView(): Promise<void> {
|
protected async _dispatchRelatedClipView(): Promise<void> {
|
||||||
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
if (
|
if (
|
||||||
!this.hass ||
|
!this.hass ||
|
||||||
!this.view ||
|
!view ||
|
||||||
!this.cameraManager ||
|
!this.cameraManager ||
|
||||||
!this.media ||
|
!this.media ||
|
||||||
// If this specific media item has no clip, then do nothing (even if all
|
// If this specific media item has no clip, then do nothing (even if all
|
||||||
// the other media items do).
|
// the other media items do).
|
||||||
!ViewMediaClassifier.isEvent(this.media) ||
|
!ViewMediaClassifier.isEvent(this.media) ||
|
||||||
!MediaQueriesClassifier.areEventQueries(this.view.query)
|
!MediaQueriesClassifier.areEventQueries(view.query)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert the query to a clips equivalent.
|
// Convert the query to a clips equivalent.
|
||||||
const clipQuery = this.view.query.clone();
|
const clipQuery = view.query.clone();
|
||||||
clipQuery.convertToClipsQueries();
|
clipQuery.convertToClipsQueries();
|
||||||
|
|
||||||
const queries = clipQuery.getQueries();
|
const queries = clipQuery.getQueries();
|
||||||
@@ -810,32 +778,18 @@ export class FrigateCardViewerProvider
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mediaArray: ViewMedia[] | null;
|
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||||
try {
|
params: {
|
||||||
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({
|
|
||||||
view: 'media',
|
view: 'media',
|
||||||
query: clipQuery,
|
query: clipQuery,
|
||||||
queryResults: results,
|
},
|
||||||
})
|
queryExecutorOptions: {
|
||||||
.dispatchChangeEvent(this);
|
selectResult: {
|
||||||
|
id: this.media.getID() ?? undefined,
|
||||||
|
},
|
||||||
|
rejectResults: (results) => !results.hasSelectedResult(),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
@@ -881,6 +835,7 @@ export class FrigateCardViewerProvider
|
|||||||
const cameraID = this.media.getCameraID();
|
const cameraID = this.media.getCameraID();
|
||||||
const mediaID = this.media.getID() ?? undefined;
|
const mediaID = this.media.getID() ?? undefined;
|
||||||
const cameraConfig = this.cameraManager?.getStore().getCameraConfig(cameraID);
|
const cameraConfig = this.cameraManager?.getStore().getCameraConfig(cameraID);
|
||||||
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
|
|
||||||
return this.viewerConfig?.zoomable
|
return this.viewerConfig?.zoomable
|
||||||
? html` <frigate-card-zoomer
|
? html` <frigate-card-zoomer
|
||||||
@@ -892,13 +847,11 @@ export class FrigateCardViewerProvider
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
)}
|
)}
|
||||||
.settings=${mediaID
|
.settings=${mediaID ? view?.context?.zoom?.[mediaID]?.requested : undefined}
|
||||||
? this.view?.context?.zoom?.[mediaID]?.requested
|
|
||||||
: undefined}
|
|
||||||
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
|
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
|
||||||
@frigate-card:zoom:unzoomed=${() => this.setControls()}
|
@frigate-card:zoom:unzoomed=${() => this.setControls()}
|
||||||
@frigate-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
|
@frigate-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
|
||||||
handleZoomSettingsObservedEvent(this, ev, mediaID)}
|
handleZoomSettingsObservedEvent(ev, this.viewManagerEpoch?.manager, mediaID)}
|
||||||
>
|
>
|
||||||
${template}
|
${template}
|
||||||
</frigate-card-zoomer>`
|
</frigate-card-zoomer>`
|
||||||
@@ -906,7 +859,7 @@ export class FrigateCardViewerProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-30
@@ -11,6 +11,7 @@ import { classMap } from 'lit/directives/class-map.js';
|
|||||||
import { CameraManager } from '../camera-manager/manager.js';
|
import { CameraManager } from '../camera-manager/manager.js';
|
||||||
import { ConditionsManagerEpoch } from '../card-controller/conditions-manager.js';
|
import { ConditionsManagerEpoch } from '../card-controller/conditions-manager.js';
|
||||||
import { ReadonlyMicrophoneManager } from '../card-controller/microphone-manager.js';
|
import { ReadonlyMicrophoneManager } from '../card-controller/microphone-manager.js';
|
||||||
|
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
||||||
import {
|
import {
|
||||||
CardWideConfig,
|
CardWideConfig,
|
||||||
FrigateCardConfig,
|
FrigateCardConfig,
|
||||||
@@ -19,8 +20,6 @@ import {
|
|||||||
import viewsStyle from '../scss/views.scss';
|
import viewsStyle from '../scss/views.scss';
|
||||||
import { ExtendedHomeAssistant } from '../types.js';
|
import { ExtendedHomeAssistant } from '../types.js';
|
||||||
import { ResolvedMediaCache } from '../utils/ha/resolved-media.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.
|
// As a special case: Diagnostics is not dynamically loaded in case something goes wrong.
|
||||||
import './diagnostics.js';
|
import './diagnostics.js';
|
||||||
@@ -31,7 +30,7 @@ export class FrigateCardViews extends LitElement {
|
|||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
@@ -64,17 +63,18 @@ export class FrigateCardViews extends LitElement {
|
|||||||
public triggeredCameraIDs?: Set<string>;
|
public triggeredCameraIDs?: Set<string>;
|
||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('view') || changedProps.has('config')) {
|
if (changedProps.has('viewManagerEpoch') || changedProps.has('config')) {
|
||||||
if (this.view?.is('live') || this._shouldLivePreload()) {
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
|
if (view?.is('live') || this._shouldLivePreload()) {
|
||||||
import('./live/live.js');
|
import('./live/live.js');
|
||||||
}
|
}
|
||||||
if (this.view?.isGalleryView()) {
|
if (view?.isGalleryView()) {
|
||||||
import('./gallery.js');
|
import('./gallery.js');
|
||||||
} else if (this.view?.isViewerView()) {
|
} else if (view?.isViewerView()) {
|
||||||
import('./viewer.js');
|
import('./viewer.js');
|
||||||
} else if (this.view?.is('image')) {
|
} else if (view?.is('image')) {
|
||||||
import('./image.js');
|
import('./image.js');
|
||||||
} else if (this.view?.is('timeline')) {
|
} else if (view?.is('timeline')) {
|
||||||
import('./timeline.js');
|
import('./timeline.js');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,10 +108,11 @@ export class FrigateCardViews extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected _shouldLivePreload(): boolean {
|
protected _shouldLivePreload(): boolean {
|
||||||
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
return (
|
return (
|
||||||
// Special case: Never preload for diagnostics -- we want that to be as
|
// Special case: Never preload for diagnostics -- we want that to be as
|
||||||
// minimal as possible.
|
// 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``;
|
return html``;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
|
|
||||||
// Render but hide the live view if there's a message, or if it's preload
|
// Render but hide the live view if there's a message, or if it's preload
|
||||||
// mode and the view is not live.
|
// mode and the view is not live.
|
||||||
const liveClasses = {
|
const liveClasses = {
|
||||||
hidden: this._shouldLivePreload() && !this.view?.is('live'),
|
hidden: this._shouldLivePreload() && !view?.is('live'),
|
||||||
};
|
};
|
||||||
const overallClasses = {
|
const overallClasses = {
|
||||||
hidden: !!this.hide,
|
hidden: !!this.hide,
|
||||||
};
|
};
|
||||||
|
|
||||||
const thumbnailConfig = this.view?.is('live')
|
const thumbnailConfig = view?.is('live')
|
||||||
? this.overriddenConfig.live.controls.thumbnails
|
? this.overriddenConfig.live.controls.thumbnails
|
||||||
: this.view?.isViewerView()
|
: view?.isViewerView()
|
||||||
? this.overriddenConfig.media_viewer.controls.thumbnails
|
? this.overriddenConfig.media_viewer.controls.thumbnails
|
||||||
: this.view?.is('timeline')
|
: view?.is('timeline')
|
||||||
? this.overriddenConfig.timeline.controls.thumbnails
|
? this.overriddenConfig.timeline.controls.thumbnails
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const miniTimelineConfig = this.view?.is('live')
|
const miniTimelineConfig = view?.is('live')
|
||||||
? this.overriddenConfig.live.controls.timeline
|
? this.overriddenConfig.live.controls.timeline
|
||||||
: this.view?.isViewerView()
|
: view?.isViewerView()
|
||||||
? this.overriddenConfig.media_viewer.controls.timeline
|
? this.overriddenConfig.media_viewer.controls.timeline
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const cameraConfig = this.view
|
const cameraConfig = view
|
||||||
? this.cameraManager?.getStore().getCameraConfig(this.view.camera) ?? null
|
? this.cameraManager?.getStore().getCameraConfig(view.camera) ?? null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return html` <frigate-card-surround
|
return html` <frigate-card-surround
|
||||||
class="${classMap(overallClasses)}"
|
class="${classMap(overallClasses)}"
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.thumbnailConfig=${!this.hide ? thumbnailConfig : undefined}
|
.thumbnailConfig=${!this.hide ? thumbnailConfig : undefined}
|
||||||
.timelineConfig=${!this.hide ? miniTimelineConfig : undefined}
|
.timelineConfig=${!this.hide ? miniTimelineConfig : undefined}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
>
|
>
|
||||||
${!this.hide && this.view?.is('image') && cameraConfig
|
${!this.hide && view?.is('image') && cameraConfig
|
||||||
? html` <frigate-card-image
|
? html` <frigate-card-image
|
||||||
.imageConfig=${this.overriddenConfig.image}
|
.imageConfig=${this.overriddenConfig.image}
|
||||||
.view=${this.view}
|
.view=${view}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraConfig=${cameraConfig}
|
.cameraConfig=${cameraConfig}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
>
|
>
|
||||||
</frigate-card-image>`
|
</frigate-card-image>`
|
||||||
: ``}
|
: ``}
|
||||||
${!this.hide && this.view?.isGalleryView()
|
${!this.hide && view?.isGalleryView()
|
||||||
? html` <frigate-card-gallery
|
? html` <frigate-card-gallery
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.galleryConfig=${this.overriddenConfig.media_gallery}
|
.galleryConfig=${this.overriddenConfig.media_gallery}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
>
|
>
|
||||||
</frigate-card-gallery>`
|
</frigate-card-gallery>`
|
||||||
: ``}
|
: ``}
|
||||||
${!this.hide && this.view?.isViewerView()
|
${!this.hide && view?.isViewerView()
|
||||||
? html`
|
? html`
|
||||||
<frigate-card-viewer
|
<frigate-card-viewer
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.viewerConfig=${this.overriddenConfig.media_viewer}
|
.viewerConfig=${this.overriddenConfig.media_viewer}
|
||||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
@@ -198,17 +201,17 @@ export class FrigateCardViews extends LitElement {
|
|||||||
</frigate-card-viewer>
|
</frigate-card-viewer>
|
||||||
`
|
`
|
||||||
: ``}
|
: ``}
|
||||||
${!this.hide && this.view?.is('timeline')
|
${!this.hide && view?.is('timeline')
|
||||||
? html` <frigate-card-timeline
|
? html` <frigate-card-timeline
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.timelineConfig=${this.overriddenConfig.timeline}
|
.timelineConfig=${this.overriddenConfig.timeline}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
>
|
>
|
||||||
</frigate-card-timeline>`
|
</frigate-card-timeline>`
|
||||||
: ``}
|
: ``}
|
||||||
${!this.hide && this.view?.is('diagnostics')
|
${!this.hide && view?.is('diagnostics')
|
||||||
? html` <frigate-card-diagnostics
|
? html` <frigate-card-diagnostics
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.rawConfig=${this.rawConfig}
|
.rawConfig=${this.rawConfig}
|
||||||
@@ -222,11 +225,11 @@ export class FrigateCardViews extends LitElement {
|
|||||||
// Note: <frigate-card-live> uses nonOverriddenConfig rather than the
|
// Note: <frigate-card-live> uses nonOverriddenConfig rather than the
|
||||||
// overriden config as it does it's own overriding as part of the camera
|
// overriden config as it does it's own overriding as part of the camera
|
||||||
// carousel.
|
// carousel.
|
||||||
this._shouldLivePreload() || (!this.hide && this.view?.is('live'))
|
this._shouldLivePreload() || (!this.hide && view?.is('live'))
|
||||||
? html`
|
? html`
|
||||||
<frigate-card-live
|
<frigate-card-live
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.nonOverriddenLiveConfig=${this.nonOverriddenConfig.live}
|
.nonOverriddenLiveConfig=${this.nonOverriddenConfig.live}
|
||||||
.overriddenLiveConfig=${this.overriddenConfig.live}
|
.overriddenLiveConfig=${this.overriddenConfig.live}
|
||||||
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
|
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
|
||||||
|
|||||||
@@ -863,10 +863,6 @@ const imageBaseConfigSchema = z.object({
|
|||||||
const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const;
|
const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const;
|
||||||
const imageConfigDefault = {
|
const imageConfigDefault = {
|
||||||
mode: 'url' as const,
|
mode: 'url' as const,
|
||||||
zoomable: true,
|
|
||||||
controls: {
|
|
||||||
ptz: ptzControlsDefaults,
|
|
||||||
},
|
|
||||||
...imageBaseConfigDefault,
|
...imageBaseConfigDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -301,7 +301,6 @@ const CONF_IMAGE = 'image' as const;
|
|||||||
export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` 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_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const;
|
||||||
export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` 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;
|
const CONF_TIMELINE = 'timeline' as const;
|
||||||
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
|
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ import {
|
|||||||
CONF_IMAGE_MODE,
|
CONF_IMAGE_MODE,
|
||||||
CONF_IMAGE_REFRESH_SECONDS,
|
CONF_IMAGE_REFRESH_SECONDS,
|
||||||
CONF_IMAGE_URL,
|
CONF_IMAGE_URL,
|
||||||
CONF_IMAGE_ZOOMABLE,
|
|
||||||
CONF_LIVE_AUTO_MUTE,
|
CONF_LIVE_AUTO_MUTE,
|
||||||
CONF_LIVE_AUTO_PAUSE,
|
CONF_LIVE_AUTO_PAUSE,
|
||||||
CONF_LIVE_AUTO_PLAY,
|
CONF_LIVE_AUTO_PLAY,
|
||||||
@@ -2735,7 +2734,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
${this._renderOptionSelector(CONF_IMAGE_MODE, this._imageModes)}
|
${this._renderOptionSelector(CONF_IMAGE_MODE, this._imageModes)}
|
||||||
${this._renderStringInput(CONF_IMAGE_URL)}
|
${this._renderStringInput(CONF_IMAGE_URL)}
|
||||||
${this._renderNumberInput(CONF_IMAGE_REFRESH_SECONDS)}
|
${this._renderNumberInput(CONF_IMAGE_REFRESH_SECONDS)}
|
||||||
${this._renderSwitch(CONF_IMAGE_ZOOMABLE, this._defaults.image.zoomable)}
|
|
||||||
</div>`
|
</div>`
|
||||||
: ''}
|
: ''}
|
||||||
${this._renderOptionSetHeader('timeline')}
|
${this._renderOptionSetHeader('timeline')}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -7,3 +7,18 @@ export const getStreamCameraID = (view: View, cameraID?: string): string => {
|
|||||||
export const hasSubstream = (view: View): boolean => {
|
export const hasSubstream = (view: View): boolean => {
|
||||||
return getStreamCameraID(view) !== view.camera;
|
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
@@ -1,11 +1,8 @@
|
|||||||
|
import merge from 'lodash-es/merge';
|
||||||
import { ViewContext } from 'view';
|
import { ViewContext } from 'view';
|
||||||
import { FrigateCardView, ViewDisplayMode } from '../config/types.js';
|
import { FrigateCardView, ViewDisplayMode } from '../config/types.js';
|
||||||
import { ClipsOrSnapshots } from '../types.js';
|
|
||||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
|
||||||
import { MediaQueries } from './media-queries';
|
import { MediaQueries } from './media-queries';
|
||||||
import { MediaQueriesClassifier } from './media-queries-classifier.js';
|
|
||||||
import { MediaQueriesResults } from './media-queries-results';
|
import { MediaQueriesResults } from './media-queries-results';
|
||||||
import merge from 'lodash-es/merge';
|
|
||||||
|
|
||||||
interface ViewEvolveParameters {
|
interface ViewEvolveParameters {
|
||||||
view?: FrigateCardView;
|
view?: FrigateCardView;
|
||||||
@@ -21,6 +18,10 @@ export interface ViewParameters extends ViewEvolveParameters {
|
|||||||
camera: string;
|
camera: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const mergeViewContext = (a?: ViewContext | null, b?: ViewContext | null): ViewContext => {
|
||||||
|
return merge({}, a, b);
|
||||||
|
}
|
||||||
|
|
||||||
export class View {
|
export class View {
|
||||||
public view: FrigateCardView;
|
public view: FrigateCardView;
|
||||||
public camera: string;
|
public camera: string;
|
||||||
@@ -38,109 +39,6 @@ export class View {
|
|||||||
this.displayMode = params.displayMode ?? null;
|
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 {
|
public clone(): View {
|
||||||
return new View({
|
return new View({
|
||||||
view: this.view,
|
view: this.view,
|
||||||
@@ -178,7 +76,7 @@ export class View {
|
|||||||
* @returns This view.
|
* @returns This view.
|
||||||
*/
|
*/
|
||||||
public mergeInContext(context?: ViewContext | null): View {
|
public mergeInContext(context?: ViewContext | null): View {
|
||||||
this.context = merge({}, this.context, context);
|
this.context = mergeViewContext(this.context, context);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,48 +157,4 @@ export class View {
|
|||||||
public isGrid(): boolean {
|
public isGrid(): boolean {
|
||||||
return this.displayMode === 'grid';
|
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);
|
await action.execute(api);
|
||||||
|
|
||||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
viewName: 'live',
|
params: {
|
||||||
cameraID: 'camera',
|
view: 'live',
|
||||||
|
camera: 'camera',
|
||||||
|
},
|
||||||
failSafe: true,
|
failSafe: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -49,10 +51,12 @@ describe('should handle camera_select action', () => {
|
|||||||
|
|
||||||
await action.execute(api);
|
await action.execute(api);
|
||||||
|
|
||||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
viewName: 'timeline',
|
params: {
|
||||||
cameraID: 'camera',
|
view: 'timeline',
|
||||||
|
camera: 'camera',
|
||||||
|
},
|
||||||
failSafe: true,
|
failSafe: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -86,10 +90,12 @@ describe('should handle camera_select action', () => {
|
|||||||
|
|
||||||
await action.execute(api);
|
await action.execute(api);
|
||||||
|
|
||||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
viewName: 'clips',
|
params: {
|
||||||
cameraID: 'camera',
|
view: 'clips',
|
||||||
|
camera: 'camera',
|
||||||
|
},
|
||||||
failSafe: true,
|
failSafe: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -114,10 +120,12 @@ describe('should handle camera_select action', () => {
|
|||||||
|
|
||||||
await action.execute(api);
|
await action.execute(api);
|
||||||
|
|
||||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
viewName: 'live',
|
params: {
|
||||||
cameraID: 'camera',
|
view: 'live',
|
||||||
|
camera: 'camera',
|
||||||
|
},
|
||||||
failSafe: true,
|
failSafe: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -141,7 +149,7 @@ describe('should handle camera_select action', () => {
|
|||||||
|
|
||||||
await action.execute(api);
|
await action.execute(api);
|
||||||
|
|
||||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('without a current view', async () => {
|
it('without a current view', async () => {
|
||||||
@@ -158,6 +166,6 @@ describe('should handle camera_select action', () => {
|
|||||||
|
|
||||||
await action.execute(api);
|
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);
|
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 { expect, it } from 'vitest';
|
||||||
import { SubstreamOffAction } from '../../../../src/card-controller/actions/actions/substream-off';
|
import { SubstreamOffAction } from '../../../../src/card-controller/actions/actions/substream-off';
|
||||||
import { createCardAPI } from '../../../test-utils';
|
import { createCardAPI } from '../../../test-utils';
|
||||||
|
import { SubstreamOffViewModifier } from '../../../../src/card-controller/view/modifiers/substream-off';
|
||||||
|
|
||||||
it('should handle live_substream_off action', async () => {
|
it('should handle live_substream_off action', async () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
@@ -14,5 +15,7 @@ it('should handle live_substream_off action', async () => {
|
|||||||
|
|
||||||
await action.execute(api);
|
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 { expect, it } from 'vitest';
|
||||||
import { SubstreamOnAction } from '../../../../src/card-controller/actions/actions/substream-on';
|
import { SubstreamOnAction } from '../../../../src/card-controller/actions/actions/substream-on';
|
||||||
import { createCardAPI } from '../../../test-utils';
|
import { createCardAPI } from '../../../test-utils';
|
||||||
|
import { SubstreamOnViewModifier } from '../../../../src/card-controller/view/modifiers/substream-on';
|
||||||
|
|
||||||
it('should handle live_substream_on action', async () => {
|
it('should handle live_substream_on action', async () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
@@ -14,5 +15,7 @@ it('should handle live_substream_on action', async () => {
|
|||||||
|
|
||||||
await action.execute(api);
|
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 { expect, it } from 'vitest';
|
||||||
import { SubstreamSelectAction } from '../../../../src/card-controller/actions/actions/substream-select';
|
import { SubstreamSelectAction } from '../../../../src/card-controller/actions/actions/substream-select';
|
||||||
import { createCardAPI } from '../../../test-utils';
|
import { createCardAPI } from '../../../test-utils';
|
||||||
|
import { SubstreamSelectViewModifier } from '../../../../src/card-controller/view/modifiers/substream-select';
|
||||||
|
|
||||||
it('should handle live_substream_select action', async () => {
|
it('should handle live_substream_select action', async () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
@@ -15,5 +16,9 @@ it('should handle live_substream_select action', async () => {
|
|||||||
|
|
||||||
await action.execute(api);
|
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);
|
await action.execute(api);
|
||||||
|
|
||||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
viewName: viewName,
|
params: {
|
||||||
|
view: viewName,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { MicrophoneManager } from '../../src/card-controller/microphone-manager'
|
|||||||
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
|
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
|
||||||
import { StyleManager } from '../../src/card-controller/style-manager';
|
import { StyleManager } from '../../src/card-controller/style-manager';
|
||||||
import { TriggersManager } from '../../src/card-controller/triggers-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 { FrigateCardEditor } from '../../src/editor';
|
||||||
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
|
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
|
||||||
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
|
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/query-string-manager');
|
||||||
vi.mock('../../src/card-controller/style-manager');
|
vi.mock('../../src/card-controller/style-manager');
|
||||||
vi.mock('../../src/card-controller/triggers-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/entity-registry');
|
||||||
vi.mock('../../src/utils/ha/resolved-media');
|
vi.mock('../../src/utils/ha/resolved-media');
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ describe('InitializationManager', () => {
|
|||||||
expect(loadLanguages).toBeCalled();
|
expect(loadLanguages).toBeCalled();
|
||||||
expect(sideLoadHomeAssistantElements).toBeCalled();
|
expect(sideLoadHomeAssistantElements).toBeCalled();
|
||||||
expect(api.getCameraManager().initializeCamerasFromConfig).toBeCalled();
|
expect(api.getCameraManager().initializeCamerasFromConfig).toBeCalled();
|
||||||
expect(api.getViewManager().setViewDefault).toBeCalled();
|
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
|
||||||
expect(api.getMicrophoneManager().connect).not.toBeCalled();
|
expect(api.getMicrophoneManager().connect).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
|
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
|
||||||
|
import { SubstreamSelectViewModifier } from '../../src/card-controller/view/modifiers/substream-select';
|
||||||
import { createCardAPI } from '../test-utils';
|
import { createCardAPI } from '../test-utils';
|
||||||
|
|
||||||
const setQueryString = (qs: string): void => {
|
const setQueryString = (qs: string): void => {
|
||||||
@@ -51,8 +52,10 @@ describe('QueryStringManager', () => {
|
|||||||
manager.executeAll();
|
manager.executeAll();
|
||||||
|
|
||||||
expect(manager.hasViewRelatedActions()).toBeTruthy();
|
expect(manager.hasViewRelatedActions()).toBeTruthy();
|
||||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
||||||
viewName: viewName,
|
params: {
|
||||||
|
view: viewName,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -92,7 +95,7 @@ describe('QueryStringManager', () => {
|
|||||||
|
|
||||||
manager.executeAll();
|
manager.executeAll();
|
||||||
|
|
||||||
expect(api.getViewManager().setViewDefault).toBeCalled();
|
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
|
||||||
|
|
||||||
expect(manager.hasViewRelatedActions()).toBeTruthy();
|
expect(manager.hasViewRelatedActions()).toBeTruthy();
|
||||||
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
||||||
@@ -107,8 +110,10 @@ describe('QueryStringManager', () => {
|
|||||||
|
|
||||||
manager.executeAll();
|
manager.executeAll();
|
||||||
|
|
||||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
||||||
cameraID: 'camera.office',
|
params: {
|
||||||
|
camera: 'camera.office',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(manager.hasViewRelatedActions()).toBeTruthy();
|
expect(manager.hasViewRelatedActions()).toBeTruthy();
|
||||||
@@ -124,8 +129,9 @@ describe('QueryStringManager', () => {
|
|||||||
|
|
||||||
manager.executeAll();
|
manager.executeAll();
|
||||||
|
|
||||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
||||||
substream: 'camera.office_hd',
|
modifiers: [expect.any(SubstreamSelectViewModifier)],
|
||||||
|
params: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(manager.hasViewRelatedActions()).toBeTruthy();
|
expect(manager.hasViewRelatedActions()).toBeTruthy();
|
||||||
@@ -190,8 +196,10 @@ describe('QueryStringManager', () => {
|
|||||||
manager.executeAll();
|
manager.executeAll();
|
||||||
|
|
||||||
expect(manager.hasViewRelatedActions()).toBeTruthy();
|
expect(manager.hasViewRelatedActions()).toBeTruthy();
|
||||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
||||||
viewName: viewName,
|
params: {
|
||||||
|
view: viewName,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -230,11 +238,13 @@ describe('QueryStringManager', () => {
|
|||||||
|
|
||||||
manager.executeAll();
|
manager.executeAll();
|
||||||
|
|
||||||
expect(api.getViewManager().setViewDefault).toBeCalledWith({
|
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledWith({
|
||||||
cameraID: 'camera.kitchen',
|
params: {
|
||||||
substream: 'camera.kitchen_hd',
|
camera: 'camera.kitchen',
|
||||||
|
},
|
||||||
|
modifiers: [expect.any(SubstreamSelectViewModifier)],
|
||||||
});
|
});
|
||||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('multiple cameras specified', () => {
|
it('multiple cameras specified', () => {
|
||||||
@@ -248,8 +258,10 @@ describe('QueryStringManager', () => {
|
|||||||
|
|
||||||
manager.executeAll();
|
manager.executeAll();
|
||||||
|
|
||||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
||||||
cameraID: 'camera.office',
|
params: {
|
||||||
|
camera: 'camera.office',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
createConfig,
|
createConfig,
|
||||||
createStore,
|
createStore,
|
||||||
createView,
|
createView,
|
||||||
|
flushPromises,
|
||||||
} from '../test-utils';
|
} from '../test-utils';
|
||||||
|
|
||||||
vi.mock('lodash-es/throttle', () => ({
|
vi.mock('lodash-es/throttle', () => ({
|
||||||
@@ -109,7 +110,7 @@ describe('TriggersManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('trigger actions', () => {
|
describe('trigger actions', () => {
|
||||||
it('update', () => {
|
it('update', async () => {
|
||||||
const api = createTriggerAPI({
|
const api = createTriggerAPI({
|
||||||
config: {
|
config: {
|
||||||
...baseTriggersConfig,
|
...baseTriggersConfig,
|
||||||
@@ -122,16 +123,18 @@ describe('TriggersManager', () => {
|
|||||||
|
|
||||||
const manager = new TriggersManager(api);
|
const manager = new TriggersManager(api);
|
||||||
|
|
||||||
manager.handleCameraEvent({
|
await manager.handleCameraEvent({
|
||||||
cameraID: 'camera_1',
|
cameraID: 'camera_1',
|
||||||
type: 'new',
|
type: 'new',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(manager.isTriggered()).toBeTruthy();
|
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({
|
const api = createTriggerAPI({
|
||||||
config: {
|
config: {
|
||||||
...baseTriggersConfig,
|
...baseTriggersConfig,
|
||||||
@@ -144,11 +147,13 @@ describe('TriggersManager', () => {
|
|||||||
|
|
||||||
const manager = new TriggersManager(api);
|
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(manager.isTriggered()).toBeTruthy();
|
||||||
expect(api.getViewManager().setViewDefault).toBeCalledWith({
|
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledWith({
|
||||||
cameraID: 'camera_1',
|
params: {
|
||||||
|
camera: 'camera_1',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,9 +173,11 @@ describe('TriggersManager', () => {
|
|||||||
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
|
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
|
||||||
|
|
||||||
expect(manager.isTriggered()).toBeTruthy();
|
expect(manager.isTriggered()).toBeTruthy();
|
||||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
||||||
viewName: 'live',
|
params: {
|
||||||
cameraID: 'camera_1',
|
view: 'live',
|
||||||
|
camera: 'camera_1',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,12 +214,16 @@ describe('TriggersManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!viewName) {
|
if (!viewName) {
|
||||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
expect(
|
||||||
|
api.getViewManager().setViewByParametersWithNewQuery,
|
||||||
|
).not.toBeCalled();
|
||||||
} else {
|
} else {
|
||||||
expect(manager.isTriggered()).toBeTruthy();
|
expect(manager.isTriggered()).toBeTruthy();
|
||||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
||||||
cameraID: 'camera_1',
|
params: {
|
||||||
viewName: viewName,
|
camera: 'camera_1',
|
||||||
|
view: viewName,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -235,9 +246,8 @@ describe('TriggersManager', () => {
|
|||||||
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
|
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
|
||||||
|
|
||||||
expect(manager.isTriggered()).toBeTruthy();
|
expect(manager.isTriggered()).toBeTruthy();
|
||||||
expect(api.getViewManager().setView).not.toBeCalled();
|
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
|
||||||
expect(api.getViewManager().setViewDefault).not.toBeCalled();
|
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
|
||||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,12 +273,11 @@ describe('TriggersManager', () => {
|
|||||||
|
|
||||||
expect(manager.isTriggered()).toBeFalsy();
|
expect(manager.isTriggered()).toBeFalsy();
|
||||||
|
|
||||||
expect(api.getViewManager().setView).not.toBeCalled();
|
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
|
||||||
expect(api.getViewManager().setViewDefault).not.toBeCalled();
|
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
|
||||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('default', () => {
|
it('default', async () => {
|
||||||
const api = createTriggerAPI({
|
const api = createTriggerAPI({
|
||||||
config: {
|
config: {
|
||||||
...baseTriggersConfig,
|
...baseTriggersConfig,
|
||||||
@@ -281,15 +290,16 @@ describe('TriggersManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const manager = new TriggersManager(api);
|
const manager = new TriggersManager(api);
|
||||||
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
|
await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
|
||||||
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' });
|
await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' });
|
||||||
|
|
||||||
vi.setSystemTime(add(start, { seconds: 10 }));
|
vi.setSystemTime(add(start, { seconds: 10 }));
|
||||||
vi.runOnlyPendingTimers();
|
vi.runOnlyPendingTimers();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
expect(manager.isTriggered()).toBeFalsy();
|
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' });
|
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', fidelity: 'high' });
|
||||||
|
|
||||||
expect(api.getViewManager().setView).not.toBeCalled();
|
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
|
||||||
expect(api.getViewManager().setViewDefault).not.toBeCalled();
|
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
|
||||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('with non-live default', () => {
|
it('with non-live default', () => {
|
||||||
@@ -364,9 +373,8 @@ describe('TriggersManager', () => {
|
|||||||
|
|
||||||
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', fidelity: 'high' });
|
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', fidelity: 'high' });
|
||||||
|
|
||||||
expect(api.getViewManager().setView).not.toBeCalled();
|
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
|
||||||
expect(api.getViewManager().setViewDefault).not.toBeCalled();
|
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
|
||||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -384,7 +392,8 @@ describe('TriggersManager', () => {
|
|||||||
|
|
||||||
expect(manager.isTriggered()).toBeTruthy();
|
expect(manager.isTriggered()).toBeTruthy();
|
||||||
|
|
||||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
|
||||||
|
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
|
||||||
|
|
||||||
manager.handleCameraEvent({
|
manager.handleCameraEvent({
|
||||||
cameraID: 'camera_1',
|
cameraID: 'camera_1',
|
||||||
@@ -396,7 +405,8 @@ describe('TriggersManager', () => {
|
|||||||
|
|
||||||
expect(manager.isTriggered()).toBeFalsy();
|
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', () => {
|
it('should take no actions when actions are set to none', () => {
|
||||||
@@ -415,7 +425,8 @@ describe('TriggersManager', () => {
|
|||||||
type: 'new',
|
type: 'new',
|
||||||
});
|
});
|
||||||
expect(manager.isTriggered()).toBeTruthy();
|
expect(manager.isTriggered()).toBeTruthy();
|
||||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
|
||||||
|
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
|
||||||
|
|
||||||
manager.handleCameraEvent({
|
manager.handleCameraEvent({
|
||||||
cameraID: 'camera_1',
|
cameraID: 'camera_1',
|
||||||
@@ -426,10 +437,11 @@ describe('TriggersManager', () => {
|
|||||||
vi.runOnlyPendingTimers();
|
vi.runOnlyPendingTimers();
|
||||||
|
|
||||||
expect(manager.isTriggered()).toBeFalsy();
|
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({
|
const api = createTriggerAPI({
|
||||||
// Interaction present.
|
// Interaction present.
|
||||||
interaction: true,
|
interaction: true,
|
||||||
@@ -443,31 +455,34 @@ describe('TriggersManager', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const manager = new TriggersManager(api);
|
const manager = new TriggersManager(api);
|
||||||
manager.handleCameraEvent({
|
await manager.handleCameraEvent({
|
||||||
cameraID: 'camera_1',
|
cameraID: 'camera_1',
|
||||||
type: 'new',
|
type: 'new',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(manager.isTriggered()).toBeTruthy();
|
expect(manager.isTriggered()).toBeTruthy();
|
||||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
||||||
viewName: 'live' as const,
|
params: {
|
||||||
cameraID: 'camera_1' as const,
|
view: 'live' as const,
|
||||||
|
camera: 'camera_1' as const,
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.handleCameraEvent({
|
await manager.handleCameraEvent({
|
||||||
cameraID: 'camera_1',
|
cameraID: 'camera_1',
|
||||||
type: 'end',
|
type: 'end',
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.setSystemTime(add(start, { seconds: 10 }));
|
vi.setSystemTime(add(start, { seconds: 10 }));
|
||||||
vi.runOnlyPendingTimers();
|
vi.runOnlyPendingTimers();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
expect(manager.isTriggered()).toBeFalsy();
|
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();
|
const api = createTriggerAPI();
|
||||||
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
|
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
|
||||||
createStore([
|
createStore([
|
||||||
@@ -496,11 +511,11 @@ describe('TriggersManager', () => {
|
|||||||
expect(manager.getMostRecentlyTriggeredCameraID()).toBeNull();
|
expect(manager.getMostRecentlyTriggeredCameraID()).toBeNull();
|
||||||
expect(manager.getTriggeredCameraIDs()).toEqual(new Set());
|
expect(manager.getTriggeredCameraIDs()).toEqual(new Set());
|
||||||
|
|
||||||
manager.handleCameraEvent({
|
await manager.handleCameraEvent({
|
||||||
cameraID: 'camera_1',
|
cameraID: 'camera_1',
|
||||||
type: 'new',
|
type: 'new',
|
||||||
});
|
});
|
||||||
manager.handleCameraEvent({
|
await manager.handleCameraEvent({
|
||||||
cameraID: 'camera_2',
|
cameraID: 'camera_2',
|
||||||
type: 'new',
|
type: 'new',
|
||||||
});
|
});
|
||||||
@@ -513,7 +528,7 @@ describe('TriggersManager', () => {
|
|||||||
manager.getMostRecentlyTriggeredCameraID(),
|
manager.getMostRecentlyTriggeredCameraID(),
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.handleCameraEvent({
|
await manager.handleCameraEvent({
|
||||||
cameraID: 'camera_1',
|
cameraID: 'camera_1',
|
||||||
type: 'end',
|
type: 'end',
|
||||||
});
|
});
|
||||||
@@ -521,6 +536,8 @@ describe('TriggersManager', () => {
|
|||||||
vi.setSystemTime(add(start, { seconds: 10 }));
|
vi.setSystemTime(add(start, { seconds: 10 }));
|
||||||
vi.runOnlyPendingTimers();
|
vi.runOnlyPendingTimers();
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_2']));
|
expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_2']));
|
||||||
expect(manager.getMostRecentlyTriggeredCameraID()).toBe('camera_2');
|
expect(manager.getMostRecentlyTriggeredCameraID()).toBe('camera_2');
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { LiveController } from '../../../src/components-lib/live/live-controller';
|
||||||
import { dispatchMessageEvent } from '../../../src/components/message';
|
import { dispatchMessageEvent } from '../../../src/components/message';
|
||||||
import {
|
|
||||||
changeViewToRecentEventsForCameraAndDependents,
|
|
||||||
changeViewToRecentRecordingForCameraAndDependents,
|
|
||||||
} from '../../../src/utils/media-to-view';
|
|
||||||
import { EventMediaQueries } from '../../../src/view/media-queries';
|
|
||||||
import {
|
import {
|
||||||
IntersectionObserverMock,
|
IntersectionObserverMock,
|
||||||
callIntersectionHandler,
|
callIntersectionHandler,
|
||||||
createCameraManager,
|
|
||||||
createConfig,
|
|
||||||
createLitElement,
|
createLitElement,
|
||||||
createMediaLoadedInfo,
|
createMediaLoadedInfo,
|
||||||
createMediaLoadedInfoEvent,
|
createMediaLoadedInfoEvent,
|
||||||
createParent,
|
createParent,
|
||||||
createView,
|
|
||||||
createViewChangeEvent,
|
|
||||||
} from '../../test-utils';
|
} from '../../test-utils';
|
||||||
|
|
||||||
vi.mock('../../../src/utils/media-to-view');
|
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
describe('LiveController', () => {
|
describe('LiveController', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -168,210 +157,4 @@ describe('LiveController', () => {
|
|||||||
expect(eventListener).toBeCalledTimes(1);
|
expect(eventListener).toBeCalledTimes(1);
|
||||||
expect(controller.getRenderEpoch()).toBe(secondRenderEpoch);
|
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 { endOfDay, startOfDay, sub } from 'date-fns';
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
import { Capabilities } from '../../src/camera-manager/capabilities';
|
import { Capabilities } from '../../src/camera-manager/capabilities';
|
||||||
import { CameraManagerStore } from '../../src/camera-manager/store';
|
import { CameraManagerStore } from '../../src/camera-manager/store';
|
||||||
import { QueryType } from '../../src/camera-manager/types';
|
import { QueryType } from '../../src/camera-manager/types';
|
||||||
|
import { ViewManager } from '../../src/card-controller/view/view-manager';
|
||||||
import {
|
import {
|
||||||
MediaFilterController,
|
MediaFilterController,
|
||||||
MediaFilterCoreDefaults,
|
MediaFilterCoreDefaults,
|
||||||
@@ -10,7 +12,6 @@ import {
|
|||||||
MediaFilterCoreWhen,
|
MediaFilterCoreWhen,
|
||||||
MediaFilterMediaType,
|
MediaFilterMediaType,
|
||||||
} from '../../src/components-lib/media-filter-controller';
|
} from '../../src/components-lib/media-filter-controller';
|
||||||
import { executeMediaQueryForViewWithErrorDispatching } from '../../src/utils/media-to-view';
|
|
||||||
import {
|
import {
|
||||||
EventMediaQueries,
|
EventMediaQueries,
|
||||||
MediaQueries,
|
MediaQueries,
|
||||||
@@ -26,8 +27,6 @@ import {
|
|||||||
createView,
|
createView,
|
||||||
} from '../test-utils';
|
} from '../test-utils';
|
||||||
|
|
||||||
vi.mock('../../src/utils/media-to-view');
|
|
||||||
|
|
||||||
const createCameraStore = (options?: {
|
const createCameraStore = (options?: {
|
||||||
capabilities: Capabilities;
|
capabilities: Capabilities;
|
||||||
}): CameraManagerStore => {
|
}): CameraManagerStore => {
|
||||||
@@ -295,59 +294,82 @@ describe('MediaFilterController', () => {
|
|||||||
|
|
||||||
describe('should get correct controls to show', () => {
|
describe('should get correct controls to show', () => {
|
||||||
it('view with events', () => {
|
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 cameraManager = createCameraManager();
|
||||||
|
|
||||||
const controller = new MediaFilterController(createLitElement());
|
const controller = new MediaFilterController(createLitElement());
|
||||||
expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({
|
controller.setViewManager(viewManager);
|
||||||
|
expect(controller.getControlsToShow(cameraManager)).toMatchObject({
|
||||||
events: true,
|
events: true,
|
||||||
recordings: false,
|
recordings: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('view with recordings', () => {
|
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 cameraManager = createCameraManager();
|
||||||
|
|
||||||
const controller = new MediaFilterController(createLitElement());
|
const controller = new MediaFilterController(createLitElement());
|
||||||
expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({
|
controller.setViewManager(viewManager);
|
||||||
|
expect(controller.getControlsToShow(cameraManager)).toMatchObject({
|
||||||
events: false,
|
events: false,
|
||||||
recordings: true,
|
recordings: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can favorite events', () => {
|
it('can favorite events', () => {
|
||||||
const view = createView({ query: new EventMediaQueries() });
|
const viewManager = mock<ViewManager>();
|
||||||
|
viewManager.getView.mockReturnValue(
|
||||||
|
createView({ query: new EventMediaQueries() }),
|
||||||
|
);
|
||||||
const cameraManager = createCameraManager();
|
const cameraManager = createCameraManager();
|
||||||
vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
|
vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
|
||||||
createCapabilities({ 'favorite-events': true }),
|
createCapabilities({ 'favorite-events': true }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const controller = new MediaFilterController(createLitElement());
|
const controller = new MediaFilterController(createLitElement());
|
||||||
expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({
|
controller.setViewManager(viewManager);
|
||||||
|
|
||||||
|
expect(controller.getControlsToShow(cameraManager)).toMatchObject({
|
||||||
favorites: true,
|
favorites: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can favorite recordings', () => {
|
it('can favorite recordings', () => {
|
||||||
const view = createView({ query: new RecordingMediaQueries() });
|
const viewManager = mock<ViewManager>();
|
||||||
|
viewManager.getView.mockReturnValue(
|
||||||
|
createView({ query: new RecordingMediaQueries() }),
|
||||||
|
);
|
||||||
|
|
||||||
const cameraManager = createCameraManager();
|
const cameraManager = createCameraManager();
|
||||||
vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
|
vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
|
||||||
createCapabilities({ 'favorite-recordings': true }),
|
createCapabilities({ 'favorite-recordings': true }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const controller = new MediaFilterController(createLitElement());
|
const controller = new MediaFilterController(createLitElement());
|
||||||
expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({
|
controller.setViewManager(viewManager);
|
||||||
|
|
||||||
|
expect(controller.getControlsToShow(cameraManager)).toMatchObject({
|
||||||
favorites: true,
|
favorites: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can not favorite without a query', () => {
|
it('can not favorite without a query', () => {
|
||||||
const view = createView();
|
const viewManager = mock<ViewManager>();
|
||||||
|
viewManager.getView.mockReturnValue(createView());
|
||||||
|
|
||||||
const cameraManager = createCameraManager();
|
const cameraManager = createCameraManager();
|
||||||
|
|
||||||
const controller = new MediaFilterController(createLitElement());
|
const controller = new MediaFilterController(createLitElement());
|
||||||
expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({
|
controller.setViewManager(viewManager);
|
||||||
|
|
||||||
|
expect(controller.getControlsToShow(cameraManager)).toMatchObject({
|
||||||
favorites: false,
|
favorites: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -355,40 +377,34 @@ describe('MediaFilterController', () => {
|
|||||||
|
|
||||||
describe('should handle value change', () => {
|
describe('should handle value change', () => {
|
||||||
it('must have visible cameras', async () => {
|
it('must have visible cameras', async () => {
|
||||||
const host = createLitElement();
|
const viewManager = mock<ViewManager>();
|
||||||
const controller = new MediaFilterController(host);
|
|
||||||
await controller.valueChangeHandler(
|
|
||||||
createCameraManager(),
|
|
||||||
createView(),
|
|
||||||
{},
|
|
||||||
{ when: {} },
|
|
||||||
);
|
|
||||||
|
|
||||||
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', () => {
|
describe('with events media type', () => {
|
||||||
it.each([['clips' as const], ['snapshots' as const]])(
|
it.each([['clips' as const], ['snapshots' as const]])(
|
||||||
'%s',
|
'%s',
|
||||||
async (viewName: 'clips' | 'snapshots') => {
|
async (viewName: 'clips' | 'snapshots') => {
|
||||||
const eventListener = vi.fn();
|
|
||||||
const host = createLitElement();
|
const host = createLitElement();
|
||||||
host.addEventListener('frigate-card:view:change', eventListener);
|
const viewManager = mock<ViewManager>();
|
||||||
|
viewManager.getView.mockReturnValue(createView());
|
||||||
|
|
||||||
const controller = new MediaFilterController(host);
|
const controller = new MediaFilterController(host);
|
||||||
const cameraManager = createCameraManager();
|
controller.setViewManager(viewManager);
|
||||||
const view = createView();
|
|
||||||
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
|
const cameraManager = createCameraManager(createCameraStore());
|
||||||
vi.mocked(executeMediaQueryForViewWithErrorDispatching).mockResolvedValueOnce(
|
|
||||||
view,
|
|
||||||
);
|
|
||||||
|
|
||||||
const from = new Date('2024-02-06T21:59');
|
const from = new Date('2024-02-06T21:59');
|
||||||
const to = new Date('2024-02-06T22:00');
|
const to = new Date('2024-02-06T22:00');
|
||||||
|
|
||||||
await controller.valueChangeHandler(
|
await controller.valueChangeHandler(
|
||||||
cameraManager,
|
cameraManager,
|
||||||
view,
|
|
||||||
{
|
{
|
||||||
performance: createPerformanceConfig({
|
performance: createPerformanceConfig({
|
||||||
features: {
|
features: {
|
||||||
@@ -397,6 +413,7 @@ describe('MediaFilterController', () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
camera: 'camera.kitchen',
|
||||||
mediaType:
|
mediaType:
|
||||||
viewName === 'clips'
|
viewName === 'clips'
|
||||||
? MediaFilterMediaType.Clips
|
? MediaFilterMediaType.Clips
|
||||||
@@ -412,20 +429,14 @@ describe('MediaFilterController', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(vi.mocked(executeMediaQueryForViewWithErrorDispatching)).toBeCalledWith(
|
expect(viewManager.setViewByParametersWithExistingQuery).toBeCalledWith({
|
||||||
host,
|
params: expect.objectContaining({
|
||||||
cameraManager,
|
camera: 'camera.kitchen',
|
||||||
view,
|
view: viewName,
|
||||||
expect.anything(),
|
}),
|
||||||
{
|
});
|
||||||
targetCameraID: 'camera.kitchen',
|
|
||||||
targetView: viewName,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
vi
|
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
|
||||||
.mocked(executeMediaQueryForViewWithErrorDispatching)
|
|
||||||
.mock.calls[0][3].getQueries(),
|
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
cameraIDs: new Set(['camera.kitchen']),
|
cameraIDs: new Set(['camera.kitchen']),
|
||||||
@@ -442,31 +453,29 @@ describe('MediaFilterController', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(eventListener).toBeCalled();
|
|
||||||
expect(host.requestUpdate).toBeCalled();
|
expect(host.requestUpdate).toBeCalled();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('with recordings media type', async () => {
|
it('with recordings media type', async () => {
|
||||||
const eventListener = vi.fn();
|
|
||||||
const host = createLitElement();
|
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 controller = new MediaFilterController(host);
|
||||||
const cameraManager = createCameraManager();
|
controller.setViewManager(viewManager);
|
||||||
const view = createView();
|
|
||||||
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
|
|
||||||
vi.mocked(executeMediaQueryForViewWithErrorDispatching).mockResolvedValueOnce(
|
|
||||||
view,
|
|
||||||
);
|
|
||||||
|
|
||||||
const from = new Date('2024-02-06T21:59');
|
const from = new Date('2024-02-06T21:59');
|
||||||
const to = new Date('2024-02-06T22:00');
|
const to = new Date('2024-02-06T22:00');
|
||||||
|
|
||||||
await controller.valueChangeHandler(
|
await controller.valueChangeHandler(
|
||||||
cameraManager,
|
cameraManager,
|
||||||
view,
|
|
||||||
{
|
{
|
||||||
performance: createPerformanceConfig({
|
performance: createPerformanceConfig({
|
||||||
features: {
|
features: {
|
||||||
@@ -484,20 +493,15 @@ describe('MediaFilterController', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(vi.mocked(executeMediaQueryForViewWithErrorDispatching)).toBeCalledWith(
|
expect(viewManager.setViewByParametersWithExistingQuery).toBeCalledWith({
|
||||||
host,
|
params: expect.objectContaining({
|
||||||
cameraManager,
|
camera: 'camera.kitchen',
|
||||||
view,
|
view: 'recordings',
|
||||||
expect.anything(),
|
}),
|
||||||
{
|
});
|
||||||
targetCameraID: 'camera.kitchen',
|
|
||||||
targetView: 'recordings',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
vi
|
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
|
||||||
.mocked(executeMediaQueryForViewWithErrorDispatching)
|
|
||||||
.mock.calls[0][3].getQueries(),
|
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
cameraIDs: new Set(['camera.kitchen']),
|
cameraIDs: new Set(['camera.kitchen']),
|
||||||
@@ -509,18 +513,21 @@ describe('MediaFilterController', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(eventListener).toBeCalled();
|
|
||||||
expect(host.requestUpdate).toBeCalled();
|
expect(host.requestUpdate).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('without favorites', async () => {
|
it('without favorites', async () => {
|
||||||
const controller = new MediaFilterController(createLitElement());
|
|
||||||
const cameraManager = createCameraManager();
|
const cameraManager = createCameraManager();
|
||||||
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
|
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(
|
await controller.valueChangeHandler(
|
||||||
cameraManager,
|
cameraManager,
|
||||||
createView(),
|
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
mediaType: MediaFilterMediaType.Recordings,
|
mediaType: MediaFilterMediaType.Recordings,
|
||||||
@@ -528,10 +535,15 @@ describe('MediaFilterController', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(viewManager.setViewByParametersWithExistingQuery).toBeCalledWith({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
camera: 'camera.kitchen',
|
||||||
|
view: 'recordings',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
vi
|
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
|
||||||
.mocked(executeMediaQueryForViewWithErrorDispatching)
|
|
||||||
.mock.calls[0][3].getQueries(),
|
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
cameraIDs: new Set(['camera.kitchen']),
|
cameraIDs: new Set(['camera.kitchen']),
|
||||||
@@ -575,13 +587,17 @@ describe('MediaFilterController', () => {
|
|||||||
new Date('2024-02-29T23:59:59.999'),
|
new Date('2024-02-29T23:59:59.999'),
|
||||||
],
|
],
|
||||||
])('%s', async (value: MediaFilterCoreWhen | string, from: Date, to: Date) => {
|
])('%s', async (value: MediaFilterCoreWhen | string, from: Date, to: Date) => {
|
||||||
const controller = new MediaFilterController(createLitElement());
|
|
||||||
const cameraManager = createCameraManager();
|
const cameraManager = createCameraManager();
|
||||||
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
|
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(
|
await controller.valueChangeHandler(
|
||||||
cameraManager,
|
cameraManager,
|
||||||
createView(),
|
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
mediaType: MediaFilterMediaType.Recordings,
|
mediaType: MediaFilterMediaType.Recordings,
|
||||||
@@ -590,11 +606,8 @@ describe('MediaFilterController', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
vi
|
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
|
||||||
.mocked(executeMediaQueryForViewWithErrorDispatching)
|
|
||||||
.mock.calls[0][3].getQueries(),
|
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
cameraIDs: new Set(['camera.kitchen']),
|
cameraIDs: new Set(['camera.kitchen']),
|
||||||
@@ -606,13 +619,17 @@ describe('MediaFilterController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('custom without values', async () => {
|
it('custom without values', async () => {
|
||||||
const controller = new MediaFilterController(createLitElement());
|
|
||||||
const cameraManager = createCameraManager();
|
const cameraManager = createCameraManager();
|
||||||
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
|
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(
|
await controller.valueChangeHandler(
|
||||||
cameraManager,
|
cameraManager,
|
||||||
createView(),
|
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
mediaType: MediaFilterMediaType.Recordings,
|
mediaType: MediaFilterMediaType.Recordings,
|
||||||
@@ -623,9 +640,7 @@ describe('MediaFilterController', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
vi
|
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
|
||||||
.mocked(executeMediaQueryForViewWithErrorDispatching)
|
|
||||||
.mock.calls[0][3].getQueries(),
|
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
cameraIDs: new Set(['camera.kitchen']),
|
cameraIDs: new Set(['camera.kitchen']),
|
||||||
@@ -638,24 +653,25 @@ describe('MediaFilterController', () => {
|
|||||||
|
|
||||||
describe('should calculate correct defaults', () => {
|
describe('should calculate correct defaults', () => {
|
||||||
it('with no queries', () => {
|
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();
|
expect(controller.getDefaults()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('with no cameras', () => {
|
it('with no cameras', () => {
|
||||||
const controller = new MediaFilterController(createLitElement());
|
const viewManager = mock<ViewManager>();
|
||||||
|
viewManager.getView.mockReturnValue(createView());
|
||||||
|
|
||||||
controller.computeInitialDefaultsFromView(
|
const controller = new MediaFilterController(createLitElement());
|
||||||
createCameraManager(),
|
controller.setViewManager(viewManager);
|
||||||
createView({
|
|
||||||
query: new EventMediaQueries([
|
controller.computeInitialDefaultsFromView(createCameraManager());
|
||||||
{ type: QueryType.Event, cameraIDs: new Set(['camera.kitchen']) },
|
|
||||||
]),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(controller.getDefaults()).toBeNull();
|
expect(controller.getDefaults()).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -948,17 +964,21 @@ describe('MediaFilterController', () => {
|
|||||||
mediaQueries: MediaQueries,
|
mediaQueries: MediaQueries,
|
||||||
defaults: MediaFilterCoreDefaults | null,
|
defaults: MediaFilterCoreDefaults | null,
|
||||||
) => {
|
) => {
|
||||||
const controller = new MediaFilterController(createLitElement());
|
const viewManager = mock<ViewManager>();
|
||||||
const cameraManager = createCameraManager();
|
viewManager.getView.mockReturnValue(
|
||||||
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
|
|
||||||
|
|
||||||
controller.computeInitialDefaultsFromView(
|
|
||||||
cameraManager,
|
|
||||||
createView({
|
createView({
|
||||||
query: mediaQueries,
|
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);
|
expect(controller.getDefaults()).toEqual(defaults);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { CameraManager } from '../../src/camera-manager/manager';
|
|||||||
import { CameraManagerCameraMetadata } from '../../src/camera-manager/types';
|
import { CameraManagerCameraMetadata } from '../../src/camera-manager/types';
|
||||||
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager';
|
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager';
|
||||||
import { MicrophoneManager } from '../../src/card-controller/microphone-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 {
|
import {
|
||||||
MenuButtonController,
|
MenuButtonController,
|
||||||
MenuButtonControllerOptions,
|
MenuButtonControllerOptions,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
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 {
|
import {
|
||||||
generateViewContextForZoom,
|
generateViewContextForZoom,
|
||||||
handleZoomSettingsObservedEvent,
|
handleZoomSettingsObservedEvent,
|
||||||
} from '../../../src/components-lib/zoom/zoom-view-context';
|
} from '../../../src/components-lib/zoom/zoom-view-context';
|
||||||
|
|
||||||
|
vi.mock('../../../src/card-controller/view/modifiers/merge-context');
|
||||||
|
|
||||||
describe('generateViewContextForZoom', () => {
|
describe('generateViewContextForZoom', () => {
|
||||||
it('with observed', () => {
|
it('with observed', () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -53,11 +58,9 @@ describe('generateViewContextForZoom', () => {
|
|||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
it('handleZoomSettingsObservedEvent', () => {
|
it('handleZoomSettingsObservedEvent', () => {
|
||||||
const element = document.createElement('div');
|
const viewManager = mock<ViewManager>();
|
||||||
const callback = vi.fn();
|
|
||||||
element.addEventListener('frigate-card:view:change-context', callback);
|
|
||||||
handleZoomSettingsObservedEvent(
|
handleZoomSettingsObservedEvent(
|
||||||
element,
|
|
||||||
new CustomEvent('frigate-card:zoom:change', {
|
new CustomEvent('frigate-card:zoom:change', {
|
||||||
detail: {
|
detail: {
|
||||||
pan: { x: 1, y: 2 },
|
pan: { x: 1, y: 2 },
|
||||||
@@ -66,18 +69,21 @@ it('handleZoomSettingsObservedEvent', () => {
|
|||||||
unzoomed: true,
|
unzoomed: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
viewManager,
|
||||||
'target',
|
'target',
|
||||||
);
|
);
|
||||||
expect(callback).toBeCalledWith(
|
expect(viewManager.setViewByParameters).toBeCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
detail: {
|
modifiers: [expect.any(MergeContextViewModifier)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(MergeContextViewModifier).toBeCalledWith({
|
||||||
zoom: {
|
zoom: {
|
||||||
target: {
|
target: {
|
||||||
observed: { pan: { x: 1, y: 2 }, zoom: 3, isDefault: true, unzoomed: true },
|
observed: { pan: { x: 1, y: 2 }, zoom: 3, isDefault: true, unzoomed: true },
|
||||||
requested: null,
|
requested: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-10
@@ -35,7 +35,7 @@ import { MicrophoneManager } from '../src/card-controller/microphone-manager';
|
|||||||
import { QueryStringManager } from '../src/card-controller/query-string-manager';
|
import { QueryStringManager } from '../src/card-controller/query-string-manager';
|
||||||
import { StyleManager } from '../src/card-controller/style-manager';
|
import { StyleManager } from '../src/card-controller/style-manager';
|
||||||
import { TriggersManager } from '../src/card-controller/triggers-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 {
|
import {
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
FrigateCardCondition,
|
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 => {
|
export const createPerformanceConfig = (config: unknown): PerformanceConfig => {
|
||||||
return performanceConfigSchema.parse(config);
|
return performanceConfigSchema.parse(config);
|
||||||
};
|
};
|
||||||
@@ -297,7 +289,12 @@ export const generateViewMediaArray = (options?: {
|
|||||||
const media: ViewMedia[] = [];
|
const media: ViewMedia[] = [];
|
||||||
for (let i = 0; i < (options?.count ?? 100); ++i) {
|
for (let i = 0; i < (options?.count ?? 100); ++i) {
|
||||||
for (const cameraID of options?.cameraIDs ?? ['kitchen', 'office']) {
|
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;
|
return media;
|
||||||
@@ -469,3 +466,10 @@ export const callHASubscribeMessageHandler = (
|
|||||||
expect(mock.calls.length).greaterThan(n);
|
expect(mock.calls.length).greaterThan(n);
|
||||||
mock.calls[n][0](ev);
|
mock.calls[n][0](ev);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush resolved promises.
|
||||||
|
*/
|
||||||
|
export const flushPromises = async (): Promise<void> => {
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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';
|
import { View } from '../../src/view/view';
|
||||||
|
|
||||||
describe('hasSubstream/getStreamCameraID', () => {
|
describe('hasSubstream/getStreamCameraID', () => {
|
||||||
@@ -54,3 +58,41 @@ describe('hasSubstream/getStreamCameraID', () => {
|
|||||||
expect(getStreamCameraID(view, 'camera3')).toBe('camera4');
|
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']]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ describe('getCameraIDsForViewName', () => {
|
|||||||
['media' as const, 'clips' as const],
|
['media' as const, 'clips' as const],
|
||||||
['media' as const, 'snapshots' as const],
|
['media' as const, 'snapshots' as const],
|
||||||
['media' as const, 'recordings' 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) => {
|
])('%s', (viewName: FrigateCardView, capabilityKey: CapabilityKey) => {
|
||||||
const cameraManager = createCameraManager();
|
const cameraManager = createCameraManager();
|
||||||
vi.mocked(cameraManager.getStore).mockReturnValue(
|
vi.mocked(cameraManager.getStore).mockReturnValue(
|
||||||
|
|||||||
+19
-342
@@ -1,12 +1,8 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { QueryType } from '../../src/camera-manager/types';
|
import { EventMediaQueries } from '../../src/view/media-queries';
|
||||||
import { ViewMedia } from '../../src/view/media';
|
|
||||||
import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries';
|
|
||||||
import { MediaQueriesResults } from '../../src/view/media-queries-results';
|
import { MediaQueriesResults } from '../../src/view/media-queries-results';
|
||||||
import { View, dispatchViewContextChangeEvent } from '../../src/view/view';
|
|
||||||
import { createView } from '../test-utils';
|
import { createView } from '../test-utils';
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
|
||||||
describe('View Basics', () => {
|
describe('View Basics', () => {
|
||||||
it('should construct from parameters', () => {
|
it('should construct from parameters', () => {
|
||||||
const query = new EventMediaQueries();
|
const query = new EventMediaQueries();
|
||||||
@@ -28,21 +24,28 @@ describe('View Basics', () => {
|
|||||||
expect(view.context).toBe(context);
|
expect(view.context).toBe(context);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clone', () => {
|
describe('should clone', () => {
|
||||||
const query = new EventMediaQueries();
|
it('with query and queryResults', () => {
|
||||||
const queryResults = new MediaQueriesResults();
|
|
||||||
const context = {};
|
|
||||||
|
|
||||||
const view = createView({
|
const view = createView({
|
||||||
view: 'live',
|
view: 'live',
|
||||||
camera: 'camera',
|
camera: 'camera',
|
||||||
query: query,
|
query: new EventMediaQueries(),
|
||||||
queryResults: queryResults,
|
queryResults: new MediaQueriesResults(),
|
||||||
context: context,
|
context: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const clone = view.clone();
|
expect(view.clone()).toEqual(view);
|
||||||
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', () => {
|
it('should evolve with everything set', () => {
|
||||||
@@ -201,316 +204,6 @@ describe('View Basics', () => {
|
|||||||
expect(createView({ view: 'timeline' }).getDefaultMediaType()).toBeNull();
|
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', () => {
|
it('should determine if display mode is grid', () => {
|
||||||
expect(createView({ displayMode: 'grid' }).isGrid()).toBeTruthy();
|
expect(createView({ displayMode: 'grid' }).isGrid()).toBeTruthy();
|
||||||
expect(createView({ displayMode: 'single' }).isGrid()).toBeFalsy();
|
expect(createView({ displayMode: 'single' }).isGrid()).toBeFalsy();
|
||||||
@@ -535,19 +228,3 @@ describe('View.adoptFromViewIfAppropriate', () => {
|
|||||||
expect(createView({ view: 'timeline' }).supportsMultipleDisplayModes()).toBeFalsy();
|
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ const FULL_COVERAGE_FILES_RELATIVE = [
|
|||||||
'utils/interaction-mode.ts',
|
'utils/interaction-mode.ts',
|
||||||
'utils/media-info.ts',
|
'utils/media-info.ts',
|
||||||
'utils/media-layout.ts',
|
'utils/media-layout.ts',
|
||||||
'utils/media-to-view.ts',
|
|
||||||
'utils/media.ts',
|
'utils/media.ts',
|
||||||
'utils/ptz.ts',
|
'utils/ptz.ts',
|
||||||
'utils/screenshot.ts',
|
'utils/screenshot.ts',
|
||||||
|
|||||||
@@ -1833,14 +1833,23 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.3.2":
|
"acorn-walk@npm:^8.0.2":
|
||||||
version: 8.3.2
|
version: 8.3.2
|
||||||
resolution: "acorn-walk@npm:8.3.2"
|
resolution: "acorn-walk@npm:8.3.2"
|
||||||
checksum: 10c0/7e2a8dad5480df7f872569b9dccff2f3da7e65f5353686b1d6032ab9f4ddf6e3a2cb83a9b52cf50b1497fd522154dda92f0abf7153290cc79cd14721ff121e52
|
checksum: 10c0/7e2a8dad5480df7f872569b9dccff2f3da7e65f5353686b1d6032ab9f4ddf6e3a2cb83a9b52cf50b1497fd522154dda92f0abf7153290cc79cd14721ff121e52
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 8.11.3
|
||||||
resolution: "acorn@npm:8.11.3"
|
resolution: "acorn@npm:8.11.3"
|
||||||
bin:
|
bin:
|
||||||
@@ -1849,6 +1858,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"agent-base@npm:6":
|
||||||
version: 6.0.2
|
version: 6.0.2
|
||||||
resolution: "agent-base@npm:6.0.2"
|
resolution: "agent-base@npm:6.0.2"
|
||||||
@@ -2621,6 +2639,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"configstore@npm:^5.0.1":
|
||||||
version: 5.0.1
|
version: 5.0.1
|
||||||
resolution: "configstore@npm:5.0.1"
|
resolution: "configstore@npm:5.0.1"
|
||||||
@@ -2981,11 +3006,11 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"deep-eql@npm:^4.1.3":
|
"deep-eql@npm:^4.1.3":
|
||||||
version: 4.1.3
|
version: 4.1.4
|
||||||
resolution: "deep-eql@npm:4.1.3"
|
resolution: "deep-eql@npm:4.1.4"
|
||||||
dependencies:
|
dependencies:
|
||||||
type-detect: "npm:^4.0.0"
|
type-detect: "npm:^4.0.0"
|
||||||
checksum: 10c0/ff34e8605d8253e1bf9fe48056e02c6f347b81d9b5df1c6650a1b0f6f847b4a86453b16dc226b34f853ef14b626e85d04e081b022e20b00cd7d54f079ce9bbdd
|
checksum: 10c0/264e0613493b43552fc908f4ff87b8b445c0e6e075656649600e1b8a17a57ee03e960156fce7177646e4d2ddaf8e5ee616d76bd79929ff593e5c79e4e5e6c517
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -5209,10 +5234,10 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"js-tokens@npm:^8.0.2":
|
"js-tokens@npm:^9.0.0":
|
||||||
version: 8.0.3
|
version: 9.0.0
|
||||||
resolution: "js-tokens@npm:8.0.3"
|
resolution: "js-tokens@npm:9.0.0"
|
||||||
checksum: 10c0/b50ba7d926b087ad31949d8155c7bc84374e0785019b17bdddeb2c4f98f5dea04ba464651fe23a8be4f7d15f50d06ce8bb536087b24ce3ebfbaea4a1dc5869f0
|
checksum: 10c0/4ad1c12f47b8c8b2a3a99e29ef338c1385c7b7442198a425f3463f3537384dab6032012791bfc2f056ea5ecdb06b1ed4f70e11a3ab3f388d3dcebfe16a52b27d
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -5337,13 +5362,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"jsonfile@npm:^4.0.0":
|
||||||
version: 4.0.0
|
version: 4.0.0
|
||||||
resolution: "jsonfile@npm:4.0.0"
|
resolution: "jsonfile@npm:4.0.0"
|
||||||
@@ -5659,7 +5677,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"magic-string@npm:^0.30.3":
|
"magic-string@npm:^0.30.3, magic-string@npm:^0.30.5":
|
||||||
version: 0.30.10
|
version: 0.30.10
|
||||||
resolution: "magic-string@npm:0.30.10"
|
resolution: "magic-string@npm:0.30.10"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5668,15 +5686,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"magicast@npm:^0.3.3":
|
||||||
version: 0.3.3
|
version: 0.3.3
|
||||||
resolution: "magicast@npm:0.3.3"
|
resolution: "magicast@npm:0.3.3"
|
||||||
@@ -6025,15 +6034,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"mlly@npm:^1.2.0, mlly@npm:^1.4.2":
|
"mlly@npm:^1.4.2, mlly@npm:^1.7.1":
|
||||||
version: 1.6.1
|
version: 1.7.1
|
||||||
resolution: "mlly@npm:1.6.1"
|
resolution: "mlly@npm:1.7.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: "npm:^8.11.3"
|
acorn: "npm:^8.11.3"
|
||||||
pathe: "npm:^1.1.2"
|
pathe: "npm:^1.1.2"
|
||||||
pkg-types: "npm:^1.0.3"
|
pkg-types: "npm:^1.1.1"
|
||||||
ufo: "npm:^1.3.2"
|
ufo: "npm:^1.5.3"
|
||||||
checksum: 10c0/a7bf26b3d4f83b0f5a5232caa3af44be08b464f562f31c11d885d1bc2d43b7d717137d47b0c06fdc69e1b33ffc09f902b6d2b18de02c577849d40914e8785092
|
checksum: 10c0/d836a7b0adff4d118af41fb93ad4d9e57f80e694a681185280ba220a4607603c19e86c80f9a6c57512b04280567f2599e3386081705c5b5fd74c9ddfd571d0fa
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -6671,7 +6680,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 1.1.2
|
||||||
resolution: "pathe@npm:1.1.2"
|
resolution: "pathe@npm:1.1.2"
|
||||||
checksum: 10c0/64ee0a4e587fb0f208d9777a6c56e4f9050039268faaaaecd50e959ef01bf847b7872785c36483fa5cdcdbdfdb31fef2ff222684d4fc21c330ab60395c681897
|
checksum: 10c0/64ee0a4e587fb0f208d9777a6c56e4f9050039268faaaaecd50e959ef01bf847b7872785c36483fa5cdcdbdfdb31fef2ff222684d4fc21c330ab60395c681897
|
||||||
@@ -6706,14 +6715,14 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"pkg-types@npm:^1.0.3":
|
"pkg-types@npm:^1.0.3, pkg-types@npm:^1.1.1":
|
||||||
version: 1.0.3
|
version: 1.1.3
|
||||||
resolution: "pkg-types@npm:1.0.3"
|
resolution: "pkg-types@npm:1.1.3"
|
||||||
dependencies:
|
dependencies:
|
||||||
jsonc-parser: "npm:^3.2.0"
|
confbox: "npm:^0.1.7"
|
||||||
mlly: "npm:^1.2.0"
|
mlly: "npm:^1.7.1"
|
||||||
pathe: "npm:^1.1.0"
|
pathe: "npm:^1.1.2"
|
||||||
checksum: 10c0/7f692ff2005f51b8721381caf9bdbc7f5461506ba19c34f8631660a215c8de5e6dca268f23a319dd180b8f7c47a0dc6efea14b376c485ff99e98d810b8f786c4
|
checksum: 10c0/4cd2c9442dd5e4ae0c61cbd8fdaa92a273939749b081f78150ce9a3f4e625cca0375607386f49f103f0720b239d02369bf181c3ea6c80cf1028a633df03706ad
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -7314,9 +7323,9 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"react-is@npm:^18.0.0":
|
"react-is@npm:^18.0.0":
|
||||||
version: 18.2.0
|
version: 18.3.1
|
||||||
resolution: "react-is@npm:18.2.0"
|
resolution: "react-is@npm:18.3.1"
|
||||||
checksum: 10c0/6eb5e4b28028c23e2bfcf73371e72cd4162e4ac7ab445ddae2afe24e347a37d6dc22fae6e1748632cd43c6d4f9b8f86dcf26bf9275e1874f436d129952528ae0
|
checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -8136,11 +8145,11 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"strip-literal@npm:^2.0.0":
|
"strip-literal@npm:^2.0.0":
|
||||||
version: 2.0.0
|
version: 2.1.0
|
||||||
resolution: "strip-literal@npm:2.0.0"
|
resolution: "strip-literal@npm:2.1.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: "npm:^8.0.2"
|
js-tokens: "npm:^9.0.0"
|
||||||
checksum: 10c0/63a6e4224ac7088ff93fd19fc0f6882705020da2f0767dbbecb929cbf9d49022e72350420f47be635866823608da9b9a5caf34f518004721895b6031199fc3c8
|
checksum: 10c0/bc8b8c8346125ae3c20fcdaf12e10a498ff85baf6f69597b4ab2b5fbf2e58cfd2827f1a44f83606b852da99a5f6c8279770046ddea974c510c17c98934c9cc24
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -8273,9 +8282,9 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"tinybench@npm:^2.5.1":
|
"tinybench@npm:^2.5.1":
|
||||||
version: 2.6.0
|
version: 2.8.0
|
||||||
resolution: "tinybench@npm:2.6.0"
|
resolution: "tinybench@npm:2.8.0"
|
||||||
checksum: 10c0/60ea35699bf8bac9bc8cf279fa5877ab5b335b4673dcd07bf0fbbab9d7953a02c0ccded374677213eaa13aa147f54eb75d3230139ddbeec3875829ebe73db310
|
checksum: 10c0/5a9a642351fa3e4955e0cbf38f5674be5f3ba6730fd872fd23a5c953ad6c914234d5aba6ea41ef88820180a81829ceece5bd8d3967c490c5171bca1141c2f24d
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -8287,9 +8296,9 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"tinypool@npm:^0.8.3":
|
"tinypool@npm:^0.8.3":
|
||||||
version: 0.8.3
|
version: 0.8.4
|
||||||
resolution: "tinypool@npm:0.8.3"
|
resolution: "tinypool@npm:0.8.4"
|
||||||
checksum: 10c0/c219d0cfb69de8e3cf17403034a508d773f2fccaad79a13cdbad68600c4fb10186ad814d2320bcaa8f6e774fff5666d2a3d3b241dc8a7ad9d970ee63fe620a32
|
checksum: 10c0/779c790adcb0316a45359652f4b025958c1dff5a82460fe49f553c864309b12ad732c8288be52f852973bc76317f5e7b3598878aee0beb8a33322c0e72c4a66c
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -8583,10 +8592,10 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"ufo@npm:^1.3.2":
|
"ufo@npm:^1.5.3":
|
||||||
version: 1.4.0
|
version: 1.5.4
|
||||||
resolution: "ufo@npm:1.4.0"
|
resolution: "ufo@npm:1.5.4"
|
||||||
checksum: 10c0/d9a3cb8c5fd13356e0af661362244fd0a901edcdd08996f42553271007cae01e85dcec29a3303a87ddab6aa705cbd630332aaa8c268d037483536b198fa67a7c
|
checksum: 10c0/b5dc4dc435c49c9ef8890f1b280a19ee4d0954d1d6f9ab66ce62ce64dd04c7be476781531f952a07c678d51638d02ad4b98e16237be29149295b0f7c09cda765
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -9049,14 +9058,14 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"why-is-node-running@npm:^2.2.2":
|
"why-is-node-running@npm:^2.2.2":
|
||||||
version: 2.2.2
|
version: 2.3.0
|
||||||
resolution: "why-is-node-running@npm:2.2.2"
|
resolution: "why-is-node-running@npm:2.3.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
siginfo: "npm:^2.0.0"
|
siginfo: "npm:^2.0.0"
|
||||||
stackback: "npm:0.0.2"
|
stackback: "npm:0.0.2"
|
||||||
bin:
|
bin:
|
||||||
why-is-node-running: cli.js
|
why-is-node-running: cli.js
|
||||||
checksum: 10c0/805d57eb5d33f0fb4e36bae5dceda7fd8c6932c2aeb705e30003970488f1a2bc70029ee64be1a0e1531e2268b11e65606e88e5b71d667ea745e6dc48fc9014bd
|
checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -9289,9 +9298,9 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"yocto-queue@npm:^1.0.0":
|
"yocto-queue@npm:^1.0.0":
|
||||||
version: 1.0.0
|
version: 1.1.1
|
||||||
resolution: "yocto-queue@npm:1.0.0"
|
resolution: "yocto-queue@npm:1.1.1"
|
||||||
checksum: 10c0/856117aa15cf5103d2a2fb173f0ab4acb12b4b4d0ed3ab249fdbbf612e55d1cadfd27a6110940e24746fb0a78cf640b522cc8bca76f30a3b00b66e90cf82abe0
|
checksum: 10c0/cb287fe5e6acfa82690acb43c283de34e945c571a78a939774f6eaba7c285bacdf6c90fbc16ce530060863984c906d2b4c6ceb069c94d1e0a06d5f2b458e2a92
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user