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_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. |
|
| `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`. |
|
| `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`. |
|
| `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.
|
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_mute: []
|
||||||
auto_unmute: []
|
auto_unmute: []
|
||||||
disconnect_seconds: 90
|
disconnect_seconds: 90
|
||||||
|
lock: true
|
||||||
mute_after_microphone_mute_seconds: 60
|
mute_after_microphone_mute_seconds: 60
|
||||||
display:
|
display:
|
||||||
mode: single
|
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`. |
|
| `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. |
|
| `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). |
|
| `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). |
|
| `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`. |
|
| `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). |
|
| `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:
|
camera_ui:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:web
|
icon: mdi:web
|
||||||
cameras:
|
cameras:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:video-switch
|
icon: mdi:video-switch
|
||||||
clips:
|
clips:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: false
|
enabled: false
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:filmstrip
|
icon: mdi:filmstrip
|
||||||
display_mode:
|
display_mode:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:grid
|
icon: mdi:grid
|
||||||
download:
|
download:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:download
|
icon: mdi:download
|
||||||
expand:
|
expand:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: false
|
enabled: false
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:arrow-expand-all
|
icon: mdi:arrow-expand-all
|
||||||
folders:
|
folders:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:folder-multiple
|
icon: mdi:folder-multiple
|
||||||
fullscreen:
|
fullscreen:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:fullscreen
|
icon: mdi:fullscreen
|
||||||
gallery:
|
gallery:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:play-box-multiple
|
icon: mdi:play-box-multiple
|
||||||
image:
|
image:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: false
|
enabled: false
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:image
|
icon: mdi:image
|
||||||
info:
|
info:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:information-outline
|
icon: mdi:information-outline
|
||||||
iris:
|
iris:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: iris
|
icon: iris
|
||||||
live:
|
live:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:cctv
|
icon: mdi:cctv
|
||||||
media_player:
|
media_player:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:cast
|
icon: mdi:cast
|
||||||
microphone:
|
microphone:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: false
|
enabled: false
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:microphone
|
icon: mdi:microphone
|
||||||
type: momentary
|
type: momentary
|
||||||
mute:
|
mute:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: false
|
enabled: false
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:volume-off
|
icon: mdi:volume-off
|
||||||
pip:
|
pip:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: false
|
enabled: false
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:picture-in-picture-bottom-right
|
icon: mdi:picture-in-picture-bottom-right
|
||||||
play:
|
play:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: false
|
enabled: false
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:play
|
icon: mdi:play
|
||||||
ptz_controls:
|
ptz_controls:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: false
|
enabled: false
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:pan
|
icon: mdi:pan
|
||||||
ptz_home:
|
ptz_home:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: false
|
enabled: false
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:home
|
icon: mdi:home
|
||||||
recordings:
|
recordings:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: false
|
enabled: false
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:album
|
icon: mdi:album
|
||||||
reviews:
|
reviews:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: false
|
enabled: false
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:play-box-edit-outline
|
icon: mdi:play-box-edit-outline
|
||||||
screenshot:
|
screenshot:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: false
|
enabled: false
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:monitor-screenshot
|
icon: mdi:monitor-screenshot
|
||||||
set_review:
|
set_review:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:check-circle
|
icon: mdi:check-circle
|
||||||
snapshots:
|
snapshots:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: false
|
enabled: false
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:camera
|
icon: mdi:camera
|
||||||
substreams:
|
substreams:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:video-input-component
|
icon: mdi:video-input-component
|
||||||
timeline:
|
timeline:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: true
|
enabled: true
|
||||||
|
inert: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:chart-gantt
|
icon: mdi:chart-gantt
|
||||||
button_size: 40
|
button_size: 40
|
||||||
|
|||||||
@@ -147,7 +147,12 @@ export class ActionsManager implements ActionsExecutor {
|
|||||||
}) as ActionConfig | ActionConfig[])
|
}) as ActionConfig | ActionConfig[])
|
||||||
: request.actions;
|
: 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,
|
config: request.config,
|
||||||
cardID: this._api.getConfigManager().getConfig()?.card_id,
|
cardID: this._api.getConfigManager().getConfig()?.card_id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { HASSManager } from './hass/hass-manager';
|
|||||||
import { InitializationManager } from './initialization-manager';
|
import { InitializationManager } from './initialization-manager';
|
||||||
import { InteractionManager } from './interaction-manager';
|
import { InteractionManager } from './interaction-manager';
|
||||||
import { KeyboardStateManager } from './keyboard-state-manager';
|
import { KeyboardStateManager } from './keyboard-state-manager';
|
||||||
|
import { LockManager } from './lock/manager';
|
||||||
import { MediaLoadedInfoManager } from './media-info-manager';
|
import { MediaLoadedInfoManager } from './media-info-manager';
|
||||||
import { MediaPlayerManager } from './media-player-manager';
|
import { MediaPlayerManager } from './media-player-manager';
|
||||||
import { MicrophoneManager } from './microphone-manager';
|
import { MicrophoneManager } from './microphone-manager';
|
||||||
@@ -55,6 +56,7 @@ import {
|
|||||||
CardInitializerAPI,
|
CardInitializerAPI,
|
||||||
CardInteractionAPI,
|
CardInteractionAPI,
|
||||||
CardKeyboardStateAPI,
|
CardKeyboardStateAPI,
|
||||||
|
CardLockAPI,
|
||||||
CardMediaLoadedAPI,
|
CardMediaLoadedAPI,
|
||||||
CardMediaPlayerAPI,
|
CardMediaPlayerAPI,
|
||||||
CardMicrophoneAPI,
|
CardMicrophoneAPI,
|
||||||
@@ -88,6 +90,7 @@ export class CardController
|
|||||||
CardInitializerAPI,
|
CardInitializerAPI,
|
||||||
CardInteractionAPI,
|
CardInteractionAPI,
|
||||||
CardKeyboardStateAPI,
|
CardKeyboardStateAPI,
|
||||||
|
CardLockAPI,
|
||||||
CardMediaLoadedAPI,
|
CardMediaLoadedAPI,
|
||||||
CardMediaPlayerAPI,
|
CardMediaPlayerAPI,
|
||||||
CardMicrophoneAPI,
|
CardMicrophoneAPI,
|
||||||
@@ -121,6 +124,7 @@ export class CardController
|
|||||||
private _initializationManager = new InitializationManager(this);
|
private _initializationManager = new InitializationManager(this);
|
||||||
private _interactionManager = new InteractionManager(this);
|
private _interactionManager = new InteractionManager(this);
|
||||||
private _keyboardStateManager = new KeyboardStateManager(this);
|
private _keyboardStateManager = new KeyboardStateManager(this);
|
||||||
|
private _lockManager = new LockManager(this);
|
||||||
private _mediaLoadedInfoManager = new MediaLoadedInfoManager(this);
|
private _mediaLoadedInfoManager = new MediaLoadedInfoManager(this);
|
||||||
|
|
||||||
private _mediaPlayerManager = new MediaPlayerManager(this);
|
private _mediaPlayerManager = new MediaPlayerManager(this);
|
||||||
@@ -234,6 +238,10 @@ export class CardController
|
|||||||
return this._keyboardStateManager;
|
return this._keyboardStateManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getLockManager(): LockManager {
|
||||||
|
return this._lockManager;
|
||||||
|
}
|
||||||
|
|
||||||
public getMediaLoadedInfoManager(): MediaLoadedInfoManager {
|
public getMediaLoadedInfoManager(): MediaLoadedInfoManager {
|
||||||
return this._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);
|
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 {
|
private _setDesiredMuteOnStream(): void {
|
||||||
this._stream?.getTracks().forEach((track) => {
|
this._stream?.getTracks().forEach((track) => {
|
||||||
track.enabled = !this._desireMute;
|
track.enabled = !this._desireMute;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { InitializationManager } from './initialization-manager';
|
|||||||
import type { InteractionManager } from './interaction-manager';
|
import type { InteractionManager } from './interaction-manager';
|
||||||
import type { IssueManager } from './issues/issue-manager';
|
import type { IssueManager } from './issues/issue-manager';
|
||||||
import type { KeyboardStateManager } from './keyboard-state-manager';
|
import type { KeyboardStateManager } from './keyboard-state-manager';
|
||||||
|
import type { LockManager } from './lock/manager';
|
||||||
import type { MediaLoadedInfoManager } from './media-info-manager';
|
import type { MediaLoadedInfoManager } from './media-info-manager';
|
||||||
import type { MediaPlayerManager } from './media-player-manager';
|
import type { MediaPlayerManager } from './media-player-manager';
|
||||||
import type { MicrophoneManager } from './microphone-manager';
|
import type { MicrophoneManager } from './microphone-manager';
|
||||||
@@ -50,6 +51,7 @@ export interface CardActionsAPI {
|
|||||||
getFoldersManager(): FoldersManager;
|
getFoldersManager(): FoldersManager;
|
||||||
getFullscreenManager(): FullscreenManager;
|
getFullscreenManager(): FullscreenManager;
|
||||||
getHASSManager(): HASSManager;
|
getHASSManager(): HASSManager;
|
||||||
|
getLockManager(): LockManager;
|
||||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||||
getMediaPlayerManager(): MediaPlayerManager;
|
getMediaPlayerManager(): MediaPlayerManager;
|
||||||
getMicrophoneManager(): MicrophoneManager;
|
getMicrophoneManager(): MicrophoneManager;
|
||||||
@@ -234,6 +236,10 @@ export interface CardKeyboardStateAPI {
|
|||||||
getConfigManager(): ConfigManager;
|
getConfigManager(): ConfigManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CardLockAPI {
|
||||||
|
getMicrophoneManager(): MicrophoneManager;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CardMediaLoadedAPI {
|
export interface CardMediaLoadedAPI {
|
||||||
getCardElementManager(): CardElementManager;
|
getCardElementManager(): CardElementManager;
|
||||||
getConditionStateManager(): ConditionStateManager;
|
getConditionStateManager(): ConditionStateManager;
|
||||||
@@ -311,6 +317,7 @@ export interface CardViewAPI {
|
|||||||
getFoldersManager(): FoldersManager;
|
getFoldersManager(): FoldersManager;
|
||||||
getHASSManager(): HASSManager;
|
getHASSManager(): HASSManager;
|
||||||
getInitializationManager(): InitializationManager;
|
getInitializationManager(): InitializationManager;
|
||||||
|
getLockManager(): LockManager;
|
||||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||||
getNotificationManager(): NotificationManager;
|
getNotificationManager(): NotificationManager;
|
||||||
getIssueManager(): IssueManager;
|
getIssueManager(): IssueManager;
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ export interface ViewFactoryOptions {
|
|||||||
// `live` view if the configured default view is not supported.
|
// `live` view if the configured default view is not supported.
|
||||||
failSafe?: boolean;
|
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
|
// Options for the query executor that control how a query is executed and the
|
||||||
// result selected.
|
// result selected.
|
||||||
queryExecutorOptions?: QueryExecutorOptions;
|
queryExecutorOptions?: QueryExecutorOptions;
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export class ViewManager implements ViewManagerInterface {
|
|||||||
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
|
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
|
||||||
options?: ViewFactoryOptions,
|
options?: ViewFactoryOptions,
|
||||||
): void {
|
): void {
|
||||||
if (!this._isAllowedToSetView()) {
|
if (!this._isAllowedToProposeView()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +125,9 @@ export class ViewManager implements ViewManagerInterface {
|
|||||||
}
|
}
|
||||||
this._api.getIssueManager().trigger('view_incompatible', { error: e });
|
this._api.getIssueManager().trigger('view_incompatible', { error: e });
|
||||||
}
|
}
|
||||||
|
if (view && !this._isAllowedToSetView(view, options)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (view) {
|
if (view) {
|
||||||
this._setView(view);
|
this._setView(view);
|
||||||
}
|
}
|
||||||
@@ -147,7 +150,10 @@ export class ViewManager implements ViewManagerInterface {
|
|||||||
view.removeContextProperty('loading', 'query');
|
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
|
// 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
|
// the same time as the cameras being initialized. Test case: Open
|
||||||
// folder-based media in the media viewer carousel, then attempt to edit the
|
// 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);
|
.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(
|
private async _setViewThenModifyAsync(
|
||||||
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
|
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
|
||||||
viewModifiersFunc: (
|
viewModifiersFunc: (
|
||||||
@@ -168,7 +188,7 @@ export class ViewManager implements ViewManagerInterface {
|
|||||||
) => Promise<ViewModifier[] | null>,
|
) => Promise<ViewModifier[] | null>,
|
||||||
options?: ViewFactoryOptions,
|
options?: ViewFactoryOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this._isAllowedToSetView()) {
|
if (!this._isAllowedToProposeView()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +215,7 @@ export class ViewManager implements ViewManagerInterface {
|
|||||||
this._api.getIssueManager().trigger('view_incompatible', { error: e });
|
this._api.getIssueManager().trigger('view_incompatible', { error: e });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!initialView) {
|
if (!initialView || !this._isAllowedToSetView(initialView, options)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ class AdvancedCameraCard extends LitElement {
|
|||||||
${ref(this._refMenu)}
|
${ref(this._refMenu)}
|
||||||
slot=${ifDefined(slot)}
|
slot=${ifDefined(slot)}
|
||||||
.hass=${this._hass}
|
.hass=${this._hass}
|
||||||
|
.lockManagerEpoch=${this._controller.getLockManager().getEpoch()}
|
||||||
.menuConfig=${this._config.menu}
|
.menuConfig=${this._config.menu}
|
||||||
.buttons=${this._menuButtonController.calculateButtons(
|
.buttons=${this._menuButtonController.calculateButtons(
|
||||||
this._hass,
|
this._hass,
|
||||||
@@ -426,6 +427,7 @@ class AdvancedCameraCard extends LitElement {
|
|||||||
.hide=${!!fullCardIssue}
|
.hide=${!!fullCardIssue}
|
||||||
.microphoneManager=${this._controller.getMicrophoneManager()}
|
.microphoneManager=${this._controller.getMicrophoneManager()}
|
||||||
.microphoneState=${this._controller.getMicrophoneManager().getState()}
|
.microphoneState=${this._controller.getMicrophoneManager().getState()}
|
||||||
|
.locked=${this._controller.getLockManager().isLocked()}
|
||||||
.conditionStateManager=${this._controller.getConditionStateManager()}
|
.conditionStateManager=${this._controller.getConditionStateManager()}
|
||||||
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
|
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
|
||||||
? this._controller.getTriggersManager().getTriggeredCameraIDs()
|
? this._controller.getTriggersManager().getTriggeredCameraIDs()
|
||||||
|
|||||||
@@ -161,12 +161,16 @@ export class MediaGridController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public selectCell(id: GridID) {
|
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) {
|
if (this._selected === id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._selected = id;
|
this._selected = id;
|
||||||
fireAdvancedCameraCardEvent(this._host, 'media-grid:selected', { selected: id });
|
|
||||||
|
|
||||||
this._sortItemsInGrid();
|
this._sortItemsInGrid();
|
||||||
this._updateSelectedStylesOnElements();
|
this._updateSelectedStylesOnElements();
|
||||||
@@ -301,7 +305,14 @@ export class MediaGridController {
|
|||||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||||
if (eventPath.includes(element)) {
|
if (eventPath.includes(element)) {
|
||||||
if (this._selected !== id) {
|
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();
|
ev.stopPropagation();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export class MenuButtonController {
|
|||||||
foldersManager: FoldersManager,
|
foldersManager: FoldersManager,
|
||||||
options?: MenuButtonControllerOptions,
|
options?: MenuButtonControllerOptions,
|
||||||
): MenuItem[] {
|
): MenuItem[] {
|
||||||
return [
|
const buttons: MenuItem[] = [
|
||||||
this._getIrisButton(config),
|
this._getIrisButton(config),
|
||||||
this._getCamerasButton(config, cameraManager, options?.view),
|
this._getCamerasButton(config, cameraManager, options?.view),
|
||||||
this._getSubstreamsButton(config, cameraManager, options?.view),
|
this._getSubstreamsButton(config, cameraManager, options?.view),
|
||||||
@@ -129,6 +129,8 @@ export class MenuButtonController {
|
|||||||
...button,
|
...button,
|
||||||
})),
|
})),
|
||||||
].filter(isTruthy);
|
].filter(isTruthy);
|
||||||
|
|
||||||
|
return buttons;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getIrisButton(config: AdvancedCameraCardConfig): MenuItem {
|
private _getIrisButton(config: AdvancedCameraCardConfig): MenuItem {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { LitElement } from 'lit';
|
import { LitElement } from 'lit';
|
||||||
import { isEqual, orderBy } from 'lodash-es';
|
import { isEqual, orderBy } from 'lodash-es';
|
||||||
import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js';
|
import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js';
|
||||||
import { SubmenuInteraction } from '../components/submenu/types.js';
|
import type { LockManagerEpoch } from '../card-controller/lock/types';
|
||||||
import { ActionConfig, ActionsConfig } from '../config/schema/actions/types.js';
|
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 { MENU_PRIORITY_MAX } from '../config/schema/common/const.js';
|
||||||
import { MenuItem } from '../config/schema/elements/custom/menu/types.js';
|
import type { MenuItem } from '../config/schema/elements/custom/menu/types.js';
|
||||||
import { MenuConfig } from '../config/schema/menu.js';
|
import type { MenuConfig } from '../config/schema/menu.js';
|
||||||
import { Interaction } from '../types.js';
|
import type { Interaction } from '../types.js';
|
||||||
import { getActionConfigGivenAction } from '../utils/action';
|
import { getActionConfigGivenAction } from '../utils/action';
|
||||||
import { arrayify, isTruthy, setOrRemoveAttribute } from '../utils/basic.js';
|
import { arrayify, isTruthy, setOrRemoveAttribute } from '../utils/basic.js';
|
||||||
|
|
||||||
@@ -15,11 +16,43 @@ export class MenuController {
|
|||||||
private _config: MenuConfig | null = null;
|
private _config: MenuConfig | null = null;
|
||||||
private _buttons: MenuItem[] = [];
|
private _buttons: MenuItem[] = [];
|
||||||
private _expanded = false;
|
private _expanded = false;
|
||||||
|
private _lockManagerEpoch?: LockManagerEpoch;
|
||||||
|
|
||||||
constructor(host: LitElement) {
|
constructor(host: LitElement) {
|
||||||
this._host = host;
|
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 {
|
public setMenuConfig(config: MenuConfig): void {
|
||||||
this._config = config;
|
this._config = config;
|
||||||
this._host.style.setProperty(
|
this._host.style.setProperty(
|
||||||
@@ -92,8 +125,13 @@ export class MenuController {
|
|||||||
// upstream has the user-provided configuration.
|
// upstream has the user-provided configuration.
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
|
|
||||||
// If the action is from a submenu, use the attached action config.
|
// Resolve which config the action belongs to. When a click bubbles up from
|
||||||
const config: ActionsConfig | null = buttonConfig ?? ev.detail.item ?? null;
|
// 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) {
|
if (!config) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public microphoneState?: MicrophoneState;
|
public microphoneState?: MicrophoneState;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public locked?: boolean;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public viewFilterCameraID?: string;
|
public viewFilterCameraID?: string;
|
||||||
|
|
||||||
@@ -247,6 +250,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
.zoomSettings=${view?.context?.zoom?.[cameraID]?.requested}
|
.zoomSettings=${view?.context?.zoom?.[cameraID]?.requested}
|
||||||
.zoom=${!this._isGesturesPTZActive(view, cameraID)}
|
.zoom=${!this._isGesturesPTZActive(view, cameraID)}
|
||||||
.forceSelected=${isSelectedSlide}
|
.forceSelected=${isSelectedSlide}
|
||||||
|
.locked=${this.locked}
|
||||||
@advanced-camera-card:zoom:change=${(
|
@advanced-camera-card:zoom:change=${(
|
||||||
ev: CustomEvent<ZoomSettingsObserved>,
|
ev: CustomEvent<ZoomSettingsObserved>,
|
||||||
) =>
|
) =>
|
||||||
@@ -327,6 +331,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
.label=${neighbor?.metadata?.title ?? ''}
|
.label=${neighbor?.metadata?.title ?? ''}
|
||||||
.icon=${neighbor?.metadata?.icon}
|
.icon=${neighbor?.metadata?.icon}
|
||||||
?disabled=${!neighbor}
|
?disabled=${!neighbor}
|
||||||
|
?locked=${!!this.locked}
|
||||||
@click=${(ev) => {
|
@click=${(ev) => {
|
||||||
this._setViewCameraID(neighbor?.id);
|
this._setViewCameraID(neighbor?.id);
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
@@ -360,7 +365,10 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
: view.context?.ptzControls?.enabled;
|
: view.context?.ptzControls?.enabled;
|
||||||
|
|
||||||
const dragEnabled =
|
const dragEnabled =
|
||||||
hasMultipleCameras && this.liveConfig?.draggable && !gesturesPTZActive;
|
hasMultipleCameras &&
|
||||||
|
this.liveConfig?.draggable &&
|
||||||
|
!gesturesPTZActive &&
|
||||||
|
!this.locked;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<advanced-camera-card-carousel
|
<advanced-camera-card-carousel
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public microphoneState?: MicrophoneState;
|
public microphoneState?: MicrophoneState;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public locked?: boolean;
|
||||||
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public triggeredCameraIDs?: Set<string>;
|
public triggeredCameraIDs?: Set<string>;
|
||||||
|
|
||||||
@@ -63,6 +66,7 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
|
|||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.microphoneState=${this.microphoneState}
|
.microphoneState=${this.microphoneState}
|
||||||
|
.locked=${this.locked}
|
||||||
?triggered=${triggeredCameraID &&
|
?triggered=${triggeredCameraID &&
|
||||||
!!this.triggeredCameraIDs?.has(triggeredCameraID)}
|
!!this.triggeredCameraIDs?.has(triggeredCameraID)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export class AdvancedCameraCardLive extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public microphoneState?: MicrophoneState;
|
public microphoneState?: MicrophoneState;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public locked?: boolean;
|
||||||
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public triggeredCameraIDs?: Set<string>;
|
public triggeredCameraIDs?: Set<string>;
|
||||||
|
|
||||||
@@ -93,6 +96,7 @@ export class AdvancedCameraCardLive extends LitElement {
|
|||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.microphoneState=${this.microphoneState}
|
.microphoneState=${this.microphoneState}
|
||||||
|
.locked=${this.locked}
|
||||||
.triggeredCameraIDs=${this.triggeredCameraIDs}
|
.triggeredCameraIDs=${this.triggeredCameraIDs}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-live-grid>
|
</advanced-camera-card-live-grid>
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public forceSelected = 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, {
|
private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, {
|
||||||
getTargetID: () => this.targetID ?? null,
|
getTargetID: () => this.targetID ?? null,
|
||||||
});
|
});
|
||||||
@@ -181,14 +187,17 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
return result;
|
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.
|
// - controls.builtin: user config enables native controls.
|
||||||
// - zoom: Whether digital zoom/panning is allowed (this will be false when a
|
// - zoom: Whether digital zoom/panning is allowed (this will be false when a
|
||||||
// 'gesture' type PTZ control is active).
|
// 'gesture' type PTZ control is active).
|
||||||
// - !_zoomed: the user has not actually digital zoomed in (when zoomed, we
|
// - !_zoomed: the user has not actually digital zoomed in (when zoomed, we
|
||||||
// want to hide the controls).
|
// want to hide the controls).
|
||||||
|
// - !locked: the UI lock is not active.
|
||||||
private _getEffectiveBuiltinControls(): boolean {
|
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 {
|
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 { customElement, property } from 'lit/decorators.js';
|
||||||
import { styleMap } from 'lit/directives/style-map.js';
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
import { actionHandler } from '../action-handler-directive.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 { MenuController } from '../components-lib/menu-controller.js';
|
||||||
import { MenuItem } from '../config/schema/elements/custom/menu/types.js';
|
import type { MenuItem } from '../config/schema/elements/custom/menu/types.js';
|
||||||
import { MenuConfig } from '../config/schema/menu.js';
|
import type { MenuConfig } from '../config/schema/menu.js';
|
||||||
import { getEntityTitle } from '../ha/get-entity-title.js';
|
import { getEntityTitle } from '../ha/get-entity-title.js';
|
||||||
import { EntityRegistryManager } from '../ha/registry/entity/types.js';
|
import type { EntityRegistryManager } from '../ha/registry/entity/types.js';
|
||||||
import { HomeAssistant } from '../ha/types.js';
|
import type { HomeAssistant } from '../ha/types.js';
|
||||||
import menuStyle from '../scss/menu.scss';
|
import menuStyle from '../scss/menu.scss';
|
||||||
import { hasAction } from '../utils/action.js';
|
import { hasAction } from '../utils/action.js';
|
||||||
import './icon.js';
|
import './icon.js';
|
||||||
@@ -24,6 +25,9 @@ export class AdvancedCameraCardMenu extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public hass?: HomeAssistant;
|
public hass?: HomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public lockManagerEpoch?: LockManagerEpoch;
|
||||||
|
|
||||||
set menuConfig(menuConfig: MenuConfig) {
|
set menuConfig(menuConfig: MenuConfig) {
|
||||||
this._controller.setMenuConfig(menuConfig);
|
this._controller.setMenuConfig(menuConfig);
|
||||||
}
|
}
|
||||||
@@ -36,6 +40,12 @@ export class AdvancedCameraCardMenu extends LitElement {
|
|||||||
this._controller.setExpanded(expanded);
|
this._controller.setExpanded(expanded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected willUpdate(changedProps: Map<string, unknown>): void {
|
||||||
|
if (changedProps.has('lockManagerEpoch')) {
|
||||||
|
this._controller.setLockManagerEpoch(this.lockManagerEpoch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public toggleMenu(): void {
|
public toggleMenu(): void {
|
||||||
this._controller.toggleExpanded();
|
this._controller.toggleExpanded();
|
||||||
}
|
}
|
||||||
@@ -49,7 +59,8 @@ export class AdvancedCameraCardMenu extends LitElement {
|
|||||||
return html` <advanced-camera-card-submenu-button
|
return html` <advanced-camera-card-submenu-button
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.submenu=${button}
|
.submenu=${button}
|
||||||
@action=${(ev) => this._controller.handleAction(ev)}
|
.lockManagerEpoch=${this.lockManagerEpoch}
|
||||||
|
@action=${(ev) => this._controller.handleAction(ev, button)}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-submenu-button>`;
|
</advanced-camera-card-submenu-button>`;
|
||||||
} else if (button.type === 'custom:advanced-camera-card-menu-submenu-select') {
|
} else if (button.type === 'custom:advanced-camera-card-menu-submenu-select') {
|
||||||
@@ -57,7 +68,8 @@ export class AdvancedCameraCardMenu extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.submenuSelect=${button}
|
.submenuSelect=${button}
|
||||||
.entityRegistryManager=${this.entityRegistryManager}
|
.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>`;
|
</advanced-camera-card-submenu-select-button>`;
|
||||||
}
|
}
|
||||||
@@ -75,6 +87,7 @@ export class AdvancedCameraCardMenu extends LitElement {
|
|||||||
hasDoubleClick: hasAction(button.double_tap_action),
|
hasDoubleClick: hasAction(button.double_tap_action),
|
||||||
})}
|
})}
|
||||||
.label=${title ?? ''}
|
.label=${title ?? ''}
|
||||||
|
?disabled=${this._controller.shouldButtonBeInert(button)}
|
||||||
@action=${(ev) => this._controller.handleAction(ev, button)}
|
@action=${(ev) => this._controller.handleAction(ev, button)}
|
||||||
>
|
>
|
||||||
<advanced-camera-card-icon
|
<advanced-camera-card-icon
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
|
|||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { styleMap } from 'lit/directives/style-map.js';
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
import { actionHandler } from '../../action-handler-directive.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 { 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 submenuStyle from '../../scss/submenu.scss';
|
||||||
import {
|
import {
|
||||||
hasAction,
|
hasAction,
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
} from '../../utils/action.js';
|
} from '../../utils/action.js';
|
||||||
import { contentsChanged } from '../../utils/basic.js';
|
import { contentsChanged } from '../../utils/basic.js';
|
||||||
import '../icon.js';
|
import '../icon.js';
|
||||||
import { SubmenuInteraction, SubmenuItem } from './types.js';
|
import type { SubmenuInteraction, SubmenuItem } from './types.js';
|
||||||
|
|
||||||
@customElement('advanced-camera-card-submenu')
|
@customElement('advanced-camera-card-submenu')
|
||||||
export class AdvancedCameraCardSubmenu extends LitElement {
|
export class AdvancedCameraCardSubmenu extends LitElement {
|
||||||
@@ -21,6 +22,9 @@ export class AdvancedCameraCardSubmenu extends LitElement {
|
|||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public items?: SubmenuItem[];
|
public items?: SubmenuItem[];
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public lockManagerEpoch?: LockManagerEpoch;
|
||||||
|
|
||||||
private _renderItem(item: SubmenuItem): TemplateResult | void {
|
private _renderItem(item: SubmenuItem): TemplateResult | void {
|
||||||
if (!this.hass) {
|
if (!this.hass) {
|
||||||
return;
|
return;
|
||||||
@@ -28,7 +32,10 @@ export class AdvancedCameraCardSubmenu extends LitElement {
|
|||||||
|
|
||||||
const title = item.title ?? getEntityTitle(this.hass, item.entity);
|
const title = item.title ?? getEntityTitle(this.hass, item.entity);
|
||||||
const style = styleMap(item.style || {});
|
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`
|
return html`
|
||||||
<ha-dropdown-item
|
<ha-dropdown-item
|
||||||
|
|||||||
@@ -8,17 +8,19 @@ import {
|
|||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { styleMap } from 'lit/directives/style-map.js';
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
import { InternalIcon } from '../../config/schema/common/icon.js';
|
import { actionHandler } from '../../action-handler-directive.js';
|
||||||
import { MenuSubmenuSelect } from '../../config/schema/elements/custom/menu/submenu-select.js';
|
import type { LockManagerEpoch } from '../../card-controller/lock/types';
|
||||||
import { MenuSubmenuItem } from '../../config/schema/elements/custom/menu/submenu.js';
|
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 { computeDomain } from '../../ha/compute-domain.js';
|
||||||
import { getEntityStateTranslation } from '../../ha/entity-state-translation.js';
|
import { getEntityStateTranslation } from '../../ha/entity-state-translation.js';
|
||||||
import { getEntityTitle } from '../../ha/get-entity-title.js';
|
import { getEntityTitle } from '../../ha/get-entity-title.js';
|
||||||
import { isHassDifferent } from '../../ha/is-hass-different.js';
|
import { isHassDifferent } from '../../ha/is-hass-different.js';
|
||||||
import { EntityRegistryManager } from '../../ha/registry/entity/types.js';
|
import type { EntityRegistryManager } from '../../ha/registry/entity/types.js';
|
||||||
import { HomeAssistant } from '../../ha/types.js';
|
import type { HomeAssistant } from '../../ha/types.js';
|
||||||
import menuButtonStyle from '../../scss/menu-button.scss';
|
import menuButtonStyle from '../../scss/menu-button.scss';
|
||||||
import { createSelectOptionAction } from '../../utils/action.js';
|
import { createSelectOptionAction, hasAction } from '../../utils/action.js';
|
||||||
import '../icon.js';
|
import '../icon.js';
|
||||||
import './index.js';
|
import './index.js';
|
||||||
|
|
||||||
@@ -33,6 +35,9 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public entityRegistryManager?: EntityRegistryManager;
|
public entityRegistryManager?: EntityRegistryManager;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public lockManagerEpoch?: LockManagerEpoch;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private _optionTitles?: Record<string, string>;
|
private _optionTitles?: Record<string, string>;
|
||||||
|
|
||||||
@@ -129,6 +134,7 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
|
|||||||
return html` <advanced-camera-card-submenu
|
return html` <advanced-camera-card-submenu
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.items=${this._generatedSubmenuItems}
|
.items=${this._generatedSubmenuItems}
|
||||||
|
.lockManagerEpoch=${this.lockManagerEpoch}
|
||||||
>
|
>
|
||||||
<ha-icon-button style="${style}" .label=${title || ''}>
|
<ha-icon-button style="${style}" .label=${title || ''}>
|
||||||
<advanced-camera-card-icon
|
<advanced-camera-card-icon
|
||||||
@@ -137,6 +143,11 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
|
|||||||
title=${title || ''}
|
title=${title || ''}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.icon=${this._generatedIcon}
|
.icon=${this._generatedIcon}
|
||||||
|
.actionHandler=${actionHandler({
|
||||||
|
allowPropagation: true,
|
||||||
|
hasHold: hasAction(this.submenuSelect.hold_action),
|
||||||
|
hasDoubleClick: hasAction(this.submenuSelect.double_tap_action),
|
||||||
|
})}
|
||||||
></advanced-camera-card-icon>
|
></advanced-camera-card-icon>
|
||||||
</ha-icon-button>
|
</ha-icon-button>
|
||||||
</advanced-camera-card-submenu>`;
|
</advanced-camera-card-submenu>`;
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
|
|||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { styleMap } from 'lit/directives/style-map.js';
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
import { actionHandler } from '../../action-handler-directive.js';
|
import { actionHandler } from '../../action-handler-directive.js';
|
||||||
import { MenuSubmenu } from '../../config/schema/elements/custom/menu/submenu.js';
|
import type { LockManagerEpoch } from '../../card-controller/lock/types';
|
||||||
import { HomeAssistant } from '../../ha/types.js';
|
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 menuButtonStyle from '../../scss/menu-button.scss';
|
||||||
import { hasAction } from '../../utils/action.js';
|
import { hasAction } from '../../utils/action.js';
|
||||||
import '../icon.js';
|
import '../icon.js';
|
||||||
@@ -17,6 +18,9 @@ export class AdvancedCameraCardSubmenuButton extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public submenu?: MenuSubmenu;
|
public submenu?: MenuSubmenu;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public lockManagerEpoch?: LockManagerEpoch;
|
||||||
|
|
||||||
protected render(): TemplateResult {
|
protected render(): TemplateResult {
|
||||||
if (!this.submenu) {
|
if (!this.submenu) {
|
||||||
return html``;
|
return html``;
|
||||||
@@ -26,6 +30,7 @@ export class AdvancedCameraCardSubmenuButton extends LitElement {
|
|||||||
return html` <advanced-camera-card-submenu
|
return html` <advanced-camera-card-submenu
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.items=${this.submenu?.items}
|
.items=${this.submenu?.items}
|
||||||
|
.lockManagerEpoch=${this.lockManagerEpoch}
|
||||||
>
|
>
|
||||||
<ha-icon-button style="${style}" .label=${this.submenu.title || ''}>
|
<ha-icon-button style="${style}" .label=${this.submenu.title || ''}>
|
||||||
<advanced-camera-card-icon
|
<advanced-camera-card-icon
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ export class AdvancedCameraCardSurround extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public locked?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if a drawer is being used.
|
* Determine if a drawer is being used.
|
||||||
*/
|
*/
|
||||||
@@ -102,6 +105,7 @@ export class AdvancedCameraCardSurround extends LitElement {
|
|||||||
.foldersManager=${this.foldersManager}
|
.foldersManager=${this.foldersManager}
|
||||||
.selected=${view.queryResults?.getSelectedIndex() ?? undefined}
|
.selected=${view.queryResults?.getSelectedIndex() ?? undefined}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
|
.locked=${this.locked}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-thumbnail-carousel>`
|
</advanced-camera-card-thumbnail-carousel>`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -123,6 +127,7 @@ export class AdvancedCameraCardSurround extends LitElement {
|
|||||||
.conditionStateManager=${this.conditionStateManager}
|
.conditionStateManager=${this.conditionStateManager}
|
||||||
.viewItemManager=${this.viewItemManager}
|
.viewItemManager=${this.viewItemManager}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
|
.locked=${this.locked}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-timeline-core>`
|
</advanced-camera-card-timeline-core>`
|
||||||
: ''}
|
: ''}
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public fadeThumbnails = false;
|
public fadeThumbnails = false;
|
||||||
|
|
||||||
|
@property({ type: Boolean, reflect: true })
|
||||||
|
public locked?: boolean;
|
||||||
|
|
||||||
private _thumbnails: TemplateResult[] = [];
|
private _thumbnails: TemplateResult[] = [];
|
||||||
private _builder: UnifiedQueryBuilder | null = null;
|
private _builder: UnifiedQueryBuilder | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public itemClickAction?: TimelineItemClickAction;
|
public itemClickAction?: TimelineItemClickAction;
|
||||||
|
|
||||||
|
@property({ type: Boolean, reflect: true })
|
||||||
|
public locked?: boolean;
|
||||||
|
|
||||||
private _refDatePicker: Ref<AdvancedCameraCardDatePicker> = createRef();
|
private _refDatePicker: Ref<AdvancedCameraCardDatePicker> = createRef();
|
||||||
private _refTimeline: Ref<HTMLElement> = createRef();
|
private _refTimeline: Ref<HTMLElement> = createRef();
|
||||||
private _controller: TimelineController = new TimelineController(this);
|
private _controller: TimelineController = new TimelineController(this);
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ export class AdvancedCameraCardViews extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public microphoneState?: MicrophoneState;
|
public microphoneState?: MicrophoneState;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public locked?: boolean;
|
||||||
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public triggeredCameraIDs?: Set<string>;
|
public triggeredCameraIDs?: Set<string>;
|
||||||
|
|
||||||
@@ -163,6 +166,7 @@ export class AdvancedCameraCardViews extends LitElement {
|
|||||||
.conditionStateManager=${this.conditionStateManager}
|
.conditionStateManager=${this.conditionStateManager}
|
||||||
.viewItemManager=${this.viewItemManager}
|
.viewItemManager=${this.viewItemManager}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
|
.locked=${this.locked}
|
||||||
>
|
>
|
||||||
${!this.hide && view?.is('image')
|
${!this.hide && view?.is('image')
|
||||||
? html` <advanced-camera-card-image
|
? html` <advanced-camera-card-image
|
||||||
@@ -236,6 +240,7 @@ export class AdvancedCameraCardViews extends LitElement {
|
|||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
.microphoneManager=${this.microphoneManager}
|
.microphoneManager=${this.microphoneManager}
|
||||||
.microphoneState=${this.microphoneState}
|
.microphoneState=${this.microphoneState}
|
||||||
|
.locked=${this.locked}
|
||||||
.triggeredCameraIDs=${this.triggeredCameraIDs}
|
.triggeredCameraIDs=${this.triggeredCameraIDs}
|
||||||
class="${classMap(liveClasses)}"
|
class="${classMap(liveClasses)}"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { MENU_PRIORITY_DEFAULT, MENU_PRIORITY_MAX } from '../../../common/const'
|
|||||||
|
|
||||||
export const menuBaseSchema = z.object({
|
export const menuBaseSchema = z.object({
|
||||||
enabled: z.boolean().default(true).optional(),
|
enabled: z.boolean().default(true).optional(),
|
||||||
|
inert: z.boolean().optional(),
|
||||||
priority: z
|
priority: z
|
||||||
.number()
|
.number()
|
||||||
.min(0)
|
.min(0)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const microphoneConfigDefault = {
|
|||||||
auto_mute: [],
|
auto_mute: [],
|
||||||
auto_unmute: [],
|
auto_unmute: [],
|
||||||
disconnect_seconds: 90,
|
disconnect_seconds: 90,
|
||||||
|
lock: true,
|
||||||
mute_after_microphone_mute_seconds: 60,
|
mute_after_microphone_mute_seconds: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ const microphoneConfigSchema = z
|
|||||||
.number()
|
.number()
|
||||||
.min(0)
|
.min(0)
|
||||||
.default(microphoneConfigDefault.disconnect_seconds),
|
.default(microphoneConfigDefault.disconnect_seconds),
|
||||||
|
lock: z.boolean().default(microphoneConfigDefault.lock),
|
||||||
mute_after_microphone_mute_seconds: z
|
mute_after_microphone_mute_seconds: z
|
||||||
.number()
|
.number()
|
||||||
.min(0)
|
.min(0)
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ export const CONF_LIVE_MICROPHONE_AUTO_UNMUTE =
|
|||||||
`${CONF_LIVE}.microphone.auto_unmute` as const;
|
`${CONF_LIVE}.microphone.auto_unmute` as const;
|
||||||
export const CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS =
|
export const CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS =
|
||||||
`${CONF_LIVE}.microphone.disconnect_seconds` as const;
|
`${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 =
|
export const CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS =
|
||||||
`${CONF_LIVE}.microphone.mute_after_microphone_mute_seconds` as const;
|
`${CONF_LIVE}.microphone.mute_after_microphone_mute_seconds` as const;
|
||||||
export const CONF_LIVE_ZOOMABLE = `${CONF_LIVE}.zoomable` 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_MUTE,
|
||||||
CONF_LIVE_MICROPHONE_AUTO_UNMUTE,
|
CONF_LIVE_MICROPHONE_AUTO_UNMUTE,
|
||||||
CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS,
|
CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS,
|
||||||
|
CONF_LIVE_MICROPHONE_LOCK,
|
||||||
CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS,
|
CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS,
|
||||||
CONF_LIVE_PRELOAD,
|
CONF_LIVE_PRELOAD,
|
||||||
CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
|
CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
|
||||||
@@ -3352,6 +3353,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
|
CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
|
||||||
this._defaults.live.microphone.always_connected,
|
this._defaults.live.microphone.always_connected,
|
||||||
)}
|
)}
|
||||||
|
${this._renderSwitch(
|
||||||
|
CONF_LIVE_MICROPHONE_LOCK,
|
||||||
|
this._defaults.live.microphone.lock,
|
||||||
|
)}
|
||||||
${this._renderOptionSelector(
|
${this._renderOptionSelector(
|
||||||
CONF_LIVE_MICROPHONE_AUTO_MUTE,
|
CONF_LIVE_MICROPHONE_AUTO_MUTE,
|
||||||
this._mediaActionNegativeConditions,
|
this._mediaActionNegativeConditions,
|
||||||
|
|||||||
@@ -427,6 +427,7 @@
|
|||||||
"auto_unmute": "Automatically unmute the microphone",
|
"auto_unmute": "Automatically unmute the microphone",
|
||||||
"disconnect_seconds": "Seconds before disconnecting microphone (0=never)",
|
"disconnect_seconds": "Seconds before disconnecting microphone (0=never)",
|
||||||
"editor_label": "Microphone",
|
"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"
|
"mute_after_microphone_mute_seconds": "Seconds after microphone mute before muting inbound audio"
|
||||||
},
|
},
|
||||||
"preload": "Preload live view in the background",
|
"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 './button.scss';
|
||||||
|
@use 'locked.scss';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
--advanced-camera-card-next-prev-size: 48px;
|
--advanced-camera-card-next-prev-size: 48px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@use 'const.scss';
|
@use 'const.scss';
|
||||||
|
@use 'locked.scss';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@use 'vis-timeline/dist/vis-timeline-graph2d.css';
|
@use 'vis-timeline/dist/vis-timeline-graph2d.css';
|
||||||
@use 'drawer';
|
@use 'drawer';
|
||||||
@use 'const.scss';
|
@use 'const.scss';
|
||||||
|
@use 'locked.scss';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -14,11 +14,21 @@ import {
|
|||||||
Interaction,
|
Interaction,
|
||||||
InteractionName,
|
InteractionName,
|
||||||
} from '../../../src/card-controller/actions/actions-manager';
|
} from '../../../src/card-controller/actions/actions-manager';
|
||||||
|
import type { CardController } from '../../../src/card-controller/controller';
|
||||||
import { TemplateRenderer } from '../../../src/card-controller/templates';
|
import { TemplateRenderer } from '../../../src/card-controller/templates';
|
||||||
import { AdvancedCameraCardView } from '../../../src/config/schema/common/const';
|
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';
|
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('ActionsManager', () => {
|
||||||
describe('getMergedActions', () => {
|
describe('getMergedActions', () => {
|
||||||
const config = {
|
const config = {
|
||||||
@@ -69,7 +79,7 @@ describe('ActionsManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should get no merged actions with an issue', () => {
|
it('should get no merged actions with an issue', () => {
|
||||||
const api = createCardAPI();
|
const api = createAPI();
|
||||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||||
createView({ view: 'live' }),
|
createView({ view: 'live' }),
|
||||||
);
|
);
|
||||||
@@ -142,7 +152,7 @@ describe('ActionsManager', () => {
|
|||||||
],
|
],
|
||||||
['timeline' as const, {}],
|
['timeline' as const, {}],
|
||||||
])('%s', (viewName: AdvancedCameraCardView, result: Record<string, unknown>) => {
|
])('%s', (viewName: AdvancedCameraCardView, result: Record<string, unknown>) => {
|
||||||
const api = createCardAPI();
|
const api = createAPI();
|
||||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||||
createView({ view: viewName }),
|
createView({ view: viewName }),
|
||||||
);
|
);
|
||||||
@@ -162,9 +172,12 @@ describe('ActionsManager', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle interaction', async () => {
|
it('should handle interaction', async () => {
|
||||||
const api = createCardAPI();
|
const api = createAPI();
|
||||||
const element = document.createElement('div');
|
const element = document.createElement('div');
|
||||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||||
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
|
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]])(
|
it.each([['malformed_type_of_tap' as const], ['double_tap' as const]])(
|
||||||
'%s',
|
'%s',
|
||||||
(interaction: string) => {
|
(interaction: string) => {
|
||||||
const api = createCardAPI();
|
const api = createAPI();
|
||||||
const element = document.createElement('div');
|
const element = document.createElement('div');
|
||||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||||
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
|
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
|
||||||
@@ -227,6 +240,9 @@ describe('ActionsManager', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle advanced camera card event', async () => {
|
it('should handle advanced camera card event', async () => {
|
||||||
const action = createLogAction('Hello, world!');
|
const action = createLogAction('Hello, world!');
|
||||||
@@ -234,7 +250,7 @@ describe('ActionsManager', () => {
|
|||||||
detail: action,
|
detail: action,
|
||||||
});
|
});
|
||||||
|
|
||||||
const api = createCardAPI();
|
const api = createAPI();
|
||||||
const manager = new ActionsManager(api);
|
const manager = new ActionsManager(api);
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||||
@@ -254,7 +270,7 @@ describe('ActionsManager', () => {
|
|||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
card.addEventListener('ll-custom', handler);
|
card.addEventListener('ll-custom', handler);
|
||||||
|
|
||||||
const api = createCardAPI();
|
const api = createAPI();
|
||||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(card);
|
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(card);
|
||||||
const manager = new ActionsManager(api);
|
const manager = new ActionsManager(api);
|
||||||
|
|
||||||
@@ -264,7 +280,7 @@ describe('ActionsManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not handle event without detail', async () => {
|
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);
|
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||||
await manager.handleCustomActionEvent(new Event('ll-custom'));
|
await manager.handleCustomActionEvent(new Event('ll-custom'));
|
||||||
@@ -273,8 +289,12 @@ describe('ActionsManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('handleActionExecutionRequestEvent', () => {
|
describe('handleActionExecutionRequestEvent', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('should execute actions', async () => {
|
it('should execute actions', async () => {
|
||||||
const api = createCardAPI();
|
const api = createAPI();
|
||||||
const manager = new ActionsManager(api);
|
const manager = new ActionsManager(api);
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||||
@@ -288,8 +308,12 @@ describe('ActionsManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('executeAction', () => {
|
describe('executeAction', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('should execute actions', async () => {
|
it('should execute actions', async () => {
|
||||||
const api = createCardAPI();
|
const api = createAPI();
|
||||||
const manager = new ActionsManager(api);
|
const manager = new ActionsManager(api);
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||||
@@ -298,7 +322,7 @@ describe('ActionsManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should execute actions', async () => {
|
it('should execute actions', async () => {
|
||||||
const api = createCardAPI();
|
const api = createAPI();
|
||||||
const manager = new ActionsManager(api);
|
const manager = new ActionsManager(api);
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||||
@@ -312,7 +336,7 @@ describe('ActionsManager', () => {
|
|||||||
const templateRenderer = mock<TemplateRenderer>();
|
const templateRenderer = mock<TemplateRenderer>();
|
||||||
templateRenderer.renderRecursively.mockReturnValue(action);
|
templateRenderer.renderRecursively.mockReturnValue(action);
|
||||||
|
|
||||||
const api = createCardAPI();
|
const api = createAPI();
|
||||||
const hass = createHASS();
|
const hass = createHASS();
|
||||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
||||||
|
|
||||||
@@ -324,6 +348,7 @@ describe('ActionsManager', () => {
|
|||||||
const manager = new ActionsManager(api, templateRenderer);
|
const manager = new ActionsManager(api, templateRenderer);
|
||||||
const config = { entity: 'light.office' };
|
const config = { entity: 'light.office' };
|
||||||
const triggerData = { view: { from: 'previous-view', to: 'view' } };
|
const triggerData = { view: { from: 'previous-view', to: 'view' } };
|
||||||
|
vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||||
|
|
||||||
await manager.executeActions({ actions: action, config, triggerData });
|
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', () => {
|
describe('should forward haptics', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -343,7 +402,7 @@ describe('ActionsManager', () => {
|
|||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
window.addEventListener('haptic', handler);
|
window.addEventListener('haptic', handler);
|
||||||
|
|
||||||
const api = createCardAPI();
|
const api = createAPI();
|
||||||
const manager = new ActionsManager(api);
|
const manager = new ActionsManager(api);
|
||||||
|
|
||||||
await manager.executeActions({ actions: { action: 'none' } });
|
await manager.executeActions({ actions: { action: 'none' } });
|
||||||
@@ -357,7 +416,7 @@ describe('ActionsManager', () => {
|
|||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
window.addEventListener('haptic', handler);
|
window.addEventListener('haptic', handler);
|
||||||
|
|
||||||
const api = createCardAPI();
|
const api = createAPI();
|
||||||
const manager = new ActionsManager(api);
|
const manager = new ActionsManager(api);
|
||||||
|
|
||||||
vi.stubGlobal('confirm', vi.fn().mockReturnValue(false));
|
vi.stubGlobal('confirm', vi.fn().mockReturnValue(false));
|
||||||
@@ -375,12 +434,15 @@ describe('ActionsManager', () => {
|
|||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should stop actions', async () => {
|
it('should stop actions', async () => {
|
||||||
const api = createCardAPI();
|
const api = createAPI();
|
||||||
const manager = new ActionsManager(api);
|
const manager = new ActionsManager(api);
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
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 { InteractionManager } from '../../src/card-controller/interaction-manager';
|
||||||
import { IssueManager } from '../../src/card-controller/issues/issue-manager';
|
import { IssueManager } from '../../src/card-controller/issues/issue-manager';
|
||||||
import { KeyboardStateManager } from '../../src/card-controller/keyboard-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 { MediaLoadedInfoManager } from '../../src/card-controller/media-info-manager';
|
||||||
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager';
|
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager';
|
||||||
import { MicrophoneManager } from '../../src/card-controller/microphone-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/initialization-manager');
|
||||||
vi.mock('../../src/card-controller/interaction-manager');
|
vi.mock('../../src/card-controller/interaction-manager');
|
||||||
vi.mock('../../src/card-controller/keyboard-state-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-info-manager');
|
||||||
vi.mock('../../src/card-controller/media-player-manager');
|
vi.mock('../../src/card-controller/media-player-manager');
|
||||||
vi.mock('../../src/card-controller/microphone-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', () => {
|
it('should return getMediaLoadedInfoManager', () => {
|
||||||
expect(createController().getMediaLoadedInfoManager()).toBe(
|
expect(createController().getMediaLoadedInfoManager()).toBe(
|
||||||
vi.mocked(MediaLoadedInfoManager).mock.instances[0],
|
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);
|
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 () => {
|
describe('should require initialization', async () => {
|
||||||
it('should require when configured and supported', async () => {
|
it('should require when configured and supported', async () => {
|
||||||
const api = createCardAPI();
|
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', () => {
|
it('should set view default', () => {
|
||||||
const factory = mock<ViewFactory>();
|
const factory = mock<ViewFactory>();
|
||||||
factory.getViewDefault.mockReturnValue(createView());
|
factory.getViewDefault.mockReturnValue(createView());
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ describe('MediaGridController', () => {
|
|||||||
).toBe('3');
|
).toBe('3');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should select cell when interacted with', () => {
|
it('should dispatch a selection request when interacted with', () => {
|
||||||
const children = createChildren();
|
const children = createChildren();
|
||||||
const parent = createParent({ children: children, width: 2000 });
|
const parent = createParent({ children: children, width: 2000 });
|
||||||
const controller = createController(parent);
|
const controller = createController(parent);
|
||||||
@@ -396,11 +396,18 @@ describe('MediaGridController', () => {
|
|||||||
const clickHandler = vi.fn();
|
const clickHandler = vi.fn();
|
||||||
parent.addEventListener('click', clickHandler);
|
parent.addEventListener('click', clickHandler);
|
||||||
|
|
||||||
|
const selectedHandler = vi.fn();
|
||||||
|
parent.addEventListener('advanced-camera-card:media-grid:selected', selectedHandler);
|
||||||
|
|
||||||
children[1].click();
|
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(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', () => {
|
it('should ignore interaction events on already selected cell', () => {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
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 { MenuController } from '../../src/components-lib/menu-controller.js';
|
||||||
import { SubmenuItem } from '../../src/components/submenu/types.js';
|
import { SubmenuItem } from '../../src/components/submenu/types.js';
|
||||||
import { MenuConfig, menuConfigSchema } from '../../src/config/schema/menu.js';
|
import { MenuConfig, menuConfigSchema } from '../../src/config/schema/menu.js';
|
||||||
@@ -12,6 +15,12 @@ const createMenuConfig = (config: unknown): MenuConfig => {
|
|||||||
return menuConfigSchema.parse(config);
|
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
|
// @vitest-environment jsdom
|
||||||
describe('MenuController', () => {
|
describe('MenuController', () => {
|
||||||
const action = {
|
const action = {
|
||||||
@@ -71,6 +80,137 @@ describe('MenuController', () => {
|
|||||||
expect(host.getAttribute('expanded')).toBeNull();
|
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', () => {
|
describe('should set and sort buttons', () => {
|
||||||
it('without a hidden menu', () => {
|
it('without a hidden menu', () => {
|
||||||
const controller = new MenuController(createLitElement());
|
const controller = new MenuController(createLitElement());
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ describe('config defaults', () => {
|
|||||||
auto_mute: [],
|
auto_mute: [],
|
||||||
auto_unmute: [],
|
auto_unmute: [],
|
||||||
disconnect_seconds: 90,
|
disconnect_seconds: 90,
|
||||||
|
lock: true,
|
||||||
mute_after_microphone_mute_seconds: 60,
|
mute_after_microphone_mute_seconds: 60,
|
||||||
},
|
},
|
||||||
preload: false,
|
preload: false,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { InteractionManager } from '../src/card-controller/interaction-manager';
|
|||||||
import { IssueManager } from '../src/card-controller/issues/issue-manager';
|
import { IssueManager } from '../src/card-controller/issues/issue-manager';
|
||||||
import { IssueStateManager } from '../src/card-controller/issues/state-manager';
|
import { IssueStateManager } from '../src/card-controller/issues/state-manager';
|
||||||
import { KeyboardStateManager } from '../src/card-controller/keyboard-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 { MediaLoadedInfoManager } from '../src/card-controller/media-info-manager';
|
||||||
import { MediaPlayerManager } from '../src/card-controller/media-player-manager';
|
import { MediaPlayerManager } from '../src/card-controller/media-player-manager';
|
||||||
import { MicrophoneManager } from '../src/card-controller/microphone-manager';
|
import { MicrophoneManager } from '../src/card-controller/microphone-manager';
|
||||||
@@ -702,6 +703,7 @@ export const createCardAPI = (): CardController => {
|
|||||||
api.getInitializationManager.mockReturnValue(mock<InitializationManager>());
|
api.getInitializationManager.mockReturnValue(mock<InitializationManager>());
|
||||||
api.getInteractionManager.mockReturnValue(mock<InteractionManager>());
|
api.getInteractionManager.mockReturnValue(mock<InteractionManager>());
|
||||||
api.getKeyboardStateManager.mockReturnValue(mock<KeyboardStateManager>());
|
api.getKeyboardStateManager.mockReturnValue(mock<KeyboardStateManager>());
|
||||||
|
api.getLockManager.mockReturnValue(mock<LockManager>());
|
||||||
api.getMediaLoadedInfoManager.mockReturnValue(mock<MediaLoadedInfoManager>());
|
api.getMediaLoadedInfoManager.mockReturnValue(mock<MediaLoadedInfoManager>());
|
||||||
api.getMediaPlayerManager.mockReturnValue(mock<MediaPlayerManager>());
|
api.getMediaPlayerManager.mockReturnValue(mock<MediaPlayerManager>());
|
||||||
api.getMicrophoneManager.mockReturnValue(mock<MicrophoneManager>());
|
api.getMicrophoneManager.mockReturnValue(mock<MicrophoneManager>());
|
||||||
|
|||||||
Reference in New Issue
Block a user