Group livekitRoomItems$ by connection

The old reducer keyed items by service URL and mutated the accumulator's
entries in place, so a transport without a service URL could not be
represented and items were not safe to compare. Group members by their
Connection object instead and carry the whole transport on the item, which
the audio renderer now receives in place of a URL.
This commit is contained in:
Robin
2026-10-12 10:11:54 +02:00
committed by Johannes Marbach
parent 3927c2b7dc
commit f260755f46
5 changed files with 42 additions and 49 deletions
+4 -3
View File
@@ -24,6 +24,7 @@ import { testAudioContext } from "../useAudioContext.test";
import * as MediaDevicesContext from "../MediaDevicesContext";
import { LivekitRoomAudioRenderer } from "./MatrixAudioRenderer";
import {
exampleTransport,
mockLocalParticipant,
mockMediaDevices,
mockRemoteParticipant,
@@ -118,7 +119,7 @@ function renderTestComponent(
<LivekitRoomAudioRenderer
validIdentities={participants.map((p) => p.identity)}
livekitRoom={livekitRoom}
url={""}
transport={exampleTransport}
/>
</MediaDevicesProvider>,
);
@@ -150,7 +151,7 @@ it("should not render or warn for the local participant", () => {
<LivekitRoomAudioRenderer
validIdentities={[]}
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
url={""}
transport={exampleTransport}
/>
</MediaDevicesProvider>,
);
@@ -177,7 +178,7 @@ it("should warn only once per unexpected participant", () => {
<LivekitRoomAudioRenderer
validIdentities={[]}
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
url={""}
transport={exampleTransport}
/>
</MediaDevicesProvider>,
);
+4 -6
View File
@@ -19,12 +19,10 @@ import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
import * as controls from "../controls";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
export interface MatrixAudioRendererProps {
/**
* The service URL of the LiveKit room.
*/
url: string;
transport: LivekitTransport;
livekitRoom: LivekitRoom;
/**
* The list of participant identities to render audio for.
@@ -54,7 +52,7 @@ export interface MatrixAudioRendererProps {
* @public
*/
export function LivekitRoomAudioRenderer({
url,
transport,
livekitRoom,
validIdentities,
muted,
@@ -88,7 +86,7 @@ export function LivekitRoomAudioRenderer({
warnedIdentities.current.add(identity);
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
logger.warn(
`Audio track ${identity} from ${url} has no matching matrix call member`,
`Audio track ${identity} from ${JSON.stringify(transport)} has no matching matrix call member`,
`current members: ${validIdentities.join()}`,
`track will not get rendered`,
);
+3 -3
View File
@@ -665,10 +665,10 @@ export const InCallView: FC<InCallViewProps> = ({
onPointerOut={onPointerOut}
>
{header}
{audioParticipants.map(({ livekitRoom, url, participants }) => (
{audioParticipants.map(({ livekitRoom, transport, participants }) => (
<LivekitRoomAudioRenderer
key={url}
url={url}
key={JSON.stringify(transport)}
transport={transport}
livekitRoom={livekitRoom}
validIdentities={participants}
muted={muteAllAudio}
+30 -36
View File
@@ -12,6 +12,7 @@ import {
ExternalE2EEKeyProvider,
type Room as LivekitRoom,
type RoomOptions,
type RemoteParticipant,
} from "livekit-client";
import { type Room as MatrixRoom } from "matrix-js-sdk";
import {
@@ -276,7 +277,7 @@ interface LayoutScanState {
export type LivekitRoomItem = {
livekitRoom: LivekitRoom;
participants: string[];
url: string;
transport: LivekitTransport;
};
/**
@@ -728,49 +729,42 @@ export function createCallViewModel$(
const allConnections$ = scope.behavior(
connectionManager.connectionManagerData$.pipe(map((d) => d.value)),
);
const livekitRoomItems$ = scope.behavior(
remoteMatrixLivekitMembers$.pipe(
switchMap((members) => {
const a$ = combineLatest(
members.value.map((member) =>
combineLatest([member.connection$, member.participant.value$]).pipe(
map(([connection, participant]) => {
// do not render audio for local participant
if (!connection || !participant || participant.isLocal)
return null;
const livekitRoom = connection.livekitRoom;
const url = connection.transport.livekit_service_url;
return {
url,
livekitRoom,
participant: participant.identity,
};
}),
const livekitRoomItems$ = scope.behavior<LivekitRoomItem[]>(
remoteMatrixLivekitMembers$.pipe(
// Flatten the members to a snapshot of their current connections and
// participant objects
switchMap(({ value: members }) => {
if (members.length === 0) return of([]);
return combineLatest(
members.map((m) =>
combineLatest(
[m.connection$, m.participant.value$],
(connection, participant) => ({ connection, participant }),
),
),
);
return a$;
}),
// Keep only those with a defined connection and participant
map((members) =>
members.reduce<LivekitRoomItem[]>((acc, curr) => {
if (!curr) return acc;
const existing = acc.find((item) => item.url === curr.url);
if (existing) {
existing.participants.push(curr.participant);
} else {
acc.push({
livekitRoom: curr.livekitRoom,
participants: [curr.participant],
url: curr.url,
});
}
return acc;
}, []),
members.filter(
(
m,
): m is { connection: Connection; participant: RemoteParticipant } =>
m.connection !== null && m.participant !== null,
),
),
// Group members by connection
map((members) =>
[...Map.groupBy(members, (m) => m.connection)].map(
([connection, members]) => ({
transport: connection.transport,
livekitRoom: connection.livekitRoom,
participants: members.map((m) => m.participant.identity),
}),
),
),
),
[],
);
const handsRaised$ = scope.behavior(