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