feat: Add UI optional UI locking when microphone is hot (#2484)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 9a763db1f3
commit be7e7d79dc
45 changed files with 922 additions and 53 deletions
+2
View File
@@ -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
+28
View File
@@ -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
@@ -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,
});
+8
View File
@@ -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;
}
+63
View File
@@ -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);
});
}
}
@@ -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<string> = 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)
);
}
}
+12
View File
@@ -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;
}
@@ -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;
+7
View File
@@ -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;
+5
View File
@@ -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;
+24 -4
View File
@@ -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<ViewModifier[] | null>,
options?: ViewFactoryOptions,
): Promise<void> {
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;
}
+2
View File
@@ -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()
+13 -2
View File
@@ -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;
+3 -1
View File
@@ -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 {
+45 -7
View File
@@ -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;
}
+9 -1
View File
@@ -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<ZoomSettingsObserved>,
) =>
@@ -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`
<advanced-camera-card-carousel
+4
View File
@@ -39,6 +39,9 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
@property({ attribute: false })
public microphoneState?: MicrophoneState;
@property({ attribute: false })
public locked?: boolean;
@property({ attribute: false, hasChanged: contentsChanged })
public triggeredCameraIDs?: Set<string>;
@@ -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)}
>
+4
View File
@@ -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<string>;
@@ -93,6 +96,7 @@ export class AdvancedCameraCardLive extends LitElement {
.cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager}
.microphoneState=${this.microphoneState}
.locked=${this.locked}
.triggeredCameraIDs=${this.triggeredCameraIDs}
>
</advanced-camera-card-live-grid>
+11 -2
View File
@@ -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 {
+19 -6
View File
@@ -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<string, unknown>): 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` <advanced-camera-card-submenu-button
.hass=${this.hass}
.submenu=${button}
@action=${(ev) => this._controller.handleAction(ev)}
.lockManagerEpoch=${this.lockManagerEpoch}
@action=${(ev) => this._controller.handleAction(ev, button)}
>
</advanced-camera-card-submenu-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)}
>
</advanced-camera-card-submenu-select-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)}
>
<advanced-camera-card-icon
+10 -3
View File
@@ -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 type { LockManagerEpoch } from '../../card-controller/lock/types';
import { getEntityTitle } from '../../ha/get-entity-title.js';
import { HomeAssistant } from '../../ha/types.js';
import type { HomeAssistant } from '../../ha/types.js';
import submenuStyle from '../../scss/submenu.scss';
import {
hasAction,
@@ -11,7 +12,7 @@ import {
} from '../../utils/action.js';
import { contentsChanged } from '../../utils/basic.js';
import '../icon.js';
import { SubmenuInteraction, SubmenuItem } from './types.js';
import type { SubmenuInteraction, SubmenuItem } from './types.js';
@customElement('advanced-camera-card-submenu')
export class AdvancedCameraCardSubmenu extends LitElement {
@@ -21,6 +22,9 @@ export class AdvancedCameraCardSubmenu extends LitElement {
@property({ attribute: false, hasChanged: contentsChanged })
public items?: SubmenuItem[];
@property({ attribute: false })
public lockManagerEpoch?: LockManagerEpoch;
private _renderItem(item: SubmenuItem): TemplateResult | void {
if (!this.hass) {
return;
@@ -28,7 +32,10 @@ export class AdvancedCameraCardSubmenu extends LitElement {
const title = item.title ?? getEntityTitle(this.hass, item.entity);
const style = styleMap(item.style || {});
const disabled = item.enabled === false;
const inert =
!!this.lockManagerEpoch?.locked &&
this.lockManagerEpoch.manager.areAllActionsBlocked(item);
const disabled = item.enabled === false || inert;
return html`
<ha-dropdown-item
+17 -6
View File
@@ -8,17 +8,19 @@ import {
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { InternalIcon } from '../../config/schema/common/icon.js';
import { MenuSubmenuSelect } from '../../config/schema/elements/custom/menu/submenu-select.js';
import { MenuSubmenuItem } from '../../config/schema/elements/custom/menu/submenu.js';
import { actionHandler } from '../../action-handler-directive.js';
import type { LockManagerEpoch } from '../../card-controller/lock/types';
import type { InternalIcon } from '../../config/schema/common/icon.js';
import type { MenuSubmenuSelect } from '../../config/schema/elements/custom/menu/submenu-select.js';
import type { MenuSubmenuItem } from '../../config/schema/elements/custom/menu/submenu.js';
import { computeDomain } from '../../ha/compute-domain.js';
import { getEntityStateTranslation } from '../../ha/entity-state-translation.js';
import { getEntityTitle } from '../../ha/get-entity-title.js';
import { isHassDifferent } from '../../ha/is-hass-different.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 menuButtonStyle from '../../scss/menu-button.scss';
import { createSelectOptionAction } from '../../utils/action.js';
import { createSelectOptionAction, hasAction } from '../../utils/action.js';
import '../icon.js';
import './index.js';
@@ -33,6 +35,9 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
@property({ attribute: false })
public entityRegistryManager?: EntityRegistryManager;
@property({ attribute: false })
public lockManagerEpoch?: LockManagerEpoch;
@state()
private _optionTitles?: Record<string, string>;
@@ -129,6 +134,7 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
return html` <advanced-camera-card-submenu
.hass=${this.hass}
.items=${this._generatedSubmenuItems}
.lockManagerEpoch=${this.lockManagerEpoch}
>
<ha-icon-button style="${style}" .label=${title || ''}>
<advanced-camera-card-icon
@@ -137,6 +143,11 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
title=${title || ''}
.hass=${this.hass}
.icon=${this._generatedIcon}
.actionHandler=${actionHandler({
allowPropagation: true,
hasHold: hasAction(this.submenuSelect.hold_action),
hasDoubleClick: hasAction(this.submenuSelect.double_tap_action),
})}
></advanced-camera-card-icon>
</ha-icon-button>
</advanced-camera-card-submenu>`;
+7 -2
View File
@@ -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` <advanced-camera-card-submenu
.hass=${this.hass}
.items=${this.submenu?.items}
.lockManagerEpoch=${this.lockManagerEpoch}
>
<ha-icon-button style="${style}" .label=${this.submenu.title || ''}>
<advanced-camera-card-icon
+5
View File
@@ -51,6 +51,9 @@ export class AdvancedCameraCardSurround extends LitElement {
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public locked?: boolean;
/**
* Determine if a drawer is being used.
*/
@@ -102,6 +105,7 @@ export class AdvancedCameraCardSurround extends LitElement {
.foldersManager=${this.foldersManager}
.selected=${view.queryResults?.getSelectedIndex() ?? undefined}
.cardWideConfig=${this.cardWideConfig}
.locked=${this.locked}
>
</advanced-camera-card-thumbnail-carousel>`
: ''}
@@ -123,6 +127,7 @@ export class AdvancedCameraCardSurround extends LitElement {
.conditionStateManager=${this.conditionStateManager}
.viewItemManager=${this.viewItemManager}
.cardWideConfig=${this.cardWideConfig}
.locked=${this.locked}
>
</advanced-camera-card-timeline-core>`
: ''}
+3
View File
@@ -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;
+3
View File
@@ -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<AdvancedCameraCardDatePicker> = createRef();
private _refTimeline: Ref<HTMLElement> = createRef();
private _controller: TimelineController = new TimelineController(this);
+5
View File
@@ -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<string>;
@@ -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` <advanced-camera-card-image
@@ -236,6 +240,7 @@ export class AdvancedCameraCardViews extends LitElement {
.cardWideConfig=${this.cardWideConfig}
.microphoneManager=${this.microphoneManager}
.microphoneState=${this.microphoneState}
.locked=${this.locked}
.triggeredCameraIDs=${this.triggeredCameraIDs}
class="${classMap(liveClasses)}"
>
@@ -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)
+2
View File
@@ -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)
+1
View File
@@ -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;
+5
View File
@@ -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,
+1
View File
@@ -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",
+8
View File
@@ -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;
}
+1
View File
@@ -1,4 +1,5 @@
@use './button.scss';
@use 'locked.scss';
:host {
--advanced-camera-card-next-prev-size: 48px;
+1
View File
@@ -1,4 +1,5 @@
@use 'const.scss';
@use 'locked.scss';
:host {
display: block;
+1
View File
@@ -1,6 +1,7 @@
@use 'vis-timeline/dist/vis-timeline-graph2d.css';
@use 'drawer';
@use 'const.scss';
@use 'locked.scss';
:host {
width: 100%;
@@ -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<string, unknown>) => {
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>();
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>();
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);
+8
View File
@@ -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],
+142
View File
@@ -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();
});
});
@@ -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();
@@ -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<ViewFactory>();
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>();
viewFactory.getViewDefault.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
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<ViewFactory>();
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>();
viewFactory.getViewDefault.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
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<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView());
@@ -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', () => {
@@ -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>();
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());
+1
View File
@@ -136,6 +136,7 @@ describe('config defaults', () => {
auto_mute: [],
auto_unmute: [],
disconnect_seconds: 90,
lock: true,
mute_after_microphone_mute_seconds: 60,
},
preload: false,
+2
View File
@@ -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<InitializationManager>());
api.getInteractionManager.mockReturnValue(mock<InteractionManager>());
api.getKeyboardStateManager.mockReturnValue(mock<KeyboardStateManager>());
api.getLockManager.mockReturnValue(mock<LockManager>());
api.getMediaLoadedInfoManager.mockReturnValue(mock<MediaLoadedInfoManager>());
api.getMediaPlayerManager.mockReturnValue(mock<MediaPlayerManager>());
api.getMicrophoneManager.mockReturnValue(mock<MicrophoneManager>());