mirror of
https://github.com/vector-im/element-call.git
synced 2026-02-02 04:05:56 +00:00
* Refactor media devices to live outside React as Observables This moves the media devices state out of React to further our transition to a MVVM architecture in which we can more easily model and store complex application state. I have created an AppViewModel to act as the overarching state holder for any future non-React state we end up creating, and the MediaDevices reside within this. We should move more application logic (including the CallViewModel itself) there in the future. * Address review feedback * Fixes from ios debugging session: (#3342) - dont use preferred vs selected concept in controlled media. Its not needed since we dont use the id for actual browser media devices (the id's are not even actual browser media devices) - add more logging - add more conditions to not accidently set a deviceId that is not a browser deviceId but one provided via controlled. --------- Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
639 lines
18 KiB
TypeScript
639 lines
18 KiB
TypeScript
/*
|
|
Copyright 2023, 2024 New Vector Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import {
|
|
type AudioSource,
|
|
type TrackReferenceOrPlaceholder,
|
|
type VideoSource,
|
|
observeParticipantEvents,
|
|
observeParticipantMedia,
|
|
roomEventSelector,
|
|
} from "@livekit/components-core";
|
|
import {
|
|
type LocalParticipant,
|
|
LocalTrack,
|
|
type Participant,
|
|
ParticipantEvent,
|
|
type RemoteParticipant,
|
|
Track,
|
|
TrackEvent,
|
|
facingModeFromLocalTrack,
|
|
type Room as LivekitRoom,
|
|
RoomEvent as LivekitRoomEvent,
|
|
RemoteTrack,
|
|
} from "livekit-client";
|
|
import { type RoomMember } from "matrix-js-sdk";
|
|
import {
|
|
BehaviorSubject,
|
|
type Observable,
|
|
Subject,
|
|
combineLatest,
|
|
distinctUntilKeyChanged,
|
|
filter,
|
|
fromEvent,
|
|
interval,
|
|
map,
|
|
merge,
|
|
of,
|
|
startWith,
|
|
switchMap,
|
|
throttleTime,
|
|
} from "rxjs";
|
|
|
|
import { ViewModel } from "./ViewModel";
|
|
import { alwaysShowSelf } from "../settings/settings";
|
|
import { showConnectionStats } from "../settings/settings";
|
|
import { accumulate } from "../utils/observable";
|
|
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
|
import { E2eeType } from "../e2ee/e2eeType";
|
|
import { type ReactionOption } from "../reactions";
|
|
|
|
export function observeTrackReference$(
|
|
participant$: Observable<Participant | undefined>,
|
|
source: Track.Source,
|
|
): Observable<TrackReferenceOrPlaceholder | undefined> {
|
|
return participant$.pipe(
|
|
switchMap((p) => {
|
|
if (p) {
|
|
return observeParticipantMedia(p).pipe(
|
|
map(() => ({
|
|
participant: p,
|
|
publication: p.getTrackPublication(source),
|
|
source,
|
|
})),
|
|
distinctUntilKeyChanged("publication"),
|
|
);
|
|
} else {
|
|
return of(undefined);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function observeRtpStreamStats$(
|
|
participant: Participant,
|
|
source: Track.Source,
|
|
type: "inbound-rtp" | "outbound-rtp",
|
|
): Observable<
|
|
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
|
> {
|
|
return combineLatest([
|
|
observeTrackReference$(of(participant), source),
|
|
interval(1000).pipe(startWith(0)),
|
|
]).pipe(
|
|
switchMap(async ([trackReference]) => {
|
|
const track = trackReference?.publication?.track;
|
|
if (
|
|
!track ||
|
|
!(track instanceof RemoteTrack || track instanceof LocalTrack)
|
|
) {
|
|
return undefined;
|
|
}
|
|
const report = await track.getRTCStatsReport();
|
|
if (!report) {
|
|
return undefined;
|
|
}
|
|
|
|
for (const v of report.values()) {
|
|
if (v.type === type) {
|
|
return v;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}),
|
|
startWith(undefined),
|
|
);
|
|
}
|
|
|
|
export function observeInboundRtpStreamStats$(
|
|
participant: Participant,
|
|
source: Track.Source,
|
|
): Observable<RTCInboundRtpStreamStats | undefined> {
|
|
return observeRtpStreamStats$(participant, source, "inbound-rtp").pipe(
|
|
map((x) => x as RTCInboundRtpStreamStats | undefined),
|
|
);
|
|
}
|
|
|
|
export function observeOutboundRtpStreamStats$(
|
|
participant: Participant,
|
|
source: Track.Source,
|
|
): Observable<RTCOutboundRtpStreamStats | undefined> {
|
|
return observeRtpStreamStats$(participant, source, "outbound-rtp").pipe(
|
|
map((x) => x as RTCOutboundRtpStreamStats | undefined),
|
|
);
|
|
}
|
|
|
|
function observeRemoteTrackReceivingOkay$(
|
|
participant: Participant,
|
|
source: Track.Source,
|
|
): Observable<boolean | undefined> {
|
|
let lastStats: {
|
|
framesDecoded: number | undefined;
|
|
framesDropped: number | undefined;
|
|
framesReceived: number | undefined;
|
|
} = {
|
|
framesDecoded: undefined,
|
|
framesDropped: undefined,
|
|
framesReceived: undefined,
|
|
};
|
|
|
|
return observeInboundRtpStreamStats$(participant, source).pipe(
|
|
map((stats) => {
|
|
if (!stats) return undefined;
|
|
const { framesDecoded, framesDropped, framesReceived } = stats;
|
|
return {
|
|
framesDecoded,
|
|
framesDropped,
|
|
framesReceived,
|
|
};
|
|
}),
|
|
filter((newStats) => !!newStats),
|
|
map((newStats): boolean | undefined => {
|
|
const oldStats = lastStats;
|
|
lastStats = newStats;
|
|
if (
|
|
typeof newStats.framesReceived === "number" &&
|
|
typeof oldStats.framesReceived === "number" &&
|
|
typeof newStats.framesDecoded === "number" &&
|
|
typeof oldStats.framesDecoded === "number"
|
|
) {
|
|
const framesReceivedDelta =
|
|
newStats.framesReceived - oldStats.framesReceived;
|
|
const framesDecodedDelta =
|
|
newStats.framesDecoded - oldStats.framesDecoded;
|
|
|
|
// if we received >0 frames and managed to decode >0 frames then we treat that as success
|
|
|
|
if (framesReceivedDelta > 0) {
|
|
return framesDecodedDelta > 0;
|
|
}
|
|
}
|
|
|
|
// no change
|
|
return undefined;
|
|
}),
|
|
filter((x) => typeof x === "boolean"),
|
|
startWith(undefined),
|
|
);
|
|
}
|
|
|
|
function encryptionErrorObservable$(
|
|
room: LivekitRoom,
|
|
participant: Participant,
|
|
encryptionSystem: EncryptionSystem,
|
|
criteria: string,
|
|
): Observable<boolean> {
|
|
return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe(
|
|
map((e) => {
|
|
const [err] = e;
|
|
if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
|
return (
|
|
// Ideally we would pull the participant identity from the field on the error.
|
|
// However, it gets lost in the serialization process between workers.
|
|
// So, instead we do a string match
|
|
(err?.message.includes(participant.identity) &&
|
|
err?.message.includes(criteria)) ??
|
|
false
|
|
);
|
|
} else if (encryptionSystem.kind === E2eeType.SHARED_KEY) {
|
|
return !!err?.message.includes(criteria);
|
|
}
|
|
|
|
return false;
|
|
}),
|
|
throttleTime(1000), // Throttle to avoid spamming the UI
|
|
startWith(false),
|
|
);
|
|
}
|
|
|
|
export enum EncryptionStatus {
|
|
Connecting,
|
|
Okay,
|
|
KeyMissing,
|
|
KeyInvalid,
|
|
PasswordInvalid,
|
|
}
|
|
|
|
abstract class BaseMediaViewModel extends ViewModel {
|
|
/**
|
|
* The LiveKit video track for this media.
|
|
*/
|
|
public readonly video$: Observable<TrackReferenceOrPlaceholder | undefined>;
|
|
/**
|
|
* Whether there should be a warning that this media is unencrypted.
|
|
*/
|
|
public readonly unencryptedWarning$: Observable<boolean>;
|
|
|
|
public readonly encryptionStatus$: Observable<EncryptionStatus>;
|
|
|
|
/**
|
|
* Whether this media corresponds to the local participant.
|
|
*/
|
|
public abstract readonly local: boolean;
|
|
|
|
public constructor(
|
|
/**
|
|
* An opaque identifier for this media.
|
|
*/
|
|
public readonly id: string,
|
|
/**
|
|
* The Matrix room member to which this media belongs.
|
|
*/
|
|
// TODO: Fully separate the data layer from the UI layer by keeping the
|
|
// member object internal
|
|
public readonly member: RoomMember | undefined,
|
|
// 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
|
|
>,
|
|
|
|
encryptionSystem: EncryptionSystem,
|
|
audioSource: AudioSource,
|
|
videoSource: VideoSource,
|
|
livekitRoom: LivekitRoom,
|
|
public readonly displayname$: Observable<string>,
|
|
) {
|
|
super();
|
|
const audio$ = observeTrackReference$(participant$, audioSource).pipe(
|
|
this.scope.state(),
|
|
);
|
|
this.video$ = observeTrackReference$(participant$, videoSource).pipe(
|
|
this.scope.state(),
|
|
);
|
|
this.unencryptedWarning$ = combineLatest(
|
|
[audio$, this.video$],
|
|
(a, v) =>
|
|
encryptionSystem.kind !== E2eeType.NONE &&
|
|
(a?.publication?.isEncrypted === false ||
|
|
v?.publication?.isEncrypted === false),
|
|
).pipe(this.scope.state());
|
|
|
|
this.encryptionStatus$ = this.participant$.pipe(
|
|
switchMap((participant): Observable<EncryptionStatus> => {
|
|
if (!participant) {
|
|
return of(EncryptionStatus.Connecting);
|
|
} else if (
|
|
participant.isLocal ||
|
|
encryptionSystem.kind === E2eeType.NONE
|
|
) {
|
|
return of(EncryptionStatus.Okay);
|
|
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
|
return combineLatest([
|
|
encryptionErrorObservable$(
|
|
livekitRoom,
|
|
participant,
|
|
encryptionSystem,
|
|
"MissingKey",
|
|
),
|
|
encryptionErrorObservable$(
|
|
livekitRoom,
|
|
participant,
|
|
encryptionSystem,
|
|
"InvalidKey",
|
|
),
|
|
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
|
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
|
]).pipe(
|
|
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
|
if (keyMissing) return EncryptionStatus.KeyMissing;
|
|
if (keyInvalid) return EncryptionStatus.KeyInvalid;
|
|
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
|
return undefined; // no change
|
|
}),
|
|
filter((x) => !!x),
|
|
startWith(EncryptionStatus.Connecting),
|
|
);
|
|
} else {
|
|
return combineLatest([
|
|
encryptionErrorObservable$(
|
|
livekitRoom,
|
|
participant,
|
|
encryptionSystem,
|
|
"InvalidKey",
|
|
),
|
|
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
|
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
|
]).pipe(
|
|
map(
|
|
([keyInvalid, audioOkay, videoOkay]):
|
|
| EncryptionStatus
|
|
| undefined => {
|
|
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
|
|
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
|
return undefined; // no change
|
|
},
|
|
),
|
|
filter((x) => !!x),
|
|
startWith(EncryptionStatus.Connecting),
|
|
);
|
|
}
|
|
}),
|
|
this.scope.state(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Some participant's media.
|
|
*/
|
|
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
|
|
export type UserMediaViewModel =
|
|
| LocalUserMediaViewModel
|
|
| RemoteUserMediaViewModel;
|
|
|
|
/**
|
|
* Some participant's user media.
|
|
*/
|
|
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|
/**
|
|
* Whether the participant is speaking.
|
|
*/
|
|
public readonly speaking$ = this.participant$.pipe(
|
|
switchMap((p) =>
|
|
p
|
|
? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe(
|
|
map((p) => p.isSpeaking),
|
|
)
|
|
: of(false),
|
|
),
|
|
this.scope.state(),
|
|
);
|
|
|
|
/**
|
|
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
|
*/
|
|
public readonly audioEnabled$: Observable<boolean>;
|
|
/**
|
|
* Whether this participant is sending video.
|
|
*/
|
|
public readonly videoEnabled$: Observable<boolean>;
|
|
|
|
private readonly _cropVideo$ = new BehaviorSubject(true);
|
|
/**
|
|
* Whether the tile video should be contained inside the tile or be cropped to fit.
|
|
*/
|
|
public readonly cropVideo$: Observable<boolean> = this._cropVideo$;
|
|
|
|
public constructor(
|
|
id: string,
|
|
member: RoomMember | undefined,
|
|
participant$: Observable<LocalParticipant | RemoteParticipant | undefined>,
|
|
encryptionSystem: EncryptionSystem,
|
|
livekitRoom: LivekitRoom,
|
|
displayname$: Observable<string>,
|
|
public readonly handRaised$: Observable<Date | null>,
|
|
public readonly reaction$: Observable<ReactionOption | null>,
|
|
) {
|
|
super(
|
|
id,
|
|
member,
|
|
participant$,
|
|
encryptionSystem,
|
|
Track.Source.Microphone,
|
|
Track.Source.Camera,
|
|
livekitRoom,
|
|
displayname$,
|
|
);
|
|
|
|
const media$ = participant$.pipe(
|
|
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
|
this.scope.state(),
|
|
);
|
|
this.audioEnabled$ = media$.pipe(
|
|
map((m) => m?.microphoneTrack?.isMuted === false),
|
|
);
|
|
this.videoEnabled$ = media$.pipe(
|
|
map((m) => m?.cameraTrack?.isMuted === false),
|
|
);
|
|
}
|
|
|
|
public toggleFitContain(): void {
|
|
this._cropVideo$.next(!this._cropVideo$.value);
|
|
}
|
|
|
|
public get local(): boolean {
|
|
return this instanceof LocalUserMediaViewModel;
|
|
}
|
|
|
|
public abstract get audioStreamStats$(): Observable<
|
|
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
|
>;
|
|
public abstract get videoStreamStats$(): Observable<
|
|
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
|
>;
|
|
}
|
|
|
|
/**
|
|
* The local participant's user media.
|
|
*/
|
|
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|
/**
|
|
* Whether the video should be mirrored.
|
|
*/
|
|
public readonly mirror$ = this.video$.pipe(
|
|
switchMap((v) => {
|
|
const track = v?.publication?.track;
|
|
if (!(track instanceof LocalTrack)) return of(false);
|
|
// Watch for track restarts, because they indicate a camera switch
|
|
return fromEvent(track, TrackEvent.Restarted).pipe(
|
|
startWith(null),
|
|
// Mirror only front-facing cameras (those that face the user)
|
|
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
|
|
);
|
|
}),
|
|
this.scope.state(),
|
|
);
|
|
|
|
/**
|
|
* Whether to show this tile in a highly visible location near the start of
|
|
* the grid.
|
|
*/
|
|
public readonly alwaysShow$ = alwaysShowSelf.value$;
|
|
public readonly setAlwaysShow = alwaysShowSelf.setValue;
|
|
|
|
public constructor(
|
|
id: string,
|
|
member: RoomMember | undefined,
|
|
participant$: Observable<LocalParticipant | undefined>,
|
|
encryptionSystem: EncryptionSystem,
|
|
livekitRoom: LivekitRoom,
|
|
displayname$: Observable<string>,
|
|
handRaised$: Observable<Date | null>,
|
|
reaction$: Observable<ReactionOption | null>,
|
|
) {
|
|
super(
|
|
id,
|
|
member,
|
|
participant$,
|
|
encryptionSystem,
|
|
livekitRoom,
|
|
displayname$,
|
|
handRaised$,
|
|
reaction$,
|
|
);
|
|
}
|
|
|
|
public audioStreamStats$ = combineLatest([
|
|
this.participant$,
|
|
showConnectionStats.value$,
|
|
]).pipe(
|
|
switchMap(([p, showConnectionStats]) => {
|
|
if (!p || !showConnectionStats) return of(undefined);
|
|
return observeOutboundRtpStreamStats$(p, Track.Source.Microphone);
|
|
}),
|
|
);
|
|
|
|
public videoStreamStats$ = combineLatest([
|
|
this.participant$,
|
|
showConnectionStats.value$,
|
|
]).pipe(
|
|
switchMap(([p, showConnectionStats]) => {
|
|
if (!p || !showConnectionStats) return of(undefined);
|
|
return observeOutboundRtpStreamStats$(p, Track.Source.Camera);
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A remote participant's user media.
|
|
*/
|
|
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|
private readonly locallyMutedToggle$ = new Subject<void>();
|
|
private readonly localVolumeAdjustment$ = new Subject<number>();
|
|
private readonly localVolumeCommit$ = new Subject<void>();
|
|
|
|
/**
|
|
* The volume to which this participant's audio is set, as a scalar
|
|
* multiplier.
|
|
*/
|
|
public readonly localVolume$: Observable<number> = merge(
|
|
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
|
|
this.localVolumeAdjustment$,
|
|
this.localVolumeCommit$.pipe(map(() => "commit" as const)),
|
|
).pipe(
|
|
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
|
|
switch (event) {
|
|
case "toggle mute":
|
|
return {
|
|
...state,
|
|
volume: state.volume === 0 ? state.committedVolume : 0,
|
|
};
|
|
case "commit":
|
|
// Dragging the slider to zero should have the same effect as
|
|
// muting: keep the original committed volume, as if it were never
|
|
// dragged
|
|
return {
|
|
...state,
|
|
committedVolume:
|
|
state.volume === 0 ? state.committedVolume : state.volume,
|
|
};
|
|
default:
|
|
// Volume adjustment
|
|
return { ...state, volume: event };
|
|
}
|
|
}),
|
|
map(({ volume }) => volume),
|
|
this.scope.state(),
|
|
);
|
|
|
|
/**
|
|
* Whether this participant's audio is disabled.
|
|
*/
|
|
public readonly locallyMuted$: Observable<boolean> = this.localVolume$.pipe(
|
|
map((volume) => volume === 0),
|
|
this.scope.state(),
|
|
);
|
|
|
|
public constructor(
|
|
id: string,
|
|
member: RoomMember | undefined,
|
|
participant$: Observable<RemoteParticipant | undefined>,
|
|
encryptionSystem: EncryptionSystem,
|
|
livekitRoom: LivekitRoom,
|
|
displayname$: Observable<string>,
|
|
handRaised$: Observable<Date | null>,
|
|
reaction$: Observable<ReactionOption | null>,
|
|
) {
|
|
super(
|
|
id,
|
|
member,
|
|
participant$,
|
|
encryptionSystem,
|
|
livekitRoom,
|
|
displayname$,
|
|
handRaised$,
|
|
reaction$,
|
|
);
|
|
|
|
// Sync the local volume with LiveKit
|
|
combineLatest([
|
|
participant$,
|
|
this.localVolume$.pipe(this.scope.bind()),
|
|
]).subscribe(([p, volume]) => p && p.setVolume(volume));
|
|
}
|
|
|
|
public toggleLocallyMuted(): void {
|
|
this.locallyMutedToggle$.next();
|
|
}
|
|
|
|
public setLocalVolume(value: number): void {
|
|
this.localVolumeAdjustment$.next(value);
|
|
}
|
|
|
|
public commitLocalVolume(): void {
|
|
this.localVolumeCommit$.next();
|
|
}
|
|
|
|
public audioStreamStats$ = combineLatest([
|
|
this.participant$,
|
|
showConnectionStats.value$,
|
|
]).pipe(
|
|
switchMap(([p, showConnectionStats]) => {
|
|
if (!p || !showConnectionStats) return of(undefined);
|
|
return observeInboundRtpStreamStats$(p, Track.Source.Microphone);
|
|
}),
|
|
);
|
|
|
|
public videoStreamStats$ = combineLatest([
|
|
this.participant$,
|
|
showConnectionStats.value$,
|
|
]).pipe(
|
|
switchMap(([p, showConnectionStats]) => {
|
|
if (!p || !showConnectionStats) return of(undefined);
|
|
return observeInboundRtpStreamStats$(p, Track.Source.Camera);
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Some participant's screen share media.
|
|
*/
|
|
export class ScreenShareViewModel extends BaseMediaViewModel {
|
|
public constructor(
|
|
id: string,
|
|
member: RoomMember | undefined,
|
|
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
|
encryptionSystem: EncryptionSystem,
|
|
livekitRoom: LivekitRoom,
|
|
displayname$: Observable<string>,
|
|
public readonly local: boolean,
|
|
) {
|
|
super(
|
|
id,
|
|
member,
|
|
participant$,
|
|
encryptionSystem,
|
|
Track.Source.ScreenShareAudio,
|
|
Track.Source.ScreenShare,
|
|
livekitRoom,
|
|
displayname$,
|
|
);
|
|
}
|
|
}
|