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