feat: Add UI optional UI locking when microphone is hot (#2484)
This commit is contained in:
committed by
dermotduffy
parent
9a763db1f3
commit
be7e7d79dc
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`
|
||||
: ''}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
@use './button.scss';
|
||||
@use 'locked.scss';
|
||||
|
||||
:host {
|
||||
--advanced-camera-card-next-prev-size: 48px;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@use 'const.scss';
|
||||
@use 'locked.scss';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -136,6 +136,7 @@ describe('config defaults', () => {
|
||||
auto_mute: [],
|
||||
auto_unmute: [],
|
||||
disconnect_seconds: 90,
|
||||
lock: true,
|
||||
mute_after_microphone_mute_seconds: 60,
|
||||
},
|
||||
preload: false,
|
||||
|
||||
@@ -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>());
|
||||
|
||||
Reference in New Issue
Block a user