feat: Add 'call' support to improve 2-way audio experience (#2486)
Draws significant inspiration (and direct styling) from https://github.com/dermotduffy/advanced-camera-card/pull/2447 . Thank you @Maudfer ! BREAKING CHANGE: The microphone condition previously bundled two unrelated signals — whether a two-way-audio session was connected and whether the microphone was muted. Connection state is now its own dedicated call condition, and microphone is reserved purely for mute state. Configs are upgraded automatically (the card rewrites affected conditions under overrides, elements, and automations). If you maintain config by hand, convert as follows: If you only used connected: # Before ```yaml condition: microphone connected: true ``` # After ```yaml condition: call call: true ``` If you used both connected and muted — they must be split into two conditions, since they no longer live together: # Before ```yaml condition: microphone connected: true muted: false ``` # After ```yaml condition: and conditions: - condition: call call: true - condition: microphone muted: false ```
This commit is contained in:
committed by
dermotduffy
parent
bb061a1a55
commit
abcba884e5
@@ -0,0 +1,11 @@
|
||||
import { CallEndActionConfig } from '../../../config/schema/actions/custom/call-end';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class CallEndAction extends AdvancedCameraCardAction<CallEndActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
api.getCallManager().end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { CallStartActionConfig } from '../../../config/schema/actions/custom/call-start';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class CallStartAction extends AdvancedCameraCardAction<CallStartActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
await api.getCallManager().start(this._action.camera, this._action.stream);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MediaPlayerActionConfig } from '../../../config/schema/actions/custom/media-player';
|
||||
import { getStreamCameraID } from '../../../utils/substream';
|
||||
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||
import { getStreamCameraID } from '../../../view/substream';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { SubstreamOffViewModifier } from '../../view/modifiers/substream-off';
|
||||
import { SubstreamViewModifier } from '../../view/modifiers/substream';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class SubstreamOffAction extends AdvancedCameraCardAction<GeneralActionConfig> {
|
||||
@@ -8,7 +8,7 @@ export class SubstreamOffAction extends AdvancedCameraCardAction<GeneralActionCo
|
||||
await super.execute(api);
|
||||
|
||||
api.getViewManager().setViewByParameters({
|
||||
modifiers: [new SubstreamOffViewModifier()],
|
||||
modifiers: [new SubstreamViewModifier()],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,48 @@
|
||||
import { CameraManager } from '../../../camera-manager/manager';
|
||||
import { SubstreamViewModifier } from '../../view/modifiers/substream';
|
||||
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
|
||||
import { View } from '../../../view/view';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { SubstreamOnViewModifier } from '../../view/modifiers/substream-on';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class SubstreamOnAction extends AdvancedCameraCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
const view = api.getViewManager().getView();
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getViewManager().setViewByParameters({
|
||||
modifiers: [new SubstreamOnViewModifier(api)],
|
||||
modifiers: [
|
||||
new SubstreamViewModifier(
|
||||
this._getCycledSubstreamID(view, api.getCameraManager()),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// The next substream in the selected camera's cycle: its `substream`
|
||||
// dependencies in order, wrapping back round. `undefined` means the camera's
|
||||
// own stream (no substream).
|
||||
private _getCycledSubstreamID(
|
||||
view: View,
|
||||
cameraManager: CameraManager,
|
||||
): string | undefined {
|
||||
if (!view.camera) {
|
||||
return undefined;
|
||||
}
|
||||
const dependencies = [
|
||||
...cameraManager.getStore().getAllDependentCameras(view.camera, 'substream'),
|
||||
];
|
||||
if (dependencies.length <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
const current = view.context?.live?.overrides?.get(view.camera) ?? view.camera;
|
||||
const currentIndex = dependencies.indexOf(current);
|
||||
const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
|
||||
// Index 0 is the camera itself, i.e. no substream.
|
||||
return dependencies[nextIndex] === view.camera ? undefined : dependencies[nextIndex];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SubstreamSelectActionConfig } from '../../../config/schema/actions/custom/substream-select';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { SubstreamSelectViewModifier } from '../../view/modifiers/substream-select';
|
||||
import { SubstreamViewModifier } from '../../view/modifiers/substream';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class SubstreamSelectAction extends AdvancedCameraCardAction<SubstreamSelectActionConfig> {
|
||||
@@ -8,7 +8,7 @@ export class SubstreamSelectAction extends AdvancedCameraCardAction<SubstreamSel
|
||||
await super.execute(api);
|
||||
|
||||
api.getViewManager().setViewByParameters({
|
||||
modifiers: [new SubstreamSelectViewModifier(this._action.camera)],
|
||||
modifiers: [new SubstreamViewModifier(this._action.camera)],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { ActionContext } from 'action';
|
||||
import { INTERNAL_CALLBACK_ACTION } from '../../config/schema/actions/custom/internal';
|
||||
import { ActionConfig, AuxillaryActionConfig } from '../../config/schema/actions/types';
|
||||
import { isAdvancedCameraCardCustomAction } from '../../utils/action';
|
||||
import { CallEndAction } from './actions/call-end';
|
||||
import { CallServiceAction } from './actions/call-service';
|
||||
import { CallStartAction } from './actions/call-start';
|
||||
import { CameraSelectAction } from './actions/camera-select';
|
||||
import { CameraUIAction } from './actions/camera-ui';
|
||||
import { CustomAction } from './actions/custom';
|
||||
@@ -122,6 +124,10 @@ export class ActionFactory {
|
||||
return new InfoAction(context, action, options?.config);
|
||||
case 'menu_toggle':
|
||||
return new MenuToggleAction(context, action, options?.config);
|
||||
case 'call_start':
|
||||
return new CallStartAction(context, action, options?.config);
|
||||
case 'call_end':
|
||||
return new CallEndAction(context, action, options?.config);
|
||||
case 'camera_select':
|
||||
return new CameraSelectAction(context, action, options?.config);
|
||||
case 'live_substream_select':
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { createNotificationFromText } from '../../components-lib/notification/factory';
|
||||
import { ConditionStateChange } from '../../conditions/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { getStreamCameraID } from '../../view/substream';
|
||||
import { View } from '../../view/view';
|
||||
import { CardCallAPI } from '../types';
|
||||
import { SubstreamViewModifier } from '../view/modifiers/substream';
|
||||
import { CallSession } from './types';
|
||||
|
||||
export class CallManager {
|
||||
private _api: CardCallAPI;
|
||||
private _call: CallSession | null = null;
|
||||
|
||||
constructor(api: CardCallAPI) {
|
||||
this._api = api;
|
||||
|
||||
// A call runs on the live view of a specific camera. Observe the
|
||||
// condition state so the call can be ended if the view, camera or engaged
|
||||
// substream moves off what the call started on.
|
||||
this._api.getConditionStateManager().addListener(this._handleConditionStateChange);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Readers
|
||||
// =========================================================================
|
||||
|
||||
public isActive(): boolean {
|
||||
return !!this._call;
|
||||
}
|
||||
|
||||
public getCall(): CallSession | null {
|
||||
return this._call;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle
|
||||
// =========================================================================
|
||||
|
||||
public async start(cameraID?: string, streamID?: string): Promise<void> {
|
||||
const view = this._api.getViewManager().getView();
|
||||
|
||||
const parentID = cameraID ?? view?.camera;
|
||||
if (!view || !parentID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!this._api
|
||||
.getCameraManager()
|
||||
.getStore()
|
||||
.getCameraIDsWithCapability('live')
|
||||
.has(parentID)
|
||||
) {
|
||||
this._notifyError('error.call_invalid_target');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetID = streamID
|
||||
? this._validateStream(parentID, streamID)
|
||||
: this._pickDefaultTarget(view, parentID);
|
||||
if (!targetID) {
|
||||
return;
|
||||
}
|
||||
|
||||
// `callCameraID` is the substream carrying the call audio -- absent when
|
||||
// the call runs on the parent camera itself.
|
||||
const callCameraID = targetID === parentID ? undefined : targetID;
|
||||
|
||||
const existingCall = this._call;
|
||||
if (
|
||||
existingCall &&
|
||||
existingCall.cameraID === parentID &&
|
||||
existingCall.callCameraID === callCameraID
|
||||
) {
|
||||
// This exact call (same parent camera and stream) is already running; a
|
||||
// repeat request must not disrupt it.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._microphonePreflight()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await this._connectMicrophone())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the previous view so it can be restored later. A call superseding
|
||||
// another inherits the earlier call's previous view -- the user never left
|
||||
// the call. `queryResults` are dropped (re-fetched fresh on restore);
|
||||
// `context` is deep-cloned so the call engaging its own substream below
|
||||
// cannot mutate the snapshot.
|
||||
const previousView = existingCall
|
||||
? existingCall.previousView
|
||||
: view.evolve({ queryResults: null, context: cloneDeep(view.context) });
|
||||
|
||||
const needsNavigation = !view.is('live') || view.camera !== parentID;
|
||||
|
||||
// Any other call in progress is superseded. Ended here -- after the
|
||||
// preflight passes -- so a failed preflight leaves the existing call
|
||||
// intact.
|
||||
if (existingCall) {
|
||||
this._end(false);
|
||||
}
|
||||
|
||||
this._call = {
|
||||
cameraID: parentID,
|
||||
...(callCameraID && { callCameraID }),
|
||||
previousView,
|
||||
};
|
||||
|
||||
this._api.getViewManager().setViewByParameters({
|
||||
...(needsNavigation && {
|
||||
params: { view: 'live', camera: parentID },
|
||||
}),
|
||||
modifiers: [new SubstreamViewModifier(callCameraID, parentID)],
|
||||
force: true,
|
||||
});
|
||||
this._api.getConditionStateManager().setState({ call: true });
|
||||
}
|
||||
|
||||
// Ends the call and returns to the view that was showing before
|
||||
// `call_start` -- the user-facing `call_end`.
|
||||
public end(): void {
|
||||
this._end(true);
|
||||
}
|
||||
|
||||
// `restoreView` navigates back to the pre-call view -- the symmetric
|
||||
// counterpart of `call_start`'s navigation -- for an explicit `call_end`. It
|
||||
// is `false` for auto-ends (navigating away, camera/substream change), where
|
||||
// the user has already chosen a destination and the pre-call view is
|
||||
// deliberately not reinstated; only the manager's own auto-end paths pass it.
|
||||
private _end(restoreView: boolean): void {
|
||||
if (!this._call) {
|
||||
return;
|
||||
}
|
||||
const call = this._call;
|
||||
const previousView = call.previousView;
|
||||
|
||||
// Clear the session first: ending the call dispatches a view change, and
|
||||
// the resulting condition-state change must not see this (now-ending) call
|
||||
// and recurse.
|
||||
this._call = null;
|
||||
|
||||
const viewManager = this._api.getViewManager();
|
||||
|
||||
// Navigate back only on an explicit end, and only when the call actually
|
||||
// moved away from where the user was (a call started from its own live
|
||||
// view has nowhere to return). The previous view's query is re-executed
|
||||
// so results are fresh.
|
||||
if (
|
||||
restoreView &&
|
||||
(previousView.view !== 'live' || previousView.camera !== call.cameraID)
|
||||
) {
|
||||
viewManager.setViewByParametersWithExistingQuery({
|
||||
baseView: previousView,
|
||||
force: true,
|
||||
});
|
||||
} else {
|
||||
// Otherwise stay where we are and just undo the call's substream change
|
||||
// on its own camera, reading the pre-call value.
|
||||
const previousStream = getStreamCameraID(previousView, call.cameraID);
|
||||
viewManager.setViewByParameters({
|
||||
modifiers: [
|
||||
new SubstreamViewModifier(
|
||||
previousStream && previousStream !== call.cameraID
|
||||
? previousStream
|
||||
: undefined,
|
||||
call.cameraID,
|
||||
),
|
||||
],
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
this._api.getConditionStateManager().setState({ call: false });
|
||||
}
|
||||
|
||||
// End the call once it can no longer be conducted from where it started: the
|
||||
// view leaves `live` (the call overlay exists only there, so the call would
|
||||
// otherwise be stranded with no controls), the selected camera changes, or
|
||||
// the engaged substream moves off the call's audio source. Covers navigation
|
||||
// and `live_substream_*` actions taken while `live.controls.call.lock` is
|
||||
// disabled, as well as any forced view change.
|
||||
private _handleConditionStateChange = (stateChange: ConditionStateChange): void => {
|
||||
if (
|
||||
this._call &&
|
||||
(stateChange.new.view !== 'live' ||
|
||||
stateChange.new.camera !== this._call.cameraID ||
|
||||
stateChange.new.substreamID !== this._call.callCameraID)
|
||||
) {
|
||||
this._end(false);
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private _notifyError(messageKey: string): void {
|
||||
this._api.getNotificationManager().setNotification(
|
||||
createNotificationFromText(localize(messageKey), {
|
||||
heading: { text: localize('error.call_unavailable_heading') },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Returns `true` to proceed, `false` to abort (with a notification already
|
||||
// surfaced).
|
||||
private _microphonePreflight(): boolean {
|
||||
const microphoneManager = this._api.getMicrophoneManager();
|
||||
|
||||
if (!microphoneManager.isSupported()) {
|
||||
this._notifyError('error.call_microphone_unsupported');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (microphoneManager.isForbidden()) {
|
||||
this._notifyError('error.call_microphone_forbidden');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _connectMicrophone(): Promise<boolean> {
|
||||
const microphoneManager = this._api.getMicrophoneManager();
|
||||
if (microphoneManager.isConnected()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await microphoneManager.connect();
|
||||
return true;
|
||||
} catch {
|
||||
this._notifyError('error.call_microphone_forbidden');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private _hasCallCapability(cameraID: string): boolean {
|
||||
return !!this._api
|
||||
.getCameraManager()
|
||||
.getCameraCapabilities(cameraID)
|
||||
?.has('2-way-audio');
|
||||
}
|
||||
|
||||
// Validate an explicitly-requested call stream: it must be `cameraID` itself
|
||||
// or one of its 2-way-audio dependencies.
|
||||
private _validateStream(cameraID: string, streamID: string): string | null {
|
||||
const eligibleCameraIDs = this._api
|
||||
.getCameraManager()
|
||||
.getStore()
|
||||
.getAllDependentCameras(cameraID, '2-way-audio');
|
||||
if (!eligibleCameraIDs.has(streamID)) {
|
||||
this._notifyError('error.call_invalid_target');
|
||||
return null;
|
||||
}
|
||||
return streamID;
|
||||
}
|
||||
|
||||
// Pick the default call target. Prefer the currently-engaged stream when
|
||||
// it's call-capable (keeps the user's substream selection intact). Else
|
||||
// fall back to the parent itself (if call-capable) or the first eligible
|
||||
// dependency. Returns null + notification if neither path finds a target.
|
||||
private _pickDefaultTarget(view: View, parentID: string): string | null {
|
||||
const currentStream = getStreamCameraID(view, parentID);
|
||||
if (currentStream && this._hasCallCapability(currentStream)) {
|
||||
return currentStream;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
...this._api
|
||||
.getCameraManager()
|
||||
.getStore()
|
||||
.getAllDependentCameras(parentID, '2-way-audio'),
|
||||
];
|
||||
if (!candidates.length) {
|
||||
this._notifyError('error.call_no_two_way_audio');
|
||||
return null;
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { View } from '../../view/view';
|
||||
|
||||
export interface CallSession {
|
||||
// The camera that owns the call.
|
||||
cameraID: string;
|
||||
|
||||
// The substream carrying the 2-way audio: a 2-way-audio-capable
|
||||
// substream/dependency of `cameraID`. Absent when the call runs on
|
||||
// `cameraID`'s own stream.
|
||||
callCameraID?: string;
|
||||
|
||||
// The view from before the call started: a clone with `queryResults` dropped.
|
||||
// Used to undo the call when it ends.
|
||||
previousView: View;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { ResolvedMediaCache } from '../ha/resolved-media';
|
||||
import { LovelaceCardEditor } from '../ha/types';
|
||||
import { ActionsManager } from './actions/actions-manager';
|
||||
import { AutomationsManager } from './automations-manager';
|
||||
import { CallManager } from './call/manager';
|
||||
import { CameraURLManager } from './camera-url-manager';
|
||||
import {
|
||||
CardElementManager,
|
||||
@@ -26,6 +27,8 @@ import { FullscreenManager } from './fullscreen/fullscreen-manager';
|
||||
import { HASSManager } from './hass/hass-manager';
|
||||
import { InitializationManager } from './initialization-manager';
|
||||
import { InteractionManager } from './interaction-manager';
|
||||
import { createIssueManager } from './issues/factory';
|
||||
import { IssueManager } from './issues/issue-manager';
|
||||
import { KeyboardStateManager } from './keyboard-state-manager';
|
||||
import { LockManager } from './lock/manager';
|
||||
import { MediaLoadedInfoManager } from './media-info-manager';
|
||||
@@ -33,8 +36,6 @@ import { MediaPlayerManager } from './media-player-manager';
|
||||
import { MicrophoneManager } from './microphone-manager';
|
||||
import { NotificationManager } from './notification-manager';
|
||||
import { PIPManager } from './pip-manager';
|
||||
import { createIssueManager } from './issues/factory';
|
||||
import { IssueManager } from './issues/issue-manager';
|
||||
import { QueryStringManager } from './query-string-manager';
|
||||
import { StatusBarItemManager } from './status-bar-item-manager';
|
||||
import { StyleManager } from './style-manager';
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
CardHASSAPI,
|
||||
CardInitializerAPI,
|
||||
CardInteractionAPI,
|
||||
CardIssueManagerAPI,
|
||||
CardKeyboardStateAPI,
|
||||
CardLockAPI,
|
||||
CardMediaLoadedAPI,
|
||||
@@ -62,7 +64,6 @@ import {
|
||||
CardMicrophoneAPI,
|
||||
CardNotificationAPI,
|
||||
CardPIPAPI,
|
||||
CardIssueManagerAPI,
|
||||
CardQueryStringAPI,
|
||||
CardStyleAPI,
|
||||
CardTriggersAPI,
|
||||
@@ -112,6 +113,7 @@ export class CardController
|
||||
|
||||
private _actionsManager = new ActionsManager(this, new TemplateRenderer());
|
||||
private _automationsManager = new AutomationsManager(this);
|
||||
private _callManager = new CallManager(this);
|
||||
private _cameraManager = new CameraManager(this);
|
||||
private _cameraURLManager = new CameraURLManager(this);
|
||||
private _cardElementManager: CardElementManager;
|
||||
@@ -166,6 +168,10 @@ export class CardController
|
||||
return this._automationsManager;
|
||||
}
|
||||
|
||||
public getCallManager(): CallManager {
|
||||
return this._callManager;
|
||||
}
|
||||
|
||||
public getCameraManager(): CameraManager {
|
||||
return this._cameraManager;
|
||||
}
|
||||
|
||||
+12
-9
@@ -4,12 +4,16 @@ import { isAdvancedCameraCardCustomAction } from '../../utils/action';
|
||||
import { CardLockAPI } from '../types';
|
||||
import type { LockPolicy } from './types';
|
||||
|
||||
// Action that disrupt a hot-microphone session. Covers two categories:
|
||||
// Actions that would disrupt an active call. 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([
|
||||
//
|
||||
// `call_start` is intentionally absent — it's the entry into the lock.
|
||||
// `call_end` is also absent — it dispatches via `setViewByParameters({ force:
|
||||
// true })` to bypass the lock, so listing it here would be redundant.
|
||||
const CALL_DISRUPTIVE_ACTIONS: ReadonlySet<string> = new Set([
|
||||
// View / camera / substream changes.
|
||||
...VIEWS_USER_SPECIFIED,
|
||||
'camera_select',
|
||||
@@ -31,7 +35,7 @@ const MICROPHONE_SESSION_DISRUPTIVE_ACTIONS: ReadonlySet<string> = new Set([
|
||||
'media_player',
|
||||
]);
|
||||
|
||||
export class MicrophoneLockPolicy implements LockPolicy {
|
||||
export class CallLockPolicy implements LockPolicy {
|
||||
private _api: CardLockAPI;
|
||||
|
||||
constructor(api: CardLockAPI) {
|
||||
@@ -39,17 +43,16 @@ export class MicrophoneLockPolicy implements LockPolicy {
|
||||
}
|
||||
|
||||
public isActive(): boolean {
|
||||
return this._api.getMicrophoneManager().isLocking();
|
||||
if (!this._api.getConfigManager().getConfig()?.live.controls.call.lock) {
|
||||
return false;
|
||||
}
|
||||
return this._api.getCallManager().isActive();
|
||||
}
|
||||
|
||||
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)
|
||||
CALL_DISRUPTIVE_ACTIONS.has(action.advanced_camera_card_action)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ActionConfig, Actions } from '../../config/schema/actions/types';
|
||||
import { arrayify } from '../../utils/basic';
|
||||
import { CardLockAPI } from '../types';
|
||||
import { MicrophoneLockPolicy } from './microphone-policy';
|
||||
import { CallLockPolicy } from './call-policy';
|
||||
import type { LockManagerEpoch, LockPolicy } from './types';
|
||||
|
||||
export class LockManager {
|
||||
@@ -9,7 +9,7 @@ export class LockManager {
|
||||
private _epoch: LockManagerEpoch | null = null;
|
||||
|
||||
constructor(api: CardLockAPI) {
|
||||
this._policies = [new MicrophoneLockPolicy(api)];
|
||||
this._policies = [new CallLockPolicy(api)];
|
||||
}
|
||||
|
||||
public isLocked(): boolean {
|
||||
|
||||
@@ -119,13 +119,6 @@ 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;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
createViewAction,
|
||||
} from '../utils/action.js';
|
||||
import { CardQueryStringAPI } from './types';
|
||||
import { SubstreamSelectViewModifier } from './view/modifiers/substream-select';
|
||||
import { SubstreamViewModifier } from './view/modifiers/substream';
|
||||
import { ViewParametersUserSpecified } from './view/types.js';
|
||||
|
||||
interface QueryStringViewIntent {
|
||||
@@ -50,7 +50,7 @@ export class QueryStringManager {
|
||||
camera: intent.view.camera,
|
||||
},
|
||||
...(intent.view.substream && {
|
||||
modifiers: [new SubstreamSelectViewModifier(intent.view.substream)],
|
||||
modifiers: [new SubstreamViewModifier(intent.view.substream)],
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
@@ -60,7 +60,7 @@ export class QueryStringManager {
|
||||
...(intent.view.camera && { camera: intent.view.camera }),
|
||||
},
|
||||
...(intent.view.substream && {
|
||||
modifiers: [new SubstreamSelectViewModifier(intent.view.substream)],
|
||||
modifiers: [new SubstreamViewModifier(intent.view.substream)],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ResolvedMediaCache } from '../ha/resolved-media';
|
||||
import type { EffectsManagerInterface } from '../types';
|
||||
import type { ActionsManager } from './actions/actions-manager';
|
||||
import type { AutomationsManager } from './automations-manager';
|
||||
import type { CallManager } from './call/manager';
|
||||
import type { CameraURLManager } from './camera-url-manager';
|
||||
import type { CardElementManager } from './card-element-manager';
|
||||
import type { ConfigManager } from './config/config-manager';
|
||||
@@ -41,6 +42,7 @@ import type { ViewManager } from './view/view-manager';
|
||||
|
||||
export interface CardActionsAPI {
|
||||
getActionsManager(): ActionsManager;
|
||||
getCallManager(): CallManager;
|
||||
getCameraManager(): CameraManager;
|
||||
getCameraURLManager(): CameraURLManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
@@ -75,6 +77,14 @@ export interface CardAutomationsAPI {
|
||||
getIssueManager(): IssueManager;
|
||||
}
|
||||
|
||||
export interface CardCallAPI {
|
||||
getCameraManager(): CameraManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getNotificationManager(): NotificationManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
export interface CardCameraAPI {
|
||||
getActionsManager(): ActionsManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
@@ -237,7 +247,9 @@ export interface CardKeyboardStateAPI {
|
||||
}
|
||||
|
||||
export interface CardLockAPI {
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getCallManager(): CallManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
export interface CardMediaLoadedAPI {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { removeSubstream } from '../../../utils/substream';
|
||||
import { View } from '../../../view/view';
|
||||
import { ViewModifier } from '../types';
|
||||
|
||||
export class SubstreamOffViewModifier implements ViewModifier {
|
||||
public modify(view: View): void {
|
||||
removeSubstream(view);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { CameraManager } from '../../../camera-manager/manager';
|
||||
import { getStreamCameraID, setSubstream } from '../../../utils/substream';
|
||||
import { View } from '../../../view/view';
|
||||
import { ViewModifier } from '../types';
|
||||
|
||||
interface SubstreamOnViewModifierAPI {
|
||||
getCameraManager(): CameraManager;
|
||||
}
|
||||
|
||||
export class SubstreamOnViewModifier implements ViewModifier {
|
||||
private _api: SubstreamOnViewModifierAPI;
|
||||
|
||||
constructor(api: SubstreamOnViewModifierAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public modify(view: View): void {
|
||||
if (!view.camera) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dependencies = [
|
||||
...this._api
|
||||
.getCameraManager()
|
||||
.getStore()
|
||||
.getAllDependentCameras(view.camera, 'substream'),
|
||||
];
|
||||
|
||||
if (dependencies.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentOverride = getStreamCameraID(view);
|
||||
|
||||
/* istanbul ignore if: the if path cannot be reached, as there is a
|
||||
view.camera guard at the start of this method and getStreamCameraID will
|
||||
always return non-null as long as camera is present -- @preserve */
|
||||
if (!currentOverride) {
|
||||
return;
|
||||
}
|
||||
const currentIndex = dependencies.indexOf(currentOverride);
|
||||
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
|
||||
|
||||
setSubstream(view, dependencies[newIndex]);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { setSubstream } from '../../../utils/substream';
|
||||
import { View } from '../../../view/view';
|
||||
import { ViewModifier } from '../types';
|
||||
|
||||
export class SubstreamSelectViewModifier implements ViewModifier {
|
||||
private _substreamID: string;
|
||||
|
||||
constructor(substreamID: string) {
|
||||
this._substreamID = substreamID;
|
||||
}
|
||||
|
||||
public modify(view: View): void {
|
||||
setSubstream(view, this._substreamID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { View } from '../../../view/view';
|
||||
import { ViewModifier } from '../types';
|
||||
|
||||
// The single write path for the camera-keyed `live.overrides` map (the read
|
||||
// path being `getStreamCameraID` in `view/substream`): sets a camera's
|
||||
// substream override, or clears it when `substreamID` is absent so the
|
||||
// camera's own stream is used. `cameraID` defaults to the selected camera.
|
||||
export class SubstreamViewModifier implements ViewModifier {
|
||||
private _substreamID?: string;
|
||||
private _cameraID?: string;
|
||||
|
||||
constructor(substreamID?: string, cameraID?: string) {
|
||||
this._substreamID = substreamID;
|
||||
this._cameraID = cameraID;
|
||||
}
|
||||
|
||||
public modify(view: View): void {
|
||||
const cameraID = this._cameraID ?? view.camera;
|
||||
if (!cameraID) {
|
||||
return;
|
||||
}
|
||||
if (!this._substreamID) {
|
||||
view.context?.live?.overrides?.delete(cameraID);
|
||||
return;
|
||||
}
|
||||
const overrides = view.context?.live?.overrides ?? new Map<string, string>();
|
||||
overrides.set(cameraID, this._substreamID);
|
||||
view.mergeInContext({ live: { overrides } });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ViewContext } from 'view';
|
||||
import { log } from '../../utils/debug';
|
||||
import { getStreamCameraID } from '../../utils/substream';
|
||||
import { View } from '../../view/view';
|
||||
import { getStreamCameraID } from '../../view/substream';
|
||||
import { getViewTargetID } from '../../view/target-id';
|
||||
import { View } from '../../view/view';
|
||||
import { InitializationAspect } from '../initialization-manager';
|
||||
import { CardViewAPI } from '../types';
|
||||
import { ViewFactory } from './factory';
|
||||
@@ -91,12 +91,25 @@ export class ViewManager implements ViewManagerInterface {
|
||||
|
||||
setViewByParametersWithExistingQuery = async (
|
||||
options?: ViewFactoryOptions,
|
||||
): Promise<void> =>
|
||||
): Promise<void> => {
|
||||
// Default the query to the base view's own, so a bare `baseView`
|
||||
// re-executes its query (`_setViewThenModifyAsync` otherwise nulls it).
|
||||
// Only an omitted query falls back; an explicit query -- including `null`
|
||||
// to clear it -- is left as the caller specified.
|
||||
const baseView = options?.baseView ?? this._view;
|
||||
const explicitQuery = options?.params?.query;
|
||||
await this._setViewThenModifyAsync(
|
||||
this._viewFactory.getViewByParameters.bind(this._viewFactory),
|
||||
this._viewQueryExecutor.getExistingQueryModifiers.bind(this._viewQueryExecutor),
|
||||
options,
|
||||
{
|
||||
...options,
|
||||
params: {
|
||||
...options?.params,
|
||||
query: explicitQuery !== undefined ? explicitQuery : baseView?.query ?? null,
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
private _setViewGeneric(
|
||||
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
|
||||
@@ -374,11 +387,13 @@ export class ViewManager implements ViewManagerInterface {
|
||||
|
||||
this._api.getStyleManager().setExpandedMode();
|
||||
|
||||
const stream = view ? getStreamCameraID(view) : null;
|
||||
this._api.getConditionStateManager()?.setState({
|
||||
view: view?.view,
|
||||
camera: view?.camera ?? undefined,
|
||||
displayMode: view?.displayMode ?? undefined,
|
||||
targetID: view ? getViewTargetID(view) ?? undefined : undefined,
|
||||
substreamID: stream && stream !== view?.camera ? stream : undefined,
|
||||
});
|
||||
|
||||
this._api.getCardElementManager().update();
|
||||
|
||||
Reference in New Issue
Block a user