Working (no local feed)

This commit is contained in:
Timo K
2025-11-07 19:07:45 +01:00
parent cf5c35bccd
commit b8635b52d8
9 changed files with 121 additions and 96 deletions

View File

@@ -373,6 +373,7 @@ export class CallViewModel {
* List of MediaItems that we want to have tiles for.
*/
// TODO KEEP THIS!! and adapt it to what our membershipManger returns
// TODO this also needs the local participant to be added.
private readonly mediaItems$ = this.scope.behavior<MediaItem[]>(
generateKeyed$<
[typeof this.matrixLivekitMembers$.value, number],

View File

@@ -98,7 +98,7 @@ interface Props {
connectionManager: IConnectionManager;
matrixRTCSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
localTransport$: Behavior<LivekitTransport | undefined>;
localTransport$: Behavior<LivekitTransport | null>;
e2eeLivekitOptions: E2EEOptions | undefined;
trackProcessorState$: Behavior<ProcessorState>;
widget: WidgetHelpers | null;
@@ -162,7 +162,11 @@ export const createLocalMembership$ = ({
// This should be used in a combineLatest with publisher$ to connect.
// to make it possible to call startTracks before the preferredTransport$ has resolved.
const shouldStartTracks$ = new BehaviorSubject(false);
const trackStartRequested$ = new BehaviorSubject(false);
// This should be used in a combineLatest with publisher$ to connect.
// to make it possible to call startTracks before the preferredTransport$ has resolved.
const connectRequested$ = new BehaviorSubject(false);
// This should be used in a combineLatest with publisher$ to connect.
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
@@ -230,26 +234,24 @@ export const createLocalMembership$ = ({
),
);
const publisher$ = scope.behavior(
connection$.pipe(
map((connection) =>
connection
? new Publisher(
scope,
connection,
mediaDevices,
muteStates,
e2eeLivekitOptions,
trackProcessorState$,
)
: null,
),
),
);
const publisher$ = new BehaviorSubject<Publisher | null>(null);
connection$.subscribe((connection) => {
if (connection !== null && publisher$.value === null) {
publisher$.next(
new Publisher(
scope,
connection,
mediaDevices,
muteStates,
e2eeLivekitOptions,
trackProcessorState$,
),
);
}
});
combineLatest(
[publisher$, shouldStartTracks$],
(publisher, shouldStartTracks) => {
combineLatest([publisher$, trackStartRequested$]).subscribe(
([publisher, shouldStartTracks]) => {
if (publisher && shouldStartTracks) {
publisher
.createAndSetupTracks()
@@ -286,41 +288,51 @@ export const createLocalMembership$ = ({
);
const startTracks = (): Behavior<LocalTrack[]> => {
shouldStartTracks$.next(true);
trackStartRequested$.next(true);
return tracks$;
};
const requestConnect = (): LocalMemberConnectionState => {
if (state.livekit$.value.state === LivekitState.Uninitialized) {
startTracks();
state.livekit$.next({ state: LivekitState.Connecting });
combineLatest([publisher$, tracks$], (publisher, tracks) => {
publisher
?.startPublishing()
.then(() => {
state.livekit$.next({ state: LivekitState.Connected });
})
.catch((error) => {
state.livekit$.next({ state: LivekitState.Error, error });
});
combineLatest([publisher$, tracks$]).subscribe(([publisher, tracks]) => {
if (
tracks.length === 0 ||
// change this to !== Publishing
state.livekit$.value.state !== LivekitState.Uninitialized
) {
return;
}
state.livekit$.next({ state: LivekitState.Connecting });
publisher
?.startPublishing()
.then(() => {
state.livekit$.next({ state: LivekitState.Connected });
})
.catch((error) => {
state.livekit$.next({ state: LivekitState.Error, error });
});
}
if (state.matrix$.value.state === MatrixState.Disconnected) {
});
combineLatest([localTransport$, connectRequested$]).subscribe(
([transport, connectRequested]) => {
if (
transport === null ||
!connectRequested ||
state.matrix$.value.state !== MatrixState.Disconnected
) {
logger.info("Waiting for transport to enter rtc session");
return;
}
state.matrix$.next({ state: MatrixState.Connecting });
localTransport$.pipe(
tap((transport) => {
if (transport !== undefined) {
enterRTCSession(matrixRTCSession, transport, options.value).catch(
(error) => {
logger.error(error);
},
);
} else {
logger.info("Waiting for transport to enter rtc session");
}
}),
enterRTCSession(matrixRTCSession, transport, options.value).catch(
(error) => {
logger.error(error);
},
);
}
},
);
const requestConnect = (): LocalMemberConnectionState => {
trackStartRequested$.next(true);
connectRequested$.next(true);
return state;
};
@@ -453,8 +465,7 @@ export const createLocalMembership$ = ({
.pipe(
// I dont see why we need this. isnt the check later on superseeding it?
takeWhile(
(c) =>
c !== undefined && c.state$.value.state !== "FailedToStart",
(c) => c !== null && c.state$.value.state !== "FailedToStart",
),
switchMap((c) =>
c?.state$.value.state === "ConnectedToLkRoom" ? of(c) : NEVER,

View File

@@ -13,20 +13,21 @@ import {
isLivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk";
import { combineLatest, distinctUntilChanged, first, from } from "rxjs";
import { combineLatest, distinctUntilChanged, first, from, map } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { type Behavior } from "../../Behavior.ts";
import {
type Epoch,
Epoch,
mapEpoch,
type ObservableScope,
} from "../../ObservableScope.ts";
import { Config } from "../../../config/Config.ts";
import { MatrixRTCTransportMissingError } from "../../../utils/errors.ts";
import { getSFUConfigWithOpenID } from "../../../livekit/openIDSFU.ts";
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
/*
* - get well known
@@ -60,15 +61,16 @@ export const createLocalTransport$ = ({
client,
roomId,
useOldestMember$,
}: Props): Behavior<LivekitTransport | undefined> => {
}: Props): Behavior<LivekitTransport | null> => {
/**
* The transport over which we should be actively publishing our media.
* undefined when not joined.
*/
const oldestMemberTransport$ = scope.behavior(
memberships$.pipe(
mapEpoch(
(memberships) => memberships[0]?.getTransport(memberships[0]) ?? null,
map(
(memberships) =>
memberships.value[0]?.getTransport(memberships.value[0]) ?? null,
),
first((t) => t != null && isLivekitTransport(t)),
),
@@ -88,13 +90,18 @@ export const createLocalTransport$ = ({
* The transport we should advertise in our MatrixRTC membership.
*/
const advertisedTransport$ = scope.behavior(
combineLatest(
[useOldestMember$, oldestMemberTransport$, preferredTransport$],
(useOldestMember, oldestMemberTransport, preferredTransport) =>
combineLatest([
useOldestMember$,
oldestMemberTransport$,
preferredTransport$,
]).pipe(
map(([useOldestMember, oldestMemberTransport, preferredTransport]) =>
useOldestMember
? (oldestMemberTransport ?? preferredTransport)
: preferredTransport,
).pipe<LivekitTransport>(distinctUntilChanged(deepCompare)),
),
distinctUntilChanged(areLivekitTransportsEqual),
),
);
return advertisedTransport$;
};

View File

@@ -99,31 +99,36 @@ export class Publisher {
// instead? This optimization would only be safe for a publish connection,
// because we don't want to leak the user's intent to perhaps join a call to
// remote servers before they actually commit to it.
const { promise, resolve, reject } = Promise.withResolvers<void>();
const sub = this.connection.state$.subscribe((s) => {
if (s.state !== "FailedToStart") {
reject(new Error("Disconnected from LiveKit server"));
} else {
resolve();
}
});
try {
await promise;
} catch (e) {
throw e;
} finally {
sub.unsubscribe();
}
// const { promise, resolve, reject } = Promise.withResolvers<void>();
// const sub = this.connection.state$.subscribe((s) => {
// if (s.state === "FailedToStart") {
// reject(new Error("Disconnected from LiveKit server"));
// } else if (s.state === "ConnectedToLkRoom") {
// resolve();
// }
// });
// try {
// await promise;
// } catch (e) {
// throw e;
// } finally {
// sub.unsubscribe();
// }
// TODO-MULTI-SFU: Prepublish a microphone track
const audio = this.muteStates.audio.enabled$.value;
const video = this.muteStates.video.enabled$.value;
// createTracks throws if called with audio=false and video=false
if (audio || video) {
// TODO this can still throw errors? It will also prompt for permissions if not already granted
this.tracks = await lkRoom.localParticipant.createTracks({
audio,
video,
});
this.tracks =
(await lkRoom.localParticipant
.createTracks({
audio,
video,
})
.catch((error) => {
this.logger?.error("Failed to create tracks", error);
})) ?? [];
}
return this.tracks;
}
@@ -153,7 +158,9 @@ export class Publisher {
for (const track of this.tracks) {
// TODO: handle errors? Needs the signaling connection to be up, but it has some retries internally
// with a timeout.
await lkRoom.localParticipant.publishTrack(track);
await lkRoom.localParticipant.publishTrack(track).catch((error) => {
this.logger?.error("Failed to publish track", error);
});
// TODO: check if the connection is still active? and break the loop if not?
}

View File

@@ -145,8 +145,8 @@ export function createMatrixLivekitMembers$({
// TODO add this to the JS-SDK
export function areLivekitTransportsEqual(
t1?: LivekitTransport,
t2?: LivekitTransport,
t1: LivekitTransport | null,
t2: LivekitTransport | null,
): boolean {
if (t1 && t2) return t1.livekit_service_url === t2.livekit_service_url;
// In case we have different lk rooms in the same SFU (depends on the livekit authorization service)

View File

@@ -220,7 +220,7 @@ abstract class BaseMediaViewModel {
/**
* The LiveKit video track for this media.
*/
public readonly video$: Behavior<TrackReferenceOrPlaceholder | undefined>;
public readonly video$: Behavior<TrackReferenceOrPlaceholder | null>;
/**
* Whether there should be a warning that this media is unencrypted.
*/
@@ -235,12 +235,10 @@ abstract class BaseMediaViewModel {
private observeTrackReference$(
source: Track.Source,
): Behavior<TrackReferenceOrPlaceholder | undefined> {
): Behavior<TrackReferenceOrPlaceholder | null> {
return this.scope.behavior(
this.participant$.pipe(
switchMap((p) =>
p === undefined ? of(undefined) : observeTrackReference$(p, source),
),
switchMap((p) => (!p ? of(null) : observeTrackReference$(p, source))),
),
);
}
@@ -260,7 +258,7 @@ abstract class BaseMediaViewModel {
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
// livekit.
protected readonly participant$: Observable<
LocalParticipant | RemoteParticipant | undefined
LocalParticipant | RemoteParticipant | null
>,
encryptionSystem: EncryptionSystem,
@@ -405,7 +403,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
scope: ObservableScope,
id: string,
member: RoomMember,
participant$: Observable<LocalParticipant | RemoteParticipant | undefined>,
participant$: Observable<LocalParticipant | RemoteParticipant | null>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
focusUrl: string,
@@ -541,7 +539,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
scope: ObservableScope,
id: string,
member: RoomMember,
participant$: Behavior<LocalParticipant | undefined>,
participant$: Behavior<LocalParticipant | null>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
focusURL: string,
@@ -651,7 +649,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
scope: ObservableScope,
id: string,
member: RoomMember,
participant$: Observable<RemoteParticipant | undefined>,
participant$: Observable<RemoteParticipant | null>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
focusUrl: string,

View File

@@ -82,7 +82,7 @@ export class UserMedia {
this.scope,
this.id,
this.member,
this.participant$ as Behavior<LocalParticipant>,
this.participant$ as Behavior<LocalParticipant | null>,
this.encryptionSystem,
this.livekitRoom,
this.focusURL,
@@ -95,7 +95,7 @@ export class UserMedia {
this.scope,
this.id,
this.member,
this.participant$ as Observable<RemoteParticipant | undefined>,
this.participant$ as Behavior<RemoteParticipant | null>,
this.encryptionSystem,
this.livekitRoom,
this.focusURL,

View File

@@ -144,7 +144,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
const tile = (
<MediaView
ref={ref}
video={video}
video={video ?? undefined}
member={vm.member}
unencryptedWarning={unencryptedWarning}
encryptionStatus={encryptionStatus}

View File

@@ -50,5 +50,6 @@
"plugins": [{ "name": "typescript-eslint-language-service" }]
},
"include": ["./src/**/*.ts", "./src/**/*.tsx", "./playwright/**/*.ts"]
"include": ["./src/**/*.ts", "./src/**/*.tsx", "./playwright/**/*.ts"],
"exclude": ["**.test.ts"]
}