From be7e7d79dc192b71590c02da95794f2e9fd4bd45 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 12 May 2026 13:06:22 -0700 Subject: [PATCH] feat: Add UI optional UI locking when microphone is hot (#2484) --- docs/configuration/live.md | 2 + docs/configuration/menu.md | 28 ++++ .../actions/actions-manager.ts | 7 +- src/card-controller/controller.ts | 8 + src/card-controller/lock/manager.ts | 63 ++++++++ src/card-controller/lock/microphone-policy.ts | 55 +++++++ src/card-controller/lock/types.ts | 12 ++ src/card-controller/microphone-manager.ts | 7 + src/card-controller/types.ts | 7 + src/card-controller/view/types.ts | 5 + src/card-controller/view/view-manager.ts | 28 +++- src/card.ts | 2 + src/components-lib/media-grid-controller.ts | 15 +- src/components-lib/menu-button-controller.ts | 4 +- src/components-lib/menu-controller.ts | 52 ++++++- src/components/live/carousel.ts | 10 +- src/components/live/grid.ts | 4 + src/components/live/index.ts | 4 + src/components/live/provider.ts | 13 +- src/components/menu.ts | 25 ++- src/components/submenu/index.ts | 13 +- src/components/submenu/select-button.ts | 23 ++- src/components/submenu/submenu-button.ts | 9 +- src/components/surround.ts | 5 + src/components/thumbnail-carousel.ts | 3 + src/components/timeline-core.ts | 3 + src/components/views.ts | 5 + .../schema/elements/custom/menu/base.ts | 1 + src/config/schema/live.ts | 2 + src/const.ts | 1 + src/editor.ts | 5 + src/localize/languages/en.json | 1 + src/scss/locked.scss | 8 + src/scss/next-previous-control.scss | 1 + src/scss/thumbnail-carousel.scss | 1 + src/scss/timeline-core.scss | 1 + .../actions/actions-manager.test.ts | 92 ++++++++++-- tests/card-controller/controller.test.ts | 8 + tests/card-controller/lock/manager.test.ts | 142 ++++++++++++++++++ .../microphone-manager.test.ts | 76 ++++++++++ .../card-controller/view/view-manager.test.ts | 68 +++++++++ .../media-grid-controller.test.ts | 13 +- tests/components-lib/menu-controller.test.ts | 140 +++++++++++++++++ tests/config/types.test.ts | 1 + tests/test-utils.ts | 2 + 45 files changed, 922 insertions(+), 53 deletions(-) create mode 100644 src/card-controller/lock/manager.ts create mode 100644 src/card-controller/lock/microphone-policy.ts create mode 100644 src/card-controller/lock/types.ts create mode 100644 src/scss/locked.scss create mode 100644 tests/card-controller/lock/manager.test.ts diff --git a/docs/configuration/live.md b/docs/configuration/live.md index 52e2b7d8..01d6ab98 100644 --- a/docs/configuration/live.md +++ b/docs/configuration/live.md @@ -183,6 +183,7 @@ live: | `auto_mute` | `[]` | A list of conditions in which the microphone is muted. `unselected` will automatically mute the microphone when a camera is unselected in the carousel or grid. `hidden` will automatically mute the microphone when the card becomes hidden (e.g. browser/tab change). Use an empty list (`[]`, the default) to never automatically mute the microphone via these conditions. | | `auto_unmute` | `[]` | A list of conditions in which the microphone is unmuted. `selected` will automatically unmute the microphone when a camera is selected in the carousel or grid (useful for an always-hot mic on the currently selected camera). `visible` will automatically unmute when the card becomes visible. Use an empty list (`[]`, the default) to never automatically unmute the microphone via these conditions. The browser will still prompt for microphone permission on first unmute. | | `disconnect_seconds` | `90` | The number of seconds after microphone usage to disconnect the microphone from the stream. `0` implies never. Not relevant if `always_connected` is `true`. | +| `lock` | `true` | Whether to lock disruptive actions (view/camera/substream changes, pause, reload, casting) while the microphone is unmuted. Prevents an accidental tap, swipe, or button press from cutting off the session mid-sentence during 2-way audio. Set to `false` to allow all actions regardless of microphone state. | | `mute_after_microphone_mute_seconds` | `60` | The number of seconds after the microphone mutes to automatically mute the inbound audio when `live.auto_mute` includes `microphone`. | See [Using 2-way audio](../usage/2-way-audio.md) for more information about the very particular requirements that must be followed for 2-way audio to work. @@ -249,6 +250,7 @@ live: auto_mute: [] auto_unmute: [] disconnect_seconds: 90 + lock: true mute_after_microphone_mute_seconds: 60 display: mode: single diff --git a/docs/configuration/menu.md b/docs/configuration/menu.md index 61c154c5..a311a4b5 100644 --- a/docs/configuration/menu.md +++ b/docs/configuration/menu.md @@ -62,6 +62,7 @@ menu: | `alignment` | `matching` | Whether this menu item should have an alignment that is `matching` the menu alignment or `opposing` the menu alignment. Can be used to create two separate groups of buttons on the menu. The `priority` option orders buttons within a given `alignment`. | | `enabled` | `true` for `camera_ui`, `cameras`, `display_mode`, `download`, `folders`, `fullscreen`, `gallery`, `info`, `iris`, `live`, `media_player`, `set_review`, `substreams` and `timeline`. `false` for `clips`, `expand`, `image`, `microphone`, `mute`, `pip`, `play`, `ptz_controls`, `ptz_home`, `recordings`, `reviews`, `screenshot` and `snapshots`. | Whether or not to show the button. | | `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. See also [custom icons](../usage/custom-icons.md). | +| `inert` | `false` | If `true` the button is shown but rendered as inert (greyed out, non-interactive). Differs from `enabled: false`, which removes the button entirely. | | `permanent` | `false` | If `false` the menu item is hidden when the menu has the `hidden` style and the menu is closed, otherwise it is shown (and sorted to the front). | | `priority` | `50` | The menu item priority. Higher priority items are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`). Priority applies separately to `matching` and `opposing` groups (see `alignment` above). Minimum `0`, maximum `100`. | | `state_color` | `true` | Whether to colorize the button based on the state of a related entity (where applicable). | @@ -103,137 +104,164 @@ menu: camera_ui: priority: 50 enabled: true + inert: false alignment: matching icon: mdi:web cameras: priority: 50 enabled: true + inert: false alignment: matching icon: mdi:video-switch clips: priority: 50 enabled: false + inert: false alignment: matching icon: mdi:filmstrip display_mode: priority: 50 enabled: true + inert: false alignment: matching icon: mdi:grid download: priority: 50 enabled: true + inert: false alignment: matching icon: mdi:download expand: priority: 50 enabled: false + inert: false alignment: matching icon: mdi:arrow-expand-all folders: priority: 50 enabled: true + inert: false alignment: matching icon: mdi:folder-multiple fullscreen: priority: 50 enabled: true + inert: false alignment: matching icon: mdi:fullscreen gallery: priority: 50 enabled: true + inert: false alignment: matching icon: mdi:play-box-multiple image: priority: 50 enabled: false + inert: false alignment: matching icon: mdi:image info: priority: 50 enabled: true + inert: false alignment: matching icon: mdi:information-outline iris: priority: 50 enabled: true + inert: false alignment: matching icon: iris live: priority: 50 enabled: true + inert: false alignment: matching icon: mdi:cctv media_player: priority: 50 enabled: true + inert: false alignment: matching icon: mdi:cast microphone: priority: 50 enabled: false + inert: false alignment: matching icon: mdi:microphone type: momentary mute: priority: 50 enabled: false + inert: false alignment: matching icon: mdi:volume-off pip: priority: 50 enabled: false + inert: false alignment: matching icon: mdi:picture-in-picture-bottom-right play: priority: 50 enabled: false + inert: false alignment: matching icon: mdi:play ptz_controls: priority: 50 enabled: false + inert: false alignment: matching icon: mdi:pan ptz_home: priority: 50 enabled: false + inert: false alignment: matching icon: mdi:home recordings: priority: 50 enabled: false + inert: false alignment: matching icon: mdi:album reviews: priority: 50 enabled: false + inert: false alignment: matching icon: mdi:play-box-edit-outline screenshot: priority: 50 enabled: false + inert: false alignment: matching icon: mdi:monitor-screenshot set_review: priority: 50 enabled: true + inert: false alignment: matching icon: mdi:check-circle snapshots: priority: 50 enabled: false + inert: false alignment: matching icon: mdi:camera substreams: priority: 50 enabled: true + inert: false alignment: matching icon: mdi:video-input-component timeline: priority: 50 enabled: true + inert: false alignment: matching icon: mdi:chart-gantt button_size: 40 diff --git a/src/card-controller/actions/actions-manager.ts b/src/card-controller/actions/actions-manager.ts index f2b3491f..eb1a80c8 100644 --- a/src/card-controller/actions/actions-manager.ts +++ b/src/card-controller/actions/actions-manager.ts @@ -147,7 +147,12 @@ export class ActionsManager implements ActionsExecutor { }) as ActionConfig | ActionConfig[]) : request.actions; - const actionSet = new ActionSet(this._actionContext, renderedAction, { + const allowedActions = this._api.getLockManager().getAllowedActions(renderedAction); + if (!allowedActions.length) { + return; + } + + const actionSet = new ActionSet(this._actionContext, allowedActions, { config: request.config, cardID: this._api.getConfigManager().getConfig()?.card_id, }); diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index ee35b809..ff8b118e 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -27,6 +27,7 @@ import { HASSManager } from './hass/hass-manager'; import { InitializationManager } from './initialization-manager'; import { InteractionManager } from './interaction-manager'; import { KeyboardStateManager } from './keyboard-state-manager'; +import { LockManager } from './lock/manager'; import { MediaLoadedInfoManager } from './media-info-manager'; import { MediaPlayerManager } from './media-player-manager'; import { MicrophoneManager } from './microphone-manager'; @@ -55,6 +56,7 @@ import { CardInitializerAPI, CardInteractionAPI, CardKeyboardStateAPI, + CardLockAPI, CardMediaLoadedAPI, CardMediaPlayerAPI, CardMicrophoneAPI, @@ -88,6 +90,7 @@ export class CardController CardInitializerAPI, CardInteractionAPI, CardKeyboardStateAPI, + CardLockAPI, CardMediaLoadedAPI, CardMediaPlayerAPI, CardMicrophoneAPI, @@ -121,6 +124,7 @@ export class CardController private _initializationManager = new InitializationManager(this); private _interactionManager = new InteractionManager(this); private _keyboardStateManager = new KeyboardStateManager(this); + private _lockManager = new LockManager(this); private _mediaLoadedInfoManager = new MediaLoadedInfoManager(this); private _mediaPlayerManager = new MediaPlayerManager(this); @@ -234,6 +238,10 @@ export class CardController return this._keyboardStateManager; } + public getLockManager(): LockManager { + return this._lockManager; + } + public getMediaLoadedInfoManager(): MediaLoadedInfoManager { return this._mediaLoadedInfoManager; } diff --git a/src/card-controller/lock/manager.ts b/src/card-controller/lock/manager.ts new file mode 100644 index 00000000..e49dc739 --- /dev/null +++ b/src/card-controller/lock/manager.ts @@ -0,0 +1,63 @@ +import { ActionConfig, Actions } from '../../config/schema/actions/types'; +import { arrayify } from '../../utils/basic'; +import { CardLockAPI } from '../types'; +import { MicrophoneLockPolicy } from './microphone-policy'; +import type { LockManagerEpoch, LockPolicy } from './types'; + +export class LockManager { + private _policies: LockPolicy[]; + private _epoch: LockManagerEpoch | null = null; + + constructor(api: CardLockAPI) { + this._policies = [new MicrophoneLockPolicy(api)]; + } + + public isLocked(): boolean { + return this._policies.some((policy) => policy.isActive()); + } + + public getEpoch(): LockManagerEpoch { + const locked = this.isLocked(); + if (!this._epoch || this._epoch.locked !== locked) { + this._epoch = { manager: this, locked }; + } + return this._epoch; + } + + public getAllowedActions(actions: ActionConfig | ActionConfig[]): ActionConfig[] { + if (!this.isLocked()) { + return arrayify(actions); + } + + return arrayify(actions).filter((action) => { + return !this._isActionBlocked(action); + }); + } + + public areAllActionsBlocked(actions: Actions): boolean { + if (!this.isLocked()) { + return false; + } + + const all = [ + ...arrayify(actions.tap_action), + ...arrayify(actions.hold_action), + ...arrayify(actions.double_tap_action), + ...arrayify(actions.start_tap_action), + ...arrayify(actions.end_tap_action), + ]; + + return ( + all.length > 0 && + all.every((action) => { + return this._isActionBlocked(action); + }) + ); + } + + private _isActionBlocked(action: ActionConfig): boolean { + return this._policies.some((policy) => { + return policy.isActive() && policy.shouldBlockAction(action); + }); + } +} diff --git a/src/card-controller/lock/microphone-policy.ts b/src/card-controller/lock/microphone-policy.ts new file mode 100644 index 00000000..470bd621 --- /dev/null +++ b/src/card-controller/lock/microphone-policy.ts @@ -0,0 +1,55 @@ +import { ActionConfig } from '../../config/schema/actions/types'; +import { VIEWS_USER_SPECIFIED } from '../../config/schema/common/const'; +import { isAdvancedCameraCardCustomAction } from '../../utils/action'; +import { CardLockAPI } from '../types'; +import type { LockPolicy } from './types'; + +// Action that disrupt a hot-microphone session. Covers two categories: +// - Major media changes (see `ViewManager.hasMajorMediaChange`): view, +// camera, and substream changes. +// - Stream-stopping / re-init actions: pause, reload, and casting (which +// rehosts the stream to a media player). +const MICROPHONE_SESSION_DISRUPTIVE_ACTIONS: ReadonlySet = new Set([ + // View / camera / substream changes. + ...VIEWS_USER_SPECIFIED, + 'camera_select', + + // Resolves to a configured view at runtime. + 'default', + + // Substreams. + 'live_substream_select', + 'live_substream_on', + 'live_substream_off', + + // Stream-disrupting actions. `play` is intentionally NOT here: it's the + // recovery path from a paused state. + 'pause', + 'reload', + + // Casting rehosts the stream away from the card. + 'media_player', +]); + +export class MicrophoneLockPolicy implements LockPolicy { + private _api: CardLockAPI; + + constructor(api: CardLockAPI) { + this._api = api; + } + + public isActive(): boolean { + return this._api.getMicrophoneManager().isLocking(); + } + + public shouldBlockAction(action: ActionConfig): boolean { + return this._isMicrophoneSessionDisruptiveAction(action); + } + + private _isMicrophoneSessionDisruptiveAction(action: ActionConfig): boolean { + return ( + isAdvancedCameraCardCustomAction(action) && + MICROPHONE_SESSION_DISRUPTIVE_ACTIONS.has(action.advanced_camera_card_action) + ); + } +} diff --git a/src/card-controller/lock/types.ts b/src/card-controller/lock/types.ts new file mode 100644 index 00000000..b4e448ee --- /dev/null +++ b/src/card-controller/lock/types.ts @@ -0,0 +1,12 @@ +import type { ActionConfig } from '../../config/schema/actions/types'; +import type { LockManager } from './manager'; + +export interface LockPolicy { + isActive(): boolean; + shouldBlockAction(action: ActionConfig): boolean; +} + +export interface LockManagerEpoch { + manager: LockManager; + locked: boolean; +} diff --git a/src/card-controller/microphone-manager.ts b/src/card-controller/microphone-manager.ts index 5b8845dd..7d451e20 100644 --- a/src/card-controller/microphone-manager.ts +++ b/src/card-controller/microphone-manager.ts @@ -119,6 +119,13 @@ export class MicrophoneManager { return !this._stream || this._stream.getTracks().every((track) => !track.enabled); } + public isLocking(): boolean { + // The user-facing rationale: while the microphone is hot (e.g. mid 2-way + // audio), prevent accidental swipes, pauses, view changes. + const microphoneConfig = this._api.getConfigManager().getConfig()?.live.microphone; + return !!microphoneConfig?.lock && !this.isMuted(); + } + private _setDesiredMuteOnStream(): void { this._stream?.getTracks().forEach((track) => { track.enabled = !this._desireMute; diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 3c59195e..b656a33b 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -19,6 +19,7 @@ import type { InitializationManager } from './initialization-manager'; import type { InteractionManager } from './interaction-manager'; import type { IssueManager } from './issues/issue-manager'; import type { KeyboardStateManager } from './keyboard-state-manager'; +import type { LockManager } from './lock/manager'; import type { MediaLoadedInfoManager } from './media-info-manager'; import type { MediaPlayerManager } from './media-player-manager'; import type { MicrophoneManager } from './microphone-manager'; @@ -50,6 +51,7 @@ export interface CardActionsAPI { getFoldersManager(): FoldersManager; getFullscreenManager(): FullscreenManager; getHASSManager(): HASSManager; + getLockManager(): LockManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMediaPlayerManager(): MediaPlayerManager; getMicrophoneManager(): MicrophoneManager; @@ -234,6 +236,10 @@ export interface CardKeyboardStateAPI { getConfigManager(): ConfigManager; } +export interface CardLockAPI { + getMicrophoneManager(): MicrophoneManager; +} + export interface CardMediaLoadedAPI { getCardElementManager(): CardElementManager; getConditionStateManager(): ConditionStateManager; @@ -311,6 +317,7 @@ export interface CardViewAPI { getFoldersManager(): FoldersManager; getHASSManager(): HASSManager; getInitializationManager(): InitializationManager; + getLockManager(): LockManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; getNotificationManager(): NotificationManager; getIssueManager(): IssueManager; diff --git a/src/card-controller/view/types.ts b/src/card-controller/view/types.ts index 69657c82..46008fe8 100644 --- a/src/card-controller/view/types.ts +++ b/src/card-controller/view/types.ts @@ -45,6 +45,11 @@ export interface ViewFactoryOptions { // `live` view if the configured default view is not supported. failSafe?: boolean; + // When force is true the view change bypasses internal gates such as the + // navigation lock. Reserved for internal callers that must override + // user-facing locks (e.g. an active call ending). + force?: boolean; + // Options for the query executor that control how a query is executed and the // result selected. queryExecutorOptions?: QueryExecutorOptions; diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts index e306ae9f..6b9344f2 100644 --- a/src/card-controller/view/view-manager.ts +++ b/src/card-controller/view/view-manager.ts @@ -102,7 +102,7 @@ export class ViewManager implements ViewManagerInterface { viewFactoryFunc: (options?: ViewFactoryOptions) => View | null, options?: ViewFactoryOptions, ): void { - if (!this._isAllowedToSetView()) { + if (!this._isAllowedToProposeView()) { return; } @@ -125,6 +125,9 @@ export class ViewManager implements ViewManagerInterface { } this._api.getIssueManager().trigger('view_incompatible', { error: e }); } + if (view && !this._isAllowedToSetView(view, options)) { + return; + } if (view) { this._setView(view); } @@ -147,7 +150,10 @@ export class ViewManager implements ViewManagerInterface { view.removeContextProperty('loading', 'query'); } - private _isAllowedToSetView(): boolean { + // Pre-computation gate: whether we should even attempt to build a candidate + // view. Skipped here for race conditions that would otherwise generate a + // spurious `view_incompatible` issue. + private _isAllowedToProposeView(): boolean { // It is possible to have a race condition where the view is being set at // the same time as the cameras being initialized. Test case: Open // folder-based media in the media viewer carousel, then attempt to edit the @@ -160,6 +166,20 @@ export class ViewManager implements ViewManagerInterface { .isInitialized(InitializationAspect.CAMERAS); } + // Post-computation gate: given a freshly proposed view, whether we should + // actually commit it. Respects the lock state by potentially rejecting + // changes that would disrupt the active session (camera, view name, or + // substream). + private _isAllowedToSetView( + proposedView: View, + options?: ViewFactoryOptions, + ): boolean { + if (options?.force || !this._api.getLockManager().isLocked()) { + return true; + } + return !this.hasMajorMediaChange(this._view, proposedView); + } + private async _setViewThenModifyAsync( viewFactoryFunc: (options?: ViewFactoryOptions) => View | null, viewModifiersFunc: ( @@ -168,7 +188,7 @@ export class ViewManager implements ViewManagerInterface { ) => Promise, options?: ViewFactoryOptions, ): Promise { - if (!this._isAllowedToSetView()) { + if (!this._isAllowedToProposeView()) { return; } @@ -195,7 +215,7 @@ export class ViewManager implements ViewManagerInterface { this._api.getIssueManager().trigger('view_incompatible', { error: e }); } - if (!initialView) { + if (!initialView || !this._isAllowedToSetView(initialView, options)) { return; } diff --git a/src/card.ts b/src/card.ts index bfd275c9..732b3da6 100644 --- a/src/card.ts +++ b/src/card.ts @@ -253,6 +253,7 @@ class AdvancedCameraCard extends LitElement { ${ref(this._refMenu)} slot=${ifDefined(slot)} .hass=${this._hass} + .lockManagerEpoch=${this._controller.getLockManager().getEpoch()} .menuConfig=${this._config.menu} .buttons=${this._menuButtonController.calculateButtons( this._hass, @@ -426,6 +427,7 @@ class AdvancedCameraCard extends LitElement { .hide=${!!fullCardIssue} .microphoneManager=${this._controller.getMicrophoneManager()} .microphoneState=${this._controller.getMicrophoneManager().getState()} + .locked=${this._controller.getLockManager().isLocked()} .conditionStateManager=${this._controller.getConditionStateManager()} .triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status ? this._controller.getTriggersManager().getTriggeredCameraIDs() diff --git a/src/components-lib/media-grid-controller.ts b/src/components-lib/media-grid-controller.ts index 3f55a75b..0b7100f0 100644 --- a/src/components-lib/media-grid-controller.ts +++ b/src/components-lib/media-grid-controller.ts @@ -161,12 +161,16 @@ export class MediaGridController { } public selectCell(id: GridID) { + // Applies a selection to the grid. Does NOT fire `media-grid:selected` -- + // that event signals a user-initiated request to navigate; it is fired + // from the click handler. Calling here would either double-fire (when + // selectCell is invoked in response to an accepted navigation) or be a + // lie (when invoked imperatively by code). if (this._selected === id) { return; } this._selected = id; - fireAdvancedCameraCardEvent(this._host, 'media-grid:selected', { selected: id }); this._sortItemsInGrid(); this._updateSelectedStylesOnElements(); @@ -301,7 +305,14 @@ export class MediaGridController { /* istanbul ignore else: the else path cannot be reached -- @preserve */ if (eventPath.includes(element)) { if (this._selected !== id) { - this.selectCell(id); + // Fire the request but do not mutate local state. The authoritative + // selection lives upstream (ViewManager via the `selected` prop). On + // acceptance, the new `selected` prop arrives and drives + // `selectCell`. On rejection (e.g. navigation locked), the prop is + // unchanged and the grid stays put. + fireAdvancedCameraCardEvent(this._host, 'media-grid:selected', { + selected: id, + }); ev.stopPropagation(); } break; diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 0e928fcb..f85f77c7 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -78,7 +78,7 @@ export class MenuButtonController { foldersManager: FoldersManager, options?: MenuButtonControllerOptions, ): MenuItem[] { - return [ + const buttons: MenuItem[] = [ this._getIrisButton(config), this._getCamerasButton(config, cameraManager, options?.view), this._getSubstreamsButton(config, cameraManager, options?.view), @@ -129,6 +129,8 @@ export class MenuButtonController { ...button, })), ].filter(isTruthy); + + return buttons; } private _getIrisButton(config: AdvancedCameraCardConfig): MenuItem { diff --git a/src/components-lib/menu-controller.ts b/src/components-lib/menu-controller.ts index a7749fa5..a6a8e6f2 100644 --- a/src/components-lib/menu-controller.ts +++ b/src/components-lib/menu-controller.ts @@ -1,12 +1,13 @@ import { LitElement } from 'lit'; import { isEqual, orderBy } from 'lodash-es'; import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js'; -import { SubmenuInteraction } from '../components/submenu/types.js'; -import { ActionConfig, ActionsConfig } from '../config/schema/actions/types.js'; +import type { LockManagerEpoch } from '../card-controller/lock/types'; +import type { SubmenuInteraction } from '../components/submenu/types.js'; +import type { ActionConfig, ActionsConfig } from '../config/schema/actions/types.js'; import { MENU_PRIORITY_MAX } from '../config/schema/common/const.js'; -import { MenuItem } from '../config/schema/elements/custom/menu/types.js'; -import { MenuConfig } from '../config/schema/menu.js'; -import { Interaction } from '../types.js'; +import type { MenuItem } from '../config/schema/elements/custom/menu/types.js'; +import type { MenuConfig } from '../config/schema/menu.js'; +import type { Interaction } from '../types.js'; import { getActionConfigGivenAction } from '../utils/action'; import { arrayify, isTruthy, setOrRemoveAttribute } from '../utils/basic.js'; @@ -15,11 +16,43 @@ export class MenuController { private _config: MenuConfig | null = null; private _buttons: MenuItem[] = []; private _expanded = false; + private _lockManagerEpoch?: LockManagerEpoch; constructor(host: LitElement) { this._host = host; } + public setLockManagerEpoch(lockManagerEpoch?: LockManagerEpoch): void { + if (isEqual(this._lockManagerEpoch, lockManagerEpoch)) { + return; + } + this._lockManagerEpoch = lockManagerEpoch; + this._host.requestUpdate(); + } + + /** + * Whether a menu button should be rendered inert. True if the user explicitly + * set `inert: true`, OR the active lock policies would block all of the + * button's configured actions. Submenu containers always stay interactive + * (tapping one opens the dropdown, which is non-disruptive). Items inside + * submenus are gated separately by the submenu renderer. + */ + public shouldButtonBeInert(button: MenuItem): boolean { + if (button.inert) { + return true; + } + if (!this._lockManagerEpoch?.locked) { + return false; + } + if ( + button.type === 'custom:advanced-camera-card-menu-submenu' || + button.type === 'custom:advanced-camera-card-menu-submenu-select' + ) { + return false; + } + return this._lockManagerEpoch.manager.areAllActionsBlocked(button); + } + public setMenuConfig(config: MenuConfig): void { this._config = config; this._host.style.setProperty( @@ -92,8 +125,13 @@ export class MenuController { // upstream has the user-provided configuration. ev.stopPropagation(); - // If the action is from a submenu, use the attached action config. - const config: ActionsConfig | null = buttonConfig ?? ev.detail.item ?? null; + // Resolve which config the action belongs to. When a click bubbles up from + // inside a submenu dropdown, the inner submenu component attaches the + // clicked item on `ev.detail.item` -- prefer that, since it identifies the + // specific item pressed (not the submenu container). Container clicks and + // plain top-level icon clicks have no item attached and fall through to + // `buttonConfig`, which the caller passes in. + const config: ActionsConfig | null = ev.detail.item ?? buttonConfig ?? null; if (!config) { return; } diff --git a/src/components/live/carousel.ts b/src/components/live/carousel.ts index 00cb9ac0..e36c3762 100644 --- a/src/components/live/carousel.ts +++ b/src/components/live/carousel.ts @@ -70,6 +70,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { @property({ attribute: false }) public microphoneState?: MicrophoneState; + @property({ attribute: false }) + public locked?: boolean; + @property({ attribute: false }) public viewFilterCameraID?: string; @@ -247,6 +250,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { .zoomSettings=${view?.context?.zoom?.[cameraID]?.requested} .zoom=${!this._isGesturesPTZActive(view, cameraID)} .forceSelected=${isSelectedSlide} + .locked=${this.locked} @advanced-camera-card:zoom:change=${( ev: CustomEvent, ) => @@ -327,6 +331,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { .label=${neighbor?.metadata?.title ?? ''} .icon=${neighbor?.metadata?.icon} ?disabled=${!neighbor} + ?locked=${!!this.locked} @click=${(ev) => { this._setViewCameraID(neighbor?.id); stopEventFromActivatingCardWideActions(ev); @@ -360,7 +365,10 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { : view.context?.ptzControls?.enabled; const dragEnabled = - hasMultipleCameras && this.liveConfig?.draggable && !gesturesPTZActive; + hasMultipleCameras && + this.liveConfig?.draggable && + !gesturesPTZActive && + !this.locked; return html` ; @@ -63,6 +66,7 @@ export class AdvancedCameraCardLiveGrid extends LitElement { .cardWideConfig=${this.cardWideConfig} .cameraManager=${this.cameraManager} .microphoneState=${this.microphoneState} + .locked=${this.locked} ?triggered=${triggeredCameraID && !!this.triggeredCameraIDs?.has(triggeredCameraID)} > diff --git a/src/components/live/index.ts b/src/components/live/index.ts index 3622146c..34ddf3dc 100644 --- a/src/components/live/index.ts +++ b/src/components/live/index.ts @@ -43,6 +43,9 @@ export class AdvancedCameraCardLive extends LitElement { @property({ attribute: false }) public microphoneState?: MicrophoneState; + @property({ attribute: false }) + public locked?: boolean; + @property({ attribute: false, hasChanged: contentsChanged }) public triggeredCameraIDs?: Set; @@ -93,6 +96,7 @@ export class AdvancedCameraCardLive extends LitElement { .cardWideConfig=${this.cardWideConfig} .cameraManager=${this.cameraManager} .microphoneState=${this.microphoneState} + .locked=${this.locked} .triggeredCameraIDs=${this.triggeredCameraIDs} > diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index 920aacf1..55fdca11 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -66,6 +66,12 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP @property({ attribute: false }) public forceSelected = false; + // When true the UI lock is active and the native video controls must be + // suppressed: those controls expose pause/play/cast/etc. directly on the + // media element. + @property({ attribute: false }) + public locked?: boolean; + private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, { getTargetID: () => this.targetID ?? null, }); @@ -181,14 +187,17 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP return result; } - // Builtin (native) video controls require all three conditions: + // Builtin (native) video controls require all four conditions: // - controls.builtin: user config enables native controls. // - zoom: Whether digital zoom/panning is allowed (this will be false when a // 'gesture' type PTZ control is active). // - !_zoomed: the user has not actually digital zoomed in (when zoomed, we // want to hide the controls). + // - !locked: the UI lock is not active. private _getEffectiveBuiltinControls(): boolean { - return !!this.liveConfig?.controls.builtin && this.zoom && !this._zoomed; + return ( + !!this.liveConfig?.controls.builtin && this.zoom && !this._zoomed && !this.locked + ); } private _renderContainer(template: TemplateResult): TemplateResult { diff --git a/src/components/menu.ts b/src/components/menu.ts index 041d1bf4..57c97b0e 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -2,12 +2,13 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit import { customElement, property } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; import { actionHandler } from '../action-handler-directive.js'; +import type { LockManagerEpoch } from '../card-controller/lock/types'; import { MenuController } from '../components-lib/menu-controller.js'; -import { MenuItem } from '../config/schema/elements/custom/menu/types.js'; -import { MenuConfig } from '../config/schema/menu.js'; +import type { MenuItem } from '../config/schema/elements/custom/menu/types.js'; +import type { MenuConfig } from '../config/schema/menu.js'; import { getEntityTitle } from '../ha/get-entity-title.js'; -import { EntityRegistryManager } from '../ha/registry/entity/types.js'; -import { HomeAssistant } from '../ha/types.js'; +import type { EntityRegistryManager } from '../ha/registry/entity/types.js'; +import type { HomeAssistant } from '../ha/types.js'; import menuStyle from '../scss/menu.scss'; import { hasAction } from '../utils/action.js'; import './icon.js'; @@ -24,6 +25,9 @@ export class AdvancedCameraCardMenu extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; + @property({ attribute: false }) + public lockManagerEpoch?: LockManagerEpoch; + set menuConfig(menuConfig: MenuConfig) { this._controller.setMenuConfig(menuConfig); } @@ -36,6 +40,12 @@ export class AdvancedCameraCardMenu extends LitElement { this._controller.setExpanded(expanded); } + protected willUpdate(changedProps: Map): void { + if (changedProps.has('lockManagerEpoch')) { + this._controller.setLockManagerEpoch(this.lockManagerEpoch); + } + } + public toggleMenu(): void { this._controller.toggleExpanded(); } @@ -49,7 +59,8 @@ export class AdvancedCameraCardMenu extends LitElement { return html` this._controller.handleAction(ev)} + .lockManagerEpoch=${this.lockManagerEpoch} + @action=${(ev) => this._controller.handleAction(ev, button)} > `; } else if (button.type === 'custom:advanced-camera-card-menu-submenu-select') { @@ -57,7 +68,8 @@ export class AdvancedCameraCardMenu extends LitElement { .hass=${this.hass} .submenuSelect=${button} .entityRegistryManager=${this.entityRegistryManager} - @action=${(ev) => this._controller.handleAction(ev)} + .lockManagerEpoch=${this.lockManagerEpoch} + @action=${(ev) => this._controller.handleAction(ev, button)} > `; } @@ -75,6 +87,7 @@ export class AdvancedCameraCardMenu extends LitElement { hasDoubleClick: hasAction(button.double_tap_action), })} .label=${title ?? ''} + ?disabled=${this._controller.shouldButtonBeInert(button)} @action=${(ev) => this._controller.handleAction(ev, button)} > ; @@ -129,6 +134,7 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement { return html` `; diff --git a/src/components/submenu/submenu-button.ts b/src/components/submenu/submenu-button.ts index 9ec60337..fde8ce3f 100644 --- a/src/components/submenu/submenu-button.ts +++ b/src/components/submenu/submenu-button.ts @@ -2,8 +2,9 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit import { customElement, property } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; import { actionHandler } from '../../action-handler-directive.js'; -import { MenuSubmenu } from '../../config/schema/elements/custom/menu/submenu.js'; -import { HomeAssistant } from '../../ha/types.js'; +import type { LockManagerEpoch } from '../../card-controller/lock/types'; +import type { MenuSubmenu } from '../../config/schema/elements/custom/menu/submenu.js'; +import type { HomeAssistant } from '../../ha/types.js'; import menuButtonStyle from '../../scss/menu-button.scss'; import { hasAction } from '../../utils/action.js'; import '../icon.js'; @@ -17,6 +18,9 @@ export class AdvancedCameraCardSubmenuButton extends LitElement { @property({ attribute: false }) public submenu?: MenuSubmenu; + @property({ attribute: false }) + public lockManagerEpoch?: LockManagerEpoch; + protected render(): TemplateResult { if (!this.submenu) { return html``; @@ -26,6 +30,7 @@ export class AdvancedCameraCardSubmenuButton extends LitElement { return html` ` : ''} @@ -123,6 +127,7 @@ export class AdvancedCameraCardSurround extends LitElement { .conditionStateManager=${this.conditionStateManager} .viewItemManager=${this.viewItemManager} .cardWideConfig=${this.cardWideConfig} + .locked=${this.locked} > ` : ''} diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 514b0324..2dddd484 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -65,6 +65,9 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { @property({ attribute: false }) public fadeThumbnails = false; + @property({ type: Boolean, reflect: true }) + public locked?: boolean; + private _thumbnails: TemplateResult[] = []; private _builder: UnifiedQueryBuilder | null = null; diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index c68e65ed..3a947754 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -137,6 +137,9 @@ export class AdvancedCameraCardTimelineCore extends LitElement { @property({ attribute: false }) public itemClickAction?: TimelineItemClickAction; + @property({ type: Boolean, reflect: true }) + public locked?: boolean; + private _refDatePicker: Ref = createRef(); private _refTimeline: Ref = createRef(); private _controller: TimelineController = new TimelineController(this); diff --git a/src/components/views.ts b/src/components/views.ts index cd494119..db388738 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -67,6 +67,9 @@ export class AdvancedCameraCardViews extends LitElement { @property({ attribute: false }) public microphoneState?: MicrophoneState; + @property({ attribute: false }) + public locked?: boolean; + @property({ attribute: false, hasChanged: contentsChanged }) public triggeredCameraIDs?: Set; @@ -163,6 +166,7 @@ export class AdvancedCameraCardViews extends LitElement { .conditionStateManager=${this.conditionStateManager} .viewItemManager=${this.viewItemManager} .cardWideConfig=${this.cardWideConfig} + .locked=${this.locked} > ${!this.hide && view?.is('image') ? html` diff --git a/src/config/schema/elements/custom/menu/base.ts b/src/config/schema/elements/custom/menu/base.ts index 8e769011..6031217f 100644 --- a/src/config/schema/elements/custom/menu/base.ts +++ b/src/config/schema/elements/custom/menu/base.ts @@ -3,6 +3,7 @@ import { MENU_PRIORITY_DEFAULT, MENU_PRIORITY_MAX } from '../../../common/const' export const menuBaseSchema = z.object({ enabled: z.boolean().default(true).optional(), + inert: z.boolean().optional(), priority: z .number() .min(0) diff --git a/src/config/schema/live.ts b/src/config/schema/live.ts index b5ec9e8c..8d0ef4d5 100644 --- a/src/config/schema/live.ts +++ b/src/config/schema/live.ts @@ -26,6 +26,7 @@ const microphoneConfigDefault = { auto_mute: [], auto_unmute: [], disconnect_seconds: 90, + lock: true, mute_after_microphone_mute_seconds: 60, }; @@ -38,6 +39,7 @@ const microphoneConfigSchema = z .number() .min(0) .default(microphoneConfigDefault.disconnect_seconds), + lock: z.boolean().default(microphoneConfigDefault.lock), mute_after_microphone_mute_seconds: z .number() .min(0) diff --git a/src/const.ts b/src/const.ts index 8fc0bd81..670e84ec 100644 --- a/src/const.ts +++ b/src/const.ts @@ -363,6 +363,7 @@ export const CONF_LIVE_MICROPHONE_AUTO_UNMUTE = `${CONF_LIVE}.microphone.auto_unmute` as const; export const CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS = `${CONF_LIVE}.microphone.disconnect_seconds` as const; +export const CONF_LIVE_MICROPHONE_LOCK = `${CONF_LIVE}.microphone.lock` as const; export const CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS = `${CONF_LIVE}.microphone.mute_after_microphone_mute_seconds` as const; export const CONF_LIVE_ZOOMABLE = `${CONF_LIVE}.zoomable` as const; diff --git a/src/editor.ts b/src/editor.ts index d15cab32..f2d0ef36 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -159,6 +159,7 @@ import { CONF_LIVE_MICROPHONE_AUTO_MUTE, CONF_LIVE_MICROPHONE_AUTO_UNMUTE, CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS, + CONF_LIVE_MICROPHONE_LOCK, CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS, CONF_LIVE_PRELOAD, CONF_LIVE_SHOW_IMAGE_DURING_LOAD, @@ -3352,6 +3353,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED, this._defaults.live.microphone.always_connected, )} + ${this._renderSwitch( + CONF_LIVE_MICROPHONE_LOCK, + this._defaults.live.microphone.lock, + )} ${this._renderOptionSelector( CONF_LIVE_MICROPHONE_AUTO_MUTE, this._mediaActionNegativeConditions, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index bbf8af7e..015df191 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -427,6 +427,7 @@ "auto_unmute": "Automatically unmute the microphone", "disconnect_seconds": "Seconds before disconnecting microphone (0=never)", "editor_label": "Microphone", + "lock": "Lock the UI while the microphone is unmuted", "mute_after_microphone_mute_seconds": "Seconds after microphone mute before muting inbound audio" }, "preload": "Preload live view in the background", diff --git a/src/scss/locked.scss b/src/scss/locked.scss new file mode 100644 index 00000000..6e69aba3 --- /dev/null +++ b/src/scss/locked.scss @@ -0,0 +1,8 @@ +// Shared styling for components that reflect a `locked` host attribute (set by +// Lit when the consumer-facing `locked` property is true). Dims the host and +// disables pointer interaction without removing it from layout, so the element +// stays in place during mic mute/unmute transitions. +:host([locked]) { + opacity: 0.4; + pointer-events: none; +} diff --git a/src/scss/next-previous-control.scss b/src/scss/next-previous-control.scss index fcae5ada..77fbb0be 100644 --- a/src/scss/next-previous-control.scss +++ b/src/scss/next-previous-control.scss @@ -1,4 +1,5 @@ @use './button.scss'; +@use 'locked.scss'; :host { --advanced-camera-card-next-prev-size: 48px; diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss index 846ad205..95e9d5a8 100644 --- a/src/scss/thumbnail-carousel.scss +++ b/src/scss/thumbnail-carousel.scss @@ -1,4 +1,5 @@ @use 'const.scss'; +@use 'locked.scss'; :host { display: block; diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss index e2d2fffd..1bf0d3e1 100644 --- a/src/scss/timeline-core.scss +++ b/src/scss/timeline-core.scss @@ -1,6 +1,7 @@ @use 'vis-timeline/dist/vis-timeline-graph2d.css'; @use 'drawer'; @use 'const.scss'; +@use 'locked.scss'; :host { width: 100%; diff --git a/tests/card-controller/actions/actions-manager.test.ts b/tests/card-controller/actions/actions-manager.test.ts index 7dc74389..c9c2e5cf 100644 --- a/tests/card-controller/actions/actions-manager.test.ts +++ b/tests/card-controller/actions/actions-manager.test.ts @@ -14,11 +14,21 @@ import { Interaction, InteractionName, } from '../../../src/card-controller/actions/actions-manager'; +import type { CardController } from '../../../src/card-controller/controller'; import { TemplateRenderer } from '../../../src/card-controller/templates'; import { AdvancedCameraCardView } from '../../../src/config/schema/common/const'; -import { createLogAction } from '../../../src/utils/action'; +import { createGeneralAction, createLogAction } from '../../../src/utils/action'; +import { arrayify } from '../../../src/utils/basic'; import { createCardAPI, createConfig, createHASS, createView } from '../../test-utils'; +const createAPI = (): CardController => { + const api = createCardAPI(); + vi.mocked(api.getLockManager().getAllowedActions).mockImplementation((actions) => + arrayify(actions), + ); + return api; +}; + describe('ActionsManager', () => { describe('getMergedActions', () => { const config = { @@ -69,7 +79,7 @@ describe('ActionsManager', () => { }); it('should get no merged actions with an issue', () => { - const api = createCardAPI(); + const api = createAPI(); vi.mocked(api.getViewManager().getView).mockReturnValue( createView({ view: 'live' }), ); @@ -142,7 +152,7 @@ describe('ActionsManager', () => { ], ['timeline' as const, {}], ])('%s', (viewName: AdvancedCameraCardView, result: Record) => { - const api = createCardAPI(); + const api = createAPI(); vi.mocked(api.getViewManager().getView).mockReturnValue( createView({ view: viewName }), ); @@ -162,9 +172,12 @@ describe('ActionsManager', () => { beforeEach(() => { vi.restoreAllMocks(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); it('should handle interaction', async () => { - const api = createCardAPI(); + const api = createAPI(); const element = document.createElement('div'); vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); @@ -193,7 +206,7 @@ describe('ActionsManager', () => { it.each([['malformed_type_of_tap' as const], ['double_tap' as const]])( '%s', (interaction: string) => { - const api = createCardAPI(); + const api = createAPI(); const element = document.createElement('div'); vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); @@ -227,6 +240,9 @@ describe('ActionsManager', () => { beforeEach(() => { vi.restoreAllMocks(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); it('should handle advanced camera card event', async () => { const action = createLogAction('Hello, world!'); @@ -234,7 +250,7 @@ describe('ActionsManager', () => { detail: action, }); - const api = createCardAPI(); + const api = createAPI(); const manager = new ActionsManager(api); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); @@ -254,7 +270,7 @@ describe('ActionsManager', () => { const handler = vi.fn(); card.addEventListener('ll-custom', handler); - const api = createCardAPI(); + const api = createAPI(); vi.mocked(api.getCardElementManager().getElement).mockReturnValue(card); const manager = new ActionsManager(api); @@ -264,7 +280,7 @@ describe('ActionsManager', () => { }); it('should not handle event without detail', async () => { - const manager = new ActionsManager(createCardAPI()); + const manager = new ActionsManager(createAPI()); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); await manager.handleCustomActionEvent(new Event('ll-custom')); @@ -273,8 +289,12 @@ describe('ActionsManager', () => { }); describe('handleActionExecutionRequestEvent', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should execute actions', async () => { - const api = createCardAPI(); + const api = createAPI(); const manager = new ActionsManager(api); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); @@ -288,8 +308,12 @@ describe('ActionsManager', () => { }); describe('executeAction', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should execute actions', async () => { - const api = createCardAPI(); + const api = createAPI(); const manager = new ActionsManager(api); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); @@ -298,7 +322,7 @@ describe('ActionsManager', () => { }); it('should execute actions', async () => { - const api = createCardAPI(); + const api = createAPI(); const manager = new ActionsManager(api); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); @@ -312,7 +336,7 @@ describe('ActionsManager', () => { const templateRenderer = mock(); templateRenderer.renderRecursively.mockReturnValue(action); - const api = createCardAPI(); + const api = createAPI(); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); @@ -324,6 +348,7 @@ describe('ActionsManager', () => { const manager = new ActionsManager(api, templateRenderer); const config = { entity: 'light.office' }; const triggerData = { view: { from: 'previous-view', to: 'view' } }; + vi.spyOn(global.console, 'info').mockReturnValue(undefined); await manager.executeActions({ actions: action, config, triggerData }); @@ -333,6 +358,40 @@ describe('ActionsManager', () => { }); }); + it('should filter rendered actions through the lock manager', async () => { + const renderedAction = createGeneralAction('reload'); + const allowedAction = createLogAction('Allowed'); + + const templateRenderer = mock(); + templateRenderer.renderRecursively.mockReturnValue(renderedAction); + + const api = createAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getLockManager().getAllowedActions).mockReturnValue([allowedAction]); + + const manager = new ActionsManager(api, templateRenderer); + const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); + + await manager.executeActions({ + actions: createLogAction('{{ action }}'), + }); + + expect(api.getLockManager().getAllowedActions).toBeCalledWith(renderedAction); + expect(consoleSpy).toBeCalledWith('Allowed'); + }); + + it('should not execute actions when the lock manager rejects them', async () => { + const api = createAPI(); + vi.mocked(api.getLockManager().getAllowedActions).mockReturnValue([]); + + const manager = new ActionsManager(api); + const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); + + await manager.executeActions({ actions: createLogAction('Blocked') }); + + expect(consoleSpy).not.toBeCalled(); + }); + describe('should forward haptics', () => { afterEach(() => { vi.restoreAllMocks(); @@ -343,7 +402,7 @@ describe('ActionsManager', () => { const handler = vi.fn(); window.addEventListener('haptic', handler); - const api = createCardAPI(); + const api = createAPI(); const manager = new ActionsManager(api); await manager.executeActions({ actions: { action: 'none' } }); @@ -357,7 +416,7 @@ describe('ActionsManager', () => { const handler = vi.fn(); window.addEventListener('haptic', handler); - const api = createCardAPI(); + const api = createAPI(); const manager = new ActionsManager(api); vi.stubGlobal('confirm', vi.fn().mockReturnValue(false)); @@ -375,12 +434,15 @@ describe('ActionsManager', () => { beforeAll(() => { vi.useFakeTimers(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); afterAll(() => { vi.useRealTimers(); }); it('should stop actions', async () => { - const api = createCardAPI(); + const api = createAPI(); const manager = new ActionsManager(api); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index 0b86de30..d73655cd 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -18,6 +18,7 @@ import { InitializationManager } from '../../src/card-controller/initialization- import { InteractionManager } from '../../src/card-controller/interaction-manager'; import { IssueManager } from '../../src/card-controller/issues/issue-manager'; import { KeyboardStateManager } from '../../src/card-controller/keyboard-state-manager'; +import { LockManager } from '../../src/card-controller/lock/manager'; import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-manager'; import { MediaPlayerManager } from '../../src/card-controller/media-player-manager'; import { MicrophoneManager } from '../../src/card-controller/microphone-manager'; @@ -50,6 +51,7 @@ vi.mock('../../src/card-controller/hass/hass-manager'); vi.mock('../../src/card-controller/initialization-manager'); vi.mock('../../src/card-controller/interaction-manager'); vi.mock('../../src/card-controller/keyboard-state-manager'); +vi.mock('../../src/card-controller/lock/manager'); vi.mock('../../src/card-controller/media-info-manager'); vi.mock('../../src/card-controller/media-player-manager'); vi.mock('../../src/card-controller/microphone-manager'); @@ -209,6 +211,12 @@ describe('CardController', () => { ); }); + it('should return getLockManager', () => { + expect(createController().getLockManager()).toBe( + vi.mocked(LockManager).mock.instances[0], + ); + }); + it('should return getMediaLoadedInfoManager', () => { expect(createController().getMediaLoadedInfoManager()).toBe( vi.mocked(MediaLoadedInfoManager).mock.instances[0], diff --git a/tests/card-controller/lock/manager.test.ts b/tests/card-controller/lock/manager.test.ts new file mode 100644 index 00000000..af810773 --- /dev/null +++ b/tests/card-controller/lock/manager.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LockManager } from '../../../src/card-controller/lock/manager'; +import { + createCameraAction, + createDisplayModeAction, + createGeneralAction, + createLogAction, + createMediaPlayerAction, + createViewAction, +} from '../../../src/utils/action'; +import { createCardAPI } from '../../test-utils'; + +describe('LockManager', () => { + it('should report unlocked when no lock source is active', () => { + const api = createCardAPI(); + vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false); + + expect(new LockManager(api).isLocked()).toBeFalsy(); + }); + + it('should report locked when the microphone is locking', () => { + const api = createCardAPI(); + vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true); + + expect(new LockManager(api).isLocked()).toBeTruthy(); + }); + + it('should reuse lock manager epoch until the lock state changes', () => { + const api = createCardAPI(); + vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false); + const manager = new LockManager(api); + + const unlockedEpoch = manager.getEpoch(); + expect(manager.getEpoch()).toBe(unlockedEpoch); + expect(unlockedEpoch.locked).toBeFalsy(); + + vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true); + const lockedEpoch = manager.getEpoch(); + expect(lockedEpoch).not.toBe(unlockedEpoch); + expect(lockedEpoch.locked).toBeTruthy(); + expect(manager.getEpoch()).toBe(lockedEpoch); + }); + + it('should not filter actions when unlocked', () => { + const api = createCardAPI(); + vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false); + + const actions = [createGeneralAction('reload'), createLogAction('Allowed')]; + + expect(new LockManager(api).getAllowedActions(actions)).toBe(actions); + }); + + it('should reject microphone-session-disruptive actions when locked', () => { + const api = createCardAPI(); + vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true); + const manager = new LockManager(api); + + for (const action of [ + createViewAction('clips'), + createCameraAction('camera_select', 'cam-1'), + createCameraAction('live_substream_select', 'cam-1'), + createGeneralAction('live_substream_on'), + createGeneralAction('live_substream_off'), + createGeneralAction('default'), + createGeneralAction('pause'), + createGeneralAction('reload'), + createMediaPlayerAction('media_player.living_room', 'play'), + ]) { + expect(manager.getAllowedActions(action)).toEqual([]); + } + }); + + it('should preserve non-disruptive actions when locked', () => { + const api = createCardAPI(); + vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true); + const manager = new LockManager(api); + + for (const action of [ + createGeneralAction('play'), + createGeneralAction('fullscreen'), + createGeneralAction('expand'), + createGeneralAction('pip'), + createGeneralAction('screenshot'), + createGeneralAction('mute'), + createGeneralAction('unmute'), + createGeneralAction('microphone_unmute'), + createDisplayModeAction('grid'), + { action: 'none' as const }, + ]) { + expect(manager.getAllowedActions(action)).toEqual([action]); + } + }); + + it('should preserve non-disruptive actions from a mixed action list when locked', () => { + const api = createCardAPI(); + vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true); + + const manager = new LockManager(api); + const allowedAction = createLogAction('Allowed'); + + expect( + manager.getAllowedActions([createGeneralAction('reload'), allowedAction]), + ).toEqual([allowedAction]); + }); + + it('should report whether all configured actions are blocked', () => { + const api = createCardAPI(); + vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true); + + const manager = new LockManager(api); + + expect( + manager.areAllActionsBlocked({ + tap_action: createGeneralAction('reload'), + hold_action: createGeneralAction('pause'), + }), + ).toBeTruthy(); + expect( + manager.areAllActionsBlocked({ + tap_action: createGeneralAction('reload'), + hold_action: createLogAction('Allowed'), + }), + ).toBeFalsy(); + expect(manager.areAllActionsBlocked({})).toBeFalsy(); + }); + + it('should never report all-actions-blocked when unlocked', () => { + const api = createCardAPI(); + vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false); + + const manager = new LockManager(api); + + // Even when every action would be blocked under an active policy, an + // inactive lock short-circuits to false. + expect( + manager.areAllActionsBlocked({ + tap_action: createGeneralAction('reload'), + hold_action: createGeneralAction('pause'), + }), + ).toBeFalsy(); + }); +}); diff --git a/tests/card-controller/microphone-manager.test.ts b/tests/card-controller/microphone-manager.test.ts index 0407cd72..dddede8a 100644 --- a/tests/card-controller/microphone-manager.test.ts +++ b/tests/card-controller/microphone-manager.test.ts @@ -244,6 +244,82 @@ describe('MicrophoneManager', () => { expect(api.getCardElementManager().update).toBeCalledTimes(1); }); + describe('isLocking', () => { + it('should not lock when muted', () => { + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + live: { + microphone: { + lock: true, + }, + }, + }), + ); + + expect(manager.isMuted()).toBeTruthy(); + expect(manager.isLocking()).toBeFalsy(); + }); + + it('should lock when unmuted and lock is enabled', async () => { + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + live: { + microphone: { + lock: true, + }, + }, + }), + ); + vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( + createMockStream(), + ); + + await manager.unmute(); + + expect(manager.isMuted()).toBeFalsy(); + expect(manager.isLocking()).toBeTruthy(); + }); + + it('should not lock when unmuted but lock is disabled', async () => { + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + live: { + microphone: { + lock: false, + }, + }, + }), + ); + vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( + createMockStream(), + ); + + await manager.unmute(); + + expect(manager.isMuted()).toBeFalsy(); + expect(manager.isLocking()).toBeFalsy(); + }); + + it('should not lock when config is unavailable', async () => { + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( + createMockStream(), + ); + + await manager.unmute(); + + expect(manager.isMuted()).toBeFalsy(); + expect(manager.isLocking()).toBeFalsy(); + }); + }); + describe('should require initialization', async () => { it('should require when configured and supported', async () => { const api = createCardAPI(); diff --git a/tests/card-controller/view/view-manager.test.ts b/tests/card-controller/view/view-manager.test.ts index 68c8ab68..0c0209dc 100644 --- a/tests/card-controller/view/view-manager.test.ts +++ b/tests/card-controller/view/view-manager.test.ts @@ -163,6 +163,74 @@ describe('should not set view without cameras being initialized', () => { }); }); +describe('should respect microphone navigation lock', () => { + it('should ignore generic view changes when microphone is locking navigation', () => { + const factory = mock(); + factory.getViewDefault.mockReturnValue(createView()); + + const api = createInitializedCardAPI(); + vi.mocked(api.getLockManager().isLocked).mockReturnValue(true); + + const manager = new ViewManager(api, { viewFactory: factory }); + manager.setViewDefault(); + + expect(manager.getView()).toBeNull(); + }); + + it('should ignore async view changes when microphone is locking navigation', async () => { + const viewFactory = mock(); + viewFactory.getViewDefault.mockReturnValue(createView()); + + const viewQueryExecutor = mock(); + viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]); + + const api = createInitializedCardAPI(); + vi.mocked(api.getLockManager().isLocked).mockReturnValue(true); + + const manager = new ViewManager(api, { + viewFactory: viewFactory, + viewQueryExecutor: viewQueryExecutor, + }); + await manager.setViewDefaultWithNewQuery(); + + expect(manager.getView()).toBeNull(); + }); + + it('should allow generic view changes when force is set', () => { + const view = createView(); + const factory = mock(); + factory.getViewDefault.mockReturnValue(view); + + const api = createInitializedCardAPI(); + vi.mocked(api.getLockManager().isLocked).mockReturnValue(true); + + const manager = new ViewManager(api, { viewFactory: factory }); + manager.setViewDefault({ force: true }); + + expect(manager.getView()).toBe(view); + }); + + it('should allow async view changes when force is set', async () => { + const viewFactory = mock(); + viewFactory.getViewDefault.mockReturnValue(createView()); + + const viewQueryExecutor = mock(); + viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]); + + const api = createInitializedCardAPI(); + vi.mocked(api.getLockManager().isLocked).mockReturnValue(true); + + const manager = new ViewManager(api, { + viewFactory: viewFactory, + viewQueryExecutor: viewQueryExecutor, + }); + await manager.setViewDefaultWithNewQuery({ force: true }); + + expect(manager.getView()?.view).toBe('live'); + expect(manager.getView()?.camera).toBe('camera'); + }); +}); + it('should set view default', () => { const factory = mock(); factory.getViewDefault.mockReturnValue(createView()); diff --git a/tests/components-lib/media-grid-controller.test.ts b/tests/components-lib/media-grid-controller.test.ts index 12020a9a..1f054ca3 100644 --- a/tests/components-lib/media-grid-controller.test.ts +++ b/tests/components-lib/media-grid-controller.test.ts @@ -386,7 +386,7 @@ describe('MediaGridController', () => { ).toBe('3'); }); - it('should select cell when interacted with', () => { + it('should dispatch a selection request when interacted with', () => { const children = createChildren(); const parent = createParent({ children: children, width: 2000 }); const controller = createController(parent); @@ -396,11 +396,18 @@ describe('MediaGridController', () => { const clickHandler = vi.fn(); parent.addEventListener('click', clickHandler); + const selectedHandler = vi.fn(); + parent.addEventListener('advanced-camera-card:media-grid:selected', selectedHandler); + children[1].click(); - // Click will not be allowed through. + // Click is consumed; the controller dispatches the selection request but + // does NOT mutate local state. The authoritative selection is applied by + // the parent via `selectCell` once it propagates back. expect(clickHandler).not.toBeCalled(); - expect(controller.getSelected()).toBe('1'); + expect(selectedHandler).toBeCalledTimes(1); + expect(selectedHandler.mock.calls[0][0].detail).toEqual({ selected: '1' }); + expect(controller.getSelected()).toBeNull(); }); it('should ignore interaction events on already selected cell', () => { diff --git a/tests/components-lib/menu-controller.test.ts b/tests/components-lib/menu-controller.test.ts index 56254bf4..81636804 100644 --- a/tests/components-lib/menu-controller.test.ts +++ b/tests/components-lib/menu-controller.test.ts @@ -1,4 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import type { LockManager } from '../../src/card-controller/lock/manager'; +import type { LockManagerEpoch } from '../../src/card-controller/lock/types'; import { MenuController } from '../../src/components-lib/menu-controller.js'; import { SubmenuItem } from '../../src/components/submenu/types.js'; import { MenuConfig, menuConfigSchema } from '../../src/config/schema/menu.js'; @@ -12,6 +15,12 @@ const createMenuConfig = (config: unknown): MenuConfig => { return menuConfigSchema.parse(config); }; +const createLock = (locked: boolean, actionsBlocked: boolean): LockManagerEpoch => { + const lockManager = mock(); + lockManager.areAllActionsBlocked.mockReturnValue(actionsBlocked); + return { manager: lockManager, locked }; +}; + // @vitest-environment jsdom describe('MenuController', () => { const action = { @@ -71,6 +80,137 @@ describe('MenuController', () => { expect(host.getAttribute('expanded')).toBeNull(); }); + describe('setLockManagerEpoch', () => { + it('should trigger update on first lock change', () => { + const host = createLitElement(); + const controller = new MenuController(host); + vi.mocked(host.requestUpdate).mockClear(); + + controller.setLockManagerEpoch(createLock(true, true)); + expect(host.requestUpdate).toBeCalledTimes(1); + }); + + it('should not trigger update when lock epoch is unchanged', () => { + const host = createLitElement(); + const controller = new MenuController(host); + const lock = createLock(false, true); + controller.setLockManagerEpoch(lock); + vi.mocked(host.requestUpdate).mockClear(); + + controller.setLockManagerEpoch({ manager: lock.manager, locked: lock.locked }); + expect(host.requestUpdate).not.toBeCalled(); + }); + + it('should reflect lock state in shouldButtonBeInert', () => { + const controller = new MenuController(createLitElement()); + controller.setLockManagerEpoch(createLock(false, true)); + const button = { + type: 'custom:advanced-camera-card-menu-icon' as const, + icon: 'mdi:cow', + tap_action: { + action: 'fire-dom-event' as const, + advanced_camera_card_action: 'camera_select' as const, + camera: 'cam-1', + }, + }; + + expect(controller.shouldButtonBeInert(button)).toBeFalsy(); + controller.setLockManagerEpoch(createLock(true, true)); + expect(controller.shouldButtonBeInert(button)).toBeTruthy(); + controller.setLockManagerEpoch(createLock(false, true)); + expect(controller.shouldButtonBeInert(button)).toBeFalsy(); + }); + }); + + describe('shouldButtonBeInert', () => { + const blockedAction = { + action: 'fire-dom-event' as const, + advanced_camera_card_action: 'camera_select' as const, + camera: 'cam-1', + }; + const unblockedAction = { + action: 'fire-dom-event' as const, + advanced_camera_card_action: 'fullscreen' as const, + }; + + it('should always return true when user explicitly set `inert: true`', () => { + const controller = new MenuController(createLitElement()); + const button = { + type: 'custom:advanced-camera-card-menu-icon' as const, + icon: 'mdi:cow', + inert: true, + tap_action: unblockedAction, + }; + + // Without the lock active. + expect(controller.shouldButtonBeInert(button)).toBeTruthy(); + // With the lock active. + controller.setLockManagerEpoch(createLock(true, false)); + expect(controller.shouldButtonBeInert(button)).toBeTruthy(); + }); + + it('should return false when lock is inactive and button is not user-inert', () => { + const controller = new MenuController(createLitElement()); + expect( + controller.shouldButtonBeInert({ + type: 'custom:advanced-camera-card-menu-icon', + icon: 'mdi:cow', + tap_action: blockedAction, + }), + ).toBeFalsy(); + }); + + it('should never mark submenu containers inert (even when all actions are blocked)', () => { + const controller = new MenuController(createLitElement()); + controller.setLockManagerEpoch(createLock(true, true)); + + expect( + controller.shouldButtonBeInert({ + type: 'custom:advanced-camera-card-menu-submenu', + icon: 'mdi:menu', + items: [], + tap_action: blockedAction, + }), + ).toBeFalsy(); + expect( + controller.shouldButtonBeInert({ + type: 'custom:advanced-camera-card-menu-submenu-select', + icon: 'mdi:menu', + entity: 'select.foo', + state_color: true, + tap_action: blockedAction, + }), + ).toBeFalsy(); + }); + + it('should mark icon buttons inert when locked and all actions are blocked', () => { + const controller = new MenuController(createLitElement()); + controller.setLockManagerEpoch(createLock(true, true)); + + expect( + controller.shouldButtonBeInert({ + type: 'custom:advanced-camera-card-menu-icon', + icon: 'mdi:cow', + tap_action: blockedAction, + }), + ).toBeTruthy(); + }); + + it('should NOT mark icon buttons inert when locked and not all actions are blocked', () => { + const controller = new MenuController(createLitElement()); + controller.setLockManagerEpoch(createLock(true, false)); + + expect( + controller.shouldButtonBeInert({ + type: 'custom:advanced-camera-card-menu-icon', + icon: 'mdi:cow', + tap_action: blockedAction, + hold_action: unblockedAction, + }), + ).toBeFalsy(); + }); + }); + describe('should set and sort buttons', () => { it('without a hidden menu', () => { const controller = new MenuController(createLitElement()); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 53977b1b..d0c4cedd 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -136,6 +136,7 @@ describe('config defaults', () => { auto_mute: [], auto_unmute: [], disconnect_seconds: 90, + lock: true, mute_after_microphone_mute_seconds: 60, }, preload: false, diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 6df16619..ddf5019e 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -43,6 +43,7 @@ import { InteractionManager } from '../src/card-controller/interaction-manager'; import { IssueManager } from '../src/card-controller/issues/issue-manager'; import { IssueStateManager } from '../src/card-controller/issues/state-manager'; import { KeyboardStateManager } from '../src/card-controller/keyboard-state-manager'; +import { LockManager } from '../src/card-controller/lock/manager'; import { MediaLoadedInfoManager } from '../src/card-controller/media-info-manager'; import { MediaPlayerManager } from '../src/card-controller/media-player-manager'; import { MicrophoneManager } from '../src/card-controller/microphone-manager'; @@ -702,6 +703,7 @@ export const createCardAPI = (): CardController => { api.getInitializationManager.mockReturnValue(mock()); api.getInteractionManager.mockReturnValue(mock()); api.getKeyboardStateManager.mockReturnValue(mock()); + api.getLockManager.mockReturnValue(mock()); api.getMediaLoadedInfoManager.mockReturnValue(mock()); api.getMediaPlayerManager.mockReturnValue(mock()); api.getMicrophoneManager.mockReturnValue(mock());