Skip to content

Commit b9e346d

Browse files
authored
Merge pull request #3596 from element-hq/valere/bugfix_earpiece_mute_video
Fix: Camera is not muted when the earpiece mode is enabled
2 parents e0bf51b + 44980a2 commit b9e346d

File tree

2 files changed

+269
-6
lines changed

2 files changed

+269
-6
lines changed

src/state/MuteStates.test.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/*
2+
Copyright 2025 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
9+
import { BehaviorSubject } from "rxjs";
10+
import { logger } from "matrix-js-sdk/lib/logger";
11+
12+
import { MuteStates, MuteState } from "./MuteStates";
13+
import {
14+
type AudioOutputDeviceLabel,
15+
type DeviceLabel,
16+
type MediaDevice,
17+
type SelectedAudioOutputDevice,
18+
type SelectedDevice,
19+
} from "./MediaDevices";
20+
import { constant } from "./Behavior";
21+
import { ObservableScope } from "./ObservableScope";
22+
import { flushPromises, mockMediaDevices } from "../utils/test";
23+
24+
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
25+
vi.mock("../UrlParams", () => ({ getUrlParams }));
26+
27+
let testScope: ObservableScope;
28+
29+
beforeEach(() => {
30+
testScope = new ObservableScope();
31+
});
32+
33+
afterEach(() => {
34+
testScope.end();
35+
});
36+
37+
describe("MuteState", () => {
38+
test("should automatically mute if force mute is set", async () => {
39+
const forceMute$ = new BehaviorSubject<boolean>(false);
40+
41+
const deviceStub = {
42+
available$: constant(
43+
new Map<string, DeviceLabel>([
44+
["fbac11", { type: "name", name: "HD Camera" }],
45+
]),
46+
),
47+
selected$: constant({ id: "fbac11" }),
48+
select(): void {},
49+
} as unknown as MediaDevice<DeviceLabel, SelectedDevice>;
50+
51+
const muteState = new MuteState(
52+
testScope,
53+
deviceStub,
54+
constant(true),
55+
true,
56+
forceMute$,
57+
);
58+
let lastEnabled: boolean = false;
59+
muteState.enabled$.subscribe((enabled) => {
60+
lastEnabled = enabled;
61+
});
62+
let setEnabled: ((enabled: boolean) => void) | null = null;
63+
muteState.setEnabled$.subscribe((setter) => {
64+
setEnabled = setter;
65+
});
66+
67+
await flushPromises();
68+
69+
setEnabled!(true);
70+
await flushPromises();
71+
expect(lastEnabled).toBe(true);
72+
73+
// Now force mute
74+
forceMute$.next(true);
75+
await flushPromises();
76+
// Should automatically mute
77+
expect(lastEnabled).toBe(false);
78+
79+
// Try to unmute can not work
80+
expect(setEnabled).toBeNull();
81+
82+
// Disable force mute
83+
forceMute$.next(false);
84+
await flushPromises();
85+
86+
// TODO I'd expect it to go back to previous state (enabled)
87+
// but actually it goes back to the initial state from construction (disabled)
88+
// Should go back to previous state (enabled)
89+
// Skip for now
90+
// expect(lastEnabled).toBe(true);
91+
92+
// But yet it can be unmuted now
93+
expect(setEnabled).not.toBeNull();
94+
95+
setEnabled!(true);
96+
await flushPromises();
97+
expect(lastEnabled).toBe(true);
98+
});
99+
});
100+
101+
describe("MuteStates", () => {
102+
function aAudioOutputDevices(): MediaDevice<
103+
AudioOutputDeviceLabel,
104+
SelectedAudioOutputDevice
105+
> {
106+
const selected$ = new BehaviorSubject<
107+
SelectedAudioOutputDevice | undefined
108+
>({
109+
id: "default",
110+
virtualEarpiece: false,
111+
});
112+
return {
113+
available$: constant(
114+
new Map<string, AudioOutputDeviceLabel>([
115+
["default", { type: "speaker" }],
116+
["0000", { type: "speaker" }],
117+
["1111", { type: "earpiece" }],
118+
["222", { type: "name", name: "Bluetooth Speaker" }],
119+
]),
120+
),
121+
selected$,
122+
select(id: string): void {
123+
if (!this.available$.getValue().has(id)) {
124+
logger.warn(`Attempted to select unknown device id: ${id}`);
125+
return;
126+
}
127+
selected$.next({
128+
id,
129+
/** For test purposes we ignore this */
130+
virtualEarpiece: false,
131+
});
132+
},
133+
};
134+
}
135+
136+
function aVideoInput(): MediaDevice<DeviceLabel, SelectedDevice> {
137+
const selected$ = new BehaviorSubject<SelectedDevice | undefined>(
138+
undefined,
139+
);
140+
return {
141+
available$: constant(
142+
new Map<string, DeviceLabel>([
143+
["0000", { type: "name", name: "HD Camera" }],
144+
["1111", { type: "name", name: "WebCam Pro" }],
145+
]),
146+
),
147+
selected$,
148+
select(id: string): void {
149+
if (!this.available$.getValue().has(id)) {
150+
logger.warn(`Attempted to select unknown device id: ${id}`);
151+
return;
152+
}
153+
selected$.next({ id });
154+
},
155+
};
156+
}
157+
158+
test("should mute camera when in earpiece mode", async () => {
159+
const audioOutputDevice = aAudioOutputDevices();
160+
161+
const mediaDevices = mockMediaDevices({
162+
audioOutput: audioOutputDevice,
163+
videoInput: aVideoInput(),
164+
// other devices are not relevant for this test
165+
});
166+
const muteStates = new MuteStates(
167+
testScope,
168+
mediaDevices,
169+
// consider joined
170+
constant(true),
171+
);
172+
173+
let latestSyncedState: boolean | null = null;
174+
muteStates.video.setHandler(async (enabled: boolean): Promise<boolean> => {
175+
logger.info(`Video mute state set to: ${enabled}`);
176+
latestSyncedState = enabled;
177+
return Promise.resolve(enabled);
178+
});
179+
180+
let lastVideoEnabled: boolean = false;
181+
muteStates.video.enabled$.subscribe((enabled) => {
182+
lastVideoEnabled = enabled;
183+
});
184+
185+
expect(muteStates.video.setEnabled$.value).toBeDefined();
186+
muteStates.video.setEnabled$.value?.(true);
187+
await flushPromises();
188+
189+
expect(lastVideoEnabled).toBe(true);
190+
191+
// Select earpiece audio output
192+
audioOutputDevice.select("1111");
193+
await flushPromises();
194+
// Video should be automatically muted
195+
expect(lastVideoEnabled).toBe(false);
196+
expect(latestSyncedState).toBe(false);
197+
198+
// Try to switch to speaker
199+
audioOutputDevice.select("0000");
200+
await flushPromises();
201+
// TODO I'd expect it to go back to previous state (enabled)??
202+
// But maybe not? If you move the phone away from your ear you may not want it
203+
// to automatically enable video?
204+
expect(lastVideoEnabled).toBe(false);
205+
206+
// But yet it can be unmuted now
207+
expect(muteStates.video.setEnabled$.value).toBeDefined();
208+
muteStates.video.setEnabled$.value?.(true);
209+
await flushPromises();
210+
expect(lastVideoEnabled).toBe(true);
211+
});
212+
});

src/state/MuteStates.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { ElementWidgetActions, widget } from "../widget";
2727
import { Config } from "../config/Config";
2828
import { getUrlParams } from "../UrlParams";
2929
import { type ObservableScope } from "./ObservableScope";
30-
import { type Behavior } from "./Behavior";
30+
import { type Behavior, constant } from "./Behavior";
3131

3232
interface MuteStateData {
3333
enabled$: Observable<boolean>;
@@ -38,31 +38,58 @@ interface MuteStateData {
3838
export type Handler = (desired: boolean) => Promise<boolean>;
3939
const defaultHandler: Handler = async (desired) => Promise.resolve(desired);
4040

41-
class MuteState<Label, Selected> {
41+
/**
42+
* Internal class - exported only for testing purposes.
43+
* Do not use directly outside of tests.
44+
*/
45+
export class MuteState<Label, Selected> {
46+
// TODO: rewrite this to explain behavior, it is not understandable, and cannot add logging
4247
private readonly enabledByDefault$ =
4348
this.enabledByConfig && !getUrlParams().skipLobby
4449
? this.joined$.pipe(map((isJoined) => !isJoined))
4550
: of(false);
4651

4752
private readonly handler$ = new BehaviorSubject(defaultHandler);
53+
4854
public setHandler(handler: Handler): void {
4955
if (this.handler$.value !== defaultHandler)
5056
throw new Error("Multiple mute state handlers are not supported");
5157
this.handler$.next(handler);
5258
}
59+
5360
public unsetHandler(): void {
5461
this.handler$.next(defaultHandler);
5562
}
5663

64+
private readonly canControlDevices$ = combineLatest([
65+
this.device.available$,
66+
this.forceMute$,
67+
]).pipe(
68+
map(([available, forceMute]) => {
69+
return !forceMute && available.size > 0;
70+
}),
71+
);
72+
5773
private readonly data$ = this.scope.behavior<MuteStateData>(
58-
this.device.available$.pipe(
59-
map((available) => available.size > 0),
74+
this.canControlDevices$.pipe(
6075
distinctUntilChanged(),
6176
withLatestFrom(
6277
this.enabledByDefault$,
63-
(devicesConnected, enabledByDefault) => {
64-
if (!devicesConnected)
78+
(canControlDevices, enabledByDefault) => {
79+
logger.info(
80+
`MuteState: canControlDevices: ${canControlDevices}, enabled by default: ${enabledByDefault}`,
81+
);
82+
if (!canControlDevices) {
83+
logger.info(
84+
`MuteState: devices connected: ${canControlDevices}, disabling`,
85+
);
86+
// We need to sync the mute state with the handler
87+
// to ensure nothing is beeing published.
88+
this.handler$.value(false).catch((err) => {
89+
logger.error("MuteState-disable: handler error", err);
90+
});
6591
return { enabled$: of(false), set: null, toggle: null };
92+
}
6693

6794
// Assume the default value only once devices are actually connected
6895
let enabled = enabledByDefault;
@@ -135,21 +162,45 @@ class MuteState<Label, Selected> {
135162
private readonly device: MediaDevice<Label, Selected>,
136163
private readonly joined$: Observable<boolean>,
137164
private readonly enabledByConfig: boolean,
165+
/**
166+
* An optional observable which, when it emits `true`, will force the mute.
167+
* Used for video to stop camera when earpiece mode is on.
168+
* @private
169+
*/
170+
private readonly forceMute$: Observable<boolean>,
138171
) {}
139172
}
140173

141174
export class MuteStates {
175+
/**
176+
* True if the selected audio output device is an earpiece.
177+
* Used to force-disable video when on earpiece.
178+
*/
179+
private readonly isEarpiece$ = combineLatest(
180+
this.mediaDevices.audioOutput.available$,
181+
this.mediaDevices.audioOutput.selected$,
182+
).pipe(
183+
map(([available, selected]) => {
184+
if (!selected?.id) return false;
185+
const device = available.get(selected.id);
186+
logger.info(`MuteStates: selected audio output device:`, device);
187+
return device?.type === "earpiece";
188+
}),
189+
);
190+
142191
public readonly audio = new MuteState(
143192
this.scope,
144193
this.mediaDevices.audioInput,
145194
this.joined$,
146195
Config.get().media_devices.enable_audio,
196+
constant(false),
147197
);
148198
public readonly video = new MuteState(
149199
this.scope,
150200
this.mediaDevices.videoInput,
151201
this.joined$,
152202
Config.get().media_devices.enable_video,
203+
this.isEarpiece$,
153204
);
154205

155206
public constructor(

0 commit comments

Comments
 (0)