diff --git a/src/card.ts b/src/card.ts index 2cb095d5..4196fc92 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, @@ -65,6 +65,7 @@ import { frigateCardHandleActionConfig, frigateCardHasAction, getActionConfigGivenAction, + isViewAction, } from './utils/action.js'; import { errorToConsole } from './utils/basic.js'; import { log } from './utils/debug.js'; @@ -440,8 +441,17 @@ class FrigateCard extends LitElement { return this._overriddenConfig || this._config; } - protected _changeView(args?: { view?: View; resetMessage?: boolean }): void { - log(this._cardWideConfig, `Frigate Card view change: `, args?.view ?? '[default]'); + protected _changeView(args?: { + view?: View; + viewName?: FrigateCardView; + cameraID?: string; + resetMessage?: boolean; + }): void { + log( + this._cardWideConfig, + `Frigate Card view change: `, + args?.view ?? args?.viewName ?? '[default]', + ); const changeView = (view: View): void => { if (View.isMajorMediaChange(this._view, view)) { this._mediaLoadedInfoController.clear(); @@ -464,12 +474,13 @@ class FrigateCard extends LitElement { } if (!args?.view) { - // Load the default view. let cameraID: string | null = null; if (this._cameraManager) { const cameras = this._cameraManager.getStore().getVisibleCameras(); if (cameras) { - if (this._view?.camera && this._getConfig().view.update_cycle_camera) { + if (args?.cameraID && cameras.has(args.cameraID)) { + cameraID = args.cameraID; + } else if (this._view?.camera && this._getConfig().view.update_cycle_camera) { const keys = Array.from(cameras.keys()); const currentIndex = keys.indexOf(this._view.camera); const targetIndex = currentIndex + 1 >= keys.length ? 0 : currentIndex + 1; @@ -484,7 +495,7 @@ class FrigateCard extends LitElement { if (cameraID) { changeView( new View({ - view: this._getConfig().view.default, + view: args?.viewName ?? this._getConfig().view.default, camera: cameraID, }), ); @@ -719,11 +730,26 @@ class FrigateCard extends LitElement { this._handleThrownError(e); } - // If there's no view set yet, set one. This will be the case on initial camera load. + // Set a view on initial load. However,if the query string contains an + // action that needs to render content (e.g. a view action or diagnostics), + // we don't set any view here and allow that content to be triggered by the + // firstUpdated() call. To do otherwise may cause a race condition between + // the default view and the querystring view, see: + // https://github.com/dermotduffy/frigate-hass-card/issues/1200 if (!this._view) { - // Don't reset the message which may be set to an error above. This sets the - // first view using the newly loaded cameras. - this._changeView({ resetMessage: false }); + const querystringActions = getActionsFromQueryString(); + if ( + !querystringActions.find( + (action) => + isViewAction(action) || action.frigate_card_action === 'diagnostics', + ) + ) { + this._changeView({ + // Don't reset the message which may be set to an error above. This sets the + // first view using the newly loaded cameras. + resetMessage: false, + }); + } } } @@ -1024,7 +1050,10 @@ class FrigateCard extends LitElement { } protected _cardActionHandler(frigateCardAction: FrigateCardCustomAction): void { - if (!this._view || !this._cameraManager) { + // 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) { return; } @@ -1052,10 +1081,8 @@ class FrigateCard extends LitElement { case 'snapshots': case 'timeline': this._changeView({ - view: new View({ - view: action, - camera: this._view.camera, - }), + viewName: action, + cameraID: this._view?.camera, }); break; case 'download': @@ -1097,21 +1124,27 @@ class FrigateCard extends LitElement { } break; case 'live_substream_select': { - const view = createViewWithSelectedSubstream( - this._view, - frigateCardAction.camera, - ); - view && this._changeView({ view: view }); + if (this._view) { + const view = createViewWithSelectedSubstream( + this._view, + frigateCardAction.camera, + ); + view && this._changeView({ view: view }); + } break; } case 'live_substream_off': { - const view = createViewWithoutSubstream(this._view); - view && this._changeView({ view: view }); + if (this._view) { + const view = createViewWithoutSubstream(this._view); + view && this._changeView({ view: view }); + } break; } case 'live_substream_on': { - const view = createViewWithNextStream(this._cameraManager, this._view); - view && this._changeView({ view: view }); + if (this._view) { + const view = createViewWithNextStream(this._cameraManager, this._view); + view && this._changeView({ view: view }); + } break; } case 'media_player': diff --git a/src/types.ts b/src/types.ts index 892991b2..7f3e0f7d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -209,15 +209,11 @@ const frigateCardCustomActionsBaseSchema = customActionSchema.extend({ const FRIGATE_CARD_GENERAL_ACTIONS = [ 'camera_ui', - 'clip', - 'clips', 'default', 'diagnostics', 'expand', 'download', 'fullscreen', - 'image', - 'live', 'menu_toggle', 'mute', 'live_substream_on', @@ -226,15 +222,11 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [ 'microphone_unmute', 'play', 'pause', - 'recording', - 'recordings', 'screenshot', - 'snapshot', - 'snapshots', - 'timeline', 'unmute', ] as const; const FRIGATE_CARD_ACTIONS = [ + ...FRIGATE_CARD_VIEWS_USER_SPECIFIED, ...FRIGATE_CARD_GENERAL_ACTIONS, 'camera_select', 'live_substream_select', @@ -242,6 +234,11 @@ const FRIGATE_CARD_ACTIONS = [ ] as const; export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number]; +const frigateCardViewActionSchema = frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED), +}); +export type FrigateCardViewAction = z.infer; + const frigateCardGeneralActionSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS), }); @@ -261,6 +258,7 @@ const frigateCardMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.ex }); export const frigateCardCustomActionSchema = z.union([ + frigateCardViewActionSchema, frigateCardGeneralActionSchema, frigateCardCameraSelectActionSchema, frigateCardLiveDependencySelectActionSchema, diff --git a/src/utils/action.ts b/src/utils/action.ts index edc700d1..e065d7be 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -10,6 +10,7 @@ import { FrigateCardAction, FrigateCardCustomAction, frigateCardCustomActionSchema, + FrigateCardViewAction, } from '../types.js'; /** @@ -173,3 +174,21 @@ export const frigateCardHasAction = (config?: ActionType | ActionType[]): boolea export const stopEventFromActivatingCardWideActions = (ev: Event): void => { ev.stopPropagation(); }; + +export const isViewAction = ( + action: FrigateCardCustomAction, +): action is FrigateCardViewAction => { + switch (action.frigate_card_action) { + case 'clip': + case 'clips': + case 'image': + case 'live': + case 'recording': + case 'recordings': + case 'snapshot': + case 'snapshots': + case 'timeline': + return true; + } + return false; +}; diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index 264db819..d4ae8af8 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -1,14 +1,21 @@ import { handleActionConfig, hasAction } from 'custom-card-helpers'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { actionSchema } from '../../src/types'; import { - convertActionToFrigateCardCustomAction, - createFrigateCardCustomAction, - frigateCardHandleActionConfig, - frigateCardHasAction, - getActionConfigGivenAction, - stopEventFromActivatingCardWideActions, + actionSchema, + FrigateCardAction, + FrigateCardCustomAction, + frigateCardCustomActionSchema, +} from '../../src/types'; +import { + convertActionToFrigateCardCustomAction, + createFrigateCardCustomAction, + frigateCardHandleAction, + frigateCardHandleActionConfig, + frigateCardHasAction, + getActionConfigGivenAction, + isViewAction, + stopEventFromActivatingCardWideActions, } from '../../src/utils/action'; import { createHASS } from '../test-utils'; @@ -173,6 +180,23 @@ describe('frigateCardHandleActionConfig', () => { }); }); +// @vitest-environment jsdom +describe('frigateCardHandleAction', () => { + const element = document.createElement('div'); + const action = actionSchema.parse({ + action: 'none', + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should call action handler', () => { + frigateCardHandleAction(element, createHASS(), {}, action); + expect(handleActionConfig).toBeCalled(); + }); +}); + describe('frigateCardHasAction', () => { const action = actionSchema.parse({ action: 'toggle', @@ -200,3 +224,45 @@ describe('stopEventFromActivatingCardWideActions', () => { expect(event.stopPropagation).toBeCalled(); }); }); + +describe('isViewAction', () => { + const createAction = (action: FrigateCardAction): FrigateCardCustomAction => { + return frigateCardCustomActionSchema.parse({ + action: 'fire-dom-event' as const, + frigate_card_action: action, + }); + }; + it('should return true for clip view ', () => { + expect(isViewAction(createAction('clip'))).toBeTruthy(); + }); + it('should return true for clips view ', () => { + expect(isViewAction(createAction('clips'))).toBeTruthy(); + }); + it('should return true for image view ', () => { + expect(isViewAction(createAction('image'))).toBeTruthy(); + }); + it('should return true for live view ', () => { + expect(isViewAction(createAction('live'))).toBeTruthy(); + }); + it('should return true for recording view ', () => { + expect(isViewAction(createAction('recording'))).toBeTruthy(); + }); + it('should return true for live view ', () => { + expect(isViewAction(createAction('live'))).toBeTruthy(); + }); + it('should return true for recordings view ', () => { + expect(isViewAction(createAction('recordings'))).toBeTruthy(); + }); + it('should return true for snapshot view ', () => { + expect(isViewAction(createAction('snapshot'))).toBeTruthy(); + }); + it('should return true for snapshots view ', () => { + expect(isViewAction(createAction('snapshots'))).toBeTruthy(); + }); + it('should return true for timeline view ', () => { + expect(isViewAction(createAction('timeline'))).toBeTruthy(); + }); + it('should return false for anything else', () => { + expect(isViewAction(createAction('diagnostics'))).toBeFalsy(); + }); +});