mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-26 00:12:27 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d563e8b57 | ||
|
|
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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,12 @@ import { expect, onTestFinished, test, vi } from "vitest";
|
||||
import {
|
||||
type LocalTrackPublication,
|
||||
LocalVideoTrack,
|
||||
ParticipantEvent,
|
||||
type RemoteAudioTrack,
|
||||
type RemoteTrackPublication,
|
||||
Track,
|
||||
TrackEvent,
|
||||
TrackPublication,
|
||||
} from "livekit-client";
|
||||
import { waitFor } from "@testing-library/dom";
|
||||
|
||||
@@ -23,6 +27,7 @@ import {
|
||||
withTestScheduler,
|
||||
mockRemoteParticipant,
|
||||
mockRemoteScreenShare,
|
||||
mockEmitter,
|
||||
} from "../../utils/test";
|
||||
import { constant } from "../Behavior";
|
||||
|
||||
@@ -160,6 +165,50 @@ test("control a participant's screen share volume", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("re-applies the playback volume when an audio element is attached", () => {
|
||||
// A participant whose microphone track does not exist yet (they joined muted)
|
||||
const track = mockEmitter<RemoteAudioTrack>() as unknown as RemoteAudioTrack;
|
||||
let micPublication: Partial<RemoteTrackPublication> = {};
|
||||
const setVolumeSpy = vi.fn();
|
||||
const participant = mockRemoteParticipant({
|
||||
setVolume: setVolumeSpy,
|
||||
getTrackPublication: (source) =>
|
||||
(source === Track.Source.Microphone
|
||||
? micPublication
|
||||
: {}) as RemoteTrackPublication,
|
||||
});
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, participant);
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a-b-c|", {
|
||||
a() {
|
||||
vm.togglePlaybackMuted();
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
setVolumeSpy.mockClear();
|
||||
},
|
||||
b() {
|
||||
// The participant unmutes: their track gets published and subscribed,
|
||||
// then the renderer attaches an audio element to it
|
||||
micPublication = { track };
|
||||
participant.emit(
|
||||
ParticipantEvent.TrackSubscriptionStatusChanged,
|
||||
micPublication as RemoteTrackPublication,
|
||||
TrackPublication.SubscriptionStatus.Subscribed,
|
||||
);
|
||||
expect(setVolumeSpy).not.toHaveBeenCalled();
|
||||
track.emit(TrackEvent.ElementAttached, {} as HTMLMediaElement);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
setVolumeSpy.mockClear();
|
||||
},
|
||||
c() {
|
||||
// ...and again for every further attach (e.g. a re-render)
|
||||
track.emit(TrackEvent.ElementAttached, {} as HTMLMediaElement);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.playbackMuted$).toBe("ab", { a: false, b: true });
|
||||
});
|
||||
});
|
||||
|
||||
test("local media remembers whether it should always be shown", () => {
|
||||
const vm1 = mockLocalMedia(
|
||||
rtcMembership,
|
||||
|
||||
@@ -6,8 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type RemoteParticipant } from "livekit-client";
|
||||
import { combineLatest, map, of, switchMap } from "rxjs";
|
||||
import { type RemoteParticipant, Track, TrackEvent } from "livekit-client";
|
||||
import { observeParticipantMedia } from "@livekit/components-core";
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
fromEvent,
|
||||
map,
|
||||
NEVER,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
@@ -60,14 +70,54 @@ export function createRemoteUserMedia(
|
||||
logger.info(`[RemoteUserMedia ${inputs.id}] waitingForMedia=${waiting}`);
|
||||
});
|
||||
|
||||
// Emits whenever an audio element is attached to this participant's
|
||||
// microphone track: a new subscription after they rejoin or reconnect, or
|
||||
// their first unmute if they joined muted (EC only publishes the track
|
||||
// then). The requested volume has to be applied again at that point.
|
||||
// livekit-client stores the volume on the RemoteAudioTrack and re-applies
|
||||
// it on attach, except that a stored volume of 0 is dropped by a truthiness
|
||||
// check (RemoteAudioTrack.attach: `if (this.elementVolume)`), so "mute for
|
||||
// me" would otherwise be lost as soon as a new track arrives.
|
||||
const audioElementAttached$ = inputs.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p === null
|
||||
? NEVER
|
||||
: observeParticipantMedia(p).pipe(
|
||||
map(() => p.getTrackPublication(Track.Source.Microphone)?.track),
|
||||
distinctUntilChanged(),
|
||||
switchMap((track) =>
|
||||
track === undefined
|
||||
? NEVER
|
||||
: fromEvent(track, TrackEvent.ElementAttached),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const volumeControls = createVolumeControls(scope, {
|
||||
pretendToBeDisconnected$,
|
||||
sink$: scope.behavior(
|
||||
combineLatest([
|
||||
inputs.participant$,
|
||||
audioElementAttached$.pipe(startWith(null)),
|
||||
]).pipe(
|
||||
map(([p, attached]) => (volume) => {
|
||||
if (attached !== null)
|
||||
logger.info(
|
||||
`[RemoteUserMedia ${inputs.id}] re-applying playback volume ${volume} after audio element attach`,
|
||||
);
|
||||
p?.setVolume(volume);
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
volumeControls.playbackMuted$.pipe(scope.bind()).subscribe((muted) => {
|
||||
logger.info(`[RemoteUserMedia ${inputs.id}] playbackMuted=${muted}`);
|
||||
});
|
||||
|
||||
return {
|
||||
...baseUserMedia,
|
||||
...createVolumeControls(scope, {
|
||||
pretendToBeDisconnected$,
|
||||
sink$: scope.behavior(
|
||||
inputs.participant$.pipe(map((p) => (volume) => p?.setVolume(volume))),
|
||||
),
|
||||
}),
|
||||
...volumeControls,
|
||||
local: false,
|
||||
speaking$: scope.behavior(
|
||||
pretendToBeDisconnected$.pipe(
|
||||
|
||||
Reference in New Issue
Block a user