This commit is contained in:
Robin
2025-11-06 11:23:36 -05:00
parent d8e29467f6
commit ee2a6718a0
4 changed files with 160 additions and 145 deletions

View File

@@ -67,7 +67,7 @@ import {
ScreenShareViewModel, ScreenShareViewModel,
type UserMediaViewModel, type UserMediaViewModel,
} from "./MediaViewModel"; } from "./MediaViewModel";
import { accumulate, generateKeyed$, pauseWhen } from "../utils/observable"; import { accumulate, generateMap$, pauseWhen } from "../utils/observable";
import { import {
duplicateTiles, duplicateTiles,
MatrixRTCMode, MatrixRTCMode,
@@ -110,7 +110,7 @@ import {
} from "./layout-types.ts"; } from "./layout-types.ts";
import { type ElementCallError } from "../utils/errors.ts"; import { type ElementCallError } from "../utils/errors.ts";
import { type ObservableScope } from "./ObservableScope.ts"; import { type ObservableScope } from "./ObservableScope.ts";
import { createMatrixLivekitMembers$ } from "./remoteMembers/matrixLivekitMerger.ts"; import { createMatrixLivekitMembers$ } from "./remoteMembers/MatrixLivekitMembers.ts";
import { createLocalMembership$ } from "./localMember/LocalMembership.ts"; import { createLocalMembership$ } from "./localMember/LocalMembership.ts";
import { createLocalTransport$ } from "./localMember/LocalTransport.ts"; import { createLocalTransport$ } from "./localMember/LocalTransport.ts";
import { createSessionMembershipsAndTransports$ } from "./SessionBehaviors.ts"; import { createSessionMembershipsAndTransports$ } from "./SessionBehaviors.ts";
@@ -335,24 +335,17 @@ export class CallViewModel {
*/ */
// TODO KEEP THIS!! and adapt it to what our membershipManger returns // TODO KEEP THIS!! and adapt it to what our membershipManger returns
private readonly mediaItems$ = this.scope.behavior<MediaItem[]>( private readonly mediaItems$ = this.scope.behavior<MediaItem[]>(
generateKeyed$< generateMap$(
[typeof this.matrixLivekitMembers$.value, number],
MediaItem,
MediaItem[]
>(
// Generate a collection of MediaItems from the list of expected (whether // Generate a collection of MediaItems from the list of expected (whether
// present or missing) LiveKit participants. // present or missing) LiveKit participants.
combineLatest([this.matrixLivekitMembers$, duplicateTiles.value$]), combineLatest([this.matrixLivekitMembers$, duplicateTiles.value$]),
([matrixLivekitMembers, duplicateTiles], createOrGet) => { function* ([matrixLivekitMembers, duplicateTiles]) {
const items: MediaItem[] = []; const items: MediaItem[] = [];
for (const { for (const [
connection,
participant,
member,
displayName$,
participantId, participantId,
} of matrixLivekitMembers) { { connection$, participant$, member$, displayName$ },
] of matrixLivekitMembers) {
if (connection === undefined) { if (connection === undefined) {
logger.warn("connection is not yet initialised."); logger.warn("connection is not yet initialised.");
continue; continue;
@@ -361,7 +354,6 @@ export class CallViewModel {
const mediaId = `${participantId}:${i}`; const mediaId = `${participantId}:${i}`;
const lkRoom = connection?.livekitRoom; const lkRoom = connection?.livekitRoom;
const url = connection?.transport.livekit_service_url; const url = connection?.transport.livekit_service_url;
const dpName$ = displayName$.pipe(map((n) => n ?? "[👻]"));
const item = createOrGet( const item = createOrGet(
mediaId, mediaId,
(scope) => (scope) =>
@@ -378,7 +370,7 @@ export class CallViewModel {
url, url,
this.mediaDevices, this.mediaDevices,
this.pretendToBeDisconnected$, this.pretendToBeDisconnected$,
dpName$, displayName$,
this.handsRaised$.pipe( this.handsRaised$.pipe(
map((v) => v[participantId]?.time ?? null), map((v) => v[participantId]?.time ?? null),
), ),
@@ -405,7 +397,7 @@ export class CallViewModel {
lkRoom, lkRoom,
url, url,
this.pretendToBeDisconnected$, this.pretendToBeDisconnected$,
dpName$, displayName$,
), ),
), ),
); );

View File

@@ -20,8 +20,8 @@ import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
import { type Behavior } from "../Behavior"; import { type Behavior } from "../Behavior";
import { type Connection } from "./Connection"; import { type Connection } from "./Connection";
import { type ObservableScope } from "../ObservableScope"; import { type ObservableScope } from "../ObservableScope";
import { generateKeyed$ } from "../../utils/observable"; import { generateItems$ } from "../../utils/observable";
import { areLivekitTransportsEqual } from "./matrixLivekitMerger"; import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts"; import { type ConnectionFactory } from "./ConnectionFactory.ts";
export class ConnectionManagerData { export class ConnectionManagerData {
@@ -142,31 +142,28 @@ export function createConnectionManager$({
* Connections for each transport in use by one or more session members. * Connections for each transport in use by one or more session members.
*/ */
const connections$ = scope.behavior( const connections$ = scope.behavior(
generateKeyed$<LivekitTransport[], Connection, Connection[]>( generateItems$(
transports$, transports$,
(transports, createOrGet) => { function* (transports) {
const createConnection = for (const transport of transports)
( yield {
transport: LivekitTransport, // We need to serialize the transport to a string to properly use it
): ((scope: ObservableScope) => Connection) => // as a Map key, but we also need the real transport object in order
(scope) => { // to construct the connection; pass it through as the item's data.
const connection = connectionFactory.createConnection( key: `${transport.livekit_service_url}|${transport.livekit_alias}`,
transport, data: transport,
scope,
logger,
);
// Start the connection immediately
// Use connection state to track connection progress
void connection.start();
// TODO subscribe to connection state to retry or log issues?
return connection;
}; };
},
return transports.map((transport) => { (scope, key, transport$) => {
const key = const connection = connectionFactory.createConnection(
transport.livekit_service_url + "|" + transport.livekit_alias; transport$.value,
return createOrGet(key, createConnection(transport)); scope,
}); logger,
);
// Start the connection immediately
// Use connection state to track connection progress
void connection.start();
return connection;
}, },
), ),
); );

View File

@@ -13,10 +13,23 @@ import {
type LivekitTransport, type LivekitTransport,
type CallMembership, type CallMembership,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { combineLatest, map, startWith, type Observable } from "rxjs"; import { logger } from "matrix-js-sdk/lib/logger";
import {
combineLatest,
filter,
fromEvent,
map,
startWith,
switchMap,
type Observable,
} from "rxjs";
// eslint-disable-next-line rxjs/no-internal // eslint-disable-next-line rxjs/no-internal
import { type NodeStyleEventEmitter } from "rxjs/internal/observable/fromEvent"; import { type NodeStyleEventEmitter } from "rxjs/internal/observable/fromEvent";
import { type Room as MatrixRoom, type RoomMember } from "matrix-js-sdk"; import {
RoomStateEvent,
type Room as MatrixRoom,
type RoomMember,
} from "matrix-js-sdk";
// import type { Logger } from "matrix-js-sdk/lib/logger"; // import type { Logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../Behavior"; import { type Behavior } from "../Behavior";
@@ -24,6 +37,7 @@ import { type ObservableScope } from "../ObservableScope";
import { type createConnectionManager$ } from "./ConnectionManager"; import { type createConnectionManager$ } from "./ConnectionManager";
import { getRoomMemberFromRtcMember, memberDisplaynames$ } from "./displayname"; import { getRoomMemberFromRtcMember, memberDisplaynames$ } from "./displayname";
import { type Connection } from "./Connection"; import { type Connection } from "./Connection";
import { generateItems$ } from "../../utils/observable";
/** /**
* Represent a matrix call member and his associated livekit participation. * Represent a matrix call member and his associated livekit participation.
@@ -31,18 +45,20 @@ import { type Connection } from "./Connection";
* or if it has no livekit transport at all. * or if it has no livekit transport at all.
*/ */
export interface MatrixLivekitMember { export interface MatrixLivekitMember {
membership: CallMembership; participantId: string;
membership$: Behavior<CallMembership>;
displayName$: Behavior<string>; displayName$: Behavior<string>;
participant?: LocalLivekitParticipant | RemoteLivekitParticipant; participant$:
connection?: Connection; | Behavior<LocalLivekitParticipant | undefined>
| Behavior<RemoteLivekitParticipant | undefined>;
connection$: Behavior<Connection | undefined>;
mxcAvatarUrl$: Behavior<string | undefined>;
/** /**
* TODO Try to remove this! Its waaay to much information. * TODO Try to remove this! Its waaay to much information.
* Just get the member's avatar * Just get the member's avatar
* @deprecated * @deprecated
*/ */
member: RoomMember; member$: Behavior<RoomMember>;
mxcAvatarUrl?: string;
participantId: string;
} }
interface Props { interface Props {
@@ -85,7 +101,7 @@ export function createMatrixLivekitMembers$({
*/ */
function createMatrixLivekitMember$(): Observable<MatrixLivekitMember[]> { function createMatrixLivekitMember$(): Observable<MatrixLivekitMember[]> {
const displaynameMap$ = memberDisplaynames$( const displayNameMap$ = memberDisplaynames$(
scope, scope,
matrixRoom, matrixRoom,
membershipsWithTransport$.pipe(map((v) => v.map((v) => v.membership))), membershipsWithTransport$.pipe(map((v) => v.map((v) => v.membership))),
@@ -93,51 +109,75 @@ export function createMatrixLivekitMembers$({
deviceId, deviceId,
); );
return combineLatest([ return generateItems$(
membershipsWithTransport$, combineLatest([
connectionManager.connectionManagerData$, membershipsWithTransport$,
]).pipe( connectionManager.connectionManagerData$,
map(([memberships, managerData]) => { ]),
const items: MatrixLivekitMember[] = memberships.map( function* ([memberships, managerData]) {
({ membership, transport }) => { for (const { membership, transport } of memberships) {
// TODO! cannot use membership.membershipID yet, Currently its hardcoded by the jwt service to // TODO! cannot use membership.membershipID yet, Currently its hardcoded by the jwt service to
const participantId = /*membership.membershipID*/ `${membership.userId}:${membership.deviceId}`; const participantId = /*membership.membershipID*/ `${membership.userId}:${membership.deviceId}`;
const participants = transport const participants = transport
? managerData.getParticipantForTransport(transport) ? managerData.getParticipantForTransport(transport)
: []; : [];
const participant = participants.find( const participant = participants.find(
(p) => p.identity == participantId, (p) => p.identity == participantId,
); );
const member = getRoomMemberFromRtcMember( const member = getRoomMemberFromRtcMember(
membership, membership,
matrixRoom, matrixRoom,
)?.member; )?.member;
const connection = transport if (member === undefined) {
? managerData.getConnectionForTransport(transport) logger.warn(`No room member for participant ${participantId}`);
: undefined; continue;
const displayName$ = scope.behavior( }
displaynameMap$.pipe(
map( const connection = transport
(displayNameMap) => ? managerData.getConnectionForTransport(transport)
displayNameMap.get(membership.membershipID) ?? "---", : undefined;
),
), yield {
); key: participantId,
return { data: {
participant, participant,
membership, membership,
connection, connection,
// This makes sense to add the the js-sdk callMembership (we only need the avatar so probably the call memberhsip just should aquire the avatar) // This makes sense to add the the js-sdk callMembership (we only need the avatar so probably the call memberhsip just should aquire the avatar)
// TODO Ugh this is hidign that it might be undefined!! best we remove the member entirely. member,
member: member as RoomMember, },
displayName$, };
mxcAvatarUrl: member?.getMxcAvatarUrl(), }
participantId, },
}; (scope, participantId, data$): MatrixLivekitMember => ({
}, participantId,
); membership$: scope.behavior(data$.pipe(map((data) => data.membership))),
return items; displayName$: scope.behavior(
displayNameMap$.pipe(
map((displayNames) => displayNames.get(participantId)),
filter((name) => name !== undefined),
),
"",
),
participant$: scope.behavior(
data$.pipe(map((data) => data.participant)),
// Assert that a local participant will never become a remote
// participant or vice versa
) as
| Behavior<LocalLivekitParticipant | undefined>
| Behavior<RemoteLivekitParticipant | undefined>,
connection$: scope.behavior(data$.pipe(map((data) => data.connection))),
mxcAvatarUrl$: scope.behavior(
// React to avatar changes
fromEvent(matrixRoom, RoomStateEvent.Members).pipe(
startWith(null),
switchMap(() =>
data$.pipe(map((data) => data.member.getMxcAvatarUrl())),
),
),
),
member$: scope.behavior(data$.pipe(map((data) => data.member))),
}), }),
); );
} }

View File

@@ -20,6 +20,7 @@ import {
takeWhile, takeWhile,
tap, tap,
withLatestFrom, withLatestFrom,
BehaviorSubject,
} from "rxjs"; } from "rxjs";
import { type Behavior } from "../state/Behavior"; import { type Behavior } from "../state/Behavior";
@@ -120,69 +121,54 @@ export function pauseWhen<T>(pause$: Behavior<boolean>) {
} }
/** /**
* Maps a changing input value to an output value consisting of items that have * Maps a changing input value to a collection of items that each capture some
* automatically generated ObservableScopes tied to a key. Items will be * dynamic data and are tied to a key. Items will be automatically created when
* automatically created when their key is requested for the first time, reused * their key is requested for the first time, reused when the same key is
* when the same key is requested at a later time, and destroyed (have their * requested acy later time, and destroyed (have their scope ended) when the key
* scope ended) when the key is no longer requested. * is no longer requested.
* *
* @param input$ The input value to be mapped. * @param input$ The input value to be mapped.
* @param project A function mapping input values to output values. This * @param generator A generator function yielding a key and the currently
* function receives an additional callback `createOrGet` which can be used * associated data for each item that it wants to exist.
* within the function body to request that an item be generated for a certain * @param factory A function constructing an actual item, given the item's key,
* key. The caller provides a factory which will be used to create the item if * dynamic data, and an automatically managed ObservableScope for the item.
* it is being requested for the first time. Otherwise, the item previously
* existing under that key will be returned.
*/ */
export function generateKeyed$<In, Item, Out>( export function generateItems$<Input, Key, Data, Item>(
input$: Observable<In>, input$: Observable<Input>,
project: ( generator: (input: Input) => Generator<{ key: Key; data: Data }, void, void>,
input: In, factory: (scope: ObservableScope, key: Key, data$: Behavior<Data>) => Item,
createOrGet: ( ): Observable<Item[]> {
key: string,
factory: (scope: ObservableScope) => Item,
) => Item,
) => Out,
): Observable<Out> {
return input$.pipe( return input$.pipe(
// Keep track of the existing items over time, so we can reuse them // Keep track of the existing items over time, so we can reuse them
scan< scan((prevItems, input) => {
In, const nextItems = new Map<
{ Key,
items: Map<string, { item: Item; scope: ObservableScope }>; { scope: ObservableScope; data$: BehaviorSubject<Data>; item: Item }
output: Out; >();
},
{ items: Map<string, { item: Item; scope: ObservableScope }> }
>(
(state, data) => {
const nextItems = new Map<
string,
{ item: Item; scope: ObservableScope }
>();
const output = project(data, (key, factory) => { for (const { key, data } of generator(input)) {
let item = state.items.get(key); let item = prevItems.get(key);
if (item === undefined) { if (item === undefined) {
// First time requesting the key; create the item // First time requesting the key; create the item
const scope = new ObservableScope(); const scope = new ObservableScope();
item = { item: factory(scope), scope }; const data$ = new BehaviorSubject(data);
} item = { scope, data$, item: factory(scope, key, data$) };
nextItems.set(key, item); } else {
return item.item; item.data$.next(data);
}); }
nextItems.set(key, item);
}
// Destroy all items that are no longer being requested // Destroy all items that are no longer being requested
for (const [key, { scope }] of state.items) for (const [key, { scope }] of prevItems)
if (!nextItems.has(key)) scope.end(); if (!nextItems.has(key)) scope.end();
return { items: nextItems, output }; return nextItems;
}, }, new Map<Key, { scope: ObservableScope; data$: BehaviorSubject<Data>; item: Item }>()),
{ items: new Map() }, finalizeValue((items) => {
),
finalizeValue((state) => {
// Destroy all remaining items when no longer subscribed // Destroy all remaining items when no longer subscribed
for (const { scope } of state.items.values()) scope.end(); for (const { scope } of items.values()) scope.end();
}), }),
map(({ output }) => output), map((items) => [...items.values()].map(({ item }) => item)),
); );
} }