Unify LiveKit and Matrix connection states
This commit is contained in:
@@ -160,6 +160,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
}, [rtcSession]);
|
}, [rtcSession]);
|
||||||
|
|
||||||
// TODO move this into the callViewModel LocalMembership.ts
|
// TODO move this into the callViewModel LocalMembership.ts
|
||||||
|
// We might actually not need this at all. Since we get into fatalError on those errors already?
|
||||||
useTypedEventEmitter(
|
useTypedEventEmitter(
|
||||||
rtcSession,
|
rtcSession,
|
||||||
MatrixRTCSessionEvent.MembershipManagerError,
|
MatrixRTCSessionEvent.MembershipManagerError,
|
||||||
|
|||||||
@@ -452,18 +452,14 @@ export function createCallViewModel$(
|
|||||||
|
|
||||||
const localMembership = createLocalMembership$({
|
const localMembership = createLocalMembership$({
|
||||||
scope: scope,
|
scope: scope,
|
||||||
homeserverConnected$: createHomeserverConnected$(
|
homeserverConnected: createHomeserverConnected$(
|
||||||
scope,
|
scope,
|
||||||
client,
|
client,
|
||||||
matrixRTCSession,
|
matrixRTCSession,
|
||||||
),
|
),
|
||||||
muteStates: muteStates,
|
muteStates: muteStates,
|
||||||
joinMatrixRTC: async (transport: LivekitTransport) => {
|
joinMatrixRTC: (transport: LivekitTransport) => {
|
||||||
return enterRTCSession(
|
enterRTCSession(matrixRTCSession, transport, connectOptions$.value);
|
||||||
matrixRTCSession,
|
|
||||||
transport,
|
|
||||||
connectOptions$.value,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
createPublisherFactory: (connection: Connection) => {
|
createPublisherFactory: (connection: Connection) => {
|
||||||
return new Publisher(
|
return new Publisher(
|
||||||
@@ -573,17 +569,6 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether various media/event sources should pretend to be disconnected from
|
|
||||||
* all network input, even if their connection still technically works.
|
|
||||||
*/
|
|
||||||
// We do this when the app is in the 'reconnecting' state, because it might be
|
|
||||||
// that the LiveKit connection is still functional while the homeserver is
|
|
||||||
// down, for example, and we want to avoid making people worry that the app is
|
|
||||||
// in a split-brained state.
|
|
||||||
// DISCUSSION own membership manager ALSO this probably can be simplifis
|
|
||||||
const reconnecting$ = localMembership.reconnecting$;
|
|
||||||
|
|
||||||
const audioParticipants$ = scope.behavior(
|
const audioParticipants$ = scope.behavior(
|
||||||
matrixLivekitMembers$.pipe(
|
matrixLivekitMembers$.pipe(
|
||||||
switchMap((membersWithEpoch) => {
|
switchMap((membersWithEpoch) => {
|
||||||
@@ -631,7 +616,7 @@ export function createCallViewModel$(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handsRaised$ = scope.behavior(
|
const handsRaised$ = scope.behavior(
|
||||||
handsRaisedSubject$.pipe(pauseWhen(reconnecting$)),
|
handsRaisedSubject$.pipe(pauseWhen(localMembership.reconnecting$)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const reactions$ = scope.behavior(
|
const reactions$ = scope.behavior(
|
||||||
@@ -644,7 +629,7 @@ export function createCallViewModel$(
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
pauseWhen(reconnecting$),
|
pauseWhen(localMembership.reconnecting$),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -735,7 +720,7 @@ export function createCallViewModel$(
|
|||||||
livekitRoom$,
|
livekitRoom$,
|
||||||
focusUrl$,
|
focusUrl$,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
reconnecting$,
|
localMembership.reconnecting$,
|
||||||
displayName$,
|
displayName$,
|
||||||
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
|
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
|
||||||
handsRaised$.pipe(map((v) => v[participantId]?.time ?? null)),
|
handsRaised$.pipe(map((v) => v[participantId]?.time ?? null)),
|
||||||
@@ -827,11 +812,17 @@ export function createCallViewModel$(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const leave$: Observable<"user" | "timeout" | "decline" | "allOthersLeft"> =
|
const shouldLeave$: Observable<
|
||||||
merge(
|
"user" | "timeout" | "decline" | "allOthersLeft"
|
||||||
autoLeave$,
|
> = merge(
|
||||||
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
|
autoLeave$,
|
||||||
).pipe(scope.share);
|
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
|
||||||
|
).pipe(scope.share);
|
||||||
|
|
||||||
|
shouldLeave$.pipe(scope.bind()).subscribe((reason) => {
|
||||||
|
logger.info(`Call left due to ${reason}`);
|
||||||
|
localMembership.requestDisconnect();
|
||||||
|
});
|
||||||
|
|
||||||
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | null>(
|
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | null>(
|
||||||
userMedia$.pipe(
|
userMedia$.pipe(
|
||||||
@@ -1453,7 +1444,7 @@ export function createCallViewModel$(
|
|||||||
autoLeave$: autoLeave$,
|
autoLeave$: autoLeave$,
|
||||||
callPickupState$: callPickupState$,
|
callPickupState$: callPickupState$,
|
||||||
ringOverlay$: ringOverlay$,
|
ringOverlay$: ringOverlay$,
|
||||||
leave$: leave$,
|
leave$: shouldLeave$,
|
||||||
hangup: (): void => userHangup$.next(),
|
hangup: (): void => userHangup$.next(),
|
||||||
join: localMembership.requestConnect,
|
join: localMembership.requestConnect,
|
||||||
toggleScreenSharing: toggleScreenSharing,
|
toggleScreenSharing: toggleScreenSharing,
|
||||||
@@ -1500,7 +1491,7 @@ export function createCallViewModel$(
|
|||||||
showFooter$: showFooter$,
|
showFooter$: showFooter$,
|
||||||
earpieceMode$: earpieceMode$,
|
earpieceMode$: earpieceMode$,
|
||||||
audioOutputSwitcher$: audioOutputSwitcher$,
|
audioOutputSwitcher$: audioOutputSwitcher$,
|
||||||
reconnecting$: reconnecting$,
|
reconnecting$: localMembership.reconnecting$,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,106 +97,106 @@ describe("createHomeserverConnected$", () => {
|
|||||||
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
|
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
|
||||||
// easy enough to read them so I think they can stay.
|
// easy enough to read them so I think they can stay.
|
||||||
it("is false when sync state is not Syncing", () => {
|
it("is false when sync state is not Syncing", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("remains false while membership status is not Connected even if sync is Syncing", () => {
|
it("remains false while membership status is not Connected even if sync is Syncing", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
expect(hsConnected$.value).toBe(false); // membership still disconnected
|
expect(hsConnected.combined$.value).toBe(false); // membership still disconnected
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => {
|
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
// Make sync loop OK
|
// Make sync loop OK
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
// Indicate probable leave before connection
|
// Indicate probable leave before connection
|
||||||
session.setProbablyLeft(true);
|
session.setProbablyLeft(true);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("becomes true only when all three conditions are satisfied", () => {
|
it("becomes true only when all three conditions are satisfied", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
// 1. Sync loop connected
|
// 1. Sync loop connected
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
expect(hsConnected$.value).toBe(false); // not yet membership connected
|
expect(hsConnected.combined$.value).toBe(false); // not yet membership connected
|
||||||
// 2. Membership connected
|
// 2. Membership connected
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected$.value).toBe(true); // probablyLeft is false
|
expect(hsConnected.combined$.value).toBe(true); // probablyLeft is false
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops back to false when sync loop leaves Syncing", () => {
|
it("drops back to false when sync loop leaves Syncing", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
// Reach connected state
|
// Reach connected state
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
|
|
||||||
// Sync loop error => should flip false
|
// Sync loop error => should flip false
|
||||||
client.setSyncState(SyncState.Error);
|
client.setSyncState(SyncState.Error);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops back to false when membership status becomes disconnected", () => {
|
it("drops back to false when membership status becomes disconnected", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
|
|
||||||
session.setMembershipStatus(Status.Disconnected);
|
session.setMembershipStatus(Status.Disconnected);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops to false when ProbablyLeft is emitted after being true", () => {
|
it("drops to false when ProbablyLeft is emitted after being true", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
|
|
||||||
session.setProbablyLeft(true);
|
session.setProbablyLeft(true);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => {
|
it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
|
|
||||||
session.setProbablyLeft(true);
|
session.setProbablyLeft(true);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
|
|
||||||
// Simulate clearing the flag (in realistic scenario membership manager would update)
|
// Simulate clearing the flag (in realistic scenario membership manager would update)
|
||||||
session.setProbablyLeft(false);
|
session.setProbablyLeft(false);
|
||||||
expect(hsConnected$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("composite sequence reflects each individual failure reason", () => {
|
it("composite sequence reflects each individual failure reason", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
|
|
||||||
// Initially false (sync error + disconnected + not probably left)
|
// Initially false (sync error + disconnected + not probably left)
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
|
|
||||||
// Fix sync only
|
// Fix sync only
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
|
|
||||||
// Fix membership
|
// Fix membership
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
|
|
||||||
// Introduce probablyLeft -> false
|
// Introduce probablyLeft -> false
|
||||||
session.setProbablyLeft(true);
|
session.setProbablyLeft(true);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
|
|
||||||
// Restore notProbablyLeft -> true again
|
// Restore notProbablyLeft -> true again
|
||||||
session.setProbablyLeft(false);
|
session.setProbablyLeft(false);
|
||||||
expect(hsConnected$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
|
|
||||||
// Drop sync -> false
|
// Drop sync -> false
|
||||||
client.setSyncState(SyncState.Error);
|
client.setSyncState(SyncState.Error);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ import { type NodeStyleEventEmitter } from "../../../utils/test";
|
|||||||
*/
|
*/
|
||||||
const logger = rootLogger.getChild("[HomeserverConnected]");
|
const logger = rootLogger.getChild("[HomeserverConnected]");
|
||||||
|
|
||||||
|
export interface HomeserverConnected {
|
||||||
|
combined$: Behavior<boolean>;
|
||||||
|
rtsSession$: Behavior<Status>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Behavior representing whether we consider ourselves connected to the Matrix homeserver
|
* Behavior representing whether we consider ourselves connected to the Matrix homeserver
|
||||||
* for the purposes of a MatrixRTC session.
|
* for the purposes of a MatrixRTC session.
|
||||||
@@ -39,7 +44,7 @@ export function createHomeserverConnected$(
|
|||||||
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
||||||
matrixRTCSession: NodeStyleEventEmitter &
|
matrixRTCSession: NodeStyleEventEmitter &
|
||||||
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||||
): Behavior<boolean> {
|
): HomeserverConnected {
|
||||||
const syncing$ = (
|
const syncing$ = (
|
||||||
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
|
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
|
||||||
).pipe(
|
).pipe(
|
||||||
@@ -47,12 +52,15 @@ export function createHomeserverConnected$(
|
|||||||
map(([state]) => state === SyncState.Syncing),
|
map(([state]) => state === SyncState.Syncing),
|
||||||
);
|
);
|
||||||
|
|
||||||
const membershipConnected$ = fromEvent(
|
const rtsSession$ = scope.behavior<Status>(
|
||||||
matrixRTCSession,
|
fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
|
||||||
MembershipManagerEvent.StatusChanged,
|
map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
|
||||||
).pipe(
|
),
|
||||||
startWith(null),
|
Status.Unknown,
|
||||||
map(() => matrixRTCSession.membershipStatus === Status.Connected),
|
);
|
||||||
|
|
||||||
|
const membershipConnected$ = rtsSession$.pipe(
|
||||||
|
map((status) => status === Status.Connected),
|
||||||
);
|
);
|
||||||
|
|
||||||
// This is basically notProbablyLeft$
|
// This is basically notProbablyLeft$
|
||||||
@@ -71,15 +79,13 @@ export function createHomeserverConnected$(
|
|||||||
map(() => matrixRTCSession.probablyLeft !== true),
|
map(() => matrixRTCSession.probablyLeft !== true),
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectedCombined$ = and$(
|
const combined$ = scope.behavior(
|
||||||
syncing$,
|
and$(syncing$, membershipConnected$, certainlyConnected$).pipe(
|
||||||
membershipConnected$,
|
tap((connected) => {
|
||||||
certainlyConnected$,
|
logger.info(`Homeserver connected update: ${connected}`);
|
||||||
).pipe(
|
}),
|
||||||
tap((connected) => {
|
),
|
||||||
logger.info(`Homeserver connected update: ${connected}`);
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return scope.behavior(connectedCombined$);
|
return { combined$, rtsSession$ };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Status,
|
||||||
type LivekitTransport,
|
type LivekitTransport,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
@@ -51,7 +52,7 @@ vi.mock("@livekit/components-core", () => ({
|
|||||||
|
|
||||||
describe("LocalMembership", () => {
|
describe("LocalMembership", () => {
|
||||||
describe("enterRTCSession", () => {
|
describe("enterRTCSession", () => {
|
||||||
it("It joins the correct Session", async () => {
|
it("It joins the correct Session", () => {
|
||||||
const focusFromOlderMembership = {
|
const focusFromOlderMembership = {
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
livekit_service_url: "http://my-oldest-member-service-url.com",
|
livekit_service_url: "http://my-oldest-member-service-url.com",
|
||||||
@@ -107,7 +108,7 @@ describe("LocalMembership", () => {
|
|||||||
joinRoomSession: vi.fn(),
|
joinRoomSession: vi.fn(),
|
||||||
}) as unknown as MatrixRTCSession;
|
}) as unknown as MatrixRTCSession;
|
||||||
|
|
||||||
await enterRTCSession(
|
enterRTCSession(
|
||||||
mockedSession,
|
mockedSession,
|
||||||
{
|
{
|
||||||
livekit_alias: "roomId",
|
livekit_alias: "roomId",
|
||||||
@@ -136,7 +137,7 @@ describe("LocalMembership", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("It should not fail with configuration error if homeserver config has livekit url but not fallback", async () => {
|
it("It should not fail with configuration error if homeserver config has livekit url but not fallback", () => {
|
||||||
mockConfig({});
|
mockConfig({});
|
||||||
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({
|
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({
|
||||||
"org.matrix.msc4143.rtc_foci": [
|
"org.matrix.msc4143.rtc_foci": [
|
||||||
@@ -165,7 +166,7 @@ describe("LocalMembership", () => {
|
|||||||
joinRoomSession: vi.fn(),
|
joinRoomSession: vi.fn(),
|
||||||
}) as unknown as MatrixRTCSession;
|
}) as unknown as MatrixRTCSession;
|
||||||
|
|
||||||
await enterRTCSession(
|
enterRTCSession(
|
||||||
mockedSession,
|
mockedSession,
|
||||||
{
|
{
|
||||||
livekit_alias: "roomId",
|
livekit_alias: "roomId",
|
||||||
@@ -190,7 +191,6 @@ describe("LocalMembership", () => {
|
|||||||
leaveRoomSession: () => {},
|
leaveRoomSession: () => {},
|
||||||
} as unknown as MatrixRTCSession,
|
} as unknown as MatrixRTCSession,
|
||||||
muteStates: mockMuteStates(),
|
muteStates: mockMuteStates(),
|
||||||
isHomeserverConnected: constant(true),
|
|
||||||
trackProcessorState$: constant({
|
trackProcessorState$: constant({
|
||||||
supported: false,
|
supported: false,
|
||||||
processor: undefined,
|
processor: undefined,
|
||||||
@@ -198,7 +198,10 @@ describe("LocalMembership", () => {
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
createPublisherFactory: vi.fn(),
|
createPublisherFactory: vi.fn(),
|
||||||
joinMatrixRTC: async (): Promise<void> => {},
|
joinMatrixRTC: async (): Promise<void> => {},
|
||||||
homeserverConnected$: constant(true),
|
homeserverConnected: {
|
||||||
|
combined$: constant(true),
|
||||||
|
rtsSession$: constant(Status.Connected),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
it("throws error on missing RTC config error", () => {
|
it("throws error on missing RTC config error", () => {
|
||||||
@@ -258,8 +261,7 @@ describe("LocalMembership", () => {
|
|||||||
} as unknown as LocalParticipant,
|
} as unknown as LocalParticipant,
|
||||||
}),
|
}),
|
||||||
state$: constant({
|
state$: constant({
|
||||||
state: "ConnectedToLkRoom",
|
state: LivekitConnectionState.Connected,
|
||||||
livekitConnectionState$: constant(LivekitConnectionState.Connected),
|
|
||||||
}),
|
}),
|
||||||
transport: aTransport,
|
transport: aTransport,
|
||||||
} as unknown as Connection,
|
} as unknown as Connection,
|
||||||
@@ -268,7 +270,7 @@ describe("LocalMembership", () => {
|
|||||||
connectionManagerData.add(
|
connectionManagerData.add(
|
||||||
{
|
{
|
||||||
state$: constant({
|
state$: constant({
|
||||||
state: "ConnectedToLkRoom",
|
state: LivekitConnectionState.Connected,
|
||||||
}),
|
}),
|
||||||
transport: bTransport,
|
transport: bTransport,
|
||||||
} as unknown as Connection,
|
} as unknown as Connection,
|
||||||
@@ -443,7 +445,7 @@ describe("LocalMembership", () => {
|
|||||||
connectionManagerData$.next(new Epoch(connectionManagerData));
|
connectionManagerData$.next(new Epoch(connectionManagerData));
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(localMembership.connectionState.livekit$.value).toStrictEqual({
|
expect(localMembership.connectionState.livekit$.value).toStrictEqual({
|
||||||
state: RTCBackendState.Initialized,
|
state: LivekitConnectionState.Connected,
|
||||||
});
|
});
|
||||||
expect(publisherFactory).toHaveBeenCalledOnce();
|
expect(publisherFactory).toHaveBeenCalledOnce();
|
||||||
expect(localMembership.tracks$.value.length).toBe(0);
|
expect(localMembership.tracks$.value.length).toBe(0);
|
||||||
@@ -473,7 +475,7 @@ describe("LocalMembership", () => {
|
|||||||
publishResolver.resolve();
|
publishResolver.resolve();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(localMembership.connectionState.livekit$.value).toStrictEqual({
|
expect(localMembership.connectionState.livekit$.value).toStrictEqual({
|
||||||
state: RTCBackendState.Connected,
|
state: RTCBackendState.ConnectedAndPublishing,
|
||||||
});
|
});
|
||||||
expect(publishers[0].stopPublishing).not.toHaveBeenCalled();
|
expect(publishers[0].stopPublishing).not.toHaveBeenCalled();
|
||||||
|
|
||||||
@@ -482,7 +484,7 @@ describe("LocalMembership", () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
// stays in connected state because it is stopped before the update to tracks update the state.
|
// stays in connected state because it is stopped before the update to tracks update the state.
|
||||||
expect(localMembership.connectionState.livekit$.value).toStrictEqual({
|
expect(localMembership.connectionState.livekit$.value).toStrictEqual({
|
||||||
state: RTCBackendState.Connected,
|
state: RTCBackendState.ConnectedAndPublishing,
|
||||||
});
|
});
|
||||||
// stop all tracks after ending scopes
|
// stop all tracks after ending scopes
|
||||||
expect(publishers[0].stopPublishing).toHaveBeenCalled();
|
expect(publishers[0].stopPublishing).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
type ScreenShareCaptureOptions,
|
type ScreenShareCaptureOptions,
|
||||||
ConnectionState,
|
ConnectionState as LivekitConnectionState,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { observeParticipantEvents } from "@livekit/components-core";
|
import { observeParticipantEvents } from "@livekit/components-core";
|
||||||
import {
|
import {
|
||||||
|
Status as RTCSessionStatus,
|
||||||
type LivekitTransport,
|
type LivekitTransport,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
@@ -27,7 +28,7 @@ import {
|
|||||||
map,
|
map,
|
||||||
type Observable,
|
type Observable,
|
||||||
of,
|
of,
|
||||||
scan,
|
pairwise,
|
||||||
startWith,
|
startWith,
|
||||||
switchMap,
|
switchMap,
|
||||||
tap,
|
tap,
|
||||||
@@ -37,10 +38,9 @@ import { deepCompare } from "matrix-js-sdk/lib/utils";
|
|||||||
|
|
||||||
import { constant, type Behavior } from "../../Behavior";
|
import { constant, type Behavior } from "../../Behavior";
|
||||||
import { type IConnectionManager } from "../remoteMembers/ConnectionManager";
|
import { type IConnectionManager } from "../remoteMembers/ConnectionManager";
|
||||||
import { ObservableScope } from "../../ObservableScope";
|
import { type ObservableScope } from "../../ObservableScope";
|
||||||
import { type Publisher } from "./Publisher";
|
import { type Publisher } from "./Publisher";
|
||||||
import { type MuteStates } from "../../MuteStates";
|
import { type MuteStates } from "../../MuteStates";
|
||||||
import { and$ } from "../../../utils/observable";
|
|
||||||
import {
|
import {
|
||||||
ElementCallError,
|
ElementCallError,
|
||||||
MembershipManagerError,
|
MembershipManagerError,
|
||||||
@@ -51,7 +51,11 @@ import { getUrlParams } from "../../../UrlParams.ts";
|
|||||||
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
|
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
|
||||||
import { MatrixRTCMode } from "../../../settings/settings.ts";
|
import { MatrixRTCMode } from "../../../settings/settings.ts";
|
||||||
import { Config } from "../../../config/Config.ts";
|
import { Config } from "../../../config/Config.ts";
|
||||||
import { type Connection } from "../remoteMembers/Connection.ts";
|
import {
|
||||||
|
type ConnectionState,
|
||||||
|
type Connection,
|
||||||
|
} from "../remoteMembers/Connection.ts";
|
||||||
|
import { type HomeserverConnected } from "./HomeserverConnected.ts";
|
||||||
|
|
||||||
export enum RTCBackendState {
|
export enum RTCBackendState {
|
||||||
Error = "error",
|
Error = "error",
|
||||||
@@ -59,47 +63,32 @@ export enum RTCBackendState {
|
|||||||
WaitingForTransport = "waiting_for_transport",
|
WaitingForTransport = "waiting_for_transport",
|
||||||
/** A connection appeared so we can initialise the publisher */
|
/** A connection appeared so we can initialise the publisher */
|
||||||
WaitingForConnection = "waiting_for_connection",
|
WaitingForConnection = "waiting_for_connection",
|
||||||
/** Connection and transport arrived, publisher Initialized */
|
/** Implies lk connection is connected */
|
||||||
Initialized = "Initialized",
|
|
||||||
CreatingTracks = "creating_tracks",
|
CreatingTracks = "creating_tracks",
|
||||||
|
/** Implies lk connection is connected */
|
||||||
ReadyToPublish = "ready_to_publish",
|
ReadyToPublish = "ready_to_publish",
|
||||||
|
/** Implies lk connection is connected */
|
||||||
WaitingToPublish = "waiting_to_publish",
|
WaitingToPublish = "waiting_to_publish",
|
||||||
Connected = "connected",
|
/** Implies lk connection is connected */
|
||||||
Disconnected = "disconnected",
|
ConnectedAndPublishing = "fully_connected",
|
||||||
Disconnecting = "disconnecting",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocalMemberRtcBackendState =
|
type LocalMemberRTCBackendState =
|
||||||
| { state: RTCBackendState.Error; error: ElementCallError }
|
| { state: RTCBackendState.Error; error: ElementCallError }
|
||||||
| { state: RTCBackendState.WaitingForTransport }
|
| { state: Exclude<RTCBackendState, RTCBackendState.Error> }
|
||||||
| { state: RTCBackendState.WaitingForConnection }
|
| ConnectionState;
|
||||||
| { state: RTCBackendState.Initialized }
|
|
||||||
| { state: RTCBackendState.CreatingTracks }
|
|
||||||
| { state: RTCBackendState.ReadyToPublish }
|
|
||||||
| { state: RTCBackendState.WaitingToPublish }
|
|
||||||
| { state: RTCBackendState.Connected }
|
|
||||||
| { state: RTCBackendState.Disconnected }
|
|
||||||
| { state: RTCBackendState.Disconnecting };
|
|
||||||
|
|
||||||
export enum MatrixState {
|
export enum MatrixAdditionalState {
|
||||||
WaitingForTransport = "waiting_for_transport",
|
WaitingForTransport = "waiting_for_transport",
|
||||||
Ready = "ready",
|
|
||||||
Connecting = "connecting",
|
|
||||||
Connected = "connected",
|
|
||||||
Disconnected = "disconnected",
|
|
||||||
Error = "Error",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocalMemberMatrixState =
|
type LocalMemberMatrixState =
|
||||||
| { state: MatrixState.Connected }
|
| { state: MatrixAdditionalState.WaitingForTransport }
|
||||||
| { state: MatrixState.WaitingForTransport }
|
| { state: "Error"; error: Error }
|
||||||
| { state: MatrixState.Ready }
|
| { state: RTCSessionStatus };
|
||||||
| { state: MatrixState.Connecting }
|
|
||||||
| { state: MatrixState.Disconnected }
|
|
||||||
| { state: MatrixState.Error; error: Error };
|
|
||||||
|
|
||||||
export interface LocalMemberConnectionState {
|
export interface LocalMemberConnectionState {
|
||||||
livekit$: Behavior<LocalMemberRtcBackendState>;
|
livekit$: Behavior<LocalMemberRTCBackendState>;
|
||||||
matrix$: Behavior<LocalMemberMatrixState>;
|
matrix$: Behavior<LocalMemberMatrixState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,8 +111,8 @@ interface Props {
|
|||||||
muteStates: MuteStates;
|
muteStates: MuteStates;
|
||||||
connectionManager: IConnectionManager;
|
connectionManager: IConnectionManager;
|
||||||
createPublisherFactory: (connection: Connection) => Publisher;
|
createPublisherFactory: (connection: Connection) => Publisher;
|
||||||
joinMatrixRTC: (transport: LivekitTransport) => Promise<void>;
|
joinMatrixRTC: (transport: LivekitTransport) => void;
|
||||||
homeserverConnected$: Behavior<boolean>;
|
homeserverConnected: HomeserverConnected;
|
||||||
localTransport$: Behavior<LivekitTransport | null>;
|
localTransport$: Behavior<LivekitTransport | null>;
|
||||||
matrixRTCSession: Pick<
|
matrixRTCSession: Pick<
|
||||||
MatrixRTCSession,
|
MatrixRTCSession,
|
||||||
@@ -149,7 +138,7 @@ export const createLocalMembership$ = ({
|
|||||||
scope,
|
scope,
|
||||||
connectionManager,
|
connectionManager,
|
||||||
localTransport$: localTransportCanThrow$,
|
localTransport$: localTransportCanThrow$,
|
||||||
homeserverConnected$,
|
homeserverConnected,
|
||||||
createPublisherFactory,
|
createPublisherFactory,
|
||||||
joinMatrixRTC,
|
joinMatrixRTC,
|
||||||
logger: parentLogger,
|
logger: parentLogger,
|
||||||
@@ -175,10 +164,14 @@ export const createLocalMembership$ = ({
|
|||||||
tracks$: Behavior<LocalTrack[]>;
|
tracks$: Behavior<LocalTrack[]>;
|
||||||
participant$: Behavior<LocalParticipant | null>;
|
participant$: Behavior<LocalParticipant | null>;
|
||||||
connection$: Behavior<Connection | null>;
|
connection$: Behavior<Connection | null>;
|
||||||
homeserverConnected$: Behavior<boolean>;
|
/** Shorthand for connectionState.matrix.state === Status.Reconnecting
|
||||||
// this needs to be discussed
|
* Direct translation to the js-sdk membership manager connection `Status`.
|
||||||
/** @deprecated use state instead*/
|
*/
|
||||||
reconnecting$: Behavior<boolean>;
|
reconnecting$: Behavior<boolean>;
|
||||||
|
/** Shorthand for connectionState.matrix.state === Status.Disconnected
|
||||||
|
* Direct translation to the js-sdk membership manager connection `Status`.
|
||||||
|
*/
|
||||||
|
disconnected$: Behavior<boolean>;
|
||||||
} => {
|
} => {
|
||||||
const logger = parentLogger.getChild("[LocalMembership]");
|
const logger = parentLogger.getChild("[LocalMembership]");
|
||||||
logger.debug(`Creating local membership..`);
|
logger.debug(`Creating local membership..`);
|
||||||
@@ -232,49 +225,31 @@ export const createLocalMembership$ = ({
|
|||||||
// * Whether we are "fully" connected to the call. Accounts for both the
|
// * Whether we are "fully" connected to the call. Accounts for both the
|
||||||
// * connection to the MatrixRTC session and the LiveKit publish connection.
|
// * connection to the MatrixRTC session and the LiveKit publish connection.
|
||||||
// */
|
// */
|
||||||
const connected$ = scope.behavior(
|
// TODO remove this and just make it one single check of livekitConnectionState$
|
||||||
and$(
|
// const connected$ = scope.behavior(
|
||||||
homeserverConnected$.pipe(
|
// localConnectionState$.pipe(
|
||||||
tap((v) => logger.debug("matrix: Connected state changed", v)),
|
// switchMap((state) => {
|
||||||
),
|
// logger.debug("livekit: Connected state changed", state);
|
||||||
localConnectionState$.pipe(
|
// if (!state) return of(false);
|
||||||
switchMap((state) => {
|
// if (state.state === "ConnectedToLkRoom") {
|
||||||
logger.debug("livekit: Connected state changed", state);
|
// logger.debug(
|
||||||
if (!state) return of(false);
|
// "livekit: Connected state changed (inner livekitConnectionState$)",
|
||||||
if (state.state === "ConnectedToLkRoom") {
|
// state.livekitConnectionState$.value,
|
||||||
logger.debug(
|
// );
|
||||||
"livekit: Connected state changed (inner livekitConnectionState$)",
|
// return state.livekitConnectionState$.pipe(
|
||||||
state.livekitConnectionState$.value,
|
// map((lkState) => lkState === ConnectionState.Connected),
|
||||||
);
|
// );
|
||||||
return state.livekitConnectionState$.pipe(
|
// }
|
||||||
map((lkState) => lkState === ConnectionState.Connected),
|
// return of(false);
|
||||||
);
|
// }),
|
||||||
}
|
// ),
|
||||||
return of(false);
|
// );
|
||||||
}),
|
|
||||||
),
|
|
||||||
).pipe(tap((v) => logger.debug("combined: Connected state changed", v))),
|
|
||||||
);
|
|
||||||
|
|
||||||
// MATRIX RELATED
|
// MATRIX RELATED
|
||||||
|
|
||||||
// /**
|
|
||||||
// * Whether we should tell the user that we're reconnecting to the call.
|
|
||||||
// */
|
|
||||||
// DISCUSSION is there a better way to do this?
|
|
||||||
// sth that is more deriectly implied from the membership manager of the js sdk. (fromEvent(matrixRTCSession, Reconnecting)) ??? or similar
|
|
||||||
const reconnecting$ = scope.behavior(
|
const reconnecting$ = scope.behavior(
|
||||||
connected$.pipe(
|
homeserverConnected.rtsSession$.pipe(
|
||||||
// We are reconnecting if we previously had some successful initial
|
map((sessionStatus) => sessionStatus === RTCSessionStatus.Reconnecting),
|
||||||
// connection but are now disconnected
|
|
||||||
scan(
|
|
||||||
({ connectedPreviously }, connectedNow) => ({
|
|
||||||
connectedPreviously: connectedPreviously || connectedNow,
|
|
||||||
reconnecting: connectedPreviously && !connectedNow,
|
|
||||||
}),
|
|
||||||
{ connectedPreviously: false, reconnecting: false },
|
|
||||||
),
|
|
||||||
map(({ reconnecting }) => reconnecting),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -374,8 +349,9 @@ export const createLocalMembership$ = ({
|
|||||||
logger.error("Multiple Livkit Errors:", e);
|
logger.error("Multiple Livkit Errors:", e);
|
||||||
else fatalLivekitError$.next(e);
|
else fatalLivekitError$.next(e);
|
||||||
};
|
};
|
||||||
const livekitState$: Behavior<LocalMemberRtcBackendState> = scope.behavior(
|
const livekitState$: Behavior<LocalMemberRTCBackendState> = scope.behavior(
|
||||||
combineLatest([
|
combineLatest([
|
||||||
|
localConnectionState$,
|
||||||
publisher$,
|
publisher$,
|
||||||
localTransport$,
|
localTransport$,
|
||||||
tracks$.pipe(
|
tracks$.pipe(
|
||||||
@@ -389,10 +365,12 @@ export const createLocalMembership$ = ({
|
|||||||
map(() => true),
|
map(() => true),
|
||||||
startWith(false),
|
startWith(false),
|
||||||
),
|
),
|
||||||
|
// TODO use local connection state here to give the full pciture of the livekit state!
|
||||||
fatalLivekitError$,
|
fatalLivekitError$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
map(
|
map(
|
||||||
([
|
([
|
||||||
|
localConnectionState,
|
||||||
publisher,
|
publisher,
|
||||||
localTransport,
|
localTransport,
|
||||||
tracks,
|
tracks,
|
||||||
@@ -411,13 +389,21 @@ export const createLocalMembership$ = ({
|
|||||||
const hasTracks = tracks.length > 0;
|
const hasTracks = tracks.length > 0;
|
||||||
if (!localTransport)
|
if (!localTransport)
|
||||||
return { state: RTCBackendState.WaitingForTransport };
|
return { state: RTCBackendState.WaitingForTransport };
|
||||||
if (!publisher)
|
if (!localConnectionState)
|
||||||
return { state: RTCBackendState.WaitingForConnection };
|
return { state: RTCBackendState.WaitingForConnection };
|
||||||
if (!shouldStartTracks) return { state: RTCBackendState.Initialized };
|
if (
|
||||||
|
localConnectionState.state !== LivekitConnectionState.Connected ||
|
||||||
|
!publisher
|
||||||
|
)
|
||||||
|
// pass through the localConnectionState while we do not yet have a publisher or the state
|
||||||
|
// of the connection is not yet connected
|
||||||
|
return { state: localConnectionState.state };
|
||||||
|
if (!shouldStartTracks)
|
||||||
|
return { state: LivekitConnectionState.Connected };
|
||||||
if (!hasTracks) return { state: RTCBackendState.CreatingTracks };
|
if (!hasTracks) return { state: RTCBackendState.CreatingTracks };
|
||||||
if (!shouldConnect) return { state: RTCBackendState.ReadyToPublish };
|
if (!shouldConnect) return { state: RTCBackendState.ReadyToPublish };
|
||||||
if (!publishing) return { state: RTCBackendState.WaitingToPublish };
|
if (!publishing) return { state: RTCBackendState.WaitingToPublish };
|
||||||
return { state: RTCBackendState.Connected };
|
return { state: RTCBackendState.ConnectedAndPublishing };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
distinctUntilChanged(deepCompare),
|
distinctUntilChanged(deepCompare),
|
||||||
@@ -431,58 +417,70 @@ export const createLocalMembership$ = ({
|
|||||||
else fatalMatrixError$.next(e);
|
else fatalMatrixError$.next(e);
|
||||||
};
|
};
|
||||||
const matrixState$: Behavior<LocalMemberMatrixState> = scope.behavior(
|
const matrixState$: Behavior<LocalMemberMatrixState> = scope.behavior(
|
||||||
combineLatest([
|
combineLatest([localTransport$, homeserverConnected.rtsSession$]).pipe(
|
||||||
localTransport$,
|
map(([localTransport, rtcSessionStatus]) => {
|
||||||
connectRequested$,
|
if (!localTransport)
|
||||||
homeserverConnected$,
|
return { state: MatrixAdditionalState.WaitingForTransport };
|
||||||
]).pipe(
|
return { state: rtcSessionStatus };
|
||||||
map(([localTransport, connectRequested, homeserverConnected]) => {
|
|
||||||
if (!localTransport) return { state: MatrixState.WaitingForTransport };
|
|
||||||
if (!connectRequested) return { state: MatrixState.Ready };
|
|
||||||
if (!homeserverConnected) return { state: MatrixState.Connecting };
|
|
||||||
return { state: MatrixState.Connected };
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep matrix rtc session in sync with localTransport$, connectRequested$ and muteStates.video.enabled$
|
// inform the widget about the connect and disconnect intent from the user.
|
||||||
|
scope
|
||||||
|
.behavior(connectRequested$.pipe(pairwise(), scope.bind()), [
|
||||||
|
undefined,
|
||||||
|
connectRequested$.value,
|
||||||
|
])
|
||||||
|
.subscribe(([prev, current]) => {
|
||||||
|
if (!widget) return;
|
||||||
|
if (!prev && current) {
|
||||||
|
try {
|
||||||
|
void widget.api.transport.send(ElementWidgetActions.JoinCall, {});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to send join action", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prev && !current) {
|
||||||
|
try {
|
||||||
|
void widget?.api.transport.send(ElementWidgetActions.HangupCall, {});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to send hangup action", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
combineLatest([muteStates.video.enabled$, homeserverConnected.combined$])
|
||||||
|
.pipe(scope.bind())
|
||||||
|
.subscribe(([videoEnabled, connected]) => {
|
||||||
|
if (!connected) return;
|
||||||
|
void matrixRTCSession.updateCallIntent(videoEnabled ? "video" : "audio");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep matrix rtc session in sync with localTransport$, connectRequested$
|
||||||
scope.reconcile(
|
scope.reconcile(
|
||||||
scope.behavior(combineLatest([localTransport$, connectRequested$])),
|
scope.behavior(combineLatest([localTransport$, connectRequested$])),
|
||||||
async ([transport, shouldConnect]) => {
|
async ([transport, shouldConnect]) => {
|
||||||
|
if (!transport) return;
|
||||||
|
// if shouldConnect=false we will do the disconnect as the cleanup from the previous reconcile iteration.
|
||||||
if (!shouldConnect) return;
|
if (!shouldConnect) return;
|
||||||
|
|
||||||
if (!transport) return;
|
|
||||||
try {
|
try {
|
||||||
await joinMatrixRTC(transport);
|
joinMatrixRTC(transport);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error entering RTC session", error);
|
logger.error("Error entering RTC session", error);
|
||||||
if (error instanceof Error)
|
if (error instanceof Error)
|
||||||
setMatrixError(new MembershipManagerError(error));
|
setMatrixError(new MembershipManagerError(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update our member event when our mute state changes.
|
return Promise.resolve(async (): Promise<void> => {
|
||||||
const callIntentScope = new ObservableScope();
|
|
||||||
// because this uses its own scope, we can start another reconciliation for the duration of one connection.
|
|
||||||
callIntentScope.reconcile(
|
|
||||||
muteStates.video.enabled$,
|
|
||||||
async (videoEnabled) =>
|
|
||||||
matrixRTCSession.updateCallIntent(videoEnabled ? "video" : "audio"),
|
|
||||||
);
|
|
||||||
|
|
||||||
return async (): Promise<void> => {
|
|
||||||
callIntentScope.end();
|
|
||||||
try {
|
try {
|
||||||
// Update matrixRTCSession to allow udpating the transport without leaving the session!
|
// TODO Update matrixRTCSession to allow udpating the transport without leaving the session!
|
||||||
await matrixRTCSession.leaveRoomSession();
|
await matrixRTCSession.leaveRoomSession(1000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("Error leaving RTC session", e);
|
logger.error("Error leaving RTC session", e);
|
||||||
}
|
}
|
||||||
try {
|
});
|
||||||
await widget?.api.transport.send(ElementWidgetActions.HangupCall, {});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("Failed to send hangup action", e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -497,7 +495,7 @@ export const createLocalMembership$ = ({
|
|||||||
// pause tracks during the initial joining sequence too until we're sure
|
// pause tracks during the initial joining sequence too until we're sure
|
||||||
// that our own media is displayed on screen.
|
// that our own media is displayed on screen.
|
||||||
// TODO refactor this based no livekitState$
|
// TODO refactor this based no livekitState$
|
||||||
combineLatest([participant$, homeserverConnected$])
|
combineLatest([participant$, homeserverConnected.combined$])
|
||||||
.pipe(scope.bind())
|
.pipe(scope.bind())
|
||||||
.subscribe(([participant, connected]) => {
|
.subscribe(([participant, connected]) => {
|
||||||
if (!participant) return;
|
if (!participant) return;
|
||||||
@@ -590,8 +588,15 @@ export const createLocalMembership$ = ({
|
|||||||
},
|
},
|
||||||
tracks$,
|
tracks$,
|
||||||
participant$,
|
participant$,
|
||||||
homeserverConnected$,
|
|
||||||
reconnecting$,
|
reconnecting$,
|
||||||
|
disconnected$: scope.behavior(
|
||||||
|
matrixState$.pipe(
|
||||||
|
map(
|
||||||
|
(sessionStatus) =>
|
||||||
|
sessionStatus.state === RTCSessionStatus.Disconnected,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
sharingScreen$,
|
sharingScreen$,
|
||||||
toggleScreenSharing,
|
toggleScreenSharing,
|
||||||
connection$: localConnection$,
|
connection$: localConnection$,
|
||||||
@@ -626,11 +631,11 @@ interface EnterRTCSessionOptions {
|
|||||||
* @throws If the widget could not send ElementWidgetActions.JoinCall action.
|
* @throws If the widget could not send ElementWidgetActions.JoinCall action.
|
||||||
*/
|
*/
|
||||||
// Exported for unit testing
|
// Exported for unit testing
|
||||||
export async function enterRTCSession(
|
export function enterRTCSession(
|
||||||
rtcSession: MatrixRTCSession,
|
rtcSession: MatrixRTCSession,
|
||||||
transport: LivekitTransport,
|
transport: LivekitTransport,
|
||||||
{ encryptMedia, matrixRTCMode }: EnterRTCSessionOptions,
|
{ encryptMedia, matrixRTCMode }: EnterRTCSessionOptions,
|
||||||
): Promise<void> {
|
): void {
|
||||||
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
|
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
|
||||||
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
|
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
|
||||||
|
|
||||||
@@ -669,7 +674,4 @@ export async function enterRTCSession(
|
|||||||
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (widget) {
|
|
||||||
await widget.api.transport.send(ElementWidgetActions.JoinCall, {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,7 @@ describe("Publisher", () => {
|
|||||||
scope = new ObservableScope();
|
scope = new ObservableScope();
|
||||||
connection = {
|
connection = {
|
||||||
state$: constant({
|
state$: constant({
|
||||||
state: "ConnectedToLkRoom",
|
state: LivekitConenctionState.Connected,
|
||||||
livekitConnectionState$: constant(LivekitConenctionState.Connected),
|
|
||||||
}),
|
}),
|
||||||
livekitRoom: mockLivekitRoom({
|
livekitRoom: mockLivekitRoom({
|
||||||
localParticipant: mockLocalParticipant({}),
|
localParticipant: mockLocalParticipant({}),
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export class Publisher {
|
|||||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||||
const sub = this.connection.state$.subscribe((s) => {
|
const sub = this.connection.state$.subscribe((s) => {
|
||||||
switch (s.state) {
|
switch (s.state) {
|
||||||
case "ConnectedToLkRoom":
|
case LivekitConnectionState.Connected:
|
||||||
resolve();
|
resolve();
|
||||||
break;
|
break;
|
||||||
case "FailedToStart":
|
case "FailedToStart":
|
||||||
|
|||||||
@@ -125,7 +125,10 @@ function setupRemoteConnection(): Connection {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
fakeLivekitRoom.connect.mockResolvedValue(undefined);
|
fakeLivekitRoom.connect.mockImplementation(async (): Promise<void> => {
|
||||||
|
fakeLivekitRoom.state = LivekitConnectionState.Connected;
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
return new Connection(opts, logger);
|
return new Connection(opts, logger);
|
||||||
}
|
}
|
||||||
@@ -309,7 +312,7 @@ describe("Start connection states", () => {
|
|||||||
|
|
||||||
capturedState = capturedStates.pop();
|
capturedState = capturedStates.pop();
|
||||||
|
|
||||||
if (capturedState && capturedState?.state === "FailedToStart") {
|
if (capturedState && capturedState.state === "FailedToStart") {
|
||||||
expect(capturedState.error.message).toContain(
|
expect(capturedState.error.message).toContain(
|
||||||
"Failed to connect to livekit",
|
"Failed to connect to livekit",
|
||||||
);
|
);
|
||||||
@@ -345,7 +348,7 @@ describe("Start connection states", () => {
|
|||||||
const connectingState = capturedStates.shift();
|
const connectingState = capturedStates.shift();
|
||||||
expect(connectingState?.state).toEqual("ConnectingToLkRoom");
|
expect(connectingState?.state).toEqual("ConnectingToLkRoom");
|
||||||
const connectedState = capturedStates.shift();
|
const connectedState = capturedStates.shift();
|
||||||
expect(connectedState?.state).toEqual("ConnectedToLkRoom");
|
expect(connectedState?.state).toEqual("connected");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shutting down the scope should stop the connection", async () => {
|
it("shutting down the scope should stop the connection", async () => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "@livekit/components-core";
|
} from "@livekit/components-core";
|
||||||
import {
|
import {
|
||||||
ConnectionError,
|
ConnectionError,
|
||||||
type ConnectionState as LivekitConenctionState,
|
type ConnectionState as LivekitConnectionState,
|
||||||
type Room as LivekitRoom,
|
type Room as LivekitRoom,
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
@@ -47,17 +47,17 @@ export interface ConnectionOpts {
|
|||||||
/** Optional factory to create the LiveKit room, mainly for testing purposes. */
|
/** Optional factory to create the LiveKit room, mainly for testing purposes. */
|
||||||
livekitRoomFactory: () => LivekitRoom;
|
livekitRoomFactory: () => LivekitRoom;
|
||||||
}
|
}
|
||||||
|
export enum ConnectionAdditionalState {
|
||||||
|
Initialized = "Initialized",
|
||||||
|
FetchingConfig = "FetchingConfig",
|
||||||
|
// FailedToStart = "FailedToStart",
|
||||||
|
Stopped = "Stopped",
|
||||||
|
ConnectingToLkRoom = "ConnectingToLkRoom",
|
||||||
|
}
|
||||||
export type ConnectionState =
|
export type ConnectionState =
|
||||||
| { state: "Initialized" }
|
| { state: ConnectionAdditionalState }
|
||||||
| { state: "FetchingConfig" }
|
| { state: LivekitConnectionState }
|
||||||
| { state: "ConnectingToLkRoom" }
|
| { state: "FailedToStart"; error: Error };
|
||||||
| {
|
|
||||||
state: "ConnectedToLkRoom";
|
|
||||||
livekitConnectionState$: Behavior<LivekitConenctionState>;
|
|
||||||
}
|
|
||||||
| { state: "FailedToStart"; error: Error }
|
|
||||||
| { state: "Stopped" };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A connection to a Matrix RTC LiveKit backend.
|
* A connection to a Matrix RTC LiveKit backend.
|
||||||
@@ -67,7 +67,7 @@ export type ConnectionState =
|
|||||||
export class Connection {
|
export class Connection {
|
||||||
// Private Behavior
|
// Private Behavior
|
||||||
private readonly _state$ = new BehaviorSubject<ConnectionState>({
|
private readonly _state$ = new BehaviorSubject<ConnectionState>({
|
||||||
state: "Initialized",
|
state: ConnectionAdditionalState.Initialized,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,14 +118,14 @@ export class Connection {
|
|||||||
this.stopped = false;
|
this.stopped = false;
|
||||||
try {
|
try {
|
||||||
this._state$.next({
|
this._state$.next({
|
||||||
state: "FetchingConfig",
|
state: ConnectionAdditionalState.FetchingConfig,
|
||||||
});
|
});
|
||||||
const { url, jwt } = await this.getSFUConfigWithOpenID();
|
const { url, jwt } = await this.getSFUConfigWithOpenID();
|
||||||
// If we were stopped while fetching the config, don't proceed to connect
|
// If we were stopped while fetching the config, don't proceed to connect
|
||||||
if (this.stopped) return;
|
if (this.stopped) return;
|
||||||
|
|
||||||
this._state$.next({
|
this._state$.next({
|
||||||
state: "ConnectingToLkRoom",
|
state: ConnectionAdditionalState.ConnectingToLkRoom,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await this.livekitRoom.connect(url, jwt);
|
await this.livekitRoom.connect(url, jwt);
|
||||||
@@ -154,12 +154,13 @@ export class Connection {
|
|||||||
// If we were stopped while connecting, don't proceed to update state.
|
// If we were stopped while connecting, don't proceed to update state.
|
||||||
if (this.stopped) return;
|
if (this.stopped) return;
|
||||||
|
|
||||||
this._state$.next({
|
connectionStateObserver(this.livekitRoom)
|
||||||
state: "ConnectedToLkRoom",
|
.pipe(this.scope.bind())
|
||||||
livekitConnectionState$: this.scope.behavior(
|
.subscribe((lkState) => {
|
||||||
connectionStateObserver(this.livekitRoom),
|
this._state$.next({
|
||||||
),
|
state: lkState,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
|
this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
|
||||||
this._state$.next({
|
this._state$.next({
|
||||||
@@ -191,7 +192,7 @@ export class Connection {
|
|||||||
if (this.stopped) return;
|
if (this.stopped) return;
|
||||||
await this.livekitRoom.disconnect();
|
await this.livekitRoom.disconnect();
|
||||||
this._state$.next({
|
this._state$.next({
|
||||||
state: "Stopped",
|
state: ConnectionAdditionalState.Stopped,
|
||||||
});
|
});
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user