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

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 9a763db1f3
commit be7e7d79dc
45 changed files with 922 additions and 53 deletions
@@ -147,7 +147,12 @@ export class ActionsManager implements ActionsExecutor {
}) as ActionConfig | ActionConfig[])
: request.actions;
const actionSet = new ActionSet(this._actionContext, renderedAction, {
const allowedActions = this._api.getLockManager().getAllowedActions(renderedAction);
if (!allowedActions.length) {
return;
}
const actionSet = new ActionSet(this._actionContext, allowedActions, {
config: request.config,
cardID: this._api.getConfigManager().getConfig()?.card_id,
});
+8
View File
@@ -27,6 +27,7 @@ import { HASSManager } from './hass/hass-manager';
import { InitializationManager } from './initialization-manager';
import { InteractionManager } from './interaction-manager';
import { KeyboardStateManager } from './keyboard-state-manager';
import { LockManager } from './lock/manager';
import { MediaLoadedInfoManager } from './media-info-manager';
import { MediaPlayerManager } from './media-player-manager';
import { MicrophoneManager } from './microphone-manager';
@@ -55,6 +56,7 @@ import {
CardInitializerAPI,
CardInteractionAPI,
CardKeyboardStateAPI,
CardLockAPI,
CardMediaLoadedAPI,
CardMediaPlayerAPI,
CardMicrophoneAPI,
@@ -88,6 +90,7 @@ export class CardController
CardInitializerAPI,
CardInteractionAPI,
CardKeyboardStateAPI,
CardLockAPI,
CardMediaLoadedAPI,
CardMediaPlayerAPI,
CardMicrophoneAPI,
@@ -121,6 +124,7 @@ export class CardController
private _initializationManager = new InitializationManager(this);
private _interactionManager = new InteractionManager(this);
private _keyboardStateManager = new KeyboardStateManager(this);
private _lockManager = new LockManager(this);
private _mediaLoadedInfoManager = new MediaLoadedInfoManager(this);
private _mediaPlayerManager = new MediaPlayerManager(this);
@@ -234,6 +238,10 @@ export class CardController
return this._keyboardStateManager;
}
public getLockManager(): LockManager {
return this._lockManager;
}
public getMediaLoadedInfoManager(): MediaLoadedInfoManager {
return this._mediaLoadedInfoManager;
}
+63
View File
@@ -0,0 +1,63 @@
import { ActionConfig, Actions } from '../../config/schema/actions/types';
import { arrayify } from '../../utils/basic';
import { CardLockAPI } from '../types';
import { MicrophoneLockPolicy } from './microphone-policy';
import type { LockManagerEpoch, LockPolicy } from './types';
export class LockManager {
private _policies: LockPolicy[];
private _epoch: LockManagerEpoch | null = null;
constructor(api: CardLockAPI) {
this._policies = [new MicrophoneLockPolicy(api)];
}
public isLocked(): boolean {
return this._policies.some((policy) => policy.isActive());
}
public getEpoch(): LockManagerEpoch {
const locked = this.isLocked();
if (!this._epoch || this._epoch.locked !== locked) {
this._epoch = { manager: this, locked };
}
return this._epoch;
}
public getAllowedActions(actions: ActionConfig | ActionConfig[]): ActionConfig[] {
if (!this.isLocked()) {
return arrayify(actions);
}
return arrayify(actions).filter((action) => {
return !this._isActionBlocked(action);
});
}
public areAllActionsBlocked(actions: Actions): boolean {
if (!this.isLocked()) {
return false;
}
const all = [
...arrayify(actions.tap_action),
...arrayify(actions.hold_action),
...arrayify(actions.double_tap_action),
...arrayify(actions.start_tap_action),
...arrayify(actions.end_tap_action),
];
return (
all.length > 0 &&
all.every((action) => {
return this._isActionBlocked(action);
})
);
}
private _isActionBlocked(action: ActionConfig): boolean {
return this._policies.some((policy) => {
return policy.isActive() && policy.shouldBlockAction(action);
});
}
}
@@ -0,0 +1,55 @@
import { ActionConfig } from '../../config/schema/actions/types';
import { VIEWS_USER_SPECIFIED } from '../../config/schema/common/const';
import { isAdvancedCameraCardCustomAction } from '../../utils/action';
import { CardLockAPI } from '../types';
import type { LockPolicy } from './types';
// Action that disrupt a hot-microphone session. Covers two categories:
// - Major media changes (see `ViewManager.hasMajorMediaChange`): view,
// camera, and substream changes.
// - Stream-stopping / re-init actions: pause, reload, and casting (which
// rehosts the stream to a media player).
const MICROPHONE_SESSION_DISRUPTIVE_ACTIONS: ReadonlySet<string> = new Set([
// View / camera / substream changes.
...VIEWS_USER_SPECIFIED,
'camera_select',
// Resolves to a configured view at runtime.
'default',
// Substreams.
'live_substream_select',
'live_substream_on',
'live_substream_off',
// Stream-disrupting actions. `play` is intentionally NOT here: it's the
// recovery path from a paused state.
'pause',
'reload',
// Casting rehosts the stream away from the card.
'media_player',
]);
export class MicrophoneLockPolicy implements LockPolicy {
private _api: CardLockAPI;
constructor(api: CardLockAPI) {
this._api = api;
}
public isActive(): boolean {
return this._api.getMicrophoneManager().isLocking();
}
public shouldBlockAction(action: ActionConfig): boolean {
return this._isMicrophoneSessionDisruptiveAction(action);
}
private _isMicrophoneSessionDisruptiveAction(action: ActionConfig): boolean {
return (
isAdvancedCameraCardCustomAction(action) &&
MICROPHONE_SESSION_DISRUPTIVE_ACTIONS.has(action.advanced_camera_card_action)
);
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { ActionConfig } from '../../config/schema/actions/types';
import type { LockManager } from './manager';
export interface LockPolicy {
isActive(): boolean;
shouldBlockAction(action: ActionConfig): boolean;
}
export interface LockManagerEpoch {
manager: LockManager;
locked: boolean;
}
@@ -119,6 +119,13 @@ export class MicrophoneManager {
return !this._stream || this._stream.getTracks().every((track) => !track.enabled);
}
public isLocking(): boolean {
// The user-facing rationale: while the microphone is hot (e.g. mid 2-way
// audio), prevent accidental swipes, pauses, view changes.
const microphoneConfig = this._api.getConfigManager().getConfig()?.live.microphone;
return !!microphoneConfig?.lock && !this.isMuted();
}
private _setDesiredMuteOnStream(): void {
this._stream?.getTracks().forEach((track) => {
track.enabled = !this._desireMute;
+7
View File
@@ -19,6 +19,7 @@ import type { InitializationManager } from './initialization-manager';
import type { InteractionManager } from './interaction-manager';
import type { IssueManager } from './issues/issue-manager';
import type { KeyboardStateManager } from './keyboard-state-manager';
import type { LockManager } from './lock/manager';
import type { MediaLoadedInfoManager } from './media-info-manager';
import type { MediaPlayerManager } from './media-player-manager';
import type { MicrophoneManager } from './microphone-manager';
@@ -50,6 +51,7 @@ export interface CardActionsAPI {
getFoldersManager(): FoldersManager;
getFullscreenManager(): FullscreenManager;
getHASSManager(): HASSManager;
getLockManager(): LockManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMediaPlayerManager(): MediaPlayerManager;
getMicrophoneManager(): MicrophoneManager;
@@ -234,6 +236,10 @@ export interface CardKeyboardStateAPI {
getConfigManager(): ConfigManager;
}
export interface CardLockAPI {
getMicrophoneManager(): MicrophoneManager;
}
export interface CardMediaLoadedAPI {
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
@@ -311,6 +317,7 @@ export interface CardViewAPI {
getFoldersManager(): FoldersManager;
getHASSManager(): HASSManager;
getInitializationManager(): InitializationManager;
getLockManager(): LockManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getNotificationManager(): NotificationManager;
getIssueManager(): IssueManager;
+5
View File
@@ -45,6 +45,11 @@ export interface ViewFactoryOptions {
// `live` view if the configured default view is not supported.
failSafe?: boolean;
// When force is true the view change bypasses internal gates such as the
// navigation lock. Reserved for internal callers that must override
// user-facing locks (e.g. an active call ending).
force?: boolean;
// Options for the query executor that control how a query is executed and the
// result selected.
queryExecutorOptions?: QueryExecutorOptions;
+24 -4
View File
@@ -102,7 +102,7 @@ export class ViewManager implements ViewManagerInterface {
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
options?: ViewFactoryOptions,
): void {
if (!this._isAllowedToSetView()) {
if (!this._isAllowedToProposeView()) {
return;
}
@@ -125,6 +125,9 @@ export class ViewManager implements ViewManagerInterface {
}
this._api.getIssueManager().trigger('view_incompatible', { error: e });
}
if (view && !this._isAllowedToSetView(view, options)) {
return;
}
if (view) {
this._setView(view);
}
@@ -147,7 +150,10 @@ export class ViewManager implements ViewManagerInterface {
view.removeContextProperty('loading', 'query');
}
private _isAllowedToSetView(): boolean {
// Pre-computation gate: whether we should even attempt to build a candidate
// view. Skipped here for race conditions that would otherwise generate a
// spurious `view_incompatible` issue.
private _isAllowedToProposeView(): boolean {
// It is possible to have a race condition where the view is being set at
// the same time as the cameras being initialized. Test case: Open
// folder-based media in the media viewer carousel, then attempt to edit the
@@ -160,6 +166,20 @@ export class ViewManager implements ViewManagerInterface {
.isInitialized(InitializationAspect.CAMERAS);
}
// Post-computation gate: given a freshly proposed view, whether we should
// actually commit it. Respects the lock state by potentially rejecting
// changes that would disrupt the active session (camera, view name, or
// substream).
private _isAllowedToSetView(
proposedView: View,
options?: ViewFactoryOptions,
): boolean {
if (options?.force || !this._api.getLockManager().isLocked()) {
return true;
}
return !this.hasMajorMediaChange(this._view, proposedView);
}
private async _setViewThenModifyAsync(
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
viewModifiersFunc: (
@@ -168,7 +188,7 @@ export class ViewManager implements ViewManagerInterface {
) => Promise<ViewModifier[] | null>,
options?: ViewFactoryOptions,
): Promise<void> {
if (!this._isAllowedToSetView()) {
if (!this._isAllowedToProposeView()) {
return;
}
@@ -195,7 +215,7 @@ export class ViewManager implements ViewManagerInterface {
this._api.getIssueManager().trigger('view_incompatible', { error: e });
}
if (!initialView) {
if (!initialView || !this._isAllowedToSetView(initialView, options)) {
return;
}