diff --git a/docs/index.html b/docs/index.html index 850d1a24..00202e41 100644 --- a/docs/index.html +++ b/docs/index.html @@ -103,10 +103,10 @@ }; - - + + + diff --git a/src/card-controller/view/factory.ts b/src/card-controller/view/factory.ts index 7b0ab381..e6cff33b 100644 --- a/src/card-controller/view/factory.ts +++ b/src/card-controller/view/factory.ts @@ -3,7 +3,11 @@ import { ViewDisplayMode } from '../../config/schema/common/display'; import { AdvancedCameraCardConfig } from '../../config/schema/types'; import { localize } from '../../localize/localize'; import { View, ViewParameters } from '../../view/view'; -import { getCameraIDsForViewName } from '../../view/view-to-cameras'; +import { + getCameraIDsForViewName, + isViewSupportedByCamera, + isViewSupportedByQueryOnly, +} from '../../view/view-support'; import { CardViewAPI } from '../types'; import { applyViewModifiers } from './modifiers'; import { ViewFactoryOptions, ViewIncompatible, ViewNoCameraError } from './types'; @@ -32,7 +36,7 @@ export class ViewFactory { cameraID = options.params.camera; } else { const cameraIDs = [ - ...getCameraIDsForViewName(this._api.getCameraManager(), viewName), + ...getCameraIDsForViewName(viewName, this._api.getCameraManager()), ]; if ( @@ -73,8 +77,8 @@ export class ViewFactory { if (!cameraID || !allCameraIDs.has(cameraID)) { const viewCameraIDs = getCameraIDsForViewName( - this._api.getCameraManager(), viewName, + this._api.getCameraManager(), ); // Reset to the default camera. @@ -98,8 +102,18 @@ export class ViewFactory { }); } - if (!this.isViewSupportedByCamera(cameraID, viewName)) { - if (options?.failSafe && this.isViewSupportedByCamera(cameraID, VIEW_DEFAULT)) { + if ( + !isViewSupportedByCamera(viewName, this._api.getCameraManager(), cameraID) && + !isViewSupportedByQueryOnly( + viewName, + options?.params?.query ?? options?.baseView?.query, + options?.params?.queryResults ?? options?.baseView?.queryResults, + ) + ) { + if ( + options?.failSafe && + isViewSupportedByCamera(VIEW_DEFAULT, this._api.getCameraManager(), cameraID) + ) { viewName = VIEW_DEFAULT; } else { const capabilities = this._api @@ -142,13 +156,6 @@ export class ViewFactory { return view; } - public isViewSupportedByCamera( - cameraID: string, - view: AdvancedCameraCardView, - ): boolean { - return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size; - } - protected _getDefaultDisplayModeForView( viewName: AdvancedCameraCardView, config: AdvancedCameraCardConfig, diff --git a/src/card-controller/view/types.ts b/src/card-controller/view/types.ts index 246e09b2..58fbae0f 100644 --- a/src/card-controller/view/types.ts +++ b/src/card-controller/view/types.ts @@ -1,5 +1,4 @@ import { ViewContext } from 'view'; -import { AdvancedCameraCardView } from '../../config/schema/common/const.js'; import { AdvancedCameraCardError } from '../../types.js'; import { ViewItem } from '../../view/item.js'; import { QueryResults } from '../../view/query-results.js'; @@ -73,7 +72,6 @@ export interface ViewManagerInterface { setViewWithMergedContext(context: ViewContext | null): void; - isViewSupportedByCamera(cameraID: string, view: AdvancedCameraCardView): boolean; hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean; } diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts index 528d1c4f..be56304e 100644 --- a/src/card-controller/view/view-manager.ts +++ b/src/card-controller/view/view-manager.ts @@ -1,10 +1,8 @@ import { ViewContext } from 'view'; -import { AdvancedCameraCardView } from '../../config/schema/common/const'; import { log } from '../../utils/debug'; import { getStreamCameraID } from '../../utils/substream'; import { QueryClassifier } from '../../view/query-classifier'; import { View } from '../../view/view'; -import { getCameraIDsForViewName } from '../../view/view-to-cameras'; import { InitializationAspect } from '../initialization-manager'; import { CardViewAPI } from '../types'; import { ViewFactory } from './factory'; @@ -257,13 +255,6 @@ export class ViewManager implements ViewManagerInterface { } } - public isViewSupportedByCamera( - cameraID: string, - view: AdvancedCameraCardView, - ): 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. diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index c0530f61..1205eca5 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -29,7 +29,11 @@ import { getStreamCameraID, hasSubstream } from '../utils/substream'; import { ViewItemClassifier } from '../view/item-classifier'; import { QueryClassifier } from '../view/query-classifier'; import { View } from '../view/view'; -import { getCameraIDsForViewName } from '../view/view-to-cameras'; +import { + getCameraIDsForViewName, + isViewSupportedByCamera, + isViewSupportedByQueryOnly, +} from '../view/view-support'; export interface MenuButtonControllerOptions { currentMediaLoadedInfo?: MediaLoadedInfo | null; @@ -73,12 +77,12 @@ export class MenuButtonController { this._getIrisButton(config), this._getCamerasButton(config, cameraManager, options?.view), this._getSubstreamsButton(config, cameraManager, options?.view), - this._getLiveButton(config, options?.view, options?.viewManager), - this._getClipsButton(config, options?.view, options?.viewManager), - this._getSnapshotsButton(config, options?.view, options?.viewManager), - this._getRecordingsButton(config, options?.view, options?.viewManager), - this._getImageButton(config, options?.view, options?.viewManager), - this._getTimelineButton(config, options?.view, options?.viewManager), + this._getLiveButton(config, cameraManager, options?.view), + this._getClipsButton(config, cameraManager, options?.view), + this._getSnapshotsButton(config, cameraManager, options?.view), + this._getRecordingsButton(config, cameraManager, options?.view), + this._getImageButton(config, cameraManager, options?.view), + this._getTimelineButton(config, cameraManager, options?.view), this._getDownloadButton(config, cameraManager, options?.view), this._getCameraUIButton(config, options?.showCameraUIButton), this._getMicrophoneButton( @@ -227,10 +231,10 @@ export class MenuButtonController { protected _getLiveButton( config: AdvancedCameraCardConfig, + cameraManager: CameraManager, view?: View | null, - viewManager?: ViewManager | null, ): MenuItem | null { - return view && viewManager?.isViewSupportedByCamera(view.camera, 'live') + return view && isViewSupportedByCamera('live', cameraManager, view.camera) ? { icon: 'mdi:cctv', ...config.menu.buttons.live, @@ -244,10 +248,10 @@ export class MenuButtonController { protected _getClipsButton( config: AdvancedCameraCardConfig, + cameraManager: CameraManager, view?: View | null, - viewManager?: ViewManager | null, ): MenuItem | null { - return view && viewManager?.isViewSupportedByCamera(view.camera, 'clips') + return view && isViewSupportedByCamera('clips', cameraManager, view.camera) ? { icon: 'mdi:filmstrip', ...config.menu.buttons.clips, @@ -262,10 +266,10 @@ export class MenuButtonController { protected _getSnapshotsButton( config: AdvancedCameraCardConfig, + cameraManager: CameraManager, view?: View | null, - viewManager?: ViewManager | null, ): MenuItem | null { - return view && viewManager?.isViewSupportedByCamera(view.camera, 'snapshots') + return view && isViewSupportedByCamera('snapshots', cameraManager, view.camera) ? { icon: 'mdi:camera', ...config.menu.buttons.snapshots, @@ -280,10 +284,10 @@ export class MenuButtonController { protected _getRecordingsButton( config: AdvancedCameraCardConfig, + cameraManager: CameraManager, view?: View | null, - viewManager?: ViewManager | null, ): MenuItem | null { - return view && viewManager?.isViewSupportedByCamera(view.camera, 'recordings') + return view && isViewSupportedByCamera('recordings', cameraManager, view.camera) ? { icon: 'mdi:album', ...config.menu.buttons.recordings, @@ -298,10 +302,10 @@ export class MenuButtonController { protected _getImageButton( config: AdvancedCameraCardConfig, + cameraManager: CameraManager, view?: View | null, - viewManager?: ViewManager | null, ): MenuItem | null { - return view && viewManager?.isViewSupportedByCamera(view.camera, 'image') + return view && isViewSupportedByCamera('image', cameraManager, view.camera) ? { icon: 'mdi:image', ...config.menu.buttons.image, @@ -315,10 +319,14 @@ export class MenuButtonController { protected _getTimelineButton( config: AdvancedCameraCardConfig, + cameraManager: CameraManager, view?: View | null, - viewManager?: ViewManager | null, ): MenuItem | null { - return view && viewManager?.isViewSupportedByCamera(view.camera, 'timeline') + const shouldShowTimelineButton = + view && + (isViewSupportedByCamera('timeline', cameraManager, view.camera) || + isViewSupportedByQueryOnly('timeline', view.query, view.queryResults)); + return shouldShowTimelineButton ? { icon: 'mdi:chart-gantt', ...config.menu.buttons.timeline, @@ -545,7 +553,7 @@ export class MenuButtonController { view?: View | null, ): MenuItem | null { const viewCameraIDs = view - ? getCameraIDsForViewName(cameraManager, view.view) + ? getCameraIDsForViewName(view.view, cameraManager) : null; if ( view?.supportsMultipleDisplayModes() && diff --git a/src/components-lib/timeline/controller.ts b/src/components-lib/timeline/controller.ts index f5792f37..21f68771 100644 --- a/src/components-lib/timeline/controller.ts +++ b/src/components-lib/timeline/controller.ts @@ -537,8 +537,13 @@ export class TimelineController { .resetSelectedResult() .selectResultIfFound((media) => media.getID() === properties.item, criteria); + const selectedItem = newResults?.getSelectedResult(); const context: ViewContext = mergeViewContext(this._getTimelineContext(), { - mediaViewer: { seek: properties.time }, + ...(ViewItemClassifier.isEvent(selectedItem) && + // Only attempt to seek if the event has a real end time, otherwise + // the viewer cannot actually seek there and shows the unseekable + // message. + selectedItem.getEndTime() && { mediaViewer: { seek: properties.time } }), }); if (!newResults || !newResults.hasSelectedResult()) { diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index f8001263..2bc06735 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -29,6 +29,7 @@ import { contentsChanged } from '../utils/basic'; import './date-picker.js'; import { AdvancedCameraCardDatePicker, DatePickerEvent } from './date-picker.js'; import './icon'; +import { renderMessage } from './message'; import './thumbnail/thumbnail.js'; /** @@ -139,7 +140,18 @@ export class AdvancedCameraCardTimelineCore extends LitElement { protected _controller: TimelineController = new TimelineController(this); protected render(): TemplateResult | void { - if (!this.hass || !this.timelineConfig || !this.keys?.length) { + if (!this.hass || !this.timelineConfig) { + return; + } + + if (!this.keys.length) { + if (!this.mini) { + return renderMessage({ + message: localize('error.no_camera_or_media_for_timeline'), + icon: 'mdi:chart-gantt', + type: 'info', + }); + } return; } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index d24653b3..667e3330 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -8,6 +8,7 @@ import { TimelineConfig } from '../config/schema/timeline'; import { CardWideConfig } from '../config/schema/types'; import { HomeAssistant } from '../ha/types'; import basicBlockStyle from '../scss/basic-block.scss'; +import { QueryClassifier } from '../view/query-classifier'; import './surround.js'; import './timeline-core.js'; @@ -32,6 +33,27 @@ export class AdvancedCameraCardTimeline extends LitElement { public cardWideConfig?: CardWideConfig; protected _getKeys(): TimelineKey[] { + const query = this.viewManagerEpoch?.manager.getView()?.query; + + // If there's a query, try to extract camera IDs or folder info from it. + if (QueryClassifier.isMediaQuery(query)) { + const cameraIDs = query.getQueryCameraIDs(); + if (cameraIDs && cameraIDs.size) { + return [...cameraIDs].map((cameraID) => ({ type: 'camera', cameraID })); + } + } else if (QueryClassifier.isFolderQuery(query)) { + const folderConfig = query.getQuery()?.folder; + if (folderConfig) { + return [ + { + type: 'folder' as const, + folder: folderConfig, + }, + ]; + } + } + + // Otherwise fall back to all cameras that support media queries. const keys: TimelineKey[] = []; for (const camera of this.cameraManager?.getStore().getCameraIDsWithCapability({ anyCapabilities: ['clips', 'snapshots', 'recordings'], diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 46105956..36942956 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -699,6 +699,7 @@ "no_camera_entity": "No s'ha pogut trobar l'entitat de la càmera", "no_camera_entity_for_triggers": "Es requereix una entitat de càmera per detectar automàticament els activadors", "no_camera_id": "No s'ha pogut determinar l'identificador de la càmera per a la següent càmera, és possible que hagis d'establir el paràmetre 'id' manualment", + "no_camera_or_media_for_timeline": "", "no_dashboard_or_view": "Tant els paràmetres 'dashboard_path' com 'view_path' són necessaris per al mètode d'emissió 'dashboard'", "no_live_camera": "El paràmetre 'camera_entity' s'ha de configurar i validar per a aquest proveïdor en directe", "no_supported_camera": "", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 2a51cbd5..3636ae05 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -699,9 +699,10 @@ "no_camera_entity": "Could not find camera entity", "no_camera_entity_for_triggers": "A camera entity is required in order to autodetect triggers", "no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually", + "no_camera_or_media_for_timeline": "No camera or media available for timeline", "no_dashboard_or_view": "Both 'dashboard_path' and 'view_path' parameters are required for the 'dashboard' cast method", "no_live_camera": "The camera_entity parameter must be set and valid for this live provider", - "no_supported_camera": "The selected camera does not support this view", + "no_supported_camera": "The selected camera or media does not support this view", "no_supported_cameras": "No cameras support this view", "reconnecting": "Reconnecting", "stream_not_loading": "The video stream has not yet loaded. This is could for any number of reasons. If configured (and by default), there will be an image refreshing every second until the stream loads correctly", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index ffa6b9c2..ab7a3ff9 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -699,6 +699,7 @@ "no_camera_entity": "Impossible de trouver l'entité de caméra", "no_camera_entity_for_triggers": "Une entité caméra est requise pour détecter automatiquement les déclencheurs", "no_camera_id": "Impossible de déterminer l'identifiant de la caméra suivante. Il faudra peut-être définir le paramètre « ID » manuellement", + "no_camera_or_media_for_timeline": "", "no_dashboard_or_view": "Les paramètres 'dashboard_path' et 'view_path' sont requis pour la méthode de conversion 'dashboard'", "no_live_camera": "Le paramètre camera_entity doit être défini et valide pour ce fournisseur en direct", "no_supported_camera": "La caméra sélectionnée ne prend pas en charge cette vue", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 34721f32..f580eb4d 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -699,6 +699,7 @@ "no_camera_entity": "Impossibile trovare l'entità fotocamera", "no_camera_entity_for_triggers": "È necessaria un'entità telecamera per rilevare automaticamente i trigger", "no_camera_id": "Impossibile determinare l'ID della telecamera , potrebbe essere necessario impostare manualmente il parametro 'ID'", + "no_camera_or_media_for_timeline": "", "no_dashboard_or_view": "", "no_live_camera": "Il parametro fotocamera_enty deve essere impostato e valido per questo provider live", "no_supported_camera": "", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index fe97c84c..839073ed 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -699,6 +699,7 @@ "no_camera_entity": "Não foi possível encontrar a entidade da câmera", "no_camera_entity_for_triggers": "Uma entidade de câmera é necessária para detectar automaticamente os gatilhos", "no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente", + "no_camera_or_media_for_timeline": "", "no_dashboard_or_view": "", "no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo", "no_supported_camera": "", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index b0d53ac3..abdd990b 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -699,6 +699,7 @@ "no_camera_entity": "Não existe uma entidade câmera", "no_camera_entity_for_triggers": "Não existe camera para a acção", "no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente", + "no_camera_or_media_for_timeline": "", "no_dashboard_or_view": "", "no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este serviço ativo", "no_supported_camera": "", diff --git a/src/view/view-to-cameras.ts b/src/view/view-support.ts similarity index 68% rename from src/view/view-to-cameras.ts rename to src/view/view-support.ts index 4cc66282..e4d60379 100644 --- a/src/view/view-to-cameras.ts +++ b/src/view/view-support.ts @@ -1,14 +1,16 @@ import { CameraManager } from '../camera-manager/manager'; import { CapabilitySearchOptions } from '../camera-manager/types'; import { AdvancedCameraCardView } from '../config/schema/common/const'; +import { Query } from './query'; +import { QueryResults } from './query-results'; /** * Get cameraIDs that are relevant for a given view name based on camera * capability (if camera specified). */ export const getCameraIDsForViewName = ( - cameraManager: CameraManager, viewName: AdvancedCameraCardView, + cameraManager: CameraManager, cameraID?: string, ): Set => { switch (viewName) { @@ -47,3 +49,27 @@ export const getCameraIDsForViewName = ( }); } }; + +export const isViewSupportedByCamera = ( + view: AdvancedCameraCardView, + cameraManager: CameraManager, + cameraID: string, +): boolean => { + return !!getCameraIDsForViewName(view, cameraManager, cameraID).size; +}; + +/** + * Whether a view is supported by a given query ONLY (i.e. regardless of what + * the camera supports). + */ +export const isViewSupportedByQueryOnly = ( + view: AdvancedCameraCardView, + query?: Query | null, + queryResults?: QueryResults | null, +): boolean => { + switch (view) { + case 'timeline': + return !!query?.getQuery() && !!queryResults?.hasResults(); + } + return false; +}; diff --git a/tests/card-controller/view/factory.test.ts b/tests/card-controller/view/factory.test.ts index a4b60d6a..9cc3a632 100644 --- a/tests/card-controller/view/factory.test.ts +++ b/tests/card-controller/view/factory.test.ts @@ -253,7 +253,7 @@ describe('getViewByParameters', () => { view: 'snapshots', }, }), - ).toThrowError(/The selected camera does not support this view/); + ).toThrowError(/The selected camera or media does not support this view/); }); it('should choose live view with failsafe', () => { diff --git a/tests/card-controller/view/view-manager.test.ts b/tests/card-controller/view/view-manager.test.ts index b7075a11..633ddc2b 100644 --- a/tests/card-controller/view/view-manager.test.ts +++ b/tests/card-controller/view/view-manager.test.ts @@ -11,18 +11,11 @@ import { } from '../../../src/card-controller/view/types'; import { ViewManager } from '../../../src/card-controller/view/view-manager'; import { ViewQueryExecutor } from '../../../src/card-controller/view/view-query-executor'; -import { AdvancedCameraCardView } from '../../../src/config/schema/common/const'; import { ViewMedia, ViewMediaType } from '../../../src/view/item'; import { EventMediaQuery } from '../../../src/view/query'; import { QueryResults } from '../../../src/view/query-results'; import { View } from '../../../src/view/view'; -import { - createCameraManager, - createCapabilities, - createCardAPI, - createStore, - createView, -} from '../../test-utils'; +import { createCardAPI, createView } from '../../test-utils'; const createInitializedCardAPI = (initialized?: boolean): CardController => { const api = createCardAPI(); @@ -301,44 +294,6 @@ describe('should handle exceptions', () => { }); }); -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, true], - ])('%s', (viewName: AdvancedCameraCardView, expected: boolean) => { - const api = createInitializedCardAPI(); - 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()); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index 70ebf1c1..06253eb0 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -23,6 +23,10 @@ import { createGeneralAction, createViewAction } from '../../src/utils/action.js import { ViewMedia, ViewMediaType } from '../../src/view/item.js'; import { QueryResults } from '../../src/view/query-results.js'; import { FolderViewQuery } from '../../src/view/query.js'; +import { + getCameraIDsForViewName, + isViewSupportedByCamera, +} from '../../src/view/view-support.js'; import { View } from '../../src/view/view.js'; import { createCameraConfig, @@ -39,6 +43,7 @@ import { TestViewMedia, } from '../test-utils.js'; +vi.mock('../../src/view/view-support.js'); vi.mock('../../src/utils/media-player-controller.js'); vi.mock('../../src/card-controller/microphone-manager.js'); @@ -498,7 +503,7 @@ describe('MenuButtonController', () => { describe('should have live menu button', () => { it('when in live view', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'live' }), viewManager: viewManager, @@ -517,7 +522,7 @@ describe('MenuButtonController', () => { it('when not in live view', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'clips' }), viewManager: viewManager, @@ -536,7 +541,7 @@ describe('MenuButtonController', () => { it('when not supported', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false); + vi.mocked(isViewSupportedByCamera).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -552,7 +557,7 @@ describe('MenuButtonController', () => { describe('should have clips menu button', () => { it('when in clips view', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'clips' }), viewManager: viewManager, @@ -572,7 +577,7 @@ describe('MenuButtonController', () => { it('when not in clips view', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -591,7 +596,7 @@ describe('MenuButtonController', () => { it('when not supported', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false); + vi.mocked(isViewSupportedByCamera).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -605,7 +610,7 @@ describe('MenuButtonController', () => { describe('should have snapshots menu button', () => { it('when in snapshots view', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'snapshots' }), viewManager: viewManager, @@ -631,7 +636,7 @@ describe('MenuButtonController', () => { it('when not in snapshots view', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -656,7 +661,7 @@ describe('MenuButtonController', () => { it('when not supported', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false); + vi.mocked(isViewSupportedByCamera).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -672,7 +677,7 @@ describe('MenuButtonController', () => { describe('should have recordings menu button', () => { it('when in recordings view', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'recordings' }), viewManager: viewManager, @@ -698,7 +703,7 @@ describe('MenuButtonController', () => { it('when not in recordings view', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -723,7 +728,7 @@ describe('MenuButtonController', () => { it('when not supported', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false); + vi.mocked(isViewSupportedByCamera).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -739,7 +744,7 @@ describe('MenuButtonController', () => { describe('should have image menu button', () => { it('when in image view', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'image' }), @@ -759,7 +764,7 @@ describe('MenuButtonController', () => { it('when not in image view', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'live' }), @@ -779,7 +784,7 @@ describe('MenuButtonController', () => { it('when not supported', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false); + vi.mocked(isViewSupportedByCamera).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -795,7 +800,7 @@ describe('MenuButtonController', () => { describe('should have timeline button', () => { it('when in timeline view', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'timeline' }), viewManager: viewManager, @@ -817,7 +822,7 @@ describe('MenuButtonController', () => { it('when not in timeline view', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'live' }), viewManager: viewManager, @@ -839,7 +844,7 @@ describe('MenuButtonController', () => { it('when not supported', () => { const viewManager = mock(); - vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false); + vi.mocked(isViewSupportedByCamera).mockReturnValue(false); const buttons = calculateButtons(controller, { view: createView({ view: 'live' }), viewManager: viewManager, @@ -1476,6 +1481,10 @@ describe('MenuButtonController', () => { ]), ); + vi.mocked(getCameraIDsForViewName).mockReturnValue( + new Set(['camera-1', 'camera-2']), + ); + expect( calculateButtons(controller, { cameraManager: cameraManager, view: view }), ).toContainEqual({ diff --git a/tests/view/view-to-cameras.test.ts b/tests/view/view-support.test.ts similarity index 53% rename from tests/view/view-to-cameras.test.ts rename to tests/view/view-support.test.ts index 7d5f02bb..dc6f67dc 100644 --- a/tests/view/view-to-cameras.test.ts +++ b/tests/view/view-support.test.ts @@ -1,12 +1,20 @@ import { describe, expect, it, vi } from 'vitest'; +import { QueryType } from '../../src/camera-manager/types'; import { AdvancedCameraCardView } from '../../src/config/schema/common/const'; import { CapabilityKey } from '../../src/types'; -import { getCameraIDsForViewName } from '../../src/view/view-to-cameras'; +import { EventMediaQuery } from '../../src/view/query'; +import { QueryResults } from '../../src/view/query-results'; +import { + getCameraIDsForViewName, + isViewSupportedByCamera, + isViewSupportedByQueryOnly, +} from '../../src/view/view-support'; import { createCameraConfig, createCameraManager, createCapabilities, createStore, + generateViewMediaArray, } from '../test-utils'; describe('getCameraIDsForViewName', () => { @@ -29,13 +37,13 @@ describe('getCameraIDsForViewName', () => { ]), ); - expect(getCameraIDsForViewName(cameraManager, viewName)).toEqual( + expect(getCameraIDsForViewName(viewName, cameraManager)).toEqual( new Set(['camera-1', 'camera-2']), ); - expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-1')).toEqual( + expect(getCameraIDsForViewName(viewName, cameraManager, 'camera-1')).toEqual( new Set(['camera-1', 'camera-2']), ); - expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-2')).toEqual( + expect(getCameraIDsForViewName(viewName, cameraManager, 'camera-2')).toEqual( new Set(['camera-1', 'camera-2']), ); }); @@ -62,7 +70,7 @@ describe('getCameraIDsForViewName', () => { ]), ); - expect(getCameraIDsForViewName(cameraManager, viewName)).toEqual( + expect(getCameraIDsForViewName(viewName, cameraManager)).toEqual( new Set(['camera-2']), ); }); @@ -89,15 +97,81 @@ describe('getCameraIDsForViewName', () => { ]), ); - expect(getCameraIDsForViewName(cameraManager, viewName)).toEqual( + expect(getCameraIDsForViewName(viewName, cameraManager)).toEqual( new Set(['camera-1', 'camera-2']), ); - expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-1')).toEqual( + expect(getCameraIDsForViewName(viewName, cameraManager, 'camera-1')).toEqual( new Set(['camera-1', 'camera-2']), ); - expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-2')).toEqual( + expect(getCameraIDsForViewName(viewName, cameraManager, 'camera-2')).toEqual( new Set(['camera-2']), ); }); }); }); + +describe('isViewSupportedByCamera', () => { + it('should return true for supported view', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ live: true }), + }, + ]), + ); + + expect(isViewSupportedByCamera('live', cameraManager, 'camera-1')).toBe(true); + }); + + it('should return false for unsupported view', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ live: false }), + }, + ]), + ); + + expect(isViewSupportedByCamera('live', cameraManager, 'camera-1')).toBe(false); + }); +}); + +describe('isViewSupportedByQueryOnly', () => { + it.each([ + ['live' as const], + ['image' as const], + ['diagnostics' as const], + ['clip' as const], + ['clips' as const], + ['snapshot' as const], + ['snapshots' as const], + ['recording' as const], + ['recordings' as const], + ['media' as const], + ])('%s', (viewName: AdvancedCameraCardView) => { + expect(isViewSupportedByQueryOnly(viewName)).toBe(false); + }); + + it('should return false for timeline without query', () => { + expect(isViewSupportedByQueryOnly('timeline')).toBe(false); + }); + + it('should return true for timeline with query and query results', () => { + const query = new EventMediaQuery([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera-1']), + }, + ]); + const queryResults = new QueryResults({ + results: generateViewMediaArray({ count: 5 }), + selectedIndex: 0, + }); + + expect(isViewSupportedByQueryOnly('timeline', query, queryResults)).toBe(true); + }); +});