diff --git a/src/card.ts b/src/card.ts index 21f182b9..9356401f 100644 --- a/src/card.ts +++ b/src/card.ts @@ -48,12 +48,12 @@ import { CameraConfig, CardWideConfig, ExtendedHomeAssistant, + FRIGATE_CARD_VIEW_DEFAULT, FrigateCardConfig, frigateCardConfigSchema, FrigateCardCustomAction, FrigateCardError, FrigateCardView, - FRIGATE_CARD_VIEW_DEFAULT, MediaLoadedInfo, MenuButton, Message, @@ -87,6 +87,7 @@ import { supportsFeature } from './utils/ha/update.js'; import { FrigateCardInitializer } from './utils/initializer.js'; import { MediaLoadedInfoController } from './utils/media-info-controller'; import { isValidMediaLoadedInfo } from './utils/media-info.js'; +import { executeMediaQueryForView } from './utils/media-to-view'; import { MenuButtonController } from './utils/menu-controller'; import { MicrophoneController } from './utils/microphone'; import { getActionsFromQueryString } from './utils/querystring.js'; @@ -1085,7 +1086,7 @@ class FrigateCard extends LitElement { // 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). - if (!this._cameraManager) { + if (!this._cameraManager || !this._hass) { return; } @@ -1241,19 +1242,33 @@ class FrigateCard extends LitElement { this._conditionController?.setState({ displayMode: this._viewDisplayMode, }); - // If the new mode is for all cameras, but the current query does not - // have a query for every cameraID, reset it. - const resetQuery = - frigateCardAction.mode === 'grid' && - !this._view?.query?.hasQueriesForCameraIDs( - this._cameraManager.getStore().getVisibleCameraIDs(), - ); - this._changeView({ - view: this._view?.evolve({ - displayMode: frigateCardAction.display_mode, - ...(resetQuery && { query: null, queryResults: null }), - }), + + const newView = this._view?.evolve({ + displayMode: frigateCardAction.display_mode, }); + + const generateNewQuery = + newView?.isGrid() && + newView.query && + (newView.query.getQueryCameraIDs()?.size ?? 0) <= 1; + + if (generateNewQuery && newView && newView.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. + executeMediaQueryForView( + this, + this._hass, + this._cameraManager, + newView, + newView.query + .clone() + .setQueryCameraIDs(this._cameraManager.getStore().getVisibleCameraIDs()), + ).then((view) => view && this._changeView({ view: view })); + } else { + this._changeView({ view: newView }); + } break; default: console.warn(`Frigate card received unknown card action: ${action}`); diff --git a/src/components/live/live.ts b/src/components/live/live.ts index cdbf87e8..957ce164 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -363,7 +363,12 @@ export class FrigateCardLiveGrid extends LitElement { protected _needsGrid(): boolean { const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); - return !!this.view?.isGrid() && !!cameraIDs && cameraIDs.size >= 1; + return ( + !!this.view?.isGrid() && + !!this.view?.supportsMultipleDisplayModes() && + !!cameraIDs && + cameraIDs.size > 1 + ); } protected willUpdate(changedProps: PropertyValues): void { @@ -377,7 +382,7 @@ export class FrigateCardLiveGrid extends LitElement { return; } const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); - if (!this._needsGrid() || !cameraIDs) { + if (!cameraIDs || !this._needsGrid()) { return this._renderCarousel(); } return html` diff --git a/src/components/media-grid.ts b/src/components/media-grid.ts index 4b5b27b1..af09624d 100644 --- a/src/components/media-grid.ts +++ b/src/components/media-grid.ts @@ -1,7 +1,8 @@ // TODO: Performance of video scanning (pause/play?) // TODO: Investigate query spam during a grid load -// TODO: Is the query reset in card.ts correct for media filter multi-camera queries that are not all cameras? + // TODO: Do I need column max? +// TODO: What thumbnails do I show in the live drawer? import { CSSResultGroup, diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 945d74ec..b18c4ded 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -149,7 +149,6 @@ export class FrigateCardViewer extends LitElement { { allCameras: this.view.isGrid(), targetView: 'recording', - select: 'latest', }, ); } else { @@ -163,7 +162,6 @@ export class FrigateCardViewer extends LitElement { allCameras: this.view.isGrid(), targetView: 'media', mediaType: mediaType, - select: 'latest', }, ); } @@ -648,18 +646,23 @@ export class FrigateCardViewerGrid extends LitElement { } protected willUpdate(changedProps: PropertyValues): void { - if ( - changedProps.has('view') && - this.view?.isGrid() && - this.view?.hasMultipleDisplayModes() - ) { + if (changedProps.has('view') && this._needsGrid()) { import('./media-grid.js'); } } + protected _needsGrid(): boolean { + const cameraIDs = this.view?.queryResults?.getCameraIDs(); + return ( + !!this.view?.isGrid() && + !!this.view?.supportsMultipleDisplayModes() && + (cameraIDs?.size ?? 0) > 1 + ); + } + protected render(): TemplateResult { const cameraIDs = this.view?.queryResults?.getCameraIDs(); - if (!cameraIDs || !this.view?.isGrid() || !this.view?.hasMultipleDisplayModes()) { + if (!cameraIDs || !this._needsGrid()) { return this._renderCarousel(); } diff --git a/src/utils/menu-controller.ts b/src/utils/menu-controller.ts index 64123abd..2790e2c6 100644 --- a/src/utils/menu-controller.ts +++ b/src/utils/menu-controller.ts @@ -394,7 +394,7 @@ export class MenuButtonController { }); } - if (view.hasMultipleDisplayModes(visibleCameras.size)) { + if (view.supportsMultipleDisplayModes() && visibleCameras.size > 1) { const isGrid = view.isGrid(); const action = createFrigateCardCustomAction('display_mode_select', { display_mode: isGrid ? 'single' : 'grid', diff --git a/src/view/media-queries.ts b/src/view/media-queries.ts index 2a75e5e3..4a77ae7f 100644 --- a/src/view/media-queries.ts +++ b/src/view/media-queries.ts @@ -1,4 +1,6 @@ import cloneDeep from 'lodash-es/cloneDeep.js'; +import isEqual from 'lodash-es/isEqual.js'; +import uniqWith from 'lodash-es/uniqWith.js'; import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types.js'; export type MediaQueries = EventMediaQueries | RecordingMediaQueries; @@ -12,7 +14,7 @@ class MediaQueriesBase { } } - public clone(): MediaQueriesBase { + public clone(): this { return cloneDeep(this); } @@ -20,8 +22,32 @@ class MediaQueriesBase { return this._queries; } - public setQueries(queries: T[]): void { + public setQueries(queries: T[]): this { this._queries = queries; + return this; + } + + public getQueryCameraIDs(): Set | null { + if (!this._queries) { + return null; + } + const cameraIDs: Set = new Set(); + this._queries.forEach((query) => + [...query.cameraIDs].forEach((cameraID) => cameraIDs.add(cameraID)), + ); + return cameraIDs; + } + + public setQueryCameraIDs(cameraIDs: Set): this { + if (!this._queries) { + return this; + } + const rewrittenQueries: T[] = []; + this._queries.forEach((query) => + rewrittenQueries.push({ ...query, cameraIDs: cameraIDs }), + ); + this._queries = uniqWith(rewrittenQueries, isEqual); + return this; } public hasQueriesForCameraIDs(cameraIDs: Set) { @@ -42,10 +68,6 @@ export class EventMediaQueries extends MediaQueriesBase { } return this; } - - public clone(): EventMediaQueries { - return cloneDeep(this); - } } export class RecordingMediaQueries extends MediaQueriesBase {} diff --git a/src/view/view.ts b/src/view/view.ts index e0f13ec1..f4619678 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -231,11 +231,8 @@ export class View { return ['clip', 'snapshot', 'media', 'recording'].includes(this.view); } - public hasMultipleDisplayModes(cameraCount?: number): boolean { - return ( - (this.is('live') && (cameraCount ?? 0) > 1) || - (this.isViewerView() && (this.queryResults?.getCameraIDs().size ?? 0) > 1) - ); + public supportsMultipleDisplayModes(): boolean { + return this.isViewerView() || this.is('live'); } /** diff --git a/tests/utils/media-grid-controller.test.ts b/tests/utils/media-grid-controller.test.ts index 255c2105..f254a718 100644 --- a/tests/utils/media-grid-controller.test.ts +++ b/tests/utils/media-grid-controller.test.ts @@ -301,7 +301,7 @@ describe('MediaGridController', () => { expect.objectContaining({ initLayout: false, percentPosition: true, - transitionDuration: '0.3s', + transitionDuration: '0.2s', }), ); }); @@ -352,10 +352,7 @@ describe('MediaGridController', () => { const controller = createController(host); expect(controller.getSelected()).toBeNull(); - - const touchEvent = new TouchEvent('touchend'); - children[1].dispatchEvent(touchEvent); - + children[1].click(); expect(controller.getSelected()).toBe('1'); }); diff --git a/tests/view/media-queries.test.ts b/tests/view/media-queries.test.ts index 9ac570f5..b8c9c817 100644 --- a/tests/view/media-queries.test.ts +++ b/tests/view/media-queries.test.ts @@ -72,6 +72,30 @@ describe('EventMediaQueries', () => { const queries = new EventMediaQueries(rawQueries); expect(queries.clone().getQueries()).toEqual(queries.getQueries()); }); + + it('should get camera IDs when queries are null', () => { + expect(new EventMediaQueries().getQueryCameraIDs()).toBeNull(); + }); + + it('should get camera IDs', () => { + const cameraIDs = ['office', 'kitchen']; + const queries = new EventMediaQueries(createRawEventQueries(new Set(cameraIDs))); + expect(queries.getQueryCameraIDs()).toEqual(new Set(cameraIDs)); + }); + + it('should set camera IDs when queries are null', () => { + expect( + new EventMediaQueries().setQueryCameraIDs(new Set(['office'])).getQueryCameraIDs(), + ).toBeNull(); + }); + + it('should set camera IDs', () => { + const queries = new EventMediaQueries(createRawEventQueries('sitting_room')); + const newCameraIDs = new Set(['office', 'kitchen']); + expect(queries.setQueryCameraIDs(newCameraIDs).getQueryCameraIDs()).toEqual( + newCameraIDs, + ); + }); }); describe('RecordingMediaQueries', () => { @@ -93,10 +117,4 @@ describe('RecordingMediaQueries', () => { const queries = new RecordingMediaQueries(rawQueries); expect(queries.getQueries()).toBe(rawQueries); }); - - it('should clone', () => { - const rawQueries = createRawRecordingQueries('office'); - const queries = new RecordingMediaQueries(rawQueries); - expect(queries.clone().getQueries()).toEqual(queries.getQueries()); - }); }); diff --git a/tests/view/view.test.ts b/tests/view/view.test.ts index de6a1912..165c7dc7 100644 --- a/tests/view/view.test.ts +++ b/tests/view/view.test.ts @@ -427,67 +427,17 @@ describe('View.adoptFromViewIfAppropriate', () => { }); it('should determine if view supports multiple display modes', () => { - const resultsOne = new MediaQueriesResults({ - results: generateViewMediaArray({ cameraIDs: ['office'] }), - }); - const resultsTwo = new MediaQueriesResults({ - results: generateViewMediaArray({ cameraIDs: ['office', 'kitchen'] }), - }); + expect(createView({ view: 'live' }).supportsMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'media' }).supportsMultipleDisplayModes()).toBeTruthy(); + expect(createView({ view: 'clip' }).supportsMultipleDisplayModes()).toBeTruthy(); + expect(createView({ view: 'snapshot' }).supportsMultipleDisplayModes()).toBeTruthy(); + expect(createView({ view: 'recording' }).supportsMultipleDisplayModes()).toBeTruthy(); - expect(createView({ view: 'live' }).hasMultipleDisplayModes()).toBeFalsy(); - expect(createView({ view: 'live' }).hasMultipleDisplayModes(0)).toBeFalsy(); - expect(createView({ view: 'live' }).hasMultipleDisplayModes(1)).toBeFalsy(); - expect(createView({ view: 'live' }).hasMultipleDisplayModes(2)).toBeTruthy(); - - expect(createView({ view: 'media' }).hasMultipleDisplayModes()).toBeFalsy(); - expect( - createView({ view: 'media', queryResults: resultsOne }).hasMultipleDisplayModes(), - ).toBeFalsy(); - expect( - createView({ view: 'media', queryResults: resultsTwo }).hasMultipleDisplayModes(), - ).toBeTruthy(); - - expect(createView({ view: 'clip' }).hasMultipleDisplayModes()).toBeFalsy(); - expect( - createView({ view: 'clip', queryResults: resultsOne }).hasMultipleDisplayModes(), - ).toBeFalsy(); - expect( - createView({ view: 'clip', queryResults: resultsTwo }).hasMultipleDisplayModes(), - ).toBeTruthy(); - - expect(createView({ view: 'snapshot' }).hasMultipleDisplayModes()).toBeFalsy(); - expect( - createView({ - view: 'snapshot', - queryResults: resultsOne, - }).hasMultipleDisplayModes(), - ).toBeFalsy(); - expect( - createView({ - view: 'snapshot', - queryResults: resultsTwo, - }).hasMultipleDisplayModes(), - ).toBeTruthy(); - - expect(createView({ view: 'recording' }).hasMultipleDisplayModes()).toBeFalsy(); - expect( - createView({ - view: 'recording', - queryResults: resultsOne, - }).hasMultipleDisplayModes(), - ).toBeFalsy(); - expect( - createView({ - view: 'recording', - queryResults: resultsTwo, - }).hasMultipleDisplayModes(), - ).toBeTruthy(); - - expect(createView({ view: 'clips' }).hasMultipleDisplayModes()).toBeFalsy(); - expect(createView({ view: 'snapshots' }).hasMultipleDisplayModes()).toBeFalsy(); - expect(createView({ view: 'recordings' }).hasMultipleDisplayModes()).toBeFalsy(); - expect(createView({ view: 'image' }).hasMultipleDisplayModes()).toBeFalsy(); - expect(createView({ view: 'timeline' }).hasMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'clips' }).supportsMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'snapshots' }).supportsMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'recordings' }).supportsMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'image' }).supportsMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'timeline' }).supportsMultipleDisplayModes()).toBeFalsy(); }); });