mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
426acd222e | ||
|
|
00d0adc3c4 | ||
|
|
ddc5a50529 | ||
|
|
27734a1a02 | ||
|
|
14c61db43d | ||
|
|
65e1b845a7 | ||
|
|
eddd95ba41 |
@@ -39,6 +39,17 @@
|
||||
"jsdoc/check-property-names": "error",
|
||||
"jsdoc/require-param-description": "warn",
|
||||
"react/display-name": "error",
|
||||
// TODO: These React Compiler-based rules joined oxlint's correctness
|
||||
// category in v1.81 and flag many pre-existing patterns, including hooks
|
||||
// like useLatest/useInitial/useReactiveState that intentionally access
|
||||
// refs during render. Re-evaluate them when adopting the React Compiler.
|
||||
"react/immutability": "off",
|
||||
"react/no-deriving-state-in-effects": "off",
|
||||
"react/preserve-manual-memoization": "off",
|
||||
"react/refs": "off",
|
||||
"react/set-state-in-effect": "off",
|
||||
"react/static-components": "off",
|
||||
"react/use-memo": "off",
|
||||
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
|
||||
// "rxjs/no-exposed-subjects": "error",
|
||||
// "rxjs/finnish": [
|
||||
|
||||
+2
-2
@@ -81,7 +81,7 @@
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/browser-playwright": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "4.1.10",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"classnames": "^2.3.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
@@ -101,7 +101,7 @@
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"observable-hooks": "^4.2.3",
|
||||
"oxfmt": "^0.61.0",
|
||||
"oxfmt": "^0.66.0",
|
||||
"oxlint": "^1.70.0",
|
||||
"oxlint-tsgolint": "^0.25.0",
|
||||
"pako": "^2.0.4",
|
||||
|
||||
Generated
+1450
-1458
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import * as ComponentsCore from "@livekit/components-core";
|
||||
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import { AudioOutput } from "./MediaDevices";
|
||||
import { audioOutput as audioOutputSetting } from "../settings/settings";
|
||||
import { withTestScheduler } from "../utils/test";
|
||||
|
||||
const BT_SPEAKER = {
|
||||
@@ -93,6 +94,8 @@ describe("AudioOutput Tests", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = new ObservableScope();
|
||||
// Device preferences persist in localStorage across tests
|
||||
audioOutputSetting.setValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -150,7 +153,9 @@ describe("AudioOutput Tests", () => {
|
||||
|
||||
expectObservable(audioOutput.selected$).toBe("abcde", {
|
||||
a: undefined,
|
||||
b: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
// No "default" pseudo-device (Firefox): the virtual browser default is
|
||||
// selected until the user picks something.
|
||||
b: { id: "", virtualEarpiece: false },
|
||||
c: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
d: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
e: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
@@ -158,6 +163,61 @@ describe("AudioOutput Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the browser default when the selected device disappears", () => {
|
||||
withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
cold("a---b", {
|
||||
a: DEVICE_LIST_B,
|
||||
// The monitor is unplugged (or a Bluetooth sink changes profile)
|
||||
b: [LAPTOP_SPEAKER],
|
||||
}),
|
||||
);
|
||||
|
||||
const audioOutput = new AudioOutput(
|
||||
behavior("a", { a: true }),
|
||||
testScope,
|
||||
);
|
||||
|
||||
schedule("--a", {
|
||||
a: () => audioOutput.select(MONITOR_SPEAKER.deviceId),
|
||||
});
|
||||
|
||||
expectObservable(audioOutput.selected$).toBe("a-b-c", {
|
||||
a: { id: "", virtualEarpiece: false },
|
||||
b: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
// Rather than pinning some other physical device, let the browser route
|
||||
c: { id: "", virtualEarpiece: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("lists the virtual browser default first when there is no default pseudo-device", () => {
|
||||
withTestScheduler(({ behavior, cold, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
cold("a", { a: DEVICE_LIST_B }),
|
||||
);
|
||||
|
||||
const audioOutput = new AudioOutput(
|
||||
behavior("a", { a: true }),
|
||||
testScope,
|
||||
);
|
||||
|
||||
expectObservable(audioOutput.available$).toBe("a", {
|
||||
a: new Map([
|
||||
["", { type: "default", name: null }],
|
||||
[
|
||||
LAPTOP_SPEAKER.deviceId,
|
||||
{ type: "name", name: LAPTOP_SPEAKER.label },
|
||||
],
|
||||
[
|
||||
MONITOR_SPEAKER.deviceId,
|
||||
{ type: "name", name: MONITOR_SPEAKER.label },
|
||||
],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Test mappings", () => {
|
||||
// In a real life setup there would be first a blanked list
|
||||
// then the real one.
|
||||
|
||||
@@ -516,11 +516,12 @@ describe("remote track logging", () => {
|
||||
it("logs remote participant and track events on the connection logger", () => {
|
||||
setupTest();
|
||||
const info = vi.fn();
|
||||
const warn = vi.fn();
|
||||
const testLogger = {
|
||||
getChild: (): unknown => testLogger,
|
||||
info,
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
warn,
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
new Connection(
|
||||
@@ -545,6 +546,7 @@ describe("remote track logging", () => {
|
||||
source: "microphone",
|
||||
trackSid: "TR_mic",
|
||||
isMuted: false,
|
||||
isEncrypted: true,
|
||||
} as unknown as RemoteTrackPublication;
|
||||
const messages = (): string[] => info.mock.calls.map((c) => c[0] as string);
|
||||
|
||||
@@ -564,11 +566,27 @@ describe("remote track logging", () => {
|
||||
);
|
||||
expect(messages()).toEqual([
|
||||
"Participant connected: @bob:example.org:DEV111 (PA_bob)",
|
||||
"Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 muted=false",
|
||||
"Muted: audio microphone TR_mic of @bob:example.org:DEV111",
|
||||
"Stream paused: audio microphone TR_mic of @bob:example.org:DEV111",
|
||||
"Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true muted=false",
|
||||
"Muted: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
|
||||
"Stream paused: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
|
||||
]);
|
||||
|
||||
// Encryption status changes are logged; cryptor errors are warnings
|
||||
fakeLivekitRoom.emit(
|
||||
RoomEvent.ParticipantEncryptionStatusChanged,
|
||||
false,
|
||||
bob,
|
||||
);
|
||||
expect(messages().at(-1)).toBe(
|
||||
"Encryption status of @bob:example.org:DEV111: encrypted=false",
|
||||
);
|
||||
const cryptorError = new Error("missing key at index 3");
|
||||
fakeLivekitRoom.emit(RoomEvent.EncryptionError, cryptorError, bob);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"Encryption error for @bob:example.org:DEV111:",
|
||||
cryptorError,
|
||||
);
|
||||
|
||||
// Local mute events are already logged by the Publisher
|
||||
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, {
|
||||
...fakeLocalParticipant,
|
||||
|
||||
@@ -186,8 +186,11 @@ export class Connection {
|
||||
private logRemoteTrackEvents(): void {
|
||||
const room = this.livekitRoom;
|
||||
const log = this.logger.getChild("[RemoteTracks]");
|
||||
// The encryption flag matters: if the publisher encrypts but this client
|
||||
// believes the track is unencrypted, the cryptor is bypassed and raw
|
||||
// ciphertext reaches the decoder (audible as loud noise bursts).
|
||||
const track = (pub: TrackPublication, p: Participant): string =>
|
||||
`${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity}`;
|
||||
`${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity} encrypted=${pub.isEncrypted}`;
|
||||
|
||||
const onParticipantConnected = (p: RemoteParticipant): void =>
|
||||
log.info(`Participant connected: ${p.identity} (${p.sid})`);
|
||||
@@ -232,6 +235,20 @@ export class Connection {
|
||||
state: Track.StreamState,
|
||||
p: RemoteParticipant,
|
||||
): void => log.info(`Stream ${state}: ${track(pub, p)}`);
|
||||
const onEncryptionStatusChanged = (
|
||||
encrypted: boolean,
|
||||
p?: Participant,
|
||||
): void =>
|
||||
log.info(
|
||||
`Encryption status of ${p?.identity ?? "unknown participant"}: encrypted=${encrypted}`,
|
||||
);
|
||||
// livekit-client throttles these per cryptor; they indicate frames being
|
||||
// dropped (missing/invalid key).
|
||||
const onEncryptionError = (error: Error, p?: Participant): void =>
|
||||
log.warn(
|
||||
`Encryption error for ${p?.identity ?? "unknown participant"}:`,
|
||||
error,
|
||||
);
|
||||
|
||||
room
|
||||
.on(RoomEvent.ParticipantConnected, onParticipantConnected)
|
||||
@@ -243,7 +260,12 @@ export class Connection {
|
||||
.on(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
|
||||
.on(RoomEvent.TrackMuted, onTrackMuted)
|
||||
.on(RoomEvent.TrackUnmuted, onTrackUnmuted)
|
||||
.on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged);
|
||||
.on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
|
||||
.on(
|
||||
RoomEvent.ParticipantEncryptionStatusChanged,
|
||||
onEncryptionStatusChanged,
|
||||
)
|
||||
.on(RoomEvent.EncryptionError, onEncryptionError);
|
||||
this.scope.onEnd(() => {
|
||||
room
|
||||
.off(RoomEvent.ParticipantConnected, onParticipantConnected)
|
||||
@@ -255,7 +277,12 @@ export class Connection {
|
||||
.off(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
|
||||
.off(RoomEvent.TrackMuted, onTrackMuted)
|
||||
.off(RoomEvent.TrackUnmuted, onTrackUnmuted)
|
||||
.off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged);
|
||||
.off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
|
||||
.off(
|
||||
RoomEvent.ParticipantEncryptionStatusChanged,
|
||||
onEncryptionStatusChanged,
|
||||
)
|
||||
.off(RoomEvent.EncryptionError, onEncryptionError);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -257,14 +257,21 @@ export class AudioOutput implements MediaDevice<
|
||||
map((availableRaw) => {
|
||||
let available: Map<string, AudioOutputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// Create a virtual default audio output for browsers that don't have one.
|
||||
// Its device ID must be the empty string because that's what setSinkId
|
||||
// recognizes.
|
||||
// Create a virtual default audio output for browsers that don't have one
|
||||
// (Firefox, Safari). Its device ID must be the empty string because
|
||||
// that's what setSinkId recognizes. It goes first so that it is the
|
||||
// fallback when no output has been explicitly chosen (or the chosen
|
||||
// one disappears), rather than pinning the first physical device
|
||||
// with setSinkId: pinned sinks are not re-routed by the browser, and
|
||||
// Firefox leaves the audio elements silent when a pinned sink goes
|
||||
// away (e.g. a Bluetooth headset switching profile when its
|
||||
// microphone is opened). We can't know which physical device the
|
||||
// browser default resolves to, so the entry carries no name.
|
||||
if (available.size && !available.has("") && !available.has("default"))
|
||||
available.set("", {
|
||||
type: "default",
|
||||
name: availableRaw[0]?.label || null,
|
||||
});
|
||||
available = new Map<string, AudioOutputDeviceLabel>([
|
||||
["", { type: "default", name: null }],
|
||||
...available,
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const isSafari = !!(window as any).GestureEvent; // non standard api only found on Safari. https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent#browser_compatibility
|
||||
if (isSafari) {
|
||||
|
||||
Reference in New Issue
Block a user