make tiles based on rtc member

This commit is contained in:
Timo
2024-10-30 14:29:16 +01:00
parent 1897210a60
commit 5f8809ce03
3 changed files with 234 additions and 140 deletions

View File

@@ -118,7 +118,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
useEffect(() => { useEffect(() => {
if (livekitRoom !== undefined) { if (livekitRoom !== undefined) {
const vm = new CallViewModel( const vm = new CallViewModel(
props.rtcSession.room, props.rtcSession,
livekitRoom, livekitRoom,
props.e2eeSystem, props.e2eeSystem,
connStateObservable, connStateObservable,
@@ -127,7 +127,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
return (): void => vm.destroy(); return (): void => vm.destroy();
} }
}, [ }, [
props.rtcSession.room, props.rtcSession,
livekitRoom, livekitRoom,
props.e2eeSystem, props.e2eeSystem,
connStateObservable, connStateObservable,

View File

@@ -18,12 +18,9 @@ import {
RemoteParticipant, RemoteParticipant,
Track, Track,
} from "livekit-client"; } from "livekit-client";
import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix";
import { import {
Room as MatrixRoom, BehaviorSubject,
RoomMember,
RoomStateEvent,
} from "matrix-js-sdk/src/matrix";
import {
EMPTY, EMPTY,
Observable, Observable,
Subject, Subject,
@@ -51,6 +48,10 @@ import {
withLatestFrom, withLatestFrom,
} from "rxjs"; } from "rxjs";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import {
MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc";
import { ViewModel } from "./ViewModel"; import { ViewModel } from "./ViewModel";
import { import {
@@ -164,28 +165,37 @@ enum SortingBin {
class UserMedia { class UserMedia {
private readonly scope = new ObservableScope(); private readonly scope = new ObservableScope();
public readonly vm: UserMediaViewModel; public readonly vm: UserMediaViewModel;
public participant: BehaviorSubject<
LocalParticipant | RemoteParticipant | undefined
>;
public readonly speaker: Observable<boolean>; public readonly speaker: Observable<boolean>;
public readonly presenter: Observable<boolean>; public readonly presenter: Observable<boolean>;
public constructor( public constructor(
public readonly id: string, public readonly id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant | undefined,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
) { ) {
this.vm = participant.isLocal this.participant = new BehaviorSubject(participant);
? new LocalUserMediaViewModel(
id, if (participant && participant.isLocal) {
member, this.vm = new LocalUserMediaViewModel(
participant as LocalParticipant, this.id,
encryptionSystem, member,
) this.participant.asObservable() as Observable<LocalParticipant>,
: new RemoteUserMediaViewModel( encryptionSystem,
id, );
member, } else {
participant as RemoteParticipant, this.vm = new RemoteUserMediaViewModel(
encryptionSystem, id,
); member,
this.participant.asObservable() as Observable<
RemoteParticipant | undefined
>,
encryptionSystem,
);
}
this.speaker = this.vm.speaking.pipe( this.speaker = this.vm.speaking.pipe(
// Require 1 s of continuous speaking to become a speaker, and 60 s of // Require 1 s of continuous speaking to become a speaker, and 60 s of
@@ -195,7 +205,7 @@ class UserMedia {
timer(s ? 1000 : 60000), timer(s ? 1000 : 60000),
// If the speaking flag resets to its original value during this time, // If the speaking flag resets to its original value during this time,
// end the silencing window to stick with that original value // end the silencing window to stick with that original value
this.vm.speaking.pipe(filter((s1) => s1 !== s)), this.vm!.speaking.pipe(filter((s1) => s1 !== s)),
), ),
), ),
startWith(false), startWith(false),
@@ -205,13 +215,21 @@ class UserMedia {
this.scope.state(), this.scope.state(),
); );
this.presenter = observeParticipantEvents( this.presenter = this.participant.pipe(
participant, switchMap(
ParticipantEvent.TrackPublished, (p) =>
ParticipantEvent.TrackUnpublished, (p &&
ParticipantEvent.LocalTrackPublished, observeParticipantEvents(
ParticipantEvent.LocalTrackUnpublished, p,
).pipe(map((p) => p.isScreenShareEnabled)); ParticipantEvent.TrackPublished,
ParticipantEvent.TrackUnpublished,
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.LocalTrackUnpublished,
).pipe(map((p) => p.isScreenShareEnabled))) ??
of(false),
),
this.scope.state(),
);
} }
public destroy(): void { public destroy(): void {
@@ -222,6 +240,7 @@ class UserMedia {
class ScreenShare { class ScreenShare {
public readonly vm: ScreenShareViewModel; public readonly vm: ScreenShareViewModel;
private participant: BehaviorSubject<LocalParticipant | RemoteParticipant>;
public constructor( public constructor(
id: string, id: string,
@@ -229,10 +248,12 @@ class ScreenShare {
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
) { ) {
this.participant = new BehaviorSubject(participant);
this.vm = new ScreenShareViewModel( this.vm = new ScreenShareViewModel(
id, id,
member, member,
participant, this.participant.asObservable(),
encryptionSystem, encryptionSystem,
); );
} }
@@ -244,7 +265,7 @@ class ScreenShare {
type MediaItem = UserMedia | ScreenShare; type MediaItem = UserMedia | ScreenShare;
function findMatrixMember( function findMatrixRoomMember(
room: MatrixRoom, room: MatrixRoom,
id: string, id: string,
): RoomMember | undefined { ): RoomMember | undefined {
@@ -344,8 +365,15 @@ export class CallViewModel extends ViewModel {
this.remoteParticipants, this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant), observeParticipantMedia(this.livekitRoom.localParticipant),
duplicateTiles.value, duplicateTiles.value,
// Also react to changes in the list of members // Also react to changes in the MatrixRTC session list:
fromEvent(this.matrixRoom, RoomStateEvent.Update).pipe(startWith(null)), fromEvent(
this.matrixRTCSession,
MatrixRTCSessionEvent.MembershipsChanged,
).pipe(startWith(null)),
// fromEvent(
// this.matrixRTCSession,
// MatrixRTCSessionEvent.EncryptionKeyChanged,
// ).pipe(startWith(null)),
]).pipe( ]).pipe(
scan( scan(
( (
@@ -354,42 +382,64 @@ export class CallViewModel extends ViewModel {
) => { ) => {
const newItems = new Map( const newItems = new Map(
function* (this: CallViewModel): Iterable<[string, MediaItem]> { function* (this: CallViewModel): Iterable<[string, MediaItem]> {
for (const p of [localParticipant, ...remoteParticipants]) { for (const rtcMember of this.matrixRTCSession.memberships) {
const id = p === localParticipant ? "local" : p.identity; const room = this.matrixRTCSession.room;
const member = findMatrixMember(this.matrixRoom, id); // WARN! This is not exactly the sender but the user defined in the state key.
if (member === undefined) // This will be available once we change to the new "member as object" format in the MatrixRTC object.
logger.warn( let mediaId = rtcMember.sender + ":" + rtcMember.deviceId;
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`, let participant = undefined;
if (
rtcMember.sender === room.client.getUserId()! &&
rtcMember.deviceId === room.client.getDeviceId()
) {
mediaId = "local";
participant = localParticipant;
} else {
participant = remoteParticipants.find(
(p) => p.identity === mediaId,
); );
}
const member = findMatrixRoomMember(room, mediaId);
// Create as many tiles for this participant as called for by
// the duplicateTiles option
for (let i = 0; i < 1 + duplicateTiles; i++) { for (let i = 0; i < 1 + duplicateTiles; i++) {
const userMediaId = `${id}:${i}`; const indexedMediaId = `${mediaId}:${i}`;
const prevMedia = prevItems.get(indexedMediaId);
if (prevMedia && prevMedia instanceof UserMedia) {
if (
prevMedia.participant.value === undefined &&
participant !== undefined
) {
// Update the BahviourSubject in the UserMedia.
prevMedia.participant.next(participant);
}
}
yield [ yield [
userMediaId, indexedMediaId,
prevItems.get(userMediaId) ?? // We create UserMedia with or without a participant.
// This will be the initial value of a BehaviourSubject.
// Once a participant appears we will update the BehaviourSubject. (see above)
prevMedia ??
new UserMedia( new UserMedia(
userMediaId, mediaId,
member, member,
p, participant,
this.encryptionSystem,
),
];
}
if (participant && participant.isScreenShareEnabled) {
const screenShareId = `${mediaId}:screen-share`;
yield [
screenShareId,
prevItems.get(screenShareId) ??
new ScreenShare(
screenShareId,
member,
participant,
this.encryptionSystem, this.encryptionSystem,
), ),
]; ];
if (p.isScreenShareEnabled) {
const screenShareId = `${userMediaId}:screen-share`;
yield [
screenShareId,
prevItems.get(screenShareId) ??
new ScreenShare(
screenShareId,
member,
p,
this.encryptionSystem,
),
];
}
} }
} }
}.bind(this)(), }.bind(this)(),
@@ -432,42 +482,43 @@ export class CallViewModel extends ViewModel {
distinctUntilChanged(), distinctUntilChanged(),
); );
private readonly spotlightSpeaker: Observable<UserMediaViewModel> = private readonly spotlightSpeaker: Observable<
this.userMedia.pipe( UserMediaViewModel | undefined
switchMap((mediaItems) => > = this.userMedia.pipe(
mediaItems.length === 0 switchMap((mediaItems) =>
? of([]) mediaItems.length === 0
: combineLatest( ? of([])
mediaItems.map((m) => : combineLatest(
m.vm.speaking.pipe(map((s) => [m, s] as const)), mediaItems.map((m) =>
), m.vm.speaking.pipe(map((s) => [m, s] as const)),
), ),
), ),
scan<(readonly [UserMedia, boolean])[], UserMedia, null>( ),
(prev, mediaItems) => { scan<(readonly [UserMedia, boolean])[], UserMedia | undefined, null>(
// Only remote users that are still in the call should be sticky (prev, mediaItems) => {
const [stickyMedia, stickySpeaking] = // Only remote users that are still in the call should be sticky
(!prev?.vm.local && mediaItems.find(([m]) => m === prev)) || []; const [stickyMedia, stickySpeaking] =
// Decide who to spotlight: (!prev?.vm.local && mediaItems.find(([m]) => m === prev)) || [];
// If the previous speaker is still speaking, stick with them rather // Decide who to spotlight:
// than switching eagerly to someone else // If the previous speaker is still speaking, stick with them rather
return stickySpeaking // than switching eagerly to someone else
? stickyMedia! return stickySpeaking
: // Otherwise, select any remote user who is speaking ? stickyMedia!
(mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ?? : // Otherwise, select any remote user who is speaking
// Otherwise, stick with the person who was last speaking (mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ??
stickyMedia ?? // Otherwise, stick with the person who was last speaking
// Otherwise, spotlight an arbitrary remote user stickyMedia ??
mediaItems.find(([m]) => !m.vm.local)?.[0] ?? // Otherwise, spotlight an arbitrary remote user
// Otherwise, spotlight the local user mediaItems.find(([m]) => !m.vm.local)?.[0] ??
mediaItems.find(([m]) => m.vm.local)![0]); // Otherwise, spotlight the local user
}, mediaItems.find(([m]) => m.vm.local)?.[0]);
null, },
), null,
map((speaker) => speaker.vm), ),
this.scope.state(), map((speaker) => speaker?.vm),
throttleTime(1600, undefined, { leading: true, trailing: true }), this.scope.state(),
); throttleTime(1600, undefined, { leading: true, trailing: true }),
);
private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe( private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe(
switchMap((mediaItems) => { switchMap((mediaItems) => {
@@ -510,20 +561,29 @@ export class CallViewModel extends ViewModel {
> = this.screenShares.pipe( > = this.screenShares.pipe(
map((screenShares) => map((screenShares) =>
screenShares.length > 0 screenShares.length > 0
? ([of(screenShares.map((m) => m.vm)), this.spotlightSpeaker] as const) ? ([
of(screenShares.map((m) => m.vm)),
this.spotlightSpeaker.pipe(
map((speaker) => (speaker && speaker) ?? null),
),
] as const)
: ([ : ([
this.spotlightSpeaker.pipe(map((speaker) => [speaker!])), this.spotlightSpeaker.pipe(
map((speaker) => (speaker && [speaker]) ?? []),
),
this.spotlightSpeaker.pipe( this.spotlightSpeaker.pipe(
switchMap((speaker) => switchMap((speaker) =>
speaker.local speaker
? of(null) ? speaker.local
: this.localUserMedia.pipe( ? of(null)
switchMap((vm) => : this.localUserMedia.pipe(
vm.alwaysShow.pipe( switchMap((vm) =>
map((alwaysShow) => (alwaysShow ? vm : null)), vm.alwaysShow.pipe(
map((alwaysShow) => (alwaysShow ? vm : null)),
),
), ),
), )
), : of(null),
), ),
), ),
] as const), ] as const),
@@ -843,7 +903,7 @@ export class CallViewModel extends ViewModel {
public constructor( public constructor(
// A call is permanently tied to a single Matrix room and LiveKit room // A call is permanently tied to a single Matrix room and LiveKit room
private readonly matrixRoom: MatrixRoom, private readonly matrixRTCSession: MatrixRTCSession,
private readonly livekitRoom: LivekitRoom, private readonly livekitRoom: LivekitRoom,
private readonly encryptionSystem: EncryptionSystem, private readonly encryptionSystem: EncryptionSystem,
private readonly connectionState: Observable<ECConnectionState>, private readonly connectionState: Observable<ECConnectionState>,

View File

@@ -68,33 +68,50 @@ export function useDisplayName(vm: MediaViewModel): string {
} }
export function observeTrackReference( export function observeTrackReference(
participant: Participant, participant: Observable<Participant | undefined>,
source: Track.Source, source: Track.Source,
): Observable<TrackReferenceOrPlaceholder> { ): Observable<TrackReferenceOrPlaceholder | undefined> {
return observeParticipantMedia(participant).pipe( const obs = participant.pipe(
map(() => ({ switchMap((p) => {
participant, if (p) {
publication: participant.getTrackPublication(source), return observeParticipantMedia(p).pipe(
source, map(() => ({
})), participant: p,
distinctUntilKeyChanged("publication"), publication: p.getTrackPublication(source),
source,
})),
distinctUntilKeyChanged("publication"),
);
} else {
return of(undefined);
}
}),
); );
return obs;
} }
abstract class BaseMediaViewModel extends ViewModel { abstract class BaseMediaViewModel extends ViewModel {
/** /**
* Whether the media belongs to the local user. * Whether the media belongs to the local user.
*/ */
public readonly local = this.participant.isLocal; public readonly local = this.participant.pipe(
// We can assume, that the user is not local if the participant is undefined
// We assume the local LK participant will always be available.
map((p) => p?.isLocal ?? false),
);
/** /**
* The LiveKit video track for this media. * The LiveKit video track for this media.
*/ */
public readonly video: Observable<TrackReferenceOrPlaceholder>; public readonly video: Observable<TrackReferenceOrPlaceholder | undefined>;
/** /**
* Whether there should be a warning that this media is unencrypted. * Whether there should be a warning that this media is unencrypted.
*/ */
public readonly unencryptedWarning: Observable<boolean>; public readonly unencryptedWarning: Observable<boolean>;
public readonly isRTCParticipantAvailable = this.participant.pipe(
map((p) => !!p),
);
public constructor( public constructor(
/** /**
* An opaque identifier for this media. * An opaque identifier for this media.
@@ -106,7 +123,12 @@ abstract class BaseMediaViewModel extends ViewModel {
// TODO: Fully separate the data layer from the UI layer by keeping the // TODO: Fully separate the data layer from the UI layer by keeping the
// member object internal // member object internal
public readonly member: RoomMember | undefined, public readonly member: RoomMember | undefined,
protected readonly participant: LocalParticipant | RemoteParticipant, // We dont necassarly have a participant if a user connects via MatrixRTC but not (not yet) through
// livekit.
protected readonly participant: Observable<
LocalParticipant | RemoteParticipant | undefined
>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
audioSource: AudioSource, audioSource: AudioSource,
videoSource: VideoSource, videoSource: VideoSource,
@@ -122,8 +144,8 @@ abstract class BaseMediaViewModel extends ViewModel {
[audio, this.video], [audio, this.video],
(a, v) => (a, v) =>
encryptionSystem.kind !== E2eeType.NONE && encryptionSystem.kind !== E2eeType.NONE &&
(a.publication?.isEncrypted === false || (a?.publication?.isEncrypted === false ||
v.publication?.isEncrypted === false), v?.publication?.isEncrypted === false),
).pipe(this.scope.state()); ).pipe(this.scope.state());
} }
} }
@@ -143,12 +165,20 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/** /**
* Whether the participant is speaking. * Whether the participant is speaking.
*/ */
public readonly speaking = observeParticipantEvents( public readonly speaking = this.participant.pipe(
this.participant, switchMap((p) => {
ParticipantEvent.IsSpeakingChanged, if (p) {
).pipe( return observeParticipantEvents(
map((p) => p.isSpeaking), p,
this.scope.state(), ParticipantEvent.IsSpeakingChanged,
).pipe(
map((p) => p.isSpeaking),
this.scope.state(),
);
} else {
return of(false);
}
}),
); );
/** /**
@@ -169,7 +199,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: Observable<LocalParticipant | RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
) { ) {
super( super(
@@ -181,12 +211,17 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
Track.Source.Camera, Track.Source.Camera,
); );
const media = observeParticipantMedia(participant).pipe(this.scope.state()); // const media = observeParticipantMedia(participant).pipe(this.scope.state());
const media = participant.pipe(
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
this.scope.state(),
);
this.audioEnabled = media.pipe( this.audioEnabled = media.pipe(
map((m) => m.microphoneTrack?.isMuted === false), map((m) => m?.microphoneTrack?.isMuted === false),
); );
this.videoEnabled = media.pipe( this.videoEnabled = media.pipe(
map((m) => m.cameraTrack?.isMuted === false), map((m) => m?.cameraTrack?.isMuted === false),
); );
} }
@@ -204,7 +239,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
*/ */
public readonly mirror = this.video.pipe( public readonly mirror = this.video.pipe(
switchMap((v) => { switchMap((v) => {
const track = v.publication?.track; const track = v?.publication?.track;
if (!(track instanceof LocalTrack)) return of(false); if (!(track instanceof LocalTrack)) return of(false);
// Watch for track restarts, because they indicate a camera switch // Watch for track restarts, because they indicate a camera switch
return fromEvent(track, TrackEvent.Restarted).pipe( return fromEvent(track, TrackEvent.Restarted).pipe(
@@ -226,7 +261,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant, participant: Observable<LocalParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
) { ) {
super(id, member, participant, encryptionSystem); super(id, member, participant, encryptionSystem);
@@ -286,17 +321,16 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: RemoteParticipant, participant: Observable<RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
) { ) {
super(id, member, participant, encryptionSystem); super(id, member, participant, encryptionSystem);
// Sync the local volume with LiveKit // Sync the local volume with LiveKit
this.localVolume combineLatest([
.pipe(this.scope.bind()) participant,
.subscribe((volume) => this.localVolume.pipe(this.scope.bind()),
(this.participant as RemoteParticipant).setVolume(volume), ]).subscribe(([p, volume]) => p && p.setVolume(volume));
);
} }
public toggleLocallyMuted(): void { public toggleLocallyMuted(): void {
@@ -319,7 +353,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: Observable<LocalParticipant | RemoteParticipant>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
) { ) {
super( super(