fix: Prevent microphone auto-disconnect during calls and restore the removed microphone connected condition (#2597)
- Closes: #2590
This commit is contained in:
@@ -89,7 +89,7 @@ export class CallManager {
|
||||
// the call runs on the parent camera itself.
|
||||
const callCameraID = targetID === parentID ? undefined : targetID;
|
||||
|
||||
const existingCall = this._call;
|
||||
let existingCall = this._call;
|
||||
if (
|
||||
existingCall &&
|
||||
existingCall.cameraID === parentID &&
|
||||
@@ -120,6 +120,13 @@ export class CallManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-read: another `start()` may have installed a session while the
|
||||
// microphone connect was in flight. Acting on the reading from before the
|
||||
// await would skip the supersede handling below, leaving that session's
|
||||
// microphone marking and view state stranded with nothing able to undo
|
||||
// them.
|
||||
existingCall = this._call;
|
||||
|
||||
// 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).
|
||||
@@ -152,6 +159,10 @@ export class CallManager {
|
||||
answered,
|
||||
};
|
||||
|
||||
// The microphone's own idle timeout knows nothing about calls, so without
|
||||
// this the tracks would be stopped mid-conversation.
|
||||
this._api.getMicrophoneManager().startUsing();
|
||||
|
||||
this._api.getViewManager().setViewByParameters({
|
||||
...(needsNavigation && {
|
||||
params: { view: 'live', camera: parentID },
|
||||
@@ -250,6 +261,7 @@ export class CallManager {
|
||||
this._unansweredTimer.stop();
|
||||
if (this._call) {
|
||||
this._call = null;
|
||||
this._api.getMicrophoneManager().stopUsing();
|
||||
this._api.getConditionStateManager().setState({ call: 'idle' });
|
||||
}
|
||||
this._api
|
||||
@@ -279,6 +291,10 @@ export class CallManager {
|
||||
// and recurse.
|
||||
this._call = null;
|
||||
|
||||
// The call no longer needs the microphone, so the normal
|
||||
// `disconnect_seconds` countdown restarts from here.
|
||||
this._api.getMicrophoneManager().stopUsing();
|
||||
|
||||
const viewManager = this._api.getViewManager();
|
||||
|
||||
// Navigate back only on an explicit end, and only when the call actually
|
||||
|
||||
@@ -25,6 +25,10 @@ export class MicrophoneManager {
|
||||
// it's created it will have the right mute status.
|
||||
private _desireMute = true;
|
||||
|
||||
// Whether something is actively using the microphone, and so when the
|
||||
// connection can safely be closed.
|
||||
private _inUse = false;
|
||||
|
||||
constructor(api: CardMicrophoneAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
@@ -73,12 +77,28 @@ export class MicrophoneManager {
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this._timer.stop();
|
||||
this._stream?.getTracks().forEach((track) => track.stop());
|
||||
|
||||
this._stream = undefined;
|
||||
this._setState();
|
||||
}
|
||||
|
||||
// Marks the microphone as in use (e.g. by an in-progress call). It stays
|
||||
// connected regardless of `disconnect_seconds` until `stopUsing`, since
|
||||
// stopping the tracks under a user would silently cut their audio.
|
||||
public startUsing(): void {
|
||||
this._inUse = true;
|
||||
this._timer.stop();
|
||||
}
|
||||
|
||||
// Marks the microphone as no longer in use. It is idle again, so the
|
||||
// disconnect countdown restarts from the full `disconnect_seconds`.
|
||||
public stopUsing(): void {
|
||||
this._inUse = false;
|
||||
this._startDisconnectTimer();
|
||||
}
|
||||
|
||||
public getStream(): MediaStream | undefined {
|
||||
return this._stream ?? undefined;
|
||||
}
|
||||
@@ -130,7 +150,7 @@ export class MicrophoneManager {
|
||||
private _startDisconnectTimer(): void {
|
||||
const microphoneConfig = this._api.getConfigManager().getConfig()?.live.microphone;
|
||||
|
||||
if (microphoneConfig?.always_connected) {
|
||||
if (microphoneConfig?.always_connected || this._inUse) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,11 @@ export class MicrophoneConditionEvaluator implements ConditionEvaluator {
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return {
|
||||
result: newState?.microphone?.muted === this._condition.muted,
|
||||
result:
|
||||
(this._condition.connected === undefined ||
|
||||
newState?.microphone?.connected === this._condition.connected) &&
|
||||
(this._condition.muted === undefined ||
|
||||
newState?.microphone?.muted === this._condition.muted),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,23 @@ import type { ConditionState } from '../../conditions/types';
|
||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
||||
import type { TriggerOfType } from './types';
|
||||
|
||||
// Triggers when the microphone mute state changes: to the given value if `muted`
|
||||
// is set, or on any change if it is omitted.
|
||||
// Triggers when the microphone connection or mute state changes: to the given
|
||||
// values if `connected` / `muted` are set, or on any change if both are
|
||||
// omitted.
|
||||
export class MicrophoneTrigger extends ConditionStateTriggerBase<
|
||||
TriggerOfType<'microphone'>
|
||||
> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.microphone?.muted;
|
||||
const unconstrained =
|
||||
this._trigger.connected === undefined && this._trigger.muted === undefined;
|
||||
|
||||
return {
|
||||
...((unconstrained || this._trigger.connected !== undefined) && {
|
||||
connected: state.microphone?.connected,
|
||||
}),
|
||||
...((unconstrained || this._trigger.muted !== undefined) && {
|
||||
muted: state.microphone?.muted,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1428,56 +1428,6 @@ const frigateCardToAdvancedCameraCardTransform = (
|
||||
return modified;
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate a `condition: microphone` condition with the (removed) `connected`
|
||||
* field into a `condition: call` node. Operates on a single condition object in
|
||||
* place. When both `connected` and `muted` are present, splits into a
|
||||
* two-condition `and` (the only way to preserve both semantics now that
|
||||
* `connected` no longer lives on `microphone`).
|
||||
*
|
||||
* @returns `true` if the node was modified.
|
||||
*/
|
||||
const microphoneConnectedToCallTransform = (data: unknown): boolean => {
|
||||
if (!isRecord(data) || data['condition'] !== 'microphone') {
|
||||
return false;
|
||||
}
|
||||
const connected = data['connected'];
|
||||
if (typeof connected !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
const muted = data['muted'];
|
||||
|
||||
// Survives the rebuild below: a condition the user had switched off must not
|
||||
// come back switched on.
|
||||
const enabled = data['enabled'];
|
||||
|
||||
// A connected microphone meant a call was underway, which is either of the
|
||||
// two active phases; a disconnected one meant no call at all.
|
||||
// This needs to be revisited: https://github.com/dermotduffy/advanced-camera-card/issues/2590
|
||||
const call = connected ? ['ringing', 'answered'] : 'idle';
|
||||
|
||||
for (const key of Object.keys(data)) {
|
||||
delete data[key];
|
||||
}
|
||||
|
||||
if (typeof muted === 'boolean') {
|
||||
// `enabled` goes on the composite, disabling both halves together exactly
|
||||
// as it disabled the single condition it replaces.
|
||||
data['condition'] = 'and';
|
||||
data['conditions'] = [
|
||||
{ condition: 'call', call: call },
|
||||
{ condition: 'microphone', muted: muted },
|
||||
];
|
||||
} else {
|
||||
data['condition'] = 'call';
|
||||
data['call'] = call;
|
||||
}
|
||||
if (enabled !== undefined) {
|
||||
data['enabled'] = enabled;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Unify the legacy trio `live_substream_{on,off,select}` into the new
|
||||
// `substream_{on,off}` pair. `live_substream_select` carried the substream ID
|
||||
// in its `camera` field; that field becomes `stream` on `substream_on`.
|
||||
@@ -1748,22 +1698,6 @@ const UPGRADES = [
|
||||
upgradeWithOverrides('ptz', ptzIncorrectDataToWebRTCDataTransform),
|
||||
),
|
||||
|
||||
// microphone.connected -> call condition migration. Conditions live under
|
||||
// overrides, elements, and automations.
|
||||
upgradeArrayOfObjects(CONF_OVERRIDES, (override) =>
|
||||
upgradeObjectRecursively(microphoneConnectedToCallTransform)(override),
|
||||
),
|
||||
(data: unknown): boolean => {
|
||||
return upgradeObjectRecursively(microphoneConnectedToCallTransform)(
|
||||
typeof data === 'object' && data ? data[CONF_ELEMENTS] : {},
|
||||
);
|
||||
},
|
||||
(data: unknown): boolean => {
|
||||
return upgradeObjectRecursively(microphoneConnectedToCallTransform)(
|
||||
typeof data === 'object' && data ? data[CONF_AUTOMATIONS] : {},
|
||||
);
|
||||
},
|
||||
|
||||
// Unify `live_substream_{on,off,select}` actions. Walked over the entire
|
||||
// tree because card actions can appear anywhere (menu buttons, elements,
|
||||
// automations, view-action handlers, etc.).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const microphoneBaseSchema = z.object({
|
||||
connected: z.boolean().optional(),
|
||||
muted: z.boolean().optional(),
|
||||
});
|
||||
export type MicrophoneBase = z.infer<typeof microphoneBaseSchema>;
|
||||
|
||||
@@ -7,5 +7,4 @@ export const microphoneConditionSchema = microphoneBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('microphone'),
|
||||
muted: z.boolean(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user