From f193f9925f557f9275c258ceed70f7e01e2a8aca Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 8 Jun 2024 11:40:05 -0700 Subject: [PATCH 01/11] Add `update` trigger action. --- docs/configuration/profiles.md | 2 +- docs/configuration/view.md | 2 +- docs/troubleshooting.md | 2 +- src/card-controller/triggers-manager.ts | 17 +- src/config/profiles/low-performance.ts | 4 + src/config/types.ts | 17 +- .../card-controller/triggers-manager.test.ts | 334 ++++++++++++------ tests/config/profiles/low-performance.test.ts | 1 + tests/config/types.test.ts | 2 +- 9 files changed, 255 insertions(+), 126 deletions(-) diff --git a/docs/configuration/profiles.md b/docs/configuration/profiles.md index 287e7e07..a3b3a845 100644 --- a/docs/configuration/profiles.md +++ b/docs/configuration/profiles.md @@ -28,7 +28,7 @@ Principles used in the selection of options set by `low-profile` profile mode: - Get 'out of the box' performance similar to the basic "Home Assistant Picture Glance" card. - Do not break the visual aesthetic of the card. -See the [source code](https://github.com/dermotduffy/frigate-hass-card/blob/dev/src/config/profiles/low-performance.ts) for an exhaustive list of options set by this profile. +See the [source code](https://github.com/dermotduffy/frigate-hass-card/blob/dev/src/config/profiles/low-performance.ts) for an exhaustive list of defaults set by this profile. ## `scrubbing` diff --git a/docs/configuration/view.md b/docs/configuration/view.md index f71ce88f..c9436084 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -120,7 +120,7 @@ view: untrigger_seconds: 0 actions: interaction_mode: inactive - trigger: default + trigger: update untrigger: none keyboard_shortcuts: enabled: true diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5d51a4ab..e76eee91 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -56,7 +56,7 @@ This could be for any number of reasons. Chromecast devices can be quite picky on network, DNS and certificate issues, as well as audio and video codecs. Check your Home Assistant log as there may be more information in there. -!>: In particular, for Frigate to support casting of clips, the default ffmpeg +!> In particular, for Frigate to support casting of clips, the default ffmpeg settings for Frigate must be modified, i.e. Frigate does not encode clips in a Chromecast compatible format out of the box (specifically: audio must be enabled in the AAC codec, whether your camera supports audio or not). See the [Frigate diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index 46de4752..67d54eab 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -82,7 +82,8 @@ export class TriggersManager { // If this is a high-fidelity event where we are certain about new media, // don't take action unless it's to change to live (Frigate engine may pump - // out events where there's no new media to show). + // out events where there's no new media to show). Other trigger actions + // (e.g. media, update) do not make sense without having some new media. if ( ev.fidelity === 'high' && !ev.snapshot && @@ -96,7 +97,19 @@ export class TriggersManager { } if (this._hasAllowableInteractionStateForAction()) { - if (triggerAction === 'live') { + 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.clone()); + } + } else if (triggerAction === 'live') { this._api.getViewManager().setViewByParameters({ viewName: 'live', cameraID: ev.cameraID, diff --git a/src/config/profiles/low-performance.ts b/src/config/profiles/low-performance.ts index 570c8771..92fcea8f 100644 --- a/src/config/profiles/low-performance.ts +++ b/src/config/profiles/low-performance.ts @@ -46,6 +46,7 @@ import { CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_TIMELINE_SHOW_RECORDINGS, + CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, } from '../../const.js'; export const LOW_PERFORMANCE_PROFILE = { @@ -134,4 +135,7 @@ export const LOW_PERFORMANCE_PROFILE = { // Refresh the live camera image every 10 seconds (same as stock Home // Assistant Picture Glance). [CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS]: 10, + + // No trigger actions. + [CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER]: 'none' }; diff --git a/src/config/types.ts b/src/config/types.ts index 09d4dab8..8289ecc3 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1406,7 +1406,7 @@ const viewConfigDefault = { filter_selected_camera: true, actions: { interaction_mode: 'inactive' as const, - trigger: 'default' as const, + trigger: 'update' as const, untrigger: 'none' as const, }, untrigger_seconds: 0, @@ -1415,26 +1415,25 @@ const viewConfigDefault = { }; export const triggersSchema = z.object({ - filter_selected_camera: z - .boolean() - .default(viewConfigDefault.triggers.filter_selected_camera), - show_trigger_status: z - .boolean() - .default(viewConfigDefault.triggers.show_trigger_status), - actions: z .object({ interaction_mode: z .enum(['all', 'inactive', 'active']) .default(viewConfigDefault.triggers.actions.interaction_mode), trigger: z - .enum(['live', 'default', 'media', 'none']) + .enum(['default', 'live', 'media', 'none', 'update']) .default(viewConfigDefault.triggers.actions.trigger), untrigger: z .enum(['default', 'none']) .default(viewConfigDefault.triggers.actions.untrigger), }) .default(viewConfigDefault.triggers.actions), + filter_selected_camera: z + .boolean() + .default(viewConfigDefault.triggers.filter_selected_camera), + show_trigger_status: z + .boolean() + .default(viewConfigDefault.triggers.show_trigger_status), untrigger_seconds: z.number().default(viewConfigDefault.triggers.untrigger_seconds), }); export type TriggersOptions = z.infer; diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index 5ae6b064..e81e8ecb 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -2,7 +2,11 @@ import { add } from 'date-fns'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CardController } from '../../src/card-controller/controller'; import { TriggersManager } from '../../src/card-controller/triggers-manager'; -import { TriggersOptions, triggersSchema } from '../../src/config/types'; +import { + FrigateCardView, + TriggersOptions, + triggersSchema, +} from '../../src/config/types'; import { createCameraConfig, createCameraManager, @@ -16,11 +20,12 @@ vi.mock('lodash-es/throttle', () => ({ default: vi.fn((fn) => fn), })); -const baseTriggersConfig: Partial = { +const baseTriggersConfig: TriggersOptions = { untrigger_seconds: 10, filter_selected_camera: false, + show_trigger_status: false, actions: { - trigger: 'live' as const, + trigger: 'update' as const, untrigger: 'default' as const, interaction_mode: 'inactive' as const, }, @@ -30,6 +35,7 @@ const baseTriggersConfig: Partial = { // function reduces it. const createTriggerAPI = (options?: { config?: Partial; + default?: FrigateCardView; interaction?: boolean; }): CardController => { const api = createCardAPI(); @@ -39,6 +45,7 @@ const createTriggerAPI = (options?: { triggers: options?.config ? triggersSchema.parse(options.config) : baseTriggersConfig, + ...(options?.default && { default: options.default }), }, }), ); @@ -101,161 +108,266 @@ describe('TriggersManager', () => { expect(manager.isTriggered()).toBeFalsy(); }); - it('should trigger and untrigger based on low fidelity event', () => { - const api = createTriggerAPI(); - const manager = new TriggersManager(api); + describe('trigger actions', () => { + it('update', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'update', + }, + }, + }); - manager.handleCameraEvent({ - cameraID: 'camera_1', - type: 'new', - }); + const manager = new TriggersManager(api); - expect(manager.isTriggered()).toBeTruthy(); - expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ - triggered: new Set(['camera_1']), - }); + manager.handleCameraEvent({ + cameraID: 'camera_1', + type: 'new', + }); - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - viewName: 'live' as const, - cameraID: 'camera_1' as const, + expect(manager.isTriggered()).toBeTruthy(); + expect(api.getViewManager().setView).toBeCalled(); }); - vi.mocked(api.getConditionsManager().getState).mockReturnValue({ - triggered: new Set(['camera_1']), - }); + it('default', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'default', + }, + }, + }); - manager.handleCameraEvent({ - cameraID: 'camera_1', - type: 'end', + const manager = new TriggersManager(api); + + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + + expect(manager.isTriggered()).toBeTruthy(); + expect(api.getViewManager().setViewDefault).toBeCalledWith({ + cameraID: 'camera_1', + }); }); - // Will still be triggered, but untrigger timer will be running. - expect(manager.isTriggered()).toBeTruthy(); + it('live', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'live', + }, + }, + }); - vi.setSystemTime(add(start, { seconds: 10 })); - vi.runOnlyPendingTimers(); + const manager = new TriggersManager(api); - expect(manager.isTriggered()).toBeFalsy(); - expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ - triggered: undefined, + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + + expect(manager.isTriggered()).toBeTruthy(); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + viewName: 'live', + cameraID: 'camera_1', + }); }); - expect(api.getViewManager().setViewDefault).toBeCalled(); - }); + describe('media', () => { + it.each([ + [false, false, null], + [false, true, 'clip' as const], + [true, false, 'snapshot' as const], + [true, true, 'clip' as const], + ])( + 'with snapshot %s and clip %s', + async ( + hasSnapshot: boolean, + hasClip: boolean, + viewName: 'clip' | 'snapshot' | null, + ) => { + const api = createTriggerAPI({ + config: { + actions: { + interaction_mode: 'all', + trigger: 'media', + untrigger: 'none', + }, + }, + }); + const manager = new TriggersManager(api); - describe('should treat high fidelity events appropriately', () => { - it('with no media', () => { - const api = createTriggerAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - default: 'clips', - }, - }), + manager.handleCameraEvent({ + cameraID: 'camera_1', + type: 'new', + fidelity: 'high', + snapshot: hasSnapshot, + clip: hasClip, + }); + + if (!viewName) { + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + } else { + expect(manager.isTriggered()).toBeTruthy(); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + cameraID: 'camera_1', + viewName: viewName, + }); + } + }, ); + }); + + it('none', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'none', + }, + }, + }); const manager = new TriggersManager(api); - manager.handleCameraEvent({ - cameraID: 'camera_1', - type: 'new', - fidelity: 'high', - // Intentionally blank. - }); + 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(); }); + }); - it('with media', () => { - const api = createTriggerAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - default: 'clips', + describe('untrigger actions', () => { + it('none', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'none', + untrigger: 'none', }, - }), - ); + }, + }); const manager = new TriggersManager(api); - manager.handleCameraEvent({ - cameraID: 'camera_1', - type: 'new', - fidelity: 'high', - clip: true, + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' }); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + expect(manager.isTriggered()).toBeFalsy(); + + expect(api.getViewManager().setView).not.toBeCalled(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + + it('default', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'none', + untrigger: 'default', + }, + }, }); - expect(manager.isTriggered()).toBeTruthy(); + const manager = new TriggersManager(api); + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' }); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + expect(manager.isTriggered()).toBeFalsy(); + expect(api.getViewManager().setViewDefault).toBeCalled(); }); }); - it('should change to default view when suitably configured', () => { + it('should manage condition state', () => { const api = createTriggerAPI({ config: { + ...baseTriggersConfig, actions: { - interaction_mode: 'all', - trigger: 'default', + ...baseTriggersConfig.actions, + trigger: 'none', untrigger: 'none', }, }, }); + const manager = new TriggersManager(api); - manager.handleCameraEvent({ - cameraID: 'camera_1', - type: 'new', + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + + expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ + triggered: new Set(['camera_1']), + }); + vi.mocked(api.getConditionsManager().getState).mockReturnValue({ + triggered: new Set(['camera_1']), }); - expect(manager.isTriggered()).toBeTruthy(); - expect(api.getViewManager().setViewDefault).toBeCalledWith({ - cameraID: 'camera_1', + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' }); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ + triggered: undefined, }); }); - describe('should change to media view', () => { - it.each([ - [false, false, null], - [false, true, 'clip' as const], - [true, false, 'snapshot' as const], - [true, true, 'clip' as const], - ])( - 'with snapshot %s and clip %s', - async ( - hasSnapshot: boolean, - hasClip: boolean, - viewName: 'clip' | 'snapshot' | null, - ) => { - const api = createTriggerAPI({ - config: { - actions: { - interaction_mode: 'all', - trigger: 'media', - untrigger: 'none', - }, + describe('should take no actions with high-fidelity event', () => { + it('with non-live action', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'media', }, - }); - const manager = new TriggersManager(api); + }, + default: 'live', + }); - manager.handleCameraEvent({ - cameraID: 'camera_1', - type: 'new', - fidelity: 'high', - snapshot: hasSnapshot, - clip: hasClip, - }); - - if (!viewName) { - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); - } else { - expect(manager.isTriggered()).toBeTruthy(); - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - cameraID: 'camera_1', - viewName: viewName, - }); - } - }, - ); + const manager = new TriggersManager(api); + + 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(); + }); + + it('with non-live default', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'default', + }, + }, + default: 'clips', + }); + + const manager = new TriggersManager(api); + + 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(); + }); }); it('should take no actions with human interactions', () => { diff --git a/tests/config/profiles/low-performance.test.ts b/tests/config/profiles/low-performance.test.ts index c048d020..30832789 100644 --- a/tests/config/profiles/low-performance.test.ts +++ b/tests/config/profiles/low-performance.test.ts @@ -50,5 +50,6 @@ it('low performance profile', () => { 'timeline.controls.thumbnails.show_favorite_control': false, 'timeline.controls.thumbnails.show_timeline_control': false, 'timeline.show_recordings': false, + 'view.triggers.actions.trigger': 'none', }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 6e90f23c..62ee482e 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -324,7 +324,7 @@ describe('config defaults', () => { show_trigger_status: false, untrigger_seconds: 0, actions: { - trigger: 'default', + trigger: 'update', untrigger: 'none', interaction_mode: 'inactive', }, From afb9af5eb4acd277fec1d48bc09f3a7a8739bdac Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 10 Jun 2024 20:01:01 -0700 Subject: [PATCH 02/11] Rename several view variables for clarity. --- docs/configuration/view.md | 40 ++++- rollup.config.js | 1 + src/camera-manager/camera.ts | 3 +- src/card-controller/auto-update-manager.ts | 42 ----- src/card-controller/card-element-manager.ts | 2 + src/card-controller/config/config-manager.ts | 12 ++ src/card-controller/controller.ts | 17 +- src/card-controller/default-manager.ts | 92 ++++++++++ src/card-controller/hass-manager.ts | 24 --- src/card-controller/initialization-manager.ts | 4 + src/card-controller/interaction-manager.ts | 5 +- src/card-controller/keyboard-state-manager.ts | 2 +- src/card-controller/types.ts | 22 ++- src/card-controller/view-manager.ts | 6 +- src/config/management.ts | 17 ++ src/config/profiles/low-performance.ts | 2 +- src/config/types.ts | 41 +++-- src/const.ts | 16 +- src/editor.ts | 72 ++++++-- src/localize/languages/ca.json | 26 ++- src/localize/languages/en.json | 19 +- src/localize/languages/fr.json | 26 ++- src/localize/languages/it.json | 26 ++- src/localize/languages/pt-BR.json | 26 ++- src/localize/languages/pt-PT.json | 26 ++- src/utils/ha/index.ts | 2 + src/utils/interaction-mode.ts | 15 ++ .../auto-update-manager.test.ts | 73 -------- .../config/config-manager.test.ts | 33 ++++ tests/card-controller/controller.test.ts | 10 +- tests/card-controller/default-manager.test.ts | 163 ++++++++++++++++++ tests/card-controller/hass-manager.test.ts | 32 +--- .../initialization-manager.test.ts | 13 +- .../interaction-manager.test.ts | 11 +- tests/card-controller/view-manager.test.ts | 4 +- tests/config/management.test.ts | 94 ++++++++++ tests/config/types.test.ts | 11 +- tests/test-utils.ts | 4 +- tests/utils/interaction-mode.test.ts | 19 ++ vite.config.ts | 1 + 40 files changed, 769 insertions(+), 285 deletions(-) delete mode 100644 src/card-controller/auto-update-manager.ts create mode 100644 src/card-controller/default-manager.ts create mode 100644 src/utils/interaction-mode.ts delete mode 100644 tests/card-controller/auto-update-manager.test.ts create mode 100644 tests/card-controller/default-manager.test.ts create mode 100644 tests/utils/interaction-mode.test.ts diff --git a/docs/configuration/view.md b/docs/configuration/view.md index c9436084..9e2ad5dd 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -13,18 +13,40 @@ view: | `camera_select` | `current` | The [view](view.md?id=supported-views) to show when a new camera is selected (e.g. in the camera menu). If `current` the view is unchanged when a new camera is selected. | | `dark_mode` | `off` | Whether or not to turn dark mode `on`, `off` or `auto` to automatically turn on if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time) or if dark mode is enabled in the HA profile theme setting. Dark mode dims the brightness by `25%`. | | `default` | `live` | The view to show in the card by default. The default camera is the first one listed. See [Supported Views](view.md?id=supported-views) below. | +| `default_reset` | | The circumstances and behavior that cause the card to reset to the default view. See below. | | `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `reset_after_interaction` to reset the view after the interaction is complete. `0` means no interactions are reported / acted upon. | | `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See below. | | `render_entities` | | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. `update_*` flags do not pertain/relate to the behavior of this flag. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). | | `reset_after_interaction` | `true` | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. | | `triggers` | | How to react when a camera is [triggered](cameras/README.md?id=triggers). | -| `update_cycle_camera` | `false` | When set to `true` the selected camera is cycled on each default view change. | +| `default_cycle_camera` | `false` | When set to `true` the selected camera is cycled on each default view change. | | `update_entities` | | **YAML only**: A card-wide list of entities that should cause the view to reset to the default (if the entity only pertains to a particular camera use [`triggers`](cameras/README.md?id=triggers) for the selected camera instead. | -| `update_force` | `false` | Whether automated card updates should ignore user interaction. | -| `update_seconds` | `0` | A number of seconds after which to automatically update/refresh the default view. If the default view occurs sooner (e.g. manually) the timer will start over. `0` disables this functionality. | + +## `default_reset` + +Configure the circumstances and behavior that cause the card to reset to the default view. All configuration is under: + +```yaml +view: + default_reset: [...] +``` + +| Option | Default | Description | +| ------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `after_interaction` | `true` | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. | +| `entities` | | A list of entities that should cause the view to reset to the default (if the entity only pertains to a particular camera use [`triggers`](cameras/README.md?id=triggers) for the selected camera instead). | +| `interaction_mode` | `inactive` | Whether the default reset should happen when the card is being interacted with. If `all`, the reset will always happen regardless. If `inactive` the reset will only be taken if the card has _not_ had human interaction recently (as defined by `view.interaction_seconds`). If `active` the reset will only be happen if the card _has_ had human interaction recently. This controls resets triggered by `entities` and `every_seconds`, but not `after_interaction` which by definition requires no interaction. | +| `every_seconds` | `0` | A number of seconds after which to automatically reset to the default view. `0` disables this functionality. | ## `keyboard_shortcuts` +All configuration is under: + +```yaml +view: + keyboard_shortcuts: [...] +``` + Configure the key-bindings for the builtin keyboard shortcuts. See [usage](../usage/keyboard-shortcuts.md) information for defaults on keyboard shortcuts. | Option | Default | Description | @@ -106,11 +128,13 @@ view: default: live camera_select: current interaction_seconds: 300 - update_seconds: 0 - update_force: false - update_cycle_camera: false - update_entities: - - binary_sensor.my_motion_sensor + default_cycle_camera: false + default_reset: + after_interaction: false + entities: + - binary_sensor.my_motion_sensor + every_seconds: 0 + interaction_mode: inactive render_entities: - switch.render_card dark_mode: 'off' diff --git a/rollup.config.js b/rollup.config.js index c3b3f421..5d40a750 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -50,6 +50,7 @@ const plugins = [ }), typescript({ sourceMap: dev, + inlineSources: dev, }), json({ exclude: 'package.json' }), replace({ diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index c987b9f3..9e83e5d9 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -3,6 +3,7 @@ import { CameraConfig } from '../config/types'; import { localize } from '../localize/localize'; import { allPromises } from '../utils/basic'; import { + DestroyCallback, isTriggeredState, parseStateChangeTrigger, subscribeToTrigger, @@ -13,8 +14,6 @@ import { CameraManagerEngine } from './engine'; import { CameraNoIDError } from './error'; import { CameraEventCallback } from './types'; -type DestroyCallback = () => Promise; - export class Camera { protected _config: CameraConfig; protected _engine: CameraManagerEngine; diff --git a/src/card-controller/auto-update-manager.ts b/src/card-controller/auto-update-manager.ts deleted file mode 100644 index bad2f5a7..00000000 --- a/src/card-controller/auto-update-manager.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Timer } from '../utils/timer'; -import { CardAutoRefreshAPI } from './types'; - -export class AutoUpdateManager { - protected _timer = new Timer(); - protected _api: CardAutoRefreshAPI; - - constructor(api: CardAutoRefreshAPI) { - this._api = api; - } - - /** - * Set the update timer to trigger an update refresh every - * `view.update_seconds`. - */ - public startDefaultViewTimer(): void { - this._timer.stop(); - const updateSeconds = this._api.getConfigManager().getConfig()?.view.update_seconds; - if (updateSeconds) { - this._timer.start(updateSeconds, () => { - if (this._isAutomatedUpdateAllowed()) { - this._api.getViewManager().setViewDefault(); - } else { - // Not allowed to update this time around, but try again at the next - // interval. - this.startDefaultViewTimer(); - } - }); - } - } - - protected _isAutomatedUpdateAllowed(): boolean { - const triggers = this._api.getTriggersManager(); - const config = this._api.getConfigManager().getConfig(); - const interactionManager = this._api.getInteractionManager(); - - return ( - !triggers.isTriggered() && - (config?.view.update_force || !interactionManager.hasInteraction()) - ); - } -} diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index 5c8b80db..14aa2c48 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -67,6 +67,7 @@ export class CardElementManager { this._api.getMediaLoadedInfoManager().initialize(); this._api.getMicrophoneManager().initialize(); this._api.getKeyboardStateManager().initialize(); + this._api.getDefaultManager().initialize(); // Whether or not the card is in panel mode on the dashboard. setOrRemoveAttribute(this._element, isCardInPanel(this._element), 'panel'); @@ -123,6 +124,7 @@ export class CardElementManager { this._api.getFullscreenManager().disconnect(); this._api.getKeyboardStateManager().uninitialize(); this._api.getActionsManager().uninitialize(); + this._api.getDefaultManager().uninitialize(); // Uninitialize cameras to cause them to reinitialize on // reconnection, to ensure the state subscription/unsubscription works diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index a2b3fa5e..7271ac99 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -142,6 +142,18 @@ export class ConfigManager { .uninitialize(InitializationAspect.MICROPHONE_CONNECT); } + if ( + previousConfig && + !isEqual( + previousConfig?.view.default_reset, + this._overriddenConfig?.view.default_reset, + ) + ) { + this._api + .getInitializationManager() + .uninitialize(InitializationAspect.DEFAULT_RESET); + } + this._api.getCardElementManager().update(); } } diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index ca69fff8..683611cd 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -6,7 +6,7 @@ import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { EntityCache } from '../utils/ha/entity-registry/cache'; import { ResolvedMediaCache } from '../utils/ha/resolved-media'; import { ActionsManager } from './actions/actions-manager'; -import { AutoUpdateManager } from './auto-update-manager'; +import { DefaultManager } from './default-manager'; import { AutomationsManager } from './automations-manager'; import { CameraURLManager } from './camera-url-manager'; import { @@ -32,12 +32,12 @@ import { StyleManager } from './style-manager'; import { TriggersManager } from './triggers-manager'; import { CardActionsManagerAPI, - CardAutoRefreshAPI, CardAutomationsAPI, CardCameraAPI, CardCameraURLAPI, CardConditionAPI, CardConfigAPI, + CardDefaultManagerAPI, CardDownloadAPI, CardElementAPI, CardExpandAPI, @@ -62,11 +62,11 @@ export class CardController implements CardActionsManagerAPI, CardAutomationsAPI, - CardAutoRefreshAPI, CardCameraAPI, CardCameraURLAPI, CardConditionAPI, CardConfigAPI, + CardDefaultManagerAPI, CardDownloadAPI, CardElementAPI, CardExpandAPI, @@ -92,12 +92,12 @@ export class CardController protected _actionsManager = new ActionsManager(this); protected _automationsManager = new AutomationsManager(this); - protected _autoUpdateManager = new AutoUpdateManager(this); protected _cameraManager = new CameraManager(this); protected _cameraURLManager = new CameraURLManager(this); protected _cardElementManager: CardElementManager; protected _conditionsManager: ConditionsManager; protected _configManager = new ConfigManager(this); + protected _defaultManager = new DefaultManager(this); protected _downloadManager = new DownloadManager(this); protected _expandManager = new ExpandManager(this); protected _fullscreenManager = new FullscreenManager(this); @@ -143,10 +143,6 @@ export class CardController return this._automationsManager; } - public getAutoUpdateManager(): AutoUpdateManager { - return this._autoUpdateManager; - } - public getCameraManager(): CameraManager { return this._cameraManager; } @@ -171,6 +167,11 @@ export class CardController public getConfigManager(): ConfigManager { return this._configManager; } + + public getDefaultManager(): DefaultManager { + return this._defaultManager; + } + public getDownloadManager(): DownloadManager { return this._downloadManager; } diff --git a/src/card-controller/default-manager.ts b/src/card-controller/default-manager.ts new file mode 100644 index 00000000..6d782b0e --- /dev/null +++ b/src/card-controller/default-manager.ts @@ -0,0 +1,92 @@ +import PQueue from 'p-queue'; +import { DestroyCallback, subscribeToTrigger } from '../utils/ha'; +import { isActionAllowedBasedOnInteractionState } from '../utils/interaction-mode'; +import { Timer } from '../utils/timer'; +import { CardDefaultManagerAPI } from './types'; + +/** + * Manages automated resetting to the default view. + */ +export class DefaultManager { + protected _timer = new Timer(); + protected _api: CardDefaultManagerAPI; + protected _unsubscribeCallback: DestroyCallback | null = null; + protected _initializationLimit = new PQueue({ concurrency: 1 }); + + constructor(api: CardDefaultManagerAPI) { + this._api = api; + } + + /** + * Initialize the default manager. Requires both hass and configuration to be + * effective (so cannot be called from just the configuration manager, as hass + * will not be available yet) + */ + public async initialize(): Promise { + const result = await this._initializationLimit.add(() => this._reconfigure()); + this._startTimer(); + return !!result; + } + + public uninitialize(): void { + this._timer.stop(); + this._unsubscribeCallback?.(); + this._unsubscribeCallback = null; + } + + protected async _reconfigure(): Promise { + const hass = this._api.getHASSManager().getHASS(); + const config = this._api.getConfigManager().getConfig()?.view.default_reset; + if (!hass || !config) { + return false; + } + + if (this._unsubscribeCallback) { + await this._unsubscribeCallback(); + } + + this._unsubscribeCallback = await subscribeToTrigger( + hass, + () => this._setToDefaultIfAllowed(), + { + entityID: config.entities, + platform: 'state', + stateOnly: true, + }, + ); + + // If the timer is running, restart it with the newly configured timer. + if (this._timer.isRunning()) { + this._timer.stop(); + this._startTimer(); + } + + return true; + } + + protected _setToDefaultIfAllowed(): void { + if (this._isAutomatedUpdateAllowed()) { + this._api.getViewManager().setViewDefault(); + } + } + + protected _isAutomatedUpdateAllowed(): boolean { + const interactionMode = this._api.getConfigManager().getConfig()?.view + .default_reset.interaction_mode; + return ( + !!interactionMode && + isActionAllowedBasedOnInteractionState( + interactionMode, + this._api.getInteractionManager().hasInteraction(), + ) + ); + } + + protected _startTimer(): void { + const timerSeconds = this._api.getConfigManager().getConfig()?.view + .default_reset.every_seconds; + if (timerSeconds) { + this._timer.startRepeated(timerSeconds, () => this._setToDefaultIfAllowed()); + } + } +} diff --git a/src/card-controller/hass-manager.ts b/src/card-controller/hass-manager.ts index 2ada031f..34c35e7d 100644 --- a/src/card-controller/hass-manager.ts +++ b/src/card-controller/hass-manager.ts @@ -37,23 +37,6 @@ export class HASSManager { this._hass = hass; if ( - // Home Assistant pumps a lot of updates through. Re-rendering the card is - // necessary at times (e.g. to update the 'clip' view as new clips - // arrive), but also is a jarring experience for the user (e.g. if they - // are browsing the mini-gallery). Do not allow re-rendering from a Home - // Assistant update if there's been recent interaction (e.g. clicks on the - // card) or if there is media active playing. - this._isAutomatedViewUpdateAllowed() && - isHassDifferent( - this._hass, - oldHass, - this._api.getConfigManager().getConfig()?.view.update_entities ?? [], - ) - ) { - // If entities being monitored have changed then reset the view to the - // default. - this._api.getViewManager().setViewDefault(); - } else if ( isHassDifferent(this._hass, oldHass, [ ...(this._api.getConfigManager().getConfig()?.view.render_entities ?? []), @@ -75,11 +58,4 @@ export class HASSManager { // Dark mode may depend on HASS. this._api.getStyleManager().setLightOrDarkMode(); } - - protected _isAutomatedViewUpdateAllowed(): boolean { - return ( - this._api.getConfigManager().getConfig()?.view.update_force || - !this._api.getInteractionManager().hasInteraction() - ); - } } diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts index cbc23190..a8de6c75 100644 --- a/src/card-controller/initialization-manager.ts +++ b/src/card-controller/initialization-manager.ts @@ -9,6 +9,7 @@ export enum InitializationAspect { MEDIA_PLAYERS = 'media-players', CAMERAS = 'cameras', MICROPHONE_CONNECT = 'microphone-connect', + DEFAULT_RESET = 'default-reset', } export class InitializationManager { @@ -125,6 +126,7 @@ export class InitializationManager { if ( this._initializer.isInitializedMultiple([ + InitializationAspect.DEFAULT_RESET, ...(config.menu.buttons.media_player.enabled ? [InitializationAspect.MEDIA_PLAYERS] : []), @@ -135,6 +137,8 @@ export class InitializationManager { if ( !(await this._initializer.initializeMultipleIfNecessary({ + [InitializationAspect.DEFAULT_RESET]: async () => + await this._api.getDefaultManager().initialize(), ...(config.menu.buttons.media_player.enabled && { [InitializationAspect.MEDIA_PLAYERS]: async () => await this._api.getMediaPlayerManager().initialize(), diff --git a/src/card-controller/interaction-manager.ts b/src/card-controller/interaction-manager.ts index 64a43912..350a6fcb 100644 --- a/src/card-controller/interaction-manager.ts +++ b/src/card-controller/interaction-manager.ts @@ -41,7 +41,10 @@ export class InteractionManager { this._api.getConditionsManager().setState({ interaction: false }); if (!this._api.getTriggersManager().isTriggered()) { - if (this._api.getConfigManager().getConfig()?.view.reset_after_interaction) { + if ( + this._api.getConfigManager().getConfig()?.view.default_reset + .after_interaction + ) { this._api.getViewManager().setViewDefault(); } this._api.getStyleManager().setLightOrDarkMode(); diff --git a/src/card-controller/keyboard-state-manager.ts b/src/card-controller/keyboard-state-manager.ts index 53a24a6c..b2ad3626 100644 --- a/src/card-controller/keyboard-state-manager.ts +++ b/src/card-controller/keyboard-state-manager.ts @@ -1,5 +1,5 @@ import { CardKeyboardStateAPI, KeysState } from './types'; -import isEqual from 'lodash/isEqual'; +import isEqual from 'lodash-es/isEqual'; export class KeyboardStateManager { protected _api: CardKeyboardStateAPI; diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 7eb9ebf7..a77d89af 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -3,7 +3,7 @@ import type { ConditionsManager } from './conditions-manager'; import type { EntityRegistryManager } from '../utils/ha/entity-registry'; import type { ResolvedMediaCache } from '../utils/ha/resolved-media'; import type { ActionsManager } from './actions/actions-manager'; -import type { AutoUpdateManager } from './auto-update-manager'; +import type { DefaultManager } from './default-manager'; import type { AutomationsManager } from './automations-manager'; import type { CameraURLManager } from './camera-url-manager'; import type { CardElementManager } from './card-element-manager'; @@ -60,13 +60,6 @@ export interface CardAutomationsAPI { getMessageManager(): MessageManager; } -export interface CardAutoRefreshAPI { - getConfigManager(): ConfigManager; - getInteractionManager(): InteractionManager; - getTriggersManager(): TriggersManager; - getViewManager(): ViewManager; -} - export interface CardCameraAPI { getActionsManager(): ActionsManager; getConfigManager(): ConfigManager; @@ -92,6 +85,7 @@ export interface CardConfigAPI { getCardElementManager(): CardElementManager; getConditionsManager(): ConditionsManager; getConfigManager(): ConfigManager; + getDefaultManager(): DefaultManager; getInitializationManager(): InitializationManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMessageManager(): MessageManager; @@ -104,6 +98,14 @@ export interface CardConfigLoaderAPI { getAutomationsManager(): AutomationsManager; } +export interface CardDefaultManagerAPI { + getConfigManager(): ConfigManager; + getHASSManager(): HASSManager; + getInteractionManager(): InteractionManager; + getTriggersManager(): TriggersManager; + getViewManager(): ViewManager; +} + export interface CardDownloadAPI { getCameraManager(): CameraManager; getHASSManager(): HASSManager; @@ -115,6 +117,7 @@ export interface CardDownloadAPI { export interface CardElementAPI { getActionsManager(): ActionsManager; getCameraManager(): CameraManager; + getDefaultManager(): DefaultManager; getExpandManager(): ExpandManager; getFullscreenManager(): FullscreenManager; getInitializationManager(): InitializationManager; @@ -143,6 +146,7 @@ export interface CardHASSAPI { getCardElementManager(): CardElementManager; getConditionsManager(): ConditionsManager; getConfigManager(): ConfigManager; + getDefaultManager(): DefaultManager; getInteractionManager(): InteractionManager; getMediaPlayerManager(): MediaPlayerManager; getMessageManager(): MessageManager; @@ -155,6 +159,7 @@ export interface CardInitializerAPI { getCameraManager(): CameraManager; getCardElementManager(): CardElementManager; getConfigManager(): ConfigManager; + getDefaultManager(): DefaultManager; getEntityRegistryManager(): EntityRegistryManager; getHASSManager(): HASSManager; getMediaPlayerManager(): MediaPlayerManager; @@ -233,7 +238,6 @@ export interface CardTriggersAPI { } export interface CardViewAPI { - getAutoUpdateManager(): AutoUpdateManager; getCameraManager(): CameraManager; getCardElementManager(): CardElementManager; getConditionsManager(): ConditionsManager; diff --git a/src/card-controller/view-manager.ts b/src/card-controller/view-manager.ts index 5a369700..655d7ab3 100644 --- a/src/card-controller/view-manager.ts +++ b/src/card-controller/view-manager.ts @@ -55,7 +55,7 @@ export class ViewManager { let forceCameraID: string | null = params?.cameraID ?? null; const viewName = config.view.default; - if (!forceCameraID && this._view?.camera && config.view.update_cycle_camera) { + if (!forceCameraID && this._view?.camera && config.view.default_cycle_camera) { const cameraIDs = [ ...getCameraIDsForViewName(this._api.getCameraManager(), viewName), ]; @@ -69,10 +69,6 @@ export class ViewManager { viewName: viewName, ...(forceCameraID && { cameraID: forceCameraID }), }); - - // Restart the refresh timer, so the default view is refreshed at a fixed - // interval from now (if so configured). - this._api.getAutoUpdateManager().startDefaultViewTimer(); } } diff --git a/src/config/management.ts b/src/config/management.ts index 031abd2a..9f7d73ae 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -25,7 +25,11 @@ import { CONF_OVERRIDES, CONF_PROFILES, CONF_TIMELINE_EVENTS_MEDIA_TYPE, + CONF_VIEW_DEFAULT_CYCLE_CAMERA, CONF_VIEW_INTERACTION_SECONDS, + CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS, + CONF_VIEW_DEFAULT_RESET_ENTITIES, + CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, CONF_VIEW_TRIGGERS, CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, @@ -805,4 +809,17 @@ const UPGRADES = [ keepOriginal: true, }), upgradeWithOverrides('live.controls.ptz', ptzControlSettingsTransform), + upgradeMoveToWithOverrides('view.update_cycle_camera', CONF_VIEW_DEFAULT_CYCLE_CAMERA), + upgradeMoveToWithOverrides( + 'view.update_force', + CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, + { + transform: (val) => (val === true ? 'all' : null), + }, + ), + upgradeMoveToWithOverrides( + 'view.update_seconds', + CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS, + ), + upgradeMoveToWithOverrides('view.update_entities', CONF_VIEW_DEFAULT_RESET_ENTITIES), ]; diff --git a/src/config/profiles/low-performance.ts b/src/config/profiles/low-performance.ts index 92fcea8f..e80e07cc 100644 --- a/src/config/profiles/low-performance.ts +++ b/src/config/profiles/low-performance.ts @@ -137,5 +137,5 @@ export const LOW_PERFORMANCE_PROFILE = { [CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS]: 10, // No trigger actions. - [CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER]: 'none' + [CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER]: 'none', }; diff --git a/src/config/types.ts b/src/config/types.ts index 8289ecc3..e62089ec 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1396,16 +1396,18 @@ const viewConfigDefault = { default: FRIGATE_CARD_VIEW_DEFAULT, camera_select: 'current' as const, interaction_seconds: 300, - reset_after_interaction: true, - update_seconds: 0, - update_force: false, - update_cycle_camera: false, + default_reset: { + every_seconds: 0, + after_interaction: false, + entities: [], + interaction_mode: 'inactive' as const, + }, + default_cycle_camera: false, dark_mode: 'off' as const, triggers: { show_trigger_status: false, filter_selected_camera: true, actions: { - interaction_mode: 'inactive' as const, trigger: 'update' as const, untrigger: 'none' as const, }, @@ -1414,12 +1416,13 @@ const viewConfigDefault = { keyboard_shortcuts: keyboardShortcutsDefault, }; +const interactionModeSchema = z.enum(['all', 'inactive', 'active']).default('inactive'); +export type InteractionMode = z.infer; + export const triggersSchema = z.object({ actions: z .object({ - interaction_mode: z - .enum(['all', 'inactive', 'active']) - .default(viewConfigDefault.triggers.actions.interaction_mode), + interaction_mode: interactionModeSchema, trigger: z .enum(['default', 'live', 'media', 'none', 'update']) .default(viewConfigDefault.triggers.actions.trigger), @@ -1447,13 +1450,21 @@ const viewConfigSchema = z .enum([...FRIGATE_CARD_VIEWS_USER_SPECIFIED, 'current']) .default(viewConfigDefault.camera_select), interaction_seconds: z.number().default(viewConfigDefault.interaction_seconds), - reset_after_interaction: z - .boolean() - .default(viewConfigDefault.reset_after_interaction), - update_seconds: z.number().default(viewConfigDefault.update_seconds), - update_force: z.boolean().default(viewConfigDefault.update_force), - update_cycle_camera: z.boolean().default(viewConfigDefault.update_cycle_camera), - update_entities: z.string().array().optional(), + default_cycle_camera: z.boolean().default(viewConfigDefault.default_cycle_camera), + + default_reset: z + .object({ + after_interaction: z + .boolean() + .default(viewConfigDefault.default_reset.after_interaction), + every_seconds: z.number().default(viewConfigDefault.default_reset.every_seconds), + entities: z.string().array().default(viewConfigDefault.default_reset.entities), + interaction_mode: interactionModeSchema.default( + viewConfigDefault.default_reset.interaction_mode, + ), + }) + .default(viewConfigDefault.default_reset), + render_entities: z.string().array().optional(), dark_mode: z.enum(['on', 'off', 'auto']).optional(), triggers: triggersSchema.default(viewConfigDefault.triggers), diff --git a/src/const.ts b/src/const.ts index 0593f1e5..0b13b4a4 100644 --- a/src/const.ts +++ b/src/const.ts @@ -123,11 +123,17 @@ export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT = `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_zoom_out` as const; export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME = `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_home` as const; -export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera` as const; -export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const; -export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const; -export const CONF_VIEW_RESET_AFTER_INTERACTION = - `${CONF_VIEW}.reset_after_interaction` as const; +export const CONF_VIEW_DEFAULT_CYCLE_CAMERA = + `${CONF_VIEW}.default_cycle_camera` as const; +export const CONF_VIEW_DEFAULT_RESET = `${CONF_VIEW}.default_reset` as const; +export const CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE = + `${CONF_VIEW_DEFAULT_RESET}.interaction_mode` as const; +export const CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS = + `${CONF_VIEW_DEFAULT_RESET}.every_seconds` as const; +export const CONF_VIEW_DEFAULT_RESET_ENTITIES = + `${CONF_VIEW_DEFAULT_RESET}.entities` as const; +export const CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION = + `${CONF_VIEW_DEFAULT_RESET}.after_interaction` as const; export const CONF_VIEW_TRIGGERS = `${CONF_VIEW}.triggers` as const; export const CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS = `${CONF_VIEW_TRIGGERS}.show_trigger_status` as const; diff --git a/src/editor.ts b/src/editor.ts index bbc7ed33..9839194b 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -201,7 +201,7 @@ import { CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP, CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN, CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT, - CONF_VIEW_RESET_AFTER_INTERACTION, + CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION, CONF_VIEW_TRIGGERS, CONF_VIEW_TRIGGERS_ACTIONS, CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE, @@ -210,10 +210,12 @@ import { CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS, CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS, - CONF_VIEW_UPDATE_CYCLE_CAMERA, - CONF_VIEW_UPDATE_FORCE, - CONF_VIEW_UPDATE_SECONDS, + CONF_VIEW_DEFAULT_CYCLE_CAMERA, MEDIA_CHUNK_SIZE_MAX, + CONF_VIEW_DEFAULT_RESET, + CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS, + CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, + CONF_VIEW_DEFAULT_RESET_ENTITIES, } from './const.js'; import { localize } from './localize/localize.js'; import frigate_card_editor_style from './scss/editor.scss'; @@ -263,6 +265,7 @@ const MENU_PERFORMANCE_FEATURES = 'performance.features'; const MENU_PERFORMANCE_STYLE = 'performance.style'; const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails'; const MENU_VIEW_KEYBOARD_SHORTCUTS = 'view.keyboard_shortcuts'; +const MENU_VIEW_DEFAULT_RESET = 'view.default_reset'; const MENU_VIEW_TRIGGERS = 'view.triggers'; const MENU_VIEW_TRIGGERS_ACTIONS = 'view.triggers.actions'; @@ -813,6 +816,22 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, ]; + protected _defaultResetInteractionModes: EditorSelectOption[] = [ + { value: '', label: '' }, + { + value: 'all', + label: localize('config.view.default_reset.interaction_modes.all'), + }, + { + value: 'inactive', + label: localize('config.view.default_reset.interaction_modes.inactive'), + }, + { + value: 'active', + label: localize('config.view.default_reset.interaction_modes.active'), + }, + ]; + public setConfig(config: RawFrigateCardConfig): void { // Note: This does not use Zod to parse the full configuration, so it may be // partially or completely invalid. It's more useful to have a partially @@ -1068,6 +1087,36 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ); } + protected _renderViewDefaultResetMenu(): TemplateResult { + return this._putInSubmenu( + MENU_VIEW_DEFAULT_RESET, + true, + `config.${CONF_VIEW_DEFAULT_RESET}.editor_label`, + { name: 'mdi:restart' }, + html` + ${this._renderSwitch( + CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION, + this._defaults.view.default_reset.after_interaction, + )} + ${this._renderNumberInput(CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS)} + ${this._renderOptionSelector( + CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, + this._defaultResetInteractionModes, + { + label: localize('config.view.default_reset.interaction_mode'), + }, + )}, + ${this._renderOptionSelector( + CONF_VIEW_DEFAULT_RESET_ENTITIES, + this.hass ? getEntitiesFromHASS(this.hass) : [], + { + multiple: true, + }, + )} + `, + ); + } + protected _renderViewTriggersMenu(): TemplateResult { return this._putInSubmenu( MENU_VIEW_TRIGGERS, @@ -2330,19 +2379,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderOptionSelector(CONF_VIEW_DARK_MODE, this._darkModes)} ${this._renderNumberInput(CONF_VIEW_INTERACTION_SECONDS)} ${this._renderSwitch( - CONF_VIEW_RESET_AFTER_INTERACTION, - this._defaults.view.reset_after_interaction, - )} - ${this._renderNumberInput(CONF_VIEW_UPDATE_SECONDS)} - ${this._renderSwitch( - CONF_VIEW_UPDATE_FORCE, - this._defaults.view.update_force, - )} - ${this._renderSwitch( - CONF_VIEW_UPDATE_CYCLE_CAMERA, - this._defaults.view.update_cycle_camera, + CONF_VIEW_DEFAULT_CYCLE_CAMERA, + this._defaults.view.default_cycle_camera, )} - ${this._renderViewTriggersMenu()} + ${this._renderViewDefaultResetMenu()} ${this._renderViewTriggersMenu()} ${this._renderViewKeyboardShortcutMenu()} ` diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 01d3b62c..9492db6f 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -436,12 +436,31 @@ "on": "Activat" }, "default": "Vista per defecte", + "default_cycle_camera": "Passeu per les càmeres quan s'actualitzi la vista predeterminada", + "default_reset": { + "after_interaction": "Restableix la vista predeterminada després de la interacció de l'usuari", + "editor_label": "", + "entities": "", + "every_seconds": "Actualitza la vista predeterminada cada X segons (0=mai)", + "interaction_mode": "", + "interaction_modes": { + "active": "", + "all": "", + "inactive": "" + } + }, "interaction_seconds": "Segons després de l'acció de l'usuari per continuar interactuant (0=mai)", "keyboard_shortcuts": { "editor_label": "", - "enabled": "" + "enabled": "", + "ptz_down": "", + "ptz_home": "", + "ptz_left": "", + "ptz_right": "", + "ptz_up": "", + "ptz_zoom_in": "", + "ptz_zoom_out": "" }, - "reset_after_interaction": "Restableix la vista predeterminada després de la interacció de l'usuari", "triggers": { "actions": { "editor_label": "Activar accions", @@ -469,9 +488,6 @@ "show_trigger_status": "Mostra la vora intermitent quan s'activa", "untrigger_seconds": "Segons després del canvi d'estat inactiu a desactivat" }, - "update_cycle_camera": "Passeu per les càmeres quan s'actualitzi la vista predeterminada", - "update_force": "Força les actualitzacions de la targeta (ignora la interacció de l'usuari)", - "update_seconds": "Actualitza la vista predeterminada cada X segons (0=mai)", "views": { "clip": "Clip més recent", "clips": "Galeria de clips", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index e7d8d321..72f3ff79 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -362,7 +362,6 @@ "camera_ui": "Camera user interface", "cameras": "Cameras", "clips": "Clips", - "ptz_home": "PTZ Home", "display_mode": "Display mode", "download": "Download", "enabled": "Button enabled", @@ -378,6 +377,7 @@ "play": "Play / Pause", "priority": "Priority", "ptz_controls": "Show PTZ controls", + "ptz_home": "PTZ Home", "recordings": "Recordings", "screenshot": "Screenshot", "snapshots": "Snapshots", @@ -436,6 +436,19 @@ "on": "On" }, "default": "Default view", + "default_cycle_camera": "Cycle through cameras when default view updates", + "default_reset": { + "after_interaction": "Reset to the default view after user interaction ends", + "editor_label": "Default view reset behavior", + "entities": "Reset to the default view on entity state change", + "every_seconds": "Reset to default view every X seconds (0=never)", + "interaction_mode": "How default reset behaves when the card has human interaction", + "interaction_modes": { + "active": "Only allow reset when card has active human interaction", + "all": "Reset regardless of human interaction", + "inactive": "Only reset when card has no human interaction" + } + }, "interaction_seconds": "Seconds after user action to remain interacted with (0=never)", "keyboard_shortcuts": { "editor_label": "Keyboard shortcuts", @@ -448,7 +461,6 @@ "ptz_zoom_in": "PTZ Zoom In", "ptz_zoom_out": "PTZ Zoom Out" }, - "reset_after_interaction": "Reset to the default view after user interaction", "triggers": { "actions": { "editor_label": "Trigger actions", @@ -476,9 +488,6 @@ "show_trigger_status": "Show pulsing border when triggered", "untrigger_seconds": "Seconds after inactive state change to untrigger" }, - "update_cycle_camera": "Cycle through cameras when default view updates", - "update_force": "Force card updates (ignore user interaction)", - "update_seconds": "Refresh default view every X seconds (0=never)", "views": { "clip": "Most recent clip", "clips": "Clips gallery", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index 5d435d66..43acdfe4 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -436,12 +436,31 @@ "on": "Activé" }, "default": "Vue par défaut", + "default_cycle_camera": "Parcourez les caméras lorsque la vue par défaut est mise à jour", + "default_reset": { + "after_interaction": "", + "editor_label": "", + "entities": "", + "every_seconds": "Actualiser la vue par défaut toutes les X secondes (0=jamais)", + "interaction_mode": "", + "interaction_modes": { + "active": "", + "all": "", + "inactive": "" + } + }, "interaction_seconds": "", "keyboard_shortcuts": { "editor_label": "", - "enabled": "" + "enabled": "", + "ptz_down": "", + "ptz_home": "", + "ptz_left": "", + "ptz_right": "", + "ptz_up": "", + "ptz_zoom_in": "", + "ptz_zoom_out": "" }, - "reset_after_interaction": "", "triggers": { "actions": { "editor_label": "", @@ -469,9 +488,6 @@ "show_trigger_status": "Afficher la bordure clignotante lors du déclenchement", "untrigger_seconds": "Quelques secondes après le changement d'état inactif pour débloquer" }, - "update_cycle_camera": "Parcourez les caméras lorsque la vue par défaut est mise à jour", - "update_force": "Forcer les mises à jour de la carte (ignorer l'interaction de l'utilisateur)", - "update_seconds": "Actualiser la vue par défaut toutes les X secondes (0=jamais)", "views": { "clip": "Clip le plus récent", "clips": "Galerie de clips", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 08276c1a..70527221 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -436,12 +436,31 @@ "on": "On" }, "default": "Visualizzazione predefinita", + "default_cycle_camera": "Scorri le telecamere quando si aggiorna la visualizzazione predefinita", + "default_reset": { + "after_interaction": "", + "editor_label": "", + "entities": "", + "every_seconds": "Aggiorna la visualizzazione predefinita ogni x secondi (0 = mai)", + "interaction_mode": "", + "interaction_modes": { + "active": "", + "all": "", + "inactive": "" + } + }, "interaction_seconds": "", "keyboard_shortcuts": { "editor_label": "", - "enabled": "" + "enabled": "", + "ptz_down": "", + "ptz_home": "", + "ptz_left": "", + "ptz_right": "", + "ptz_up": "", + "ptz_zoom_in": "", + "ptz_zoom_out": "" }, - "reset_after_interaction": "", "triggers": { "actions": { "editor_label": "", @@ -469,9 +488,6 @@ "show_trigger_status": "Mostra bordo pulsante quando attivato", "untrigger_seconds": "Reimposta la vista ai valori predefiniti dopo aver annullato l'attivazione" }, - "update_cycle_camera": "Scorri le telecamere quando si aggiorna la visualizzazione predefinita", - "update_force": "Aggiornamenti della scheda forza (ignora l'interazione dell'utente)", - "update_seconds": "Aggiorna la visualizzazione predefinita ogni x secondi (0 = mai)", "views": { "clip": "Clip più recente", "clips": "Galleria delle clip", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index df882da2..865334e1 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -436,12 +436,31 @@ "on": "Ligado" }, "default": "Visualização padrão", + "default_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada", + "default_reset": { + "after_interaction": "", + "editor_label": "", + "entities": "", + "every_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)", + "interaction_mode": "", + "interaction_modes": { + "active": "", + "all": "", + "inactive": "" + } + }, "interaction_seconds": "", "keyboard_shortcuts": { "editor_label": "", - "enabled": "" + "enabled": "", + "ptz_down": "", + "ptz_home": "", + "ptz_left": "", + "ptz_right": "", + "ptz_up": "", + "ptz_zoom_in": "", + "ptz_zoom_out": "" }, - "reset_after_interaction": "", "triggers": { "actions": { "editor_label": "", @@ -469,9 +488,6 @@ "show_trigger_status": "Pulsar borda quando acionado", "untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar" }, - "update_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada", - "update_force": "Forçar atualizações do cartão (ignore a interação do usuário)", - "update_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)", "views": { "clip": "Clipe mais recente", "clips": "Galeria de clipes", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index ad966a0b..119c7ada 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -436,12 +436,31 @@ "on": "Ligado" }, "default": "Visualização padrão", + "default_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada", + "default_reset": { + "after_interaction": "", + "editor_label": "", + "entities": "", + "every_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)", + "interaction_mode": "", + "interaction_modes": { + "active": "", + "all": "", + "inactive": "" + } + }, "interaction_seconds": "", "keyboard_shortcuts": { "editor_label": "", - "enabled": "" + "enabled": "", + "ptz_down": "", + "ptz_home": "", + "ptz_left": "", + "ptz_right": "", + "ptz_up": "", + "ptz_zoom_in": "", + "ptz_zoom_out": "" }, - "reset_after_interaction": "", "triggers": { "actions": { "editor_label": "", @@ -469,9 +488,6 @@ "show_trigger_status": "Exibir estado do gatilho", "untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar" }, - "update_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada", - "update_force": "Forçar atualizações do cartão (ignore a interação do Utilizador)", - "update_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)", "views": { "clip": "Clipe mais recente", "clips": "Galeria de clipes", diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts index 1d52a860..1a85e69a 100644 --- a/src/utils/ha/index.ts +++ b/src/utils/ha/index.ts @@ -24,6 +24,8 @@ import { SubscriptionUnsubscribe, } from './types.js'; +export type DestroyCallback = () => Promise; + /** * Make a HomeAssistant websocket request. May throw. * @param hass The HomeAssistant object to send the request with. diff --git a/src/utils/interaction-mode.ts b/src/utils/interaction-mode.ts new file mode 100644 index 00000000..a500f7b5 --- /dev/null +++ b/src/utils/interaction-mode.ts @@ -0,0 +1,15 @@ +import { InteractionMode } from '../config/types'; + +export const isActionAllowedBasedOnInteractionState = ( + interactionMode: InteractionMode, + interactionState: boolean, +): boolean => { + switch (interactionMode) { + case 'all': + return true; + case 'active': + return interactionState; + case 'inactive': + return !interactionState; + } +}; diff --git a/tests/card-controller/auto-update-manager.test.ts b/tests/card-controller/auto-update-manager.test.ts deleted file mode 100644 index 34382e1b..00000000 --- a/tests/card-controller/auto-update-manager.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { add } from 'date-fns'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { AutoUpdateManager } from '../../src/card-controller/auto-update-manager'; -import { createCardAPI, createConfig } from '../test-utils'; - -// @vitest-environment jsdom -describe('AutoUpdateManager', () => { - const start = new Date('2023-09-23T19:12:00'); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('should set default view when allowed', () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - update_seconds: 10, - }, - }), - ); - // Card is triggered. - vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(true); - vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); - - vi.useFakeTimers(); - vi.setSystemTime(start); - - const manager = new AutoUpdateManager(api); - manager.startDefaultViewTimer(); - - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - - vi.setSystemTime(add(start, { seconds: 10 })); - vi.runOnlyPendingTimers(); - - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - - vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(false); - - vi.setSystemTime(add(start, { seconds: 20 })); - vi.runOnlyPendingTimers(); - - expect(api.getViewManager().setViewDefault).toBeCalled(); - }); - - it('should not set default view when not configured', () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - update_seconds: 0, - }, - }), - ); - vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(false); - vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); - - vi.useFakeTimers(); - vi.setSystemTime(start); - - const manager = new AutoUpdateManager(api); - manager.startDefaultViewTimer(); - - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - - vi.setSystemTime(add(start, { seconds: 10 })); - vi.runOnlyPendingTimers(); - - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - }); -}); diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index de06f683..2272b86e 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -301,5 +301,38 @@ describe('ConfigManager', () => { InitializationAspect.MICROPHONE_CONNECT, ); }); + + it('view.default_reset', () => { + const api = createCardAPI(); + const manager = new ConfigManager(api); + const config_1 = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + view: { + default_reset: { + every_seconds: 1, + }, + }, + }; + vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1)); + + manager.setConfig(config_1); + expect(api.getInitializationManager().uninitialize).not.toBeCalled(); + + const config_2 = { + ...config_1, + view: { + default_reset: { + every_seconds: 2, + }, + }, + }; + vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2)); + manager.computeOverrideConfig(); + + expect(api.getInitializationManager().uninitialize).toBeCalledWith( + InitializationAspect.DEFAULT_RESET, + ); + }); }); }); diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index ca4f6d7b..f2ae1f97 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CameraManager } from '../../src/camera-manager/manager'; import { ActionsManager } from '../../src/card-controller/actions/actions-manager'; -import { AutoUpdateManager } from '../../src/card-controller/auto-update-manager'; import { AutomationsManager } from '../../src/card-controller/automations-manager'; import { CameraURLManager } from '../../src/card-controller/camera-url-manager'; import { @@ -11,6 +10,7 @@ import { import { ConditionsManager } from '../../src/card-controller/conditions-manager'; import { ConfigManager } from '../../src/card-controller/config/config-manager'; import { CardController } from '../../src/card-controller/controller'; +import { DefaultManager } from '../../src/card-controller/default-manager'; import { DownloadManager } from '../../src/card-controller/download-manager'; import { ExpandManager } from '../../src/card-controller/expand-manager'; import { FullscreenManager } from '../../src/card-controller/fullscreen-manager'; @@ -32,12 +32,12 @@ import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media'; vi.mock('../../src/camera-manager/manager'); vi.mock('../../src/card-controller/actions/actions-manager'); -vi.mock('../../src/card-controller/auto-update-manager'); vi.mock('../../src/card-controller/automations-manager'); vi.mock('../../src/card-controller/camera-url-manager'); vi.mock('../../src/card-controller/card-element-manager'); vi.mock('../../src/card-controller/conditions-manager'); vi.mock('../../src/card-controller/config/config-manager'); +vi.mock('../../src/card-controller/default-manager'); vi.mock('../../src/card-controller/download-manager'); vi.mock('../../src/card-controller/expand-manager'); vi.mock('../../src/card-controller/fullscreen-manager'); @@ -107,9 +107,9 @@ describe('CardController', () => { ); }); - it('getAutoUpdateManager', () => { - expect(createController().getAutoUpdateManager()).toBe( - vi.mocked(AutoUpdateManager).mock.instances[0], + it('getDefaultManager', () => { + expect(createController().getDefaultManager()).toBe( + vi.mocked(DefaultManager).mock.instances[0], ); }); diff --git a/tests/card-controller/default-manager.test.ts b/tests/card-controller/default-manager.test.ts new file mode 100644 index 00000000..fedb496e --- /dev/null +++ b/tests/card-controller/default-manager.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DefaultManager } from '../../src/card-controller/default-manager'; +import { + callHASubscribeMessageHandler, + createCardAPI, + createConfig, + createHASS, +} from '../test-utils'; + +// @vitest-environment jsdom +describe('DefaultManager', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + describe('time based', () => { + it('should set default view when allowed', async () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + default_reset: { + every_seconds: 10, + }, + }, + }), + ); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(true); + + vi.useFakeTimers(); + + const manager = new DefaultManager(api); + await manager.initialize(); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewDefault).toBeCalledTimes(1); + + manager.uninitialize(); + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewDefault).toBeCalledTimes(1); + }); + + it('should not set default view when not configured', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + default_reset: { + every_seconds: 0, + }, + }, + }), + ); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); + + vi.useFakeTimers(); + + const manager = new DefaultManager(api); + manager.initialize(); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); + + it('should restart timer when reconfigured', async () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + default_reset: { + every_seconds: 10, + }, + }, + }), + ); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); + + const hass = createHASS(); + const unsubcribeCallback = vi.fn(); + vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubcribeCallback); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + vi.useFakeTimers(); + + const manager = new DefaultManager(api); + + await manager.initialize(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewDefault).toBeCalled(); + }); + }); + + describe('state based', () => { + it('should set default view when state changed', async () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + default_reset: { + every_seconds: 10, + }, + }, + }), + ); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); + + const hass = createHASS(); + const unsubcribeCallback = vi.fn(); + vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubcribeCallback); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const manager = new DefaultManager(api); + await manager.initialize(); + + callHASubscribeMessageHandler(hass, { + variables: { + trigger: { + from_state: { + entity_id: 'binary_sensor.foo', + state: 'off', + }, + to_state: { + entity_id: 'binary_sensor.foo', + state: 'on', + }, + }, + }, + }); + + await manager.initialize(); + expect(unsubcribeCallback).toBeCalledTimes(1); + + expect(api.getViewManager().setViewDefault).toBeCalledTimes(1); + }); + + it('should not monitor state without config', async () => { + const api = createCardAPI(); + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const manager = new DefaultManager(api); + await manager.initialize(); + + const mock = vi.mocked(hass.connection.subscribeMessage).mock; + expect(mock.calls.length).toBe(0); + }); + }); +}); diff --git a/tests/card-controller/hass-manager.test.ts b/tests/card-controller/hass-manager.test.ts index 3d0a1fa3..6bf3bf5a 100644 --- a/tests/card-controller/hass-manager.test.ts +++ b/tests/card-controller/hass-manager.test.ts @@ -146,7 +146,7 @@ describe('HASSManager', () => { }); }); - describe('should set default view when', () => { + describe('should not set default view when', () => { it('selected camera is unknown', () => { const api = createAPIWithoutMediaPlayers(); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); @@ -178,15 +178,18 @@ describe('HASSManager', () => { expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); - it('view.update_entities changes', () => { + it('when there is card interaction', () => { const api = createAPIWithoutMediaPlayers(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { - update_entities: ['sensor.force_default_view'], + default_reset: { + entities: ['sensor.force_default_view'], + }, }, }), ); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(true); const manager = new HASSManager(api); const hass = createHASS({ @@ -195,7 +198,7 @@ describe('HASSManager', () => { manager.setHASS(hass); - expect(api.getViewManager().setViewDefault).toBeCalled(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); }); @@ -236,25 +239,4 @@ describe('HASSManager', () => { expect(api.getCardElementManager().update).toBeCalled(); }); }); - - it('set view default is not called when there is card interaction', () => { - const api = createAPIWithoutMediaPlayers(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - update_entities: ['sensor.force_default_view'], - }, - }), - ); - vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(true); - - const manager = new HASSManager(api); - const hass = createHASS({ - 'sensor.force_default_view': createStateEntity(), - }); - - manager.setHASS(hass); - - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - }); }); diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts index 6ba18553..fb8ba160 100644 --- a/tests/card-controller/initialization-manager.test.ts +++ b/tests/card-controller/initialization-manager.test.ts @@ -208,9 +208,13 @@ describe('InitializationManager', () => { expect(await manager.initializeBackgroundIfNecessary()).toBeFalsy(); }); - it('successfully with minimal initializers', async () => { + it('successfully when already initialized', async () => { const api = createCardAPI(); - const manager = new InitializationManager(api); + + const initializer = mock(); + initializer.isInitializedMultiple.mockReturnValue(true); + + const manager = new InitializationManager(api, initializer); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ @@ -226,6 +230,8 @@ describe('InitializationManager', () => { expect(await manager.initializeBackgroundIfNecessary()).toBeTruthy(); expect(api.getMediaPlayerManager().initialize).not.toBeCalled(); + expect(api.getDefaultManager().initialize).not.toBeCalled(); + expect(api.getCardElementManager().update).not.toBeCalled(); }); it('successfully with all inititalizers', async () => { @@ -246,10 +252,11 @@ describe('InitializationManager', () => { expect(await manager.initializeBackgroundIfNecessary()).toBeTruthy(); expect(api.getMediaPlayerManager().initialize).toBeCalled(); + expect(api.getDefaultManager().initialize).toBeCalled(); expect(api.getCardElementManager().update).toBeCalled(); }); - it('with media player in progress', async () => { + it('with initializers in progress', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( diff --git a/tests/card-controller/interaction-manager.test.ts b/tests/card-controller/interaction-manager.test.ts index 8c17ae03..b0e2546a 100644 --- a/tests/card-controller/interaction-manager.test.ts +++ b/tests/card-controller/interaction-manager.test.ts @@ -23,12 +23,15 @@ describe('InteractionManager', () => { expect(api.getConditionsManager().setState).toBeCalledWith({ interaction: false }); }); - it('should take action when interaction is reported', () => { + it('should take action after interaction ends', () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { interaction_seconds: 10, + default_reset: { + after_interaction: true, + }, }, }), ); @@ -90,12 +93,14 @@ describe('InteractionManager', () => { expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); - it('should not take action without reset_after_interaction', () => { + it('should not take action without default_reset.after_interaction', () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { - reset_after_interaction: false, + default_reset: { + after_interaction: false, + }, interaction_seconds: 10, }, }), diff --git a/tests/card-controller/view-manager.test.ts b/tests/card-controller/view-manager.test.ts index c0bed650..ae911bb4 100644 --- a/tests/card-controller/view-manager.test.ts +++ b/tests/card-controller/view-manager.test.ts @@ -129,7 +129,6 @@ describe('ViewManager.setViewDefault', () => { expect(manager.getView()?.view).toBe('live'); expect(manager.getView()?.camera).toBe('camera'); - expect(api.getAutoUpdateManager().startDefaultViewTimer).toBeCalled(); }); it('should not set default view without config', () => { @@ -140,7 +139,6 @@ describe('ViewManager.setViewDefault', () => { manager.setViewDefault(); expect(manager.getView()).toBeNull(); - expect(api.getAutoUpdateManager().startDefaultViewTimer).not.toBeCalled(); }); it('should cycle camera when configured', () => { @@ -161,7 +159,7 @@ describe('ViewManager.setViewDefault', () => { vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { - update_cycle_camera: true, + default_cycle_camera: true, }, }), ); diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index bd9cfbcb..1ba51ed9 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -25,6 +25,7 @@ import { frigateCardConfigSchema, } from '../../src/config/types'; import { getParseErrorPaths } from '../../src/utils/zod'; +import { update } from 'lodash-es'; describe('general functions', () => { it('should set value', () => { @@ -134,6 +135,7 @@ describe('upgrade functions', () => { }); }); it('with non-number', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars expect(createRangedTransform((_val) => 'foo')(1)).toBe('foo'); }); }); @@ -220,6 +222,7 @@ describe('upgrade functions', () => { c: 10, }; expect( + // eslint-disable-next-line @typescript-eslint/no-unused-vars moveConfigValue(config, 'c', 'd', { transform: (_val) => null }), ).toBeTruthy(); expect(config).toEqual({}); @@ -231,6 +234,7 @@ describe('upgrade functions', () => { }; expect( moveConfigValue(config, 'c', 'd', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars transform: (_val) => null, keepOriginal: true, }), @@ -244,6 +248,7 @@ describe('upgrade functions', () => { c: 10, }; expect( + // eslint-disable-next-line @typescript-eslint/no-unused-vars moveConfigValue(config, 'c', 'd', { transform: (_val) => undefined }), ).toBeFalsy(); expect(config).toEqual({ c: 10 }); @@ -294,11 +299,13 @@ describe('upgrade functions', () => { describe('should upgrade array', () => { it('in case of non-array', () => { const config = { c: 10 }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars expect(upgradeArrayOfObjects('c', (_val) => false)(config)).toBeFalsy(); }); it('in case of non-object items', () => { const config = { c: [10, 11] }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars expect(upgradeArrayOfObjects('c', (_val) => false)(config)).toBeFalsy(); }); @@ -328,6 +335,7 @@ describe('upgrade functions', () => { describe('should recursively upgrade', () => { it('ignoring simple objects', () => { const config = { c: 10, d: 10 }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars expect(upgradeObjectRecursively((_val) => false)(config)).toBeFalsy(); expect(config).toEqual({ c: 10, d: 10 }); }); @@ -3099,5 +3107,91 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); }); + + it('view.update_cycle_camera -> view.default_cycle_camera', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{}], + view: { + update_cycle_camera: true, + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.view).toEqual({ + default_cycle_camera: true, + }); + postUpgradeChecks(config); + }); + + describe('view.update_force -> view.default_reset.interaction_mode', () => { + it('should convert to all when true', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{}], + view: { + update_force: true, + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.view).toEqual({ + default_reset: { + interaction_mode: 'all', + }, + }); + postUpgradeChecks(config); + }); + + it('should remove when false', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{}], + view: { + update_force: false, + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.view).toEqual({}); + postUpgradeChecks(config); + }); + }); + + it('view.update_seconds', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{}], + view: { + update_seconds: 42, + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.view).toEqual({ + default_reset: { + every_seconds: 42, + }, + }); + postUpgradeChecks(config); + }); + + it('view.update_entities', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{}], + view: { + update_entities: ['binary_sensor.foo', 'camera.bar'], + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.view).toEqual({ + default_reset: { + entities: ['binary_sensor.foo', 'camera.bar'], + }, + }); + postUpgradeChecks(config); + }); }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 62ee482e..5b926078 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -331,10 +331,13 @@ describe('config defaults', () => { filter_selected_camera: true, }, interaction_seconds: 300, - reset_after_interaction: true, - update_cycle_camera: false, - update_force: false, - update_seconds: 0, + default_cycle_camera: false, + default_reset: { + after_interaction: false, + every_seconds: 0, + entities: [], + interaction_mode: 'inactive', + }, }, }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 6b97f48e..2e5defb3 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -15,7 +15,7 @@ import { CameraManagerMediaCapabilities, } from '../src/camera-manager/types'; import { ActionsManager } from '../src/card-controller/actions/actions-manager'; -import { AutoUpdateManager } from '../src/card-controller/auto-update-manager'; +import { DefaultManager } from '../src/card-controller/default-manager'; import { AutomationsManager } from '../src/card-controller/automations-manager'; import { CameraURLManager } from '../src/card-controller/camera-url-manager'; import { CardElementManager } from '../src/card-controller/card-element-manager'; @@ -434,7 +434,7 @@ export const createCardAPI = (): CardController => { api.getActionsManager.mockReturnValue(mock()); api.getAutomationsManager.mockReturnValue(mock()); - api.getAutoUpdateManager.mockReturnValue(mock()); + api.getDefaultManager.mockReturnValue(mock()); api.getCameraManager.mockReturnValue(mock()); api.getCameraURLManager.mockReturnValue(mock()); api.getCardElementManager.mockReturnValue(mock()); diff --git a/tests/utils/interaction-mode.test.ts b/tests/utils/interaction-mode.test.ts new file mode 100644 index 00000000..41e68b9e --- /dev/null +++ b/tests/utils/interaction-mode.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { isActionAllowedBasedOnInteractionState } from '../../src/utils/interaction-mode'; + +describe('isActionAllowedBasedOnInteractionState', () => { + it('should handle interactionMode: all', () => { + expect(isActionAllowedBasedOnInteractionState('all', true)).toBeTruthy(); + expect(isActionAllowedBasedOnInteractionState('all', false)).toBeTruthy(); + }); + + it('should handle interactionMode: active', () => { + expect(isActionAllowedBasedOnInteractionState('active', true)).toBeTruthy(); + expect(isActionAllowedBasedOnInteractionState('active', false)).toBeFalsy(); + }); + + it('should handle interactionMode: inactive', () => { + expect(isActionAllowedBasedOnInteractionState('inactive', true)).toBeFalsy(); + expect(isActionAllowedBasedOnInteractionState('inactive', false)).toBeTruthy(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 74dd7e43..a35a2154 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -34,6 +34,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'utils/endpoint.ts', 'utils/ha/entity-registry/types.ts', 'utils/ha/types.ts', + 'utils/interaction-mode.ts', 'utils/media-info.ts', 'utils/media-layout.ts', 'utils/media-to-view.ts', From 8c7c2c8c18cb1b2937f5d2f3bb7958abe5c37e04 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 11 Jun 2024 19:48:10 -0700 Subject: [PATCH 03/11] Use an automation to change view to default. --- src/card-controller/card-element-manager.ts | 18 ++-- src/card-controller/default-manager.ts | 18 ++++ src/card-controller/interaction-manager.ts | 11 +-- src/card-controller/types.ts | 1 + src/utils/action.ts | 2 +- tests/card-controller/default-manager.test.ts | 64 ++++++++++++++ .../interaction-manager.test.ts | 84 +------------------ 7 files changed, 101 insertions(+), 97 deletions(-) diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index 14aa2c48..f8587919 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -79,6 +79,10 @@ export class CardElementManager { 'mousemove', this._api.getInteractionManager().reportInteraction, ); + this._element.addEventListener( + 'wheel', + this._api.getInteractionManager().reportInteraction, + ); this._element.addEventListener( 'll-custom', this._api.getActionsManager().handleCustomActionEvent, @@ -129,11 +133,15 @@ export class CardElementManager { // Uninitialize cameras to cause them to reinitialize on // reconnection, to ensure the state subscription/unsubscription works // correctly for triggers. - this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS), - this._element.removeEventListener( - 'mousemove', - this._api.getInteractionManager().reportInteraction, - ); + this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS); + this._element.removeEventListener( + 'mousemove', + this._api.getInteractionManager().reportInteraction, + ); + this._element.removeEventListener( + 'wheel', + this._api.getInteractionManager().reportInteraction, + ); this._element.removeEventListener( 'll-custom', this._api.getActionsManager().handleCustomActionEvent, diff --git a/src/card-controller/default-manager.ts b/src/card-controller/default-manager.ts index 6d782b0e..88d10027 100644 --- a/src/card-controller/default-manager.ts +++ b/src/card-controller/default-manager.ts @@ -3,6 +3,7 @@ import { DestroyCallback, subscribeToTrigger } from '../utils/ha'; import { isActionAllowedBasedOnInteractionState } from '../utils/interaction-mode'; import { Timer } from '../utils/timer'; import { CardDefaultManagerAPI } from './types'; +import { createGeneralAction } from '../utils/action'; /** * Manages automated resetting to the default view. @@ -25,6 +26,22 @@ export class DefaultManager { public async initialize(): Promise { const result = await this._initializationLimit.add(() => this._reconfigure()); this._startTimer(); + + if (this._api.getConfigManager().getConfig()?.view.default_reset.after_interaction) { + this._api.getAutomationsManager().addAutomations([ + { + actions: [createGeneralAction('default')], + conditions: [ + { + condition: 'interaction' as const, + interaction: false, + }, + ], + tag: this, + }, + ]); + } + return !!result; } @@ -32,6 +49,7 @@ export class DefaultManager { this._timer.stop(); this._unsubscribeCallback?.(); this._unsubscribeCallback = null; + this._api.getAutomationsManager().deleteAutomations(this); } protected async _reconfigure(): Promise { diff --git a/src/card-controller/interaction-manager.ts b/src/card-controller/interaction-manager.ts index 350a6fcb..287fe4ca 100644 --- a/src/card-controller/interaction-manager.ts +++ b/src/card-controller/interaction-manager.ts @@ -39,16 +39,7 @@ export class InteractionManager { this._timer.start(timeoutSeconds, () => { this._api.getConditionsManager().setState({ interaction: false }); - - if (!this._api.getTriggersManager().isTriggered()) { - if ( - this._api.getConfigManager().getConfig()?.view.default_reset - .after_interaction - ) { - this._api.getViewManager().setViewDefault(); - } - this._api.getStyleManager().setLightOrDarkMode(); - } + this._api.getStyleManager().setLightOrDarkMode(); }); } } diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index a77d89af..f5c627c6 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -99,6 +99,7 @@ export interface CardConfigLoaderAPI { } export interface CardDefaultManagerAPI { + getAutomationsManager(): AutomationsManager; getConfigManager(): ConfigManager; getHASSManager(): HASSManager; getInteractionManager(): InteractionManager; diff --git a/src/utils/action.ts b/src/utils/action.ts index b3ec8805..d26af9be 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -39,7 +39,7 @@ export function createGeneralAction( options?: { cardID?: string; }, -): FrigateCardCustomAction | null { +): FrigateCardCustomAction { return { action: 'fire-dom-event', frigate_card_action: action, diff --git a/tests/card-controller/default-manager.test.ts b/tests/card-controller/default-manager.test.ts index fedb496e..5f3be314 100644 --- a/tests/card-controller/default-manager.test.ts +++ b/tests/card-controller/default-manager.test.ts @@ -160,4 +160,68 @@ describe('DefaultManager', () => { expect(mock.calls.length).toBe(0); }); }); + + describe('interaction based', () => { + it('should not register automation on initialization', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + default_reset: { + after_interaction: false, + }, + }, + }), + ); + + const manager = new DefaultManager(api); + await manager.initialize(); + + expect(api.getAutomationsManager().addAutomations).not.toBeCalled(); + }); + + it('should register automation on initialization', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + default_reset: { + after_interaction: true, + }, + }, + }), + ); + + const manager = new DefaultManager(api); + await manager.initialize(); + + expect(api.getAutomationsManager().addAutomations).toBeCalledWith([ + { + actions: [ + { + action: 'fire-dom-event', + frigate_card_action: 'default', + }, + ], + conditions: [ + { + condition: 'interaction', + interaction: false, + }, + ], + tag: expect.anything(), + }, + ]); + }); + + it('should remove automation on uninitalize', async () => { + const api = createCardAPI(); + const manager = new DefaultManager(api); + await manager.uninitialize(); + + expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith(manager); + }); + }); }); diff --git a/tests/card-controller/interaction-manager.test.ts b/tests/card-controller/interaction-manager.test.ts index b0e2546a..d0426f0d 100644 --- a/tests/card-controller/interaction-manager.test.ts +++ b/tests/card-controller/interaction-manager.test.ts @@ -23,58 +23,6 @@ describe('InteractionManager', () => { expect(api.getConditionsManager().setState).toBeCalledWith({ interaction: false }); }); - it('should take action after interaction ends', () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - interaction_seconds: 10, - default_reset: { - after_interaction: true, - }, - }, - }), - ); - const manager = new InteractionManager(api); - vi.useFakeTimers(); - vi.setSystemTime(start); - - manager.reportInteraction(); - - expect(manager.hasInteraction()).toBeTruthy(); - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - - vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(false); - vi.setSystemTime(add(start, { seconds: 10 })); - vi.runOnlyPendingTimers(); - - expect(api.getViewManager().setViewDefault).toBeCalled(); - }); - - it('should not take action when triggered', () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - interaction_seconds: 10, - }, - }), - ); - const manager = new InteractionManager(api); - vi.useFakeTimers(); - vi.setSystemTime(start); - - manager.reportInteraction(); - - vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(true); - vi.setSystemTime(add(start, { seconds: 10 })); - vi.runOnlyPendingTimers(); - - // First call is blocked by triggers (above), so interaction will report - // true but the default view will not have been set. - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - }); - it('should not take action without an interaction timeout', () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( @@ -88,35 +36,7 @@ describe('InteractionManager', () => { manager.reportInteraction(); - // First call is blocked by triggers (above), so interaction will report - // true but the default view will not have been set. - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - }); - - it('should not take action without default_reset.after_interaction', () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - default_reset: { - after_interaction: false, - }, - interaction_seconds: 10, - }, - }), - ); - const manager = new InteractionManager(api); - vi.useFakeTimers(); - vi.setSystemTime(start); - - manager.reportInteraction(); - - vi.setSystemTime(add(start, { seconds: 10 })); - vi.runOnlyPendingTimers(); - - // First call is blocked by triggers (above), so interaction will report - // true but the default view will not have been set. - expect(api.getViewManager().setViewDefault).not.toBeCalled(); + expect(manager.hasInteraction()).toBeFalsy(); }); it('should set condition state', () => { @@ -140,6 +60,7 @@ describe('InteractionManager', () => { interaction: true, }), ); + expect(manager.hasInteraction()).toBeTruthy(); vi.setSystemTime(add(start, { seconds: 10 })); vi.runOnlyPendingTimers(); @@ -149,5 +70,6 @@ describe('InteractionManager', () => { interaction: false, }), ); + expect(manager.hasInteraction()).toBeFalsy(); }); }); From 33dee472050052d2291a943a1122b72ef691b52f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 14 Jun 2024 19:21:55 -0700 Subject: [PATCH 04/11] Tiny fixes. --- .prettierignore | 1 + src/card-controller/triggers-manager.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.prettierignore b/.prettierignore index b51ecd95..1eeeaddd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ docs/js/ +.devcontainer/ diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index 67d54eab..6e3e41ae 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -107,7 +107,7 @@ export class TriggersManager { /* 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.clone()); + this._api.getViewManager().setView(view); } } else if (triggerAction === 'live') { this._api.getViewManager().setViewByParameters({ From d5c4e56c45b1baf5be60283521ce2da5681148ec Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 26 Jul 2024 19:15:38 -0700 Subject: [PATCH 05/11] Refactor views to support dynamic updates. --- project.inlang/.gitignore | 1 + rollup.config.js | 1 + .../actions/actions/camera-select.ts | 8 +- .../actions/actions/display-mode-select.ts | 6 +- .../actions/actions/substream-off.ts | 5 +- .../actions/actions/substream-on.ts | 5 +- .../actions/actions/substream-select.ts | 5 +- src/card-controller/actions/actions/view.ts | 11 +- src/card-controller/controller.ts | 2 +- src/card-controller/initialization-manager.ts | 4 +- src/card-controller/query-string-manager.ts | 36 +- src/card-controller/triggers-manager.ts | 52 +- src/card-controller/types.ts | 2 +- src/card-controller/view-manager.ts | 323 ----- src/card-controller/view/factory.ts | 373 ++++++ .../view/modifiers/merge-context.ts | 15 + .../view/modifiers/remove-context-property.ts | 17 + .../view/modifiers/remove-context.ts | 15 + .../view/modifiers/substream-off.ts | 10 + .../view/modifiers/substream-on.ts | 35 + .../view/modifiers/substream-select.ts | 15 + src/card-controller/view/query-executor.ts | 131 +++ src/card-controller/view/types.ts | 80 ++ src/card-controller/view/view-manager.ts | 164 +++ src/card.ts | 8 +- src/components-lib/live/live-controller.ts | 94 +- src/components-lib/media-filter-controller.ts | 72 +- src/components-lib/menu-button-controller.ts | 2 +- src/components-lib/zoom/zoom-view-context.ts | 28 +- src/components/gallery.ts | 100 +- src/components/live/live.ts | 150 +-- src/components/media-filter.ts | 46 +- src/components/select.ts | 13 +- src/components/surround.ts | 60 +- src/components/thumbnail-carousel.ts | 26 +- src/components/thumbnail.ts | 45 +- src/components/timeline-core.ts | 273 +++-- src/components/timeline.ts | 6 +- src/components/title-control.ts | 2 +- src/components/viewer.ts | 287 ++--- src/components/views.ts | 63 +- src/config/types.ts | 4 - src/const.ts | 1 - src/editor.ts | 2 - src/utils/find-best-media-index.ts | 48 + src/utils/media-to-view.ts | 282 ----- src/utils/substream.ts | 15 + src/view/view.ts | 158 +-- .../actions/actions/camera-select.test.ts | 36 +- .../actions/display-mode-select.test.ts | 6 +- .../actions/actions/substream-off.test.ts | 5 +- .../actions/actions/substream-on.test.ts | 5 +- .../actions/actions/substream-select.test.ts | 7 +- .../actions/actions/view.test.ts | 6 +- tests/card-controller/controller.test.ts | 4 +- .../initialization-manager.test.ts | 2 +- .../query-string-manager.test.ts | 42 +- .../card-controller/triggers-manager.test.ts | 107 +- tests/card-controller/view-manager.test.ts | 1035 ----------------- tests/card-controller/view/factory.test.ts | 751 ++++++++++++ .../view/modifiers/merge-context.test.ts | 28 + .../modifiers/remove-context-property.test.ts | 25 + .../view/modifiers/remove-context.test.ts | 26 + .../view/modifiers/substream-off.test.ts | 20 + .../view/modifiers/substream-on.test.ts | 101 ++ .../view/modifiers/substream-select.test.ts | 19 + .../view/query-executor.test.ts | 360 ++++++ tests/card-controller/view/test-utils.ts | 41 + .../card-controller/view/view-manager.test.ts | 363 ++++++ .../live/live-controller.test.ts | 219 +--- .../media-filter-controller.test.ts | 218 ++-- .../menu-button-controller.test.ts | 2 +- .../zoom/zoom-view-context.test.ts | 32 +- tests/test-utils.ts | 24 +- tests/utils/media-to-view.test.ts | 600 ---------- tests/utils/substream.test.ts | 44 +- tests/view/view-to-cameras.test.ts | 3 + tests/view/view.test.ts | 365 +----- vite.config.ts | 1 - yarn.lock | 135 ++- 80 files changed, 3713 insertions(+), 4020 deletions(-) create mode 100644 project.inlang/.gitignore delete mode 100644 src/card-controller/view-manager.ts create mode 100644 src/card-controller/view/factory.ts create mode 100644 src/card-controller/view/modifiers/merge-context.ts create mode 100644 src/card-controller/view/modifiers/remove-context-property.ts create mode 100644 src/card-controller/view/modifiers/remove-context.ts create mode 100644 src/card-controller/view/modifiers/substream-off.ts create mode 100644 src/card-controller/view/modifiers/substream-on.ts create mode 100644 src/card-controller/view/modifiers/substream-select.ts create mode 100644 src/card-controller/view/query-executor.ts create mode 100644 src/card-controller/view/types.ts create mode 100644 src/card-controller/view/view-manager.ts create mode 100644 src/utils/find-best-media-index.ts delete mode 100644 src/utils/media-to-view.ts delete mode 100644 tests/card-controller/view-manager.test.ts create mode 100644 tests/card-controller/view/factory.test.ts create mode 100644 tests/card-controller/view/modifiers/merge-context.test.ts create mode 100644 tests/card-controller/view/modifiers/remove-context-property.test.ts create mode 100644 tests/card-controller/view/modifiers/remove-context.test.ts create mode 100644 tests/card-controller/view/modifiers/substream-off.test.ts create mode 100644 tests/card-controller/view/modifiers/substream-on.test.ts create mode 100644 tests/card-controller/view/modifiers/substream-select.test.ts create mode 100644 tests/card-controller/view/query-executor.test.ts create mode 100644 tests/card-controller/view/test-utils.ts create mode 100644 tests/card-controller/view/view-manager.test.ts delete mode 100644 tests/utils/media-to-view.test.ts diff --git a/project.inlang/.gitignore b/project.inlang/.gitignore new file mode 100644 index 00000000..5e465967 --- /dev/null +++ b/project.inlang/.gitignore @@ -0,0 +1 @@ +cache \ No newline at end of file diff --git a/rollup.config.js b/rollup.config.js index 5d40a750..33ca36e7 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -51,6 +51,7 @@ const plugins = [ typescript({ sourceMap: dev, inlineSources: dev, + exclude: ["tests/**/*.test.ts"], }), json({ exclude: 'package.json' }), replace({ diff --git a/src/card-controller/actions/actions/camera-select.ts b/src/card-controller/actions/actions/camera-select.ts index c5f82ce2..395b47d3 100644 --- a/src/card-controller/actions/actions/camera-select.ts +++ b/src/card-controller/actions/actions/camera-select.ts @@ -16,9 +16,11 @@ export class CameraSelectAction extends FrigateCardAction { public async execute(api: CardActionsAPI): Promise { - await api.getViewManager().setViewWithNewDisplayMode(this._action.display_mode); + await api.getViewManager().setViewByParametersWithNewQuery({ + params: { + displayMode: this._action.display_mode, + }, + }); } } diff --git a/src/card-controller/actions/actions/substream-off.ts b/src/card-controller/actions/actions/substream-off.ts index 8503e4a1..0a89b7a6 100644 --- a/src/card-controller/actions/actions/substream-off.ts +++ b/src/card-controller/actions/actions/substream-off.ts @@ -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 { public async execute(api: CardActionsAPI): Promise { - api.getViewManager().setViewWithoutSubstream(); + api.getViewManager().setViewByParameters({ + modifiers: [new SubstreamOffViewModifier()], + }); } } diff --git a/src/card-controller/actions/actions/substream-on.ts b/src/card-controller/actions/actions/substream-on.ts index 0fd13310..78e6976d 100644 --- a/src/card-controller/actions/actions/substream-on.ts +++ b/src/card-controller/actions/actions/substream-on.ts @@ -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 { public async execute(api: CardActionsAPI): Promise { - api.getViewManager().setViewWithSubstream(); + api.getViewManager().setViewByParameters({ + modifiers: [new SubstreamOnViewModifier(api)], + }); } } diff --git a/src/card-controller/actions/actions/substream-select.ts b/src/card-controller/actions/actions/substream-select.ts index 7935ca5d..ec55c5f6 100644 --- a/src/card-controller/actions/actions/substream-select.ts +++ b/src/card-controller/actions/actions/substream-select.ts @@ -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 { public async execute(api: CardActionsAPI): Promise { - api.getViewManager().setViewWithSubstream(this._action.camera); + api.getViewManager().setViewByParameters({ + modifiers: [new SubstreamSelectViewModifier(this._action.camera)], + }); } } diff --git a/src/card-controller/actions/actions/view.ts b/src/card-controller/actions/actions/view.ts index 045b65de..55e03e24 100644 --- a/src/card-controller/actions/actions/view.ts +++ b/src/card-controller/actions/actions/view.ts @@ -4,13 +4,10 @@ import { FrigateCardAction } from './base'; export class ViewAction extends FrigateCardAction { public async execute(api: CardActionsAPI): Promise { - 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, + }, }); } } diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index 683611cd..758f1f31 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -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 diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts index a8de6c75..7b5a78da 100644 --- a/src/card-controller/initialization-manager.ts +++ b/src/card-controller/initialization-manager.ts @@ -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(); } diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts index 15d3a1a6..9009bb67 100644 --- a/src/card-controller/query-string-manager.ts +++ b/src/card-controller/query-string-manager.ts @@ -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 & { 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 { 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 { diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index 6e3e41ae..bebd92ee 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -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 { 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 { 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 { 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); }, ); } diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index f5c627c6..cca66b7a 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -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'; diff --git a/src/card-controller/view-manager.ts b/src/card-controller/view-manager.ts deleted file mode 100644 index 655d7ab3..00000000 --- a/src/card-controller/view-manager.ts +++ /dev/null @@ -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 { - 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 { - 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 = - 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 = 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 | undefined = view.context?.live?.overrides; - if (overrides && overrides.has(view.camera)) { - view.context?.live?.overrides?.delete(view.camera); - } - return view; - } -} diff --git a/src/card-controller/view/factory.ts b/src/card-controller/view/factory.ts new file mode 100644 index 00000000..9f43d5f3 --- /dev/null +++ b/src/card-controller/view/factory.ts @@ -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 { + return this._executeNewQuery(this.getViewDefault(options), { + ...options, + queryExecutorOptions: { + useCache: false, + ...options?.queryExecutorOptions, + }, + }); + } + + public async getViewByParametersWithNewQuery( + options?: ViewFactoryOptions, + ): Promise { + return this._executeNewQuery(this.getViewByParameters(options), { + ...options, + queryExecutorOptions: { + useCache: false, + ...options?.queryExecutorOptions, + }, + }); + } + + public async getViewByParametersWithExistingQuery( + options?: ViewFactoryOptions, + ): Promise { + 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 { + 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 => { + /* 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 { + 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'; + } +} diff --git a/src/card-controller/view/modifiers/merge-context.ts b/src/card-controller/view/modifiers/merge-context.ts new file mode 100644 index 00000000..96f86b33 --- /dev/null +++ b/src/card-controller/view/modifiers/merge-context.ts @@ -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); + } +} diff --git a/src/card-controller/view/modifiers/remove-context-property.ts b/src/card-controller/view/modifiers/remove-context-property.ts new file mode 100644 index 00000000..75c2d9eb --- /dev/null +++ b/src/card-controller/view/modifiers/remove-context-property.ts @@ -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); + } +} diff --git a/src/card-controller/view/modifiers/remove-context.ts b/src/card-controller/view/modifiers/remove-context.ts new file mode 100644 index 00000000..52e33c21 --- /dev/null +++ b/src/card-controller/view/modifiers/remove-context.ts @@ -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)); + } +} diff --git a/src/card-controller/view/modifiers/substream-off.ts b/src/card-controller/view/modifiers/substream-off.ts new file mode 100644 index 00000000..c9eaf443 --- /dev/null +++ b/src/card-controller/view/modifiers/substream-off.ts @@ -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); + } +} + diff --git a/src/card-controller/view/modifiers/substream-on.ts b/src/card-controller/view/modifiers/substream-on.ts new file mode 100644 index 00000000..b3e1ac74 --- /dev/null +++ b/src/card-controller/view/modifiers/substream-on.ts @@ -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]); + } +} diff --git a/src/card-controller/view/modifiers/substream-select.ts b/src/card-controller/view/modifiers/substream-select.ts new file mode 100644 index 00000000..af36777c --- /dev/null +++ b/src/card-controller/view/modifiers/substream-select.ts @@ -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); + } +} diff --git a/src/card-controller/view/query-executor.ts b/src/card-controller/view/query-executor.ts new file mode 100644 index 00000000..8e3df7e1 --- /dev/null +++ b/src/card-controller/view/query-executor.ts @@ -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 { + 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 { + 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 { + const queries = query.getQueries(); + if (!queries) { + return null; + } + + const mediaArray = await this._api + .getCameraManager() + .executeMediaQueries(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 + ); + } +} diff --git a/src/card-controller/view/types.ts b/src/card-controller/view/types.ts new file mode 100644 index 00000000..a1aae634 --- /dev/null +++ b/src/card-controller/view/types.ts @@ -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; + + // 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; + setViewByParametersWithNewQuery(options?: ViewFactoryOptions): Promise; + setViewByParametersWithExistingQuery(options?: ViewFactoryOptions): Promise; + + 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 {} diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts new file mode 100644 index 00000000..da6ccde6 --- /dev/null +++ b/src/card-controller/view/view-manager.ts @@ -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 => + await this._setViewGenericAsync(this._factory.getViewDefaultWithNewQuery, options); + + setViewByParametersWithNewQuery = async ( + options?: ViewFactoryOptions, + ): Promise => + await this._setViewGenericAsync( + this._factory.getViewByParametersWithNewQuery, + options, + ); + + setViewByParametersWithExistingQuery = async ( + options?: ViewFactoryOptions, + ): Promise => + 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, + options?: ViewFactoryOptions, + ): Promise { + 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(); + } +} diff --git a/src/card.ts b/src/card.ts index f36bdbe3..8f9000c5 100644 --- a/src/card.ts +++ b/src/card.ts @@ -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) => this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)} - @frigate-card:view:change=${(ev: CustomEvent) => - this._controller.getViewManager().setView(ev.detail)} - @frigate-card:view:change-context=${(ev: CustomEvent) => - this._controller.getViewManager().setViewWithMergedContext(ev.detail)} @frigate-card:media:loaded=${(ev: CustomEvent) => this._controller.getMediaLoadedInfoManager().set(ev.detail)} @frigate-card:media:unloaded=${() => @@ -299,7 +293,7 @@ class FrigateCard extends LitElement { html`; - - 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): 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 { - 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, - }, - ); - } - } } diff --git a/src/components-lib/media-filter-controller.ts b/src/components-lib/media-filter-controller.ts index 5a46f6f1..a92b6321 100644 --- a/src/components-lib/media-filter-controller.ts +++ b/src/components-lib/media-filter-controller.ts @@ -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 { diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 160b256f..5e1e7fd1 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -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, diff --git a/src/components-lib/zoom/zoom-view-context.ts b/src/components-lib/zoom/zoom-view-context.ts index bd13e89c..3b67e80f 100644 --- a/src/components-lib/zoom/zoom-view-context.ts +++ b/src/components-lib/zoom/zoom-view-context.ts @@ -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, + 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, + }), + ), + ], + }); }; diff --git a/src/components/gallery.ts b/src/components/gallery.ts index c98608c7..55e5e73a 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -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; + 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` @@ -126,7 +95,7 @@ export class FrigateCardGallery extends LitElement { : ''} ; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public galleryConfig?: GalleryConfig; @@ -328,13 +297,15 @@ export class FrigateCardGalleryCore extends LitElement { direction: 'earlier' | 'later', useCache = true, ): Promise { - 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`
${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 ) { diff --git a/src/components/live/live.ts b/src/components/live/live.ts index c435f88b..2f440e43 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -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; + 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` ; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public nonOverriddenLiveConfig?: LiveConfig; @@ -207,13 +186,14 @@ export class FrigateCardLiveGrid extends LitElement { public triggeredCameraIDs?: Set; protected _renderCarousel(cameraID?: string): TemplateResult { - const triggeredCameraID = cameraID ?? this.view?.camera; + const view = this.viewManagerEpoch?.manager.getView(); + const triggeredCameraID = cameraID ?? view?.camera; return html` 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` ) => this._gridSelectCamera(ev.detail.selected)} - @frigate-card:view:change=${(ev: CustomEvent) => { - ev.stopPropagation(); - this._gridSelectCamera(ev.detail.camera, ev.detail); - }} > ${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))} @@ -288,7 +266,7 @@ export class FrigateCardLiveCarousel extends LitElement { public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - public view?: Readonly; + 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`
) => - handleZoomSettingsObservedEvent(this, ev, cameraID)} + handleZoomSettingsObservedEvent( + ev, + this.viewManagerEpoch?.manager, + cameraID, + )} >
@@ -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 { ${cameraMetadataCurrent && titleConfig diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index b85be987..e7a6bb06 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -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 = 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(); diff --git a/src/components/select.ts b/src/components/select.ts index 3a1bca79..94393e96 100644 --- a/src/components/select.ts +++ b/src/components/select.ts @@ -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); + } } } diff --git a/src/components/surround.ts b/src/components/surround.ts index 45ee50f1..c48b5c7b 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -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; + 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 | 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, ) => { 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` ; + 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( this, 'thumbnail-carousel:tap', { - queryResults: this.view.queryResults.clone().selectIndex(index), + queryResults: view.queryResults.clone().selectIndex(index), }, ); } diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 61f4464f..6a34dce4 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -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; + 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'])], + }); }} >` : ''} diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 84eb0a20..502ac0cc 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -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 {} @@ -115,7 +114,7 @@ class ThumbnailDataRequestEvent extends CustomEvent {} 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} > `; @@ -182,12 +181,12 @@ export class FrigateCardTimelineCore extends LitElement { public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - public view?: Readonly; + 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 { - 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 { 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 { - 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 { - 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(); } } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 08f737e6..4e0c11e9 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -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; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public timelineConfig?: TimelineConfig; @@ -33,7 +33,7 @@ export class FrigateCardTimeline extends LitElement { return html` , + view?: Readonly | null, baseConfig?: TitleControlConfig, ): TitleControlConfig | null => { if (!baseConfig && view?.isGrid()) { diff --git a/src/components/viewer.ts b/src/components/viewer.ts index d8deceda..3c79774b 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -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; + 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` ; + 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; }} > - { - scroll('previous'); - stopEventFromActivatingCardWideActions(ev); - }} - > - ${guard([this._media, this.view], () => this._getSlides())} - { - scroll('next'); - stopEventFromActivatingCardWideActions(ev); - }} - > + ${this.showControls + ? html` { + scroll('previous'); + stopEventFromActivatingCardWideActions(ev); + }} + >` + : ''} + ${guard([this._media, view], () => this._getSlides())} + ${this.showControls + ? html` { + scroll('next'); + stopEventFromActivatingCardWideActions(ev); + }} + >` + : ''} - ${this.view + ${view ? html` ` : ''} @@ -530,7 +497,8 @@ export class FrigateCardViewerCarousel extends LitElement { * Fire a media show event when a slide is selected. */ protected async _seekHandler(): Promise { - 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`
; + 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` `; } - 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` ) => this._gridSelectCamera(ev.detail.selected)} - @frigate-card:view:change=${(ev: CustomEvent) => { - ev.stopPropagation(); - const childView = ev.detail; - this._gridSelectCamera(childView.camera, childView); - }} > ${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))} @@ -680,7 +647,7 @@ export class FrigateCardViewerProvider public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - public view?: Readonly; + 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 { + 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` this.setControls(false)} @frigate-card:zoom:unzoomed=${() => this.setControls()} @frigate-card:zoom:change=${(ev: CustomEvent) => - handleZoomSettingsObservedEvent(this, ev, mediaID)} + handleZoomSettingsObservedEvent(ev, this.viewManagerEpoch?.manager, mediaID)} > ${template} ` @@ -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; } diff --git a/src/components/views.ts b/src/components/views.ts index 160ffeb0..8d735574 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -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; + public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public cameraManager?: CameraManager; @@ -64,17 +63,18 @@ export class FrigateCardViews extends LitElement { public triggeredCameraIDs?: Set; 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` - ${!this.hide && this.view?.is('image') && cameraConfig + ${!this.hide && view?.is('image') && cameraConfig ? html` ` : ``} - ${!this.hide && this.view?.isGalleryView() + ${!this.hide && view?.isGalleryView() ? html` ` : ``} - ${!this.hide && this.view?.isViewerView() + ${!this.hide && view?.isViewerView() ? html` ` : ``} - ${!this.hide && this.view?.is('timeline') + ${!this.hide && view?.is('timeline') ? html` ` : ``} - ${!this.hide && this.view?.is('diagnostics') + ${!this.hide && view?.is('diagnostics') ? html` 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` ` : ''} ${this._renderOptionSetHeader('timeline')} diff --git a/src/utils/find-best-media-index.ts b/src/utils/find-best-media-index.ts new file mode 100644 index 00000000..f59c3c55 --- /dev/null +++ b/src/utils/find-best-media-index.ts @@ -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; +}; diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts deleted file mode 100644 index 718f2646..00000000 --- a/src/utils/media-to-view.ts +++ /dev/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 => { - 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, - 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 => { - 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, -): 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 => { - const queries = query.getQueries(); - if (!queries) { - return null; - } - - const mediaArray = await cameraManager.executeMediaQueries(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 => { - 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; -}; diff --git a/src/utils/substream.ts b/src/utils/substream.ts index 938eb218..7f1aafaa 100644 --- a/src/utils/substream.ts +++ b/src/utils/substream.ts @@ -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 = 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 | undefined = view.context?.live?.overrides; + if (overrides && overrides.has(view.camera)) { + view.context?.live?.overrides?.delete(view.camera); + } +}; diff --git a/src/view/view.ts b/src/view/view.ts index 918ff818..b2cde44e 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -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) => 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) => 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); -}; diff --git a/tests/card-controller/actions/actions/camera-select.test.ts b/tests/card-controller/actions/actions/camera-select.test.ts index 7c2abc2c..57d30a19 100644 --- a/tests/card-controller/actions/actions/camera-select.test.ts +++ b/tests/card-controller/actions/actions/camera-select.test.ts @@ -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(); }); }); diff --git a/tests/card-controller/actions/actions/display-mode-select.test.ts b/tests/card-controller/actions/actions/display-mode-select.test.ts index 57da6936..7c7900e5 100644 --- a/tests/card-controller/actions/actions/display-mode-select.test.ts +++ b/tests/card-controller/actions/actions/display-mode-select.test.ts @@ -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', + }, + }); }); diff --git a/tests/card-controller/actions/actions/substream-off.test.ts b/tests/card-controller/actions/actions/substream-off.test.ts index 149b2d17..03b86187 100644 --- a/tests/card-controller/actions/actions/substream-off.test.ts +++ b/tests/card-controller/actions/actions/substream-off.test.ts @@ -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)], + }); }); diff --git a/tests/card-controller/actions/actions/substream-on.test.ts b/tests/card-controller/actions/actions/substream-on.test.ts index 21ede4bf..b37061f3 100644 --- a/tests/card-controller/actions/actions/substream-on.test.ts +++ b/tests/card-controller/actions/actions/substream-on.test.ts @@ -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)], + }); }); diff --git a/tests/card-controller/actions/actions/substream-select.test.ts b/tests/card-controller/actions/actions/substream-select.test.ts index 7ab186f8..a0f326f9 100644 --- a/tests/card-controller/actions/actions/substream-select.test.ts +++ b/tests/card-controller/actions/actions/substream-select.test.ts @@ -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)]), + }), + ); }); diff --git a/tests/card-controller/actions/actions/view.test.ts b/tests/card-controller/actions/actions/view.test.ts index 16f17f79..41215a63 100644 --- a/tests/card-controller/actions/actions/view.test.ts +++ b/tests/card-controller/actions/actions/view.test.ts @@ -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, + }, }), ); }); diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index f2ae1f97..6148cb7e 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -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'); diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts index fb8ba160..add474ce 100644 --- a/tests/card-controller/initialization-manager.test.ts +++ b/tests/card-controller/initialization-manager.test.ts @@ -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(); }); diff --git a/tests/card-controller/query-string-manager.test.ts b/tests/card-controller/query-string-manager.test.ts index 7d1c6427..6ee8ae42 100644 --- a/tests/card-controller/query-string-manager.test.ts +++ b/tests/card-controller/query-string-manager.test.ts @@ -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', + }, }); }); }); diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index e81e8ecb..88b058e6 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -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'); }); diff --git a/tests/card-controller/view-manager.test.ts b/tests/card-controller/view-manager.test.ts deleted file mode 100644 index ae911bb4..00000000 --- a/tests/card-controller/view-manager.test.ts +++ /dev/null @@ -1,1035 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { QueryType } from '../../src/camera-manager/types'; -import { ViewManager } from '../../src/card-controller/view-manager'; -import { FrigateCardView } from '../../src/config/types'; -import { executeMediaQueryForView } from '../../src/utils/media-to-view'; -import { EventMediaQueries } from '../../src/view/media-queries'; -import { MediaQueriesResults } from '../../src/view/media-queries-results'; -import { View } from '../../src/view/view'; -import { - createCameraConfig, - createCameraManager, - createCapabilities, - createCardAPI, - createConfig, - createHASS, - createStore, - createView, - generateViewMediaArray, -} from '../test-utils'; - -vi.mock('../../src/utils/media-to-view'); - -describe('ViewManager.setView', () => { - it('should set view', () => { - const api = createCardAPI(); - const manager = new ViewManager(api); - - const view = createView({ - view: 'live', - camera: 'camera', - displayMode: 'grid', - }); - manager.setView(view); - - 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('should set view with minor changes without media clearing or scroll', () => { - const api = createCardAPI(); - const manager = new ViewManager(api); - - const view_1 = createView({ - view: 'live', - camera: 'camera', - }); - manager.setView(view_1); - - vi.mocked(api.getMediaLoadedInfoManager().clear).mockClear(); - vi.mocked(api.getCardElementManager().scrollReset).mockClear(); - - const view_2 = createView({ - view: 'live', - camera: 'camera', - displayMode: 'single', - }); - - manager.setView(view_2); - - 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('should set view with new context', () => { - const api = createCardAPI(); - const manager = new ViewManager(api); - const context = { live: { fetchThumbnails: false } }; - - // Setting context with no existing view does nothing. - manager.setViewWithMergedContext(context); - expect(manager.getView()).toBeNull(); - - const view = createView({ - view: 'live', - camera: 'camera', - }); - manager.setView(view); - manager.setViewWithMergedContext(context); - - expect(manager.getView()?.camera).toBe('camera'); - expect(manager.getView()?.view).toBe('live'); - expect(manager.getView()?.context).toEqual(context); - }); -}); - -describe('ViewManager.reset', () => { - it('should reset', () => { - const manager = new ViewManager(createCardAPI()); - - const view = createView(); - manager.setView(view); - manager.reset(); - - expect(manager.getView()).toBeNull(); - expect(manager.hasView()).toBeFalsy(); - }); -}); - -describe('ViewManager.setViewDefault', () => { - it('should set default view', () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera', - capabilities: createCapabilities({ live: true }), - }, - ]), - ); - - const manager = new ViewManager(api); - manager.setViewDefault(); - - expect(manager.getView()?.view).toBe('live'); - expect(manager.getView()?.camera).toBe('camera'); - }); - - it('should not set default view without config', () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); - - const manager = new ViewManager(api); - manager.setViewDefault(); - - expect(manager.getView()).toBeNull(); - }); - - it('should cycle camera when configured', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera_1', - capabilities: createCapabilities({ live: true }), - }, - { - cameraID: 'camera_2', - capabilities: createCapabilities({ live: true }), - }, - ]), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - default_cycle_camera: true, - }, - }), - ); - const manager = new ViewManager(api); - - manager.setViewDefault(); - expect(manager.getView()?.camera).toBe('camera_1'); - - manager.setViewDefault(); - expect(manager.getView()?.camera).toBe('camera_2'); - - manager.setViewDefault(); - expect(manager.getView()?.camera).toBe('camera_1'); - - // When a parameter is specified, it will not cycle. - manager.setViewDefault({ cameraID: 'camera_1' }); - expect(manager.getView()?.camera).toBe('camera_1'); - }); - - it('should respect parameters', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ live: true }), - }, - { - cameraID: 'camera.office', - capabilities: createCapabilities({ live: true }), - }, - ]), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - const manager = new ViewManager(api); - - manager.setViewDefault({ - cameraID: 'camera.office', - substream: 'camera.office_hd', - }); - expect(manager.getView()?.view).toBe('live'); - expect(manager.getView()?.camera).toBe('camera.office'); - expect(manager.getView()?.context?.live?.overrides).toEqual( - new Map([['camera.office', 'camera.office_hd']]), - ); - }); -}); - -describe('ViewManager.setViewByParameters', () => { - it('should set view by parameters specifying camera and view', () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ clips: true }), - }, - ]), - ); - vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createCapabilities({ - clips: true, - }), - ); - - const manager = new ViewManager(api); - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - viewName: 'clips', - }); - - expect(manager.getView()?.view).toBe('clips'); - expect(manager.getView()?.camera).toBe('camera.kitchen'); - }); - - it('should set view by parameters using existing view if unspecified', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ clips: true }), - }, - { - cameraID: 'camera.office', - capabilities: createCapabilities({ clips: true }), - }, - ]), - ); - vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createCapabilities({ - clips: true, - }), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - - const manager = new ViewManager(api); - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - viewName: 'clips', - }); - - manager.setViewByParameters({ - cameraID: 'camera.office', - }); - - expect(manager.getView()?.view).toBe('clips'); - expect(manager.getView()?.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 manager = new ViewManager(api); - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - // No prior view, and no specified view. This could happen during query - // string based initialization. - }); - - expect(manager.getView()?.view).toBe('live'); - expect(manager.getView()?.camera).toBe('camera.kitchen'); - }); - - it('should not set view by parameters without config', () => { - const manager = new ViewManager(createCardAPI()); - - manager.setViewByParameters({ - viewName: 'live', - }); - - expect(manager.getView()).toBeNull(); - }); - - describe('should handle unsupported view', () => { - it('without camera 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()); - - const manager = new ViewManager(api); - manager.setViewByParameters({ - // Since no camera is specified, and no camera supports the capabilities - // necessary for this view, the view will be null. - viewName: 'snapshots', - }); - - expect(manager.getView()).toBeNull(); - }); - - it('without camera with 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 manager = new ViewManager(api); - manager.setViewByParameters({ - // Since no camera is specified, and no camera supports the capabilities - // necessary for this view, and since failSafe is specified, an error - // will be shown. - viewName: 'snapshots', - failSafe: true, - }); - - expect(manager.getView()).toBeNull(); - expect(manager.hasView()).toBeFalsy(); - expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith( - expect.objectContaining({ - type: 'error', - message: 'No cameras support this view', - context: { - view: 'snapshots', - cameras_capabilities: { - 'camera.kitchen': { - 'favorite-events': false, - 'favorite-recordings': false, - clips: false, - live: false, - recordings: false, - seek: false, - snapshots: false, - }, - }, - }, - }), - ); - }); - - it('with camera 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 manager = new ViewManager(api); - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - viewName: 'snapshots', - }); - - expect(manager.hasView()).toBeFalsy(); - expect(manager.getView()).toBeNull(); - }); - - it('with camera with failsafe', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ snapshots: false, live: true }), - }, - ]), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createCapabilities({ - snapshots: false, - live: true, - }), - ); - - const manager = new ViewManager(api); - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - viewName: 'snapshots', - failSafe: true, - }); - - expect(manager.hasView()).toBeTruthy(); - expect(manager.getView()?.view).toBe('live'); - }); - - it('with camera with failsafe when live unsupported', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ snapshots: false, live: false }), - }, - ]), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createCapabilities({ - snapshots: false, - live: false, - }), - ); - - const manager = new ViewManager(api); - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - viewName: 'snapshots', - failSafe: true, - }); - - expect(manager.hasView()).toBeFalsy(); - expect(manager.getView()).toBeNull(); - expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith( - expect.objectContaining({ - type: 'error', - message: 'The selected camera does not support this view', - context: { - view: 'snapshots', - camera: 'camera.kitchen', - camera_capabilities: { - 'favorite-events': false, - 'favorite-recordings': false, - clips: false, - live: false, - recordings: false, - seek: false, - snapshots: false, - }, - }, - }), - ); - }); - }); - - describe('should set view by parameters and respect display mode in config for view', () => { - it.each([ - ['media' as const], - ['clip' as const], - ['recording' as const], - ['snapshot' as const], - ['live' as const], - ])('%s', (viewName: FrigateCardView) => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - live: true, - clips: true, - recordings: true, - snapshots: true, - }), - }, - ]), - ); - vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createCapabilities({ - live: true, - clips: true, - recordings: true, - snapshots: true, - }), - ); - - vi.mocked(api.getConfigManager()).getConfig.mockReturnValue( - createConfig({ - media_viewer: { - display: { - mode: 'grid', - }, - }, - live: { - display: { - mode: 'grid', - }, - }, - }), - ); - const manager = new ViewManager(api); - - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - viewName: viewName, - }); - - expect(manager.getView()?.displayMode).toBe('grid'); - }); - }); - - describe('should set view by parameters and leave display mode unset for view', () => { - it.each([ - ['media' as const], - ['clip' as const], - ['recording' as const], - ['snapshot' as const], - ['live' as const], - ])('%s', (viewName: FrigateCardView) => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - live: true, - clips: true, - recordings: true, - snapshots: true, - }), - }, - ]), - ); - vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createCapabilities({ - live: true, - clips: true, - recordings: true, - snapshots: true, - }), - ); - - const manager = new ViewManager(api); - - manager.setViewByParameters({ - cameraID: 'camera.kitchen', - viewName: viewName, - }); - - expect(manager.getView()?.displayMode).toBe('single'); - }); - }); -}); - -// @vitest-environment jsdom -describe('ViewManager.setViewWithNewDisplayMode', () => { - it('should set display mode', async () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - - const manager = new ViewManager(api); - manager.setView(createView()); - - await manager.setViewWithNewDisplayMode('grid'); - - expect(manager.getView()?.displayMode).toBe('grid'); - }); - - it('should not set display mode without view', async () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - const manager = new ViewManager(api); - - manager.setViewWithNewDisplayMode('grid'); - - expect(manager.getView()).toBeNull(); - }); - - it('should set display mode to grid and create new query', async () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - { - cameraID: 'camera.office', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - ]), - ); - - const hass = createHASS(); - vi.mocked(api.getHASSManager()).getHASS.mockReturnValue(hass); - - const mediaArray = generateViewMediaArray({ count: 5 }); - vi.mocked(executeMediaQueryForView).mockResolvedValue( - new View({ - view: 'clip', - camera: 'camera.kitchen', - queryResults: new MediaQueriesResults({ results: mediaArray }), - }), - ); - - const manager = new ViewManager(api); - const query = new EventMediaQueries([ - { type: QueryType.Event, cameraIDs: new Set(['camera_1']), hasClip: true }, - ]); - - manager.setView( - createView({ - camera: 'camera_1', - view: 'clip', - query: query, - }), - ); - - await manager.setViewWithNewDisplayMode('grid'); - - expect(manager.getView()?.queryResults?.getResults()).toBe(mediaArray); - }); - - it('should set display mode to single and create new query', async () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - { - cameraID: 'camera.office', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - ]), - ); - - const hass = createHASS(); - vi.mocked(api.getHASSManager()).getHASS.mockReturnValue(hass); - - const mediaArray = generateViewMediaArray({ count: 5 }); - vi.mocked(executeMediaQueryForView).mockResolvedValue( - new View({ - view: 'clip', - camera: 'camera.kitchen', - queryResults: new MediaQueriesResults({ results: mediaArray }), - }), - ); - - const manager = new ViewManager(api); - const query = new EventMediaQueries([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen', 'camera.office']), - hasClip: true, - }, - ]); - - manager.setView( - createView({ - view: 'clip', - camera: 'camera.office', - query: query, - }), - ); - - await manager.setViewWithNewDisplayMode('single'); - - expect(manager.getView()?.queryResults?.getResults()).toBe(mediaArray); - }); - - it('should set display mode to single and handle failed new query', async () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - { - cameraID: 'camera.office', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - ]), - ); - - const manager = new ViewManager(api); - const query = new EventMediaQueries([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen', 'camera.office']), - hasClip: true, - }, - ]); - - const originalView = createView({ - view: 'clip', - camera: 'camera.office', - query: query, - }); - manager.setView(originalView); - - // Query execution fails / returns null. - vi.mocked(executeMediaQueryForView).mockRejectedValue(null); - - await manager.setViewWithNewDisplayMode('single'); - - expect(manager.getView()).toBe(originalView); - }); - - it('should set display mode and handle empty new query results', async () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - { - cameraID: 'camera.office', - capabilities: createCapabilities({ - clips: true, - recordings: true, - snapshots: true, - }), - }, - ]), - ); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - - const manager = new ViewManager(api); - - const query = new EventMediaQueries([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - hasClip: true, - }, - ]); - const originalView = createView({ - view: 'clip', - camera: 'camera.office', - query: query, - }); - manager.setView(originalView); - - await manager.setViewWithNewDisplayMode('grid'); - - vi.mocked(executeMediaQueryForView).mockResolvedValue(null); - - // Empty queries will not be executed, so view will not be changed. - expect(manager.getView()?.displayMode).toBeNull(); - }); -}); - -describe('ViewManager.setViewWithSubstream', () => { - it('should set new equal view with no dependencies', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - }, - ]), - ); - const manager = new ViewManager(api); - const view = createView({ - view: 'live', - camera: 'camera', - }); - - manager.setView(view); - manager.setViewWithSubstream(); - - expect(manager.getView()?.camera).toBe(view.camera); - expect(manager.getView()?.view).toBe(view.view); - expect(manager.getView()?.context).toEqual(view.context); - }); - - it('should set new view with next substream', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - config: createCameraConfig({ - dependencies: { - cameras: ['camera.kitchen_hd'], - }, - }), - }, - { - cameraID: 'camera.kitchen_hd', - }, - ]), - ); - const manager = new ViewManager(api); - const view = createView({ - view: 'live', - camera: 'camera.kitchen', - }); - - manager.setView(view); - manager.setViewWithSubstream(); - - expect(manager.getView()?.context?.live?.overrides).toEqual( - new Map([['camera.kitchen', 'camera.kitchen_hd']]), - ); - }); - - it('should set new view with next substream when view has invalid substream', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - config: createCameraConfig({ - dependencies: { - cameras: ['camera.kitchen_hd'], - }, - }), - }, - { - cameraID: 'camera.kitchen_hd', - }, - ]), - ); - const manager = new ViewManager(api); - const view = createView({ - view: 'live', - camera: 'camera.kitchen', - context: { - live: { - overrides: new Map([['camera.kitchen', 'camera-that-does-not-exist']]), - }, - }, - }); - - manager.setView(view); - manager.setViewWithSubstream(); - - expect(manager.getView()?.context?.live?.overrides).toEqual( - new Map([['camera.kitchen', 'camera.kitchen']]), - ); - }); - - it('should set new view with selected substream', () => { - const view = createView({ - view: 'live', - camera: 'camera', - }); - - const manager = new ViewManager(createCardAPI()); - manager.setView(view); - manager.setViewWithSubstream('substream'); - - expect(manager.getView()?.context?.live?.overrides).toEqual( - new Map([['camera', 'substream']]), - ); - }); - - it('should not set view with next substream without an existing view', () => { - const manager = new ViewManager(createCardAPI()); - manager.setViewWithSubstream(); - expect(manager.getView()).toBeNull(); - }); - - it('should not set view with selected substream without an existing view', () => { - const manager = new ViewManager(createCardAPI()); - manager.setViewWithSubstream('substream'); - expect(manager.getView()).toBeNull(); - }); - - it('should not set view without substream without an existing view', () => { - const manager = new ViewManager(createCardAPI()); - manager.setViewWithoutSubstream(); - expect(manager.getView()).toBeNull(); - }); - - it('should set new view without substream', () => { - const view = createView({ - view: 'live', - camera: 'camera', - context: { - live: { - overrides: new Map([['camera', 'camera']]), - }, - }, - }); - - const manager = new ViewManager(createCardAPI()); - manager.setView(view); - manager.setViewWithoutSubstream(); - - expect(manager.getView()?.context?.live?.overrides).toEqual(new Map()); - }); - - it('should set new view without substream', () => { - const view = createView({ - view: 'live', - camera: 'camera', - context: { - live: { - overrides: new Map([['camera-2', 'camera-3']]), - }, - }, - }); - - const manager = new ViewManager(createCardAPI()); - manager.setView(view); - manager.setViewWithoutSubstream(); - - expect(manager.getView()?.context?.live?.overrides).toEqual( - view.context?.live?.overrides, - ); - }); -}); - -describe('ViewManager.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); - }); -}); diff --git a/tests/card-controller/view/factory.test.ts b/tests/card-controller/view/factory.test.ts new file mode 100644 index 00000000..7e84115c --- /dev/null +++ b/tests/card-controller/view/factory.test.ts @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/tests/card-controller/view/modifiers/merge-context.test.ts b/tests/card-controller/view/modifiers/merge-context.test.ts new file mode 100644 index 00000000..43e91f41 --- /dev/null +++ b/tests/card-controller/view/modifiers/merge-context.test.ts @@ -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); +}); diff --git a/tests/card-controller/view/modifiers/remove-context-property.test.ts b/tests/card-controller/view/modifiers/remove-context-property.test.ts new file mode 100644 index 00000000..ba4f5241 --- /dev/null +++ b/tests/card-controller/view/modifiers/remove-context-property.test.ts @@ -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: {} }); +}); diff --git a/tests/card-controller/view/modifiers/remove-context.test.ts b/tests/card-controller/view/modifiers/remove-context.test.ts new file mode 100644 index 00000000..28795272 --- /dev/null +++ b/tests/card-controller/view/modifiers/remove-context.test.ts @@ -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({}); +}); + \ No newline at end of file diff --git a/tests/card-controller/view/modifiers/substream-off.test.ts b/tests/card-controller/view/modifiers/substream-off.test.ts new file mode 100644 index 00000000..d58d0fbf --- /dev/null +++ b/tests/card-controller/view/modifiers/substream-off.test.ts @@ -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); +}); diff --git a/tests/card-controller/view/modifiers/substream-on.test.ts b/tests/card-controller/view/modifiers/substream-on.test.ts new file mode 100644 index 00000000..cbba3e27 --- /dev/null +++ b/tests/card-controller/view/modifiers/substream-on.test.ts @@ -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); + }); +}); diff --git a/tests/card-controller/view/modifiers/substream-select.test.ts b/tests/card-controller/view/modifiers/substream-select.test.ts new file mode 100644 index 00000000..7c1a4455 --- /dev/null +++ b/tests/card-controller/view/modifiers/substream-select.test.ts @@ -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'); +}); diff --git a/tests/card-controller/view/query-executor.test.ts b/tests/card-controller/view/query-executor.test.ts new file mode 100644 index 00000000..61355da4 --- /dev/null +++ b/tests/card-controller/view/query-executor.test.ts @@ -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'); + }); +}); diff --git a/tests/card-controller/view/test-utils.ts b/tests/card-controller/view/test-utils.ts new file mode 100644 index 00000000..926208a5 --- /dev/null +++ b/tests/card-controller/view/test-utils.ts @@ -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; +}; diff --git a/tests/card-controller/view/view-manager.test.ts b/tests/card-controller/view/view-manager.test.ts new file mode 100644 index 00000000..0f4094a6 --- /dev/null +++ b/tests/card-controller/view/view-manager.test.ts @@ -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(); + 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(); + 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(); + + 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(); + const manager = new ViewManager(createCardAPI(), factory); + expect(manager.getEpoch()).toBeTruthy(); + expect(manager.getEpoch().manager).toBe(manager); +}); + +it('reset', () => { + const factory = mock(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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 = new Map(); + overrides_1.set('camera', 'camera-2'); + + const overrides_2: Map = new Map(); + overrides_2.set('camera', 'camera-3'); + + const factory = mock(); + 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 = new Map(); + overrides_1.set('camera', 'camera-2'); + + const overrides_2: Map = new Map(); + overrides_2.set('camera', 'camera-3'); + + const factory = mock(); + 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(); + 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(); + factory.getViewDefault.mockReturnValue(createView({ queryResults: queryResults_1 })); + + const manager = new ViewManager(createCardAPI(), factory); + manager.setViewDefault(); + + expect( + manager.hasMajorMediaChange(createView({ queryResults: queryResults_2 })), + ).toBeFalsy(); + }); +}); diff --git a/tests/components-lib/live/live-controller.test.ts b/tests/components-lib/live/live-controller.test.ts index b3dea6c9..85fd3b31 100644 --- a/tests/components-lib/live/live-controller.test.ts +++ b/tests/components-lib/live/live-controller.test.ts @@ -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, - }, - }, - }), - }), - ); - }); - }); - }); }); diff --git a/tests/components-lib/media-filter-controller.test.ts b/tests/components-lib/media-filter-controller.test.ts index cf08281d..da45ef7f 100644 --- a/tests/components-lib/media-filter-controller.test.ts +++ b/tests/components-lib/media-filter-controller.test.ts @@ -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.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.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.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.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.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(); - 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.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 controller = new MediaFilterController(host); - const cameraManager = createCameraManager(); - const view = createView(); + const cameraManager = createCameraManager(createCameraStore()); + + const viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); + vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); - vi.mocked(executeMediaQueryForViewWithErrorDispatching).mockResolvedValueOnce( - view, - ); + + const controller = new MediaFilterController(host); + 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.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.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.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 viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); + const controller = new MediaFilterController(createLitElement()); + controller.setViewManager(viewManager); - controller.computeInitialDefaultsFromView(createCameraManager(), createView()); + controller.computeInitialDefaultsFromView(createCameraManager()); expect(controller.getDefaults()).toBeNull(); }); it('with no cameras', () => { + const viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); + const controller = new MediaFilterController(createLitElement()); + controller.setViewManager(viewManager); - controller.computeInitialDefaultsFromView( - createCameraManager(), - createView({ - query: new EventMediaQueries([ - { type: QueryType.Event, cameraIDs: new Set(['camera.kitchen']) }, - ]), - }), - ); + 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.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); }, ); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index c55b9c0c..5e7d5c30 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -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, diff --git a/tests/components-lib/zoom/zoom-view-context.test.ts b/tests/components-lib/zoom/zoom-view-context.test.ts index 25508b5a..cf2320ba 100644 --- a/tests/components-lib/zoom/zoom-view-context.test.ts +++ b/tests/components-lib/zoom/zoom-view-context.test.ts @@ -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(); + 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, + }, + }, + }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 2e5defb3..e066e719 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -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 => { - 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 => { + await new Promise(process.nextTick); +}; diff --git a/tests/utils/media-to-view.test.ts b/tests/utils/media-to-view.test.ts deleted file mode 100644 index 05493cb3..00000000 --- a/tests/utils/media-to-view.test.ts +++ /dev/null @@ -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): 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); - }); -}); diff --git a/tests/utils/substream.test.ts b/tests/utils/substream.test.ts index a9d1ea88..c75330b3 100644 --- a/tests/utils/substream.test.ts +++ b/tests/utils/substream.test.ts @@ -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']]), + }, + }); + }); +}); diff --git a/tests/view/view-to-cameras.test.ts b/tests/view/view-to-cameras.test.ts index 0d90ad73..723ce232 100644 --- a/tests/view/view-to-cameras.test.ts +++ b/tests/view/view-to-cameras.test.ts @@ -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( diff --git a/tests/view/view.test.ts b/tests/view/view.test.ts index bea28a03..ab2ffa29 100644 --- a/tests/view/view.test.ts +++ b/tests/view/view.test.ts @@ -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 = new Map(); - overrides_1.set('camera', 'camera-2'); - - const overrides_2: Map = 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 = new Map(); - overrides_1.set('camera', 'camera-2'); - - const overrides_2: Map = 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(); - }); -}); diff --git a/vite.config.ts b/vite.config.ts index a35a2154..0808f0ab 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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', diff --git a/yarn.lock b/yarn.lock index 4d2adda6..7de17cfc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 From 73e7e6cf0fb8098031c0b1dbce173933da9597f6 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 29 Jul 2024 09:46:32 -0700 Subject: [PATCH 06/11] Bind to correct object. --- src/card-controller/view/view-manager.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts index da6ccde6..a3718a75 100644 --- a/src/card-controller/view/view-manager.ts +++ b/src/card-controller/view/view-manager.ts @@ -42,19 +42,22 @@ export class ViewManager implements ViewManagerInterface { } setViewDefault = (options?: ViewFactoryOptions): void => - this._setViewGeneric(this._factory.getViewDefault, options); + this._setViewGeneric(this._factory.getViewDefault.bind(this._factory), options); setViewByParameters = (options?: ViewFactoryOptions): void => - this._setViewGeneric(this._factory.getViewByParameters, options); + this._setViewGeneric(this._factory.getViewByParameters.bind(this._factory), options); setViewDefaultWithNewQuery = async (options?: ViewFactoryOptions): Promise => - await this._setViewGenericAsync(this._factory.getViewDefaultWithNewQuery, options); + await this._setViewGenericAsync( + this._factory.getViewDefaultWithNewQuery.bind(this._factory), + options, + ); setViewByParametersWithNewQuery = async ( options?: ViewFactoryOptions, ): Promise => await this._setViewGenericAsync( - this._factory.getViewByParametersWithNewQuery, + this._factory.getViewByParametersWithNewQuery.bind(this._factory), options, ); @@ -62,7 +65,7 @@ export class ViewManager implements ViewManagerInterface { options?: ViewFactoryOptions, ): Promise => await this._setViewGenericAsync( - this._factory.getViewByParametersWithExistingQuery, + this._factory.getViewByParametersWithExistingQuery.bind(this._factory), options, ); From d171aac09b14707b16977f5587b9ca20e8f46fce Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 29 Jul 2024 09:54:45 -0700 Subject: [PATCH 07/11] Make description more intuitive. --- src/localize/languages/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 72f3ff79..31904859 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -483,7 +483,7 @@ "none": "No action" } }, - "editor_label": "Behavior when a camera is triggered", + "editor_label": "Trigger behavior", "filter_selected_camera": "Only trigger on selected camera", "show_trigger_status": "Show pulsing border when triggered", "untrigger_seconds": "Seconds after inactive state change to untrigger" From f2ca80821e7faa911a22ca629bdf751e5563bdb4 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 29 Jul 2024 10:09:44 -0700 Subject: [PATCH 08/11] Update docs. --- docs/configuration/view.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/view.md b/docs/configuration/view.md index 9e2ad5dd..f97cfc8b 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -98,7 +98,7 @@ already occupied room will not trigger, but a newly occupied room will). | Option | Default | Description | | ------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `interaction_mode` | `inactive` | Whether actions should be taken when the card is being interacted with. If `all`, actions will always left be taken regardless. If `inactive` actions will only be taken if the card has _not_ had human interaction recently (as defined by `view.interaction_seconds`). If `active` actions will only be taken if the card _has_ had human interaction recently. This does not stop triggering itself (i.e. border will still pulse if `show_trigger_status` is true) but rather just prevents the actions being performed. | -| `trigger` | `default` | If set to `default` the default view of the card will be reloaded. If set to `live` the triggered camera will be selected in `live` view. If set to `media` the appropriate media view (e.g. `clip` or `snapshot`) will be chosen to match a newly available media item (please note that only some [camera engines](cameras/engine.md) support new media detection, e.g. `frigate`). If set to `none` no action is taken. | +| `trigger` | `update` | If set to `update` the current view is updated in place. If set to `default` the default view of the card will be reloaded. If set to `live` the triggered camera will be selected in `live` view. If set to `media` the appropriate media view (e.g. `clip` or `snapshot`) will be chosen to match a newly available media item (please note that only some [camera engines](cameras/engine.md) support new media detection, e.g. `frigate`). If set to `none` no action is taken. | | `untrigger` | `none` | If set to `default` the the default view of the card will be reloaded. If set to `none` no action will be taken. | ## Supported views From 67c90aa577b5f065ad6096400994f6e002008d49 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 30 Jul 2024 02:17:47 -0700 Subject: [PATCH 09/11] Minor docs example updates. --- docs/configuration/view.md | 1 - docs/examples.md | 11 ++++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/configuration/view.md b/docs/configuration/view.md index f97cfc8b..73bb0fde 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -20,7 +20,6 @@ view: | `reset_after_interaction` | `true` | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. | | `triggers` | | How to react when a camera is [triggered](cameras/README.md?id=triggers). | | `default_cycle_camera` | `false` | When set to `true` the selected camera is cycled on each default view change. | -| `update_entities` | | **YAML only**: A card-wide list of entities that should cause the view to reset to the default (if the entity only pertains to a particular camera use [`triggers`](cameras/README.md?id=triggers) for the selected camera instead. | ## `default_reset` diff --git a/docs/examples.md b/docs/examples.md index a6608a72..0bb99c27 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -479,9 +479,9 @@ overrides: This example changes the default card view from `live` to `image` depending on the value of the `binary_sensor.alarm_armed` sensor. The override alone will only change the _default_ when the card next is requested to change to the -default view. By also including the `update_entities` parameter, we ask the card -to trigger a card update based on that entity -- which causes it to use the new -overriden default immediately. +default view. By also including the `view.default_reset.entities` parameter, we +ask the card to trigger a card update based on that entity -- which causes it to +use the new overriden default immediately. ```yaml type: custom:frigate-card @@ -489,8 +489,9 @@ cameras: - camera_entity: camera.office view: default: live - update_entities: - - binary_sensor.alarm_armed + default_reset: + entities: + - binary_sensor.alarm_armed overrides: - conditions: - condition: state From 8823e769a2eefea8f6eecb7b88d4d2fe6b17c08f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 30 Jul 2024 02:22:50 -0700 Subject: [PATCH 10/11] Remove unnecessary sentence. --- docs/configuration/view.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/view.md b/docs/configuration/view.md index 73bb0fde..c2003f82 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -16,7 +16,7 @@ view: | `default_reset` | | The circumstances and behavior that cause the card to reset to the default view. See below. | | `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `reset_after_interaction` to reset the view after the interaction is complete. `0` means no interactions are reported / acted upon. | | `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See below. | -| `render_entities` | | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. `update_*` flags do not pertain/relate to the behavior of this flag. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). | +| `render_entities` | | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). | | `reset_after_interaction` | `true` | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. | | `triggers` | | How to react when a camera is [triggered](cameras/README.md?id=triggers). | | `default_cycle_camera` | `false` | When set to `true` the selected camera is cycled on each default view change. | From ef7b4b0ddf50c1775c039ae353a781c6e45ccfc5 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 30 Jul 2024 02:23:51 -0700 Subject: [PATCH 11/11] Formatting pass. --- docs/configuration/view.md | 26 +++++++++---------- rollup.config.js | 2 +- .../actions/actions/camera-select.ts | 2 +- src/card-controller/triggers-manager.ts | 8 +++--- .../view/modifiers/merge-context.ts | 6 ++--- .../view/modifiers/remove-context.ts | 6 ++--- .../view/modifiers/substream-off.ts | 1 - src/components/gallery.ts | 5 +++- src/view/view.ts | 7 +++-- .../card-controller/triggers-manager.test.ts | 2 +- .../view/modifiers/remove-context.test.ts | 1 - .../view/modifiers/substream-on.test.ts | 6 ++++- 12 files changed, 39 insertions(+), 33 deletions(-) diff --git a/docs/configuration/view.md b/docs/configuration/view.md index c2003f82..a33c5c71 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -7,19 +7,19 @@ view: # [...] ``` -| Option | Default | Description | -| ------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `actions` | | [Actions](actions/README.md) to use for all views, individual actions may be overriden by view-specific actions. | -| `camera_select` | `current` | The [view](view.md?id=supported-views) to show when a new camera is selected (e.g. in the camera menu). If `current` the view is unchanged when a new camera is selected. | -| `dark_mode` | `off` | Whether or not to turn dark mode `on`, `off` or `auto` to automatically turn on if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time) or if dark mode is enabled in the HA profile theme setting. Dark mode dims the brightness by `25%`. | -| `default` | `live` | The view to show in the card by default. The default camera is the first one listed. See [Supported Views](view.md?id=supported-views) below. | -| `default_reset` | | The circumstances and behavior that cause the card to reset to the default view. See below. | -| `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `reset_after_interaction` to reset the view after the interaction is complete. `0` means no interactions are reported / acted upon. | -| `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See below. | +| Option | Default | Description | +| ------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `actions` | | [Actions](actions/README.md) to use for all views, individual actions may be overriden by view-specific actions. | +| `camera_select` | `current` | The [view](view.md?id=supported-views) to show when a new camera is selected (e.g. in the camera menu). If `current` the view is unchanged when a new camera is selected. | +| `dark_mode` | `off` | Whether or not to turn dark mode `on`, `off` or `auto` to automatically turn on if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time) or if dark mode is enabled in the HA profile theme setting. Dark mode dims the brightness by `25%`. | +| `default` | `live` | The view to show in the card by default. The default camera is the first one listed. See [Supported Views](view.md?id=supported-views) below. | +| `default_reset` | | The circumstances and behavior that cause the card to reset to the default view. See below. | +| `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `reset_after_interaction` to reset the view after the interaction is complete. `0` means no interactions are reported / acted upon. | +| `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See below. | | `render_entities` | | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). | -| `reset_after_interaction` | `true` | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. | -| `triggers` | | How to react when a camera is [triggered](cameras/README.md?id=triggers). | -| `default_cycle_camera` | `false` | When set to `true` the selected camera is cycled on each default view change. | +| `reset_after_interaction` | `true` | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. | +| `triggers` | | How to react when a camera is [triggered](cameras/README.md?id=triggers). | +| `default_cycle_camera` | `false` | When set to `true` the selected camera is cycled on each default view change. | ## `default_reset` @@ -97,7 +97,7 @@ already occupied room will not trigger, but a newly occupied room will). | Option | Default | Description | | ------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `interaction_mode` | `inactive` | Whether actions should be taken when the card is being interacted with. If `all`, actions will always left be taken regardless. If `inactive` actions will only be taken if the card has _not_ had human interaction recently (as defined by `view.interaction_seconds`). If `active` actions will only be taken if the card _has_ had human interaction recently. This does not stop triggering itself (i.e. border will still pulse if `show_trigger_status` is true) but rather just prevents the actions being performed. | -| `trigger` | `update` | If set to `update` the current view is updated in place. If set to `default` the default view of the card will be reloaded. If set to `live` the triggered camera will be selected in `live` view. If set to `media` the appropriate media view (e.g. `clip` or `snapshot`) will be chosen to match a newly available media item (please note that only some [camera engines](cameras/engine.md) support new media detection, e.g. `frigate`). If set to `none` no action is taken. | +| `trigger` | `update` | If set to `update` the current view is updated in place. If set to `default` the default view of the card will be reloaded. If set to `live` the triggered camera will be selected in `live` view. If set to `media` the appropriate media view (e.g. `clip` or `snapshot`) will be chosen to match a newly available media item (please note that only some [camera engines](cameras/engine.md) support new media detection, e.g. `frigate`). If set to `none` no action is taken. | | `untrigger` | `none` | If set to `default` the the default view of the card will be reloaded. If set to `none` no action will be taken. | ## Supported views diff --git a/rollup.config.js b/rollup.config.js index 33ca36e7..be40c43a 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -51,7 +51,7 @@ const plugins = [ typescript({ sourceMap: dev, inlineSources: dev, - exclude: ["tests/**/*.test.ts"], + exclude: ['tests/**/*.test.ts'], }), json({ exclude: 'package.json' }), replace({ diff --git a/src/card-controller/actions/actions/camera-select.ts b/src/card-controller/actions/actions/camera-select.ts index 395b47d3..b492bf0d 100644 --- a/src/card-controller/actions/actions/camera-select.ts +++ b/src/card-controller/actions/actions/camera-select.ts @@ -17,7 +17,7 @@ export class CameraSelectAction extends FrigateCardAction { +export const mergeViewContext = ( + a?: ViewContext | null, + b?: ViewContext | null, +): ViewContext => { return merge({}, a, b); -} +}; export class View { public view: FrigateCardView; diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index 88b058e6..0ea09bec 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -465,7 +465,7 @@ describe('TriggersManager', () => { params: { view: 'live' as const, camera: 'camera_1' as const, - } + }, }); await manager.handleCameraEvent({ diff --git a/tests/card-controller/view/modifiers/remove-context.test.ts b/tests/card-controller/view/modifiers/remove-context.test.ts index 28795272..27ad3fde 100644 --- a/tests/card-controller/view/modifiers/remove-context.test.ts +++ b/tests/card-controller/view/modifiers/remove-context.test.ts @@ -23,4 +23,3 @@ it('should remove context property', () => { expect(view.context).toEqual({}); }); - \ No newline at end of file diff --git a/tests/card-controller/view/modifiers/substream-on.test.ts b/tests/card-controller/view/modifiers/substream-on.test.ts index cbba3e27..ea82b18a 100644 --- a/tests/card-controller/view/modifiers/substream-on.test.ts +++ b/tests/card-controller/view/modifiers/substream-on.test.ts @@ -2,7 +2,11 @@ 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 { + getStreamCameraID, + hasSubstream, + setSubstream, +} from '../../../../src/utils/substream'; import { createCameraConfig, createCameraManager,