Split MediaViewModel into multiple files
This commit is contained in:
@@ -50,11 +50,6 @@ import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/En
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
||||||
|
|
||||||
import {
|
|
||||||
type MediaViewModel,
|
|
||||||
type ScreenShareViewModel,
|
|
||||||
type UserMediaViewModel,
|
|
||||||
} from "../MediaViewModel";
|
|
||||||
import {
|
import {
|
||||||
createToggle$,
|
createToggle$,
|
||||||
filterBehavior,
|
filterBehavior,
|
||||||
@@ -142,9 +137,12 @@ import { type Connection } from "./remoteMembers/Connection.ts";
|
|||||||
import { createLayoutModeSwitch } from "./LayoutSwitch.ts";
|
import { createLayoutModeSwitch } from "./LayoutSwitch.ts";
|
||||||
import {
|
import {
|
||||||
createWrappedUserMedia,
|
createWrappedUserMedia,
|
||||||
type WrappedUserMediaViewModel,
|
|
||||||
type MediaItem,
|
type MediaItem,
|
||||||
} from "../MediaItem.ts";
|
type WrappedUserMediaViewModel,
|
||||||
|
} from "../media/MediaItem.ts";
|
||||||
|
import { type ScreenShareViewModel } from "../media/ScreenShareViewModel.ts";
|
||||||
|
import { type UserMediaViewModel } from "../media/UserMediaViewModel.ts";
|
||||||
|
import { type MediaViewModel } from "../media/MediaViewModel.ts";
|
||||||
|
|
||||||
const logger = rootLogger.getChild("[CallViewModel]");
|
const logger = rootLogger.getChild("[CallViewModel]");
|
||||||
//TODO
|
//TODO
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
trackProcessorSync,
|
trackProcessorSync,
|
||||||
} from "../../../livekit/TrackProcessorContext.tsx";
|
} from "../../../livekit/TrackProcessorContext.tsx";
|
||||||
import { getUrlParams } from "../../../UrlParams.ts";
|
import { getUrlParams } from "../../../UrlParams.ts";
|
||||||
import { observeTrackReference$ } from "../../MediaViewModel.ts";
|
import { observeTrackReference$ } from "../../observeTrackReference";
|
||||||
import { type Connection } from "../remoteMembers/Connection.ts";
|
import { type Connection } from "../remoteMembers/Connection.ts";
|
||||||
import { ObservableScope } from "../../ObservableScope.ts";
|
import { ObservableScope } from "../../ObservableScope.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -1,713 +0,0 @@
|
|||||||
/*
|
|
||||||
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 VideoSource,
|
|
||||||
type TrackReference,
|
|
||||||
observeParticipantEvents,
|
|
||||||
observeParticipantMedia,
|
|
||||||
roomEventSelector,
|
|
||||||
} from "@livekit/components-core";
|
|
||||||
import {
|
|
||||||
type LocalParticipant,
|
|
||||||
LocalTrack,
|
|
||||||
LocalVideoTrack,
|
|
||||||
type Participant,
|
|
||||||
ParticipantEvent,
|
|
||||||
type RemoteParticipant,
|
|
||||||
Track,
|
|
||||||
TrackEvent,
|
|
||||||
facingModeFromLocalTrack,
|
|
||||||
type Room as LivekitRoom,
|
|
||||||
RoomEvent as LivekitRoomEvent,
|
|
||||||
RemoteTrack,
|
|
||||||
} from "livekit-client";
|
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
|
||||||
import {
|
|
||||||
type Observable,
|
|
||||||
Subject,
|
|
||||||
combineLatest,
|
|
||||||
filter,
|
|
||||||
fromEvent,
|
|
||||||
interval,
|
|
||||||
map,
|
|
||||||
merge,
|
|
||||||
of,
|
|
||||||
startWith,
|
|
||||||
switchMap,
|
|
||||||
throttleTime,
|
|
||||||
distinctUntilChanged,
|
|
||||||
} from "rxjs";
|
|
||||||
|
|
||||||
import { alwaysShowSelf } from "../settings/settings";
|
|
||||||
import { showConnectionStats } from "../settings/settings";
|
|
||||||
import { createToggle$ } from "../utils/observable";
|
|
||||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
|
||||||
import { type ReactionOption } from "../reactions";
|
|
||||||
import { platform } from "../Platform";
|
|
||||||
import { type MediaDevices } from "./MediaDevices";
|
|
||||||
import { type Behavior } from "./Behavior";
|
|
||||||
import { type ObservableScope } from "./ObservableScope";
|
|
||||||
import { createVolumeControls, type VolumeControls } from "./VolumeControls";
|
|
||||||
|
|
||||||
export function observeTrackReference$(
|
|
||||||
participant: Participant,
|
|
||||||
source: Track.Source,
|
|
||||||
): Observable<TrackReference | undefined> {
|
|
||||||
return observeParticipantMedia(participant).pipe(
|
|
||||||
map(() => participant.getTrackPublication(source)),
|
|
||||||
distinctUntilChanged(),
|
|
||||||
map((publication) => publication && { participant, publication, source }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function observeRtpStreamStats$(
|
|
||||||
participant: Participant,
|
|
||||||
source: Track.Source,
|
|
||||||
type: "inbound-rtp" | "outbound-rtp",
|
|
||||||
): Observable<
|
|
||||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
|
||||||
> {
|
|
||||||
return combineLatest([
|
|
||||||
observeTrackReference$(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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function observeInboundRtpStreamStats$(
|
|
||||||
participant: Participant,
|
|
||||||
source: Track.Source,
|
|
||||||
): Observable<RTCInboundRtpStreamStats | undefined> {
|
|
||||||
return observeRtpStreamStats$(participant, source, "inbound-rtp").pipe(
|
|
||||||
map((x) => x as RTCInboundRtpStreamStats | 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$: Behavior<LivekitRoom | undefined>,
|
|
||||||
participant: Participant,
|
|
||||||
encryptionSystem: EncryptionSystem,
|
|
||||||
criteria: string,
|
|
||||||
): Observable<boolean> {
|
|
||||||
return room$.pipe(
|
|
||||||
switchMap((room) => {
|
|
||||||
if (room === undefined) return of(false);
|
|
||||||
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;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
distinctUntilChanged(),
|
|
||||||
throttleTime(1000), // Throttle to avoid spamming the UI
|
|
||||||
startWith(false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum EncryptionStatus {
|
|
||||||
Connecting,
|
|
||||||
Okay,
|
|
||||||
KeyMissing,
|
|
||||||
KeyInvalid,
|
|
||||||
PasswordInvalid,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BaseMediaViewModel {
|
|
||||||
/**
|
|
||||||
* An opaque identifier for this media.
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/**
|
|
||||||
* The Matrix user to which this media belongs.
|
|
||||||
*/
|
|
||||||
userId: string;
|
|
||||||
displayName$: Behavior<string>;
|
|
||||||
mxcAvatarUrl$: Behavior<string | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type BaseMediaInputs = BaseMediaViewModel;
|
|
||||||
|
|
||||||
// This function exists to strip out superfluous data from the input object
|
|
||||||
function createBaseMedia({
|
|
||||||
id,
|
|
||||||
userId,
|
|
||||||
displayName$,
|
|
||||||
mxcAvatarUrl$,
|
|
||||||
}: BaseMediaInputs): BaseMediaViewModel {
|
|
||||||
return { id, userId, displayName$, mxcAvatarUrl$ };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MemberMediaViewModel extends BaseMediaViewModel {
|
|
||||||
/**
|
|
||||||
* The LiveKit video track for this media.
|
|
||||||
*/
|
|
||||||
video$: Behavior<TrackReference | undefined>;
|
|
||||||
/**
|
|
||||||
* The URL of the LiveKit focus on which this member should be publishing.
|
|
||||||
* Exposed for debugging.
|
|
||||||
*/
|
|
||||||
focusUrl$: Behavior<string | undefined>;
|
|
||||||
/**
|
|
||||||
* Whether there should be a warning that this media is unencrypted.
|
|
||||||
*/
|
|
||||||
unencryptedWarning$: Behavior<boolean>;
|
|
||||||
encryptionStatus$: Behavior<EncryptionStatus>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MemberMediaInputs extends BaseMediaViewModel {
|
|
||||||
participant$: Behavior<LocalParticipant | RemoteParticipant | null>;
|
|
||||||
livekitRoom$: Behavior<LivekitRoom | undefined>;
|
|
||||||
audioSource: AudioSource;
|
|
||||||
videoSource: VideoSource;
|
|
||||||
focusUrl$: Behavior<string | undefined>;
|
|
||||||
encryptionSystem: EncryptionSystem;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMemberMedia(
|
|
||||||
scope: ObservableScope,
|
|
||||||
{
|
|
||||||
participant$,
|
|
||||||
livekitRoom$,
|
|
||||||
audioSource,
|
|
||||||
videoSource,
|
|
||||||
focusUrl$,
|
|
||||||
encryptionSystem,
|
|
||||||
...inputs
|
|
||||||
}: MemberMediaInputs,
|
|
||||||
): MemberMediaViewModel {
|
|
||||||
const trackBehavior$ = (
|
|
||||||
source: Track.Source,
|
|
||||||
): Behavior<TrackReference | undefined> =>
|
|
||||||
scope.behavior(
|
|
||||||
participant$.pipe(
|
|
||||||
switchMap((p) =>
|
|
||||||
!p ? of(undefined) : observeTrackReference$(p, source),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const audio$ = trackBehavior$(audioSource);
|
|
||||||
const video$ = trackBehavior$(videoSource);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...createBaseMedia(inputs),
|
|
||||||
video$,
|
|
||||||
focusUrl$,
|
|
||||||
unencryptedWarning$: scope.behavior(
|
|
||||||
combineLatest(
|
|
||||||
[audio$, video$],
|
|
||||||
(a, v) =>
|
|
||||||
encryptionSystem.kind !== E2eeType.NONE &&
|
|
||||||
(a?.publication.isEncrypted === false ||
|
|
||||||
v?.publication.isEncrypted === false),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
encryptionStatus$: scope.behavior(
|
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BaseUserMediaViewModel extends MemberMediaViewModel {
|
|
||||||
type: "user";
|
|
||||||
speaking$: Behavior<boolean>;
|
|
||||||
audioEnabled$: Behavior<boolean>;
|
|
||||||
videoEnabled$: Behavior<boolean>;
|
|
||||||
cropVideo$: Behavior<boolean>;
|
|
||||||
toggleCropVideo: () => void;
|
|
||||||
/**
|
|
||||||
* The expected identity of the LiveKit participant. Exposed for debugging.
|
|
||||||
*/
|
|
||||||
rtcBackendIdentity: string;
|
|
||||||
handRaised$: Behavior<Date | null>;
|
|
||||||
reaction$: Behavior<ReactionOption | null>;
|
|
||||||
audioStreamStats$: Observable<
|
|
||||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
|
||||||
>;
|
|
||||||
videoStreamStats$: Observable<
|
|
||||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BaseUserMediaInputs extends Omit<
|
|
||||||
MemberMediaInputs,
|
|
||||||
"audioSource" | "videoSource"
|
|
||||||
> {
|
|
||||||
rtcBackendIdentity: string;
|
|
||||||
handRaised$: Behavior<Date | null>;
|
|
||||||
reaction$: Behavior<ReactionOption | null>;
|
|
||||||
statsType: "inbound-rtp" | "outbound-rtp";
|
|
||||||
}
|
|
||||||
|
|
||||||
function createBaseUserMedia(
|
|
||||||
scope: ObservableScope,
|
|
||||||
{
|
|
||||||
rtcBackendIdentity,
|
|
||||||
handRaised$,
|
|
||||||
reaction$,
|
|
||||||
statsType,
|
|
||||||
...inputs
|
|
||||||
}: BaseUserMediaInputs,
|
|
||||||
): BaseUserMediaViewModel {
|
|
||||||
const { participant$ } = inputs;
|
|
||||||
const media$ = scope.behavior(
|
|
||||||
participant$.pipe(
|
|
||||||
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const toggleCropVideo$ = new Subject<void>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
...createMemberMedia(scope, {
|
|
||||||
...inputs,
|
|
||||||
audioSource: Track.Source.Microphone,
|
|
||||||
videoSource: Track.Source.Camera,
|
|
||||||
}),
|
|
||||||
type: "user",
|
|
||||||
speaking$: scope.behavior(
|
|
||||||
participant$.pipe(
|
|
||||||
switchMap((p) =>
|
|
||||||
p
|
|
||||||
? observeParticipantEvents(
|
|
||||||
p,
|
|
||||||
ParticipantEvent.IsSpeakingChanged,
|
|
||||||
).pipe(map((p) => p.isSpeaking))
|
|
||||||
: of(false),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
audioEnabled$: scope.behavior(
|
|
||||||
media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
|
|
||||||
),
|
|
||||||
videoEnabled$: scope.behavior(
|
|
||||||
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
|
||||||
),
|
|
||||||
cropVideo$: createToggle$(scope, true, toggleCropVideo$),
|
|
||||||
toggleCropVideo: () => toggleCropVideo$.next(),
|
|
||||||
rtcBackendIdentity,
|
|
||||||
handRaised$,
|
|
||||||
reaction$,
|
|
||||||
audioStreamStats$: combineLatest([
|
|
||||||
participant$,
|
|
||||||
showConnectionStats.value$,
|
|
||||||
]).pipe(
|
|
||||||
switchMap(([p, showConnectionStats]) => {
|
|
||||||
//
|
|
||||||
if (!p || !showConnectionStats) return of(undefined);
|
|
||||||
return observeRtpStreamStats$(p, Track.Source.Microphone, statsType);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
videoStreamStats$: combineLatest([
|
|
||||||
participant$,
|
|
||||||
showConnectionStats.value$,
|
|
||||||
]).pipe(
|
|
||||||
switchMap(([p, showConnectionStats]) => {
|
|
||||||
if (!p || !showConnectionStats) return of(undefined);
|
|
||||||
return observeRtpStreamStats$(p, Track.Source.Camera, statsType);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|
||||||
local: true;
|
|
||||||
/**
|
|
||||||
* Whether the video should be mirrored.
|
|
||||||
*/
|
|
||||||
mirror$: Behavior<boolean>;
|
|
||||||
/**
|
|
||||||
* Whether to show this tile in a highly visible location near the start of
|
|
||||||
* the grid.
|
|
||||||
*/
|
|
||||||
alwaysShow$: Behavior<boolean>;
|
|
||||||
setAlwaysShow: (value: boolean) => void;
|
|
||||||
switchCamera$: Behavior<(() => void) | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalUserMediaInputs extends Omit<
|
|
||||||
BaseUserMediaInputs,
|
|
||||||
"statsType"
|
|
||||||
> {
|
|
||||||
participant$: Behavior<LocalParticipant | null>;
|
|
||||||
mediaDevices: MediaDevices;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createLocalUserMedia(
|
|
||||||
scope: ObservableScope,
|
|
||||||
{ mediaDevices, ...inputs }: LocalUserMediaInputs,
|
|
||||||
): LocalUserMediaViewModel {
|
|
||||||
const baseUserMedia = createBaseUserMedia(scope, {
|
|
||||||
...inputs,
|
|
||||||
statsType: "outbound-rtp",
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The local video track as an observable that emits whenever the track
|
|
||||||
* changes, the camera is switched, or the track is muted.
|
|
||||||
*/
|
|
||||||
const videoTrack$: Observable<LocalVideoTrack | null> =
|
|
||||||
baseUserMedia.video$.pipe(
|
|
||||||
switchMap((v) => {
|
|
||||||
const track = v?.publication.track;
|
|
||||||
if (!(track instanceof LocalVideoTrack)) return of(null);
|
|
||||||
return merge(
|
|
||||||
// Watch for track restarts because they indicate a camera switch.
|
|
||||||
// This event is also emitted when unmuting the track object.
|
|
||||||
fromEvent(track, TrackEvent.Restarted).pipe(
|
|
||||||
startWith(null),
|
|
||||||
map(() => track),
|
|
||||||
),
|
|
||||||
// When the track object is muted, reset it to null.
|
|
||||||
fromEvent(track, TrackEvent.Muted).pipe(map(() => null)),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...baseUserMedia,
|
|
||||||
local: true,
|
|
||||||
mirror$: scope.behavior(
|
|
||||||
videoTrack$.pipe(
|
|
||||||
// Mirror only front-facing cameras (those that face the user)
|
|
||||||
map(
|
|
||||||
(track) =>
|
|
||||||
track !== null &&
|
|
||||||
facingModeFromLocalTrack(track).facingMode === "user",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
alwaysShow$: alwaysShowSelf.value$,
|
|
||||||
setAlwaysShow: alwaysShowSelf.setValue,
|
|
||||||
switchCamera$: scope.behavior(
|
|
||||||
platform === "desktop"
|
|
||||||
? of(null)
|
|
||||||
: videoTrack$.pipe(
|
|
||||||
map((track) => {
|
|
||||||
if (track === null) return null;
|
|
||||||
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
|
||||||
// If the camera isn't front or back-facing, don't provide a switch
|
|
||||||
// camera shortcut at all
|
|
||||||
if (facingMode !== "user" && facingMode !== "environment")
|
|
||||||
return null;
|
|
||||||
// Restart the track with a camera facing the opposite direction
|
|
||||||
return (): void =>
|
|
||||||
void track
|
|
||||||
.restartTrack({
|
|
||||||
facingMode: facingMode === "user" ? "environment" : "user",
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// Inform the MediaDevices which camera was chosen
|
|
||||||
const deviceId =
|
|
||||||
track.mediaStreamTrack.getSettings().deviceId;
|
|
||||||
if (deviceId !== undefined)
|
|
||||||
mediaDevices.videoInput.select(deviceId);
|
|
||||||
})
|
|
||||||
.catch((e) =>
|
|
||||||
logger.error("Failed to switch camera", facingMode, e),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RemoteUserMediaViewModel
|
|
||||||
extends BaseUserMediaViewModel, VolumeControls {
|
|
||||||
local: false;
|
|
||||||
/**
|
|
||||||
* Whether we are waiting for this user's LiveKit participant to exist. This
|
|
||||||
* could be because either we or the remote party are still connecting.
|
|
||||||
*/
|
|
||||||
waitingForMedia$: Behavior<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RemoteUserMediaInputs extends Omit<
|
|
||||||
BaseUserMediaInputs,
|
|
||||||
"statsType"
|
|
||||||
> {
|
|
||||||
participant$: Behavior<RemoteParticipant | null>;
|
|
||||||
pretendToBeDisconnected$: Behavior<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRemoteUserMedia(
|
|
||||||
scope: ObservableScope,
|
|
||||||
{ pretendToBeDisconnected$, ...inputs }: RemoteUserMediaInputs,
|
|
||||||
): RemoteUserMediaViewModel {
|
|
||||||
const baseUserMedia = createBaseUserMedia(scope, {
|
|
||||||
...inputs,
|
|
||||||
statsType: "inbound-rtp",
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...baseUserMedia,
|
|
||||||
...createVolumeControls(scope, {
|
|
||||||
pretendToBeDisconnected$,
|
|
||||||
sink$: scope.behavior(
|
|
||||||
inputs.participant$.pipe(map((p) => (volume) => p?.setVolume(volume))),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
local: false,
|
|
||||||
speaking$: scope.behavior(
|
|
||||||
pretendToBeDisconnected$.pipe(
|
|
||||||
switchMap((disconnected) =>
|
|
||||||
disconnected ? of(false) : baseUserMedia.speaking$,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
videoEnabled$: scope.behavior(
|
|
||||||
pretendToBeDisconnected$.pipe(
|
|
||||||
switchMap((disconnected) =>
|
|
||||||
disconnected ? of(false) : baseUserMedia.videoEnabled$,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
waitingForMedia$: scope.behavior(
|
|
||||||
combineLatest(
|
|
||||||
[inputs.livekitRoom$, inputs.participant$],
|
|
||||||
(livekitRoom, participant) =>
|
|
||||||
// If livekitRoom is undefined, the user is not attempting to publish on
|
|
||||||
// any transport and so we shouldn't expect a participant. (They might
|
|
||||||
// be a subscribe-only bot for example.)
|
|
||||||
livekitRoom !== undefined && participant === null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BaseScreenShareViewModel extends MemberMediaViewModel {
|
|
||||||
type: "screen share";
|
|
||||||
}
|
|
||||||
|
|
||||||
type BaseScreenShareInputs = Omit<
|
|
||||||
MemberMediaInputs,
|
|
||||||
"audioSource" | "videoSource"
|
|
||||||
>;
|
|
||||||
|
|
||||||
function createBaseScreenShare(
|
|
||||||
scope: ObservableScope,
|
|
||||||
inputs: BaseScreenShareInputs,
|
|
||||||
): BaseScreenShareViewModel {
|
|
||||||
return {
|
|
||||||
...createMemberMedia(scope, {
|
|
||||||
...inputs,
|
|
||||||
audioSource: Track.Source.ScreenShareAudio,
|
|
||||||
videoSource: Track.Source.ScreenShare,
|
|
||||||
}),
|
|
||||||
type: "screen share",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalScreenShareViewModel extends BaseScreenShareViewModel {
|
|
||||||
local: true;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LocalScreenShareInputs extends BaseScreenShareInputs {
|
|
||||||
participant$: Behavior<LocalParticipant | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createLocalScreenShare(
|
|
||||||
scope: ObservableScope,
|
|
||||||
inputs: LocalScreenShareInputs,
|
|
||||||
): LocalScreenShareViewModel {
|
|
||||||
return { ...createBaseScreenShare(scope, inputs), local: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RemoteScreenShareViewModel extends BaseScreenShareViewModel {
|
|
||||||
local: false;
|
|
||||||
/**
|
|
||||||
* Whether this screen share's video should be displayed.
|
|
||||||
*/
|
|
||||||
videoEnabled$: Behavior<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RemoteScreenShareInputs extends BaseScreenShareInputs {
|
|
||||||
participant$: Behavior<RemoteParticipant | null>;
|
|
||||||
pretendToBeDisconnected$: Behavior<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRemoteScreenShare(
|
|
||||||
scope: ObservableScope,
|
|
||||||
{ pretendToBeDisconnected$, ...inputs }: RemoteScreenShareInputs,
|
|
||||||
): RemoteScreenShareViewModel {
|
|
||||||
return {
|
|
||||||
...createBaseScreenShare(scope, inputs),
|
|
||||||
local: false,
|
|
||||||
videoEnabled$: scope.behavior(
|
|
||||||
pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Some participant's media.
|
|
||||||
*/
|
|
||||||
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
|
|
||||||
/**
|
|
||||||
* Some participant's user media (i.e. their microphone and camera feed).
|
|
||||||
*/
|
|
||||||
export type UserMediaViewModel =
|
|
||||||
| LocalUserMediaViewModel
|
|
||||||
| RemoteUserMediaViewModel;
|
|
||||||
/**
|
|
||||||
* Some participant's screen share media.
|
|
||||||
*/
|
|
||||||
export type ScreenShareViewModel =
|
|
||||||
| LocalScreenShareViewModel
|
|
||||||
| RemoteScreenShareViewModel;
|
|
||||||
@@ -8,10 +8,11 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject } from "rxjs";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
|
|
||||||
import { GridTileViewModel, SpotlightTileViewModel } from "./TileViewModel";
|
import { GridTileViewModel, SpotlightTileViewModel } from "./TileViewModel";
|
||||||
import { fillGaps } from "../utils/iter";
|
import { fillGaps } from "../utils/iter";
|
||||||
import { debugTileLayout } from "../settings/settings";
|
import { debugTileLayout } from "../settings/settings";
|
||||||
|
import { type MediaViewModel } from "./media/MediaViewModel";
|
||||||
|
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
|
||||||
|
|
||||||
function debugEntries(entries: GridTileData[]): string[] {
|
function debugEntries(entries: GridTileData[]): string[] {
|
||||||
return entries.map((e) => e.media.displayName$.value);
|
return entries.map((e) => e.media.displayName$.value);
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
|
|
||||||
import { type Behavior } from "./Behavior";
|
import { type Behavior } from "./Behavior";
|
||||||
|
import { type MediaViewModel } from "./media/MediaViewModel";
|
||||||
|
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
|
||||||
|
|
||||||
let nextId = 0;
|
let nextId = 0;
|
||||||
function createId(): string {
|
function createId(): string {
|
||||||
|
|||||||
@@ -5,16 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
|
||||||
|
import { type MediaViewModel } from "./media/MediaViewModel.ts";
|
||||||
|
import { type RemoteUserMediaViewModel } from "./media/RemoteUserMediaViewModel.ts";
|
||||||
|
import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts";
|
||||||
import {
|
import {
|
||||||
type GridTileViewModel,
|
type GridTileViewModel,
|
||||||
type SpotlightTileViewModel,
|
type SpotlightTileViewModel,
|
||||||
} from "./TileViewModel.ts";
|
} from "./TileViewModel.ts";
|
||||||
import {
|
|
||||||
type LocalUserMediaViewModel,
|
|
||||||
type RemoteUserMediaViewModel,
|
|
||||||
type MediaViewModel,
|
|
||||||
type UserMediaViewModel,
|
|
||||||
} from "./MediaViewModel.ts";
|
|
||||||
|
|
||||||
export interface GridLayoutMedia {
|
export interface GridLayoutMedia {
|
||||||
type: "grid";
|
type: "grid";
|
||||||
|
|||||||
32
src/state/media/LocalScreenShareViewModel.ts
Normal file
32
src/state/media/LocalScreenShareViewModel.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type LocalParticipant } from "livekit-client";
|
||||||
|
|
||||||
|
import { type Behavior } from "../Behavior";
|
||||||
|
import {
|
||||||
|
type BaseScreenShareInputs,
|
||||||
|
type BaseScreenShareViewModel,
|
||||||
|
createBaseScreenShare,
|
||||||
|
} from "./ScreenShareViewModel";
|
||||||
|
import { type ObservableScope } from "../ObservableScope";
|
||||||
|
|
||||||
|
export interface LocalScreenShareViewModel extends BaseScreenShareViewModel {
|
||||||
|
local: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalScreenShareInputs extends BaseScreenShareInputs {
|
||||||
|
participant$: Behavior<LocalParticipant | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLocalScreenShare(
|
||||||
|
scope: ObservableScope,
|
||||||
|
inputs: LocalScreenShareInputs,
|
||||||
|
): LocalScreenShareViewModel {
|
||||||
|
return { ...createBaseScreenShare(scope, inputs), local: true };
|
||||||
|
}
|
||||||
137
src/state/media/LocalUserMediaViewModel.ts
Normal file
137
src/state/media/LocalUserMediaViewModel.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
facingModeFromLocalTrack,
|
||||||
|
type LocalParticipant,
|
||||||
|
LocalVideoTrack,
|
||||||
|
TrackEvent,
|
||||||
|
} from "livekit-client";
|
||||||
|
import {
|
||||||
|
fromEvent,
|
||||||
|
map,
|
||||||
|
merge,
|
||||||
|
type Observable,
|
||||||
|
of,
|
||||||
|
startWith,
|
||||||
|
switchMap,
|
||||||
|
} from "rxjs";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { type Behavior } from "../Behavior";
|
||||||
|
import {
|
||||||
|
type BaseUserMediaInputs,
|
||||||
|
type BaseUserMediaViewModel,
|
||||||
|
createBaseUserMedia,
|
||||||
|
} from "./UserMediaViewModel";
|
||||||
|
import { type ObservableScope } from "../ObservableScope";
|
||||||
|
import { alwaysShowSelf } from "../../settings/settings";
|
||||||
|
import { platform } from "../../Platform";
|
||||||
|
import { type MediaDevices } from "../MediaDevices";
|
||||||
|
|
||||||
|
export interface LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||||
|
local: true;
|
||||||
|
/**
|
||||||
|
* Whether the video should be mirrored.
|
||||||
|
*/
|
||||||
|
mirror$: Behavior<boolean>;
|
||||||
|
/**
|
||||||
|
* Whether to show this tile in a highly visible location near the start of
|
||||||
|
* the grid.
|
||||||
|
*/
|
||||||
|
alwaysShow$: Behavior<boolean>;
|
||||||
|
setAlwaysShow: (value: boolean) => void;
|
||||||
|
switchCamera$: Behavior<(() => void) | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalUserMediaInputs extends Omit<
|
||||||
|
BaseUserMediaInputs,
|
||||||
|
"statsType"
|
||||||
|
> {
|
||||||
|
participant$: Behavior<LocalParticipant | null>;
|
||||||
|
mediaDevices: MediaDevices;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLocalUserMedia(
|
||||||
|
scope: ObservableScope,
|
||||||
|
{ mediaDevices, ...inputs }: LocalUserMediaInputs,
|
||||||
|
): LocalUserMediaViewModel {
|
||||||
|
const baseUserMedia = createBaseUserMedia(scope, {
|
||||||
|
...inputs,
|
||||||
|
statsType: "outbound-rtp",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local video track as an observable that emits whenever the track
|
||||||
|
* changes, the camera is switched, or the track is muted.
|
||||||
|
*/
|
||||||
|
const videoTrack$: Observable<LocalVideoTrack | null> =
|
||||||
|
baseUserMedia.video$.pipe(
|
||||||
|
switchMap((v) => {
|
||||||
|
const track = v?.publication.track;
|
||||||
|
if (!(track instanceof LocalVideoTrack)) return of(null);
|
||||||
|
return merge(
|
||||||
|
// Watch for track restarts because they indicate a camera switch.
|
||||||
|
// This event is also emitted when unmuting the track object.
|
||||||
|
fromEvent(track, TrackEvent.Restarted).pipe(
|
||||||
|
startWith(null),
|
||||||
|
map(() => track),
|
||||||
|
),
|
||||||
|
// When the track object is muted, reset it to null.
|
||||||
|
fromEvent(track, TrackEvent.Muted).pipe(map(() => null)),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseUserMedia,
|
||||||
|
local: true,
|
||||||
|
mirror$: scope.behavior(
|
||||||
|
videoTrack$.pipe(
|
||||||
|
// Mirror only front-facing cameras (those that face the user)
|
||||||
|
map(
|
||||||
|
(track) =>
|
||||||
|
track !== null &&
|
||||||
|
facingModeFromLocalTrack(track).facingMode === "user",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alwaysShow$: alwaysShowSelf.value$,
|
||||||
|
setAlwaysShow: alwaysShowSelf.setValue,
|
||||||
|
switchCamera$: scope.behavior(
|
||||||
|
platform === "desktop"
|
||||||
|
? of(null)
|
||||||
|
: videoTrack$.pipe(
|
||||||
|
map((track) => {
|
||||||
|
if (track === null) return null;
|
||||||
|
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
||||||
|
// If the camera isn't front or back-facing, don't provide a switch
|
||||||
|
// camera shortcut at all
|
||||||
|
if (facingMode !== "user" && facingMode !== "environment")
|
||||||
|
return null;
|
||||||
|
// Restart the track with a camera facing the opposite direction
|
||||||
|
return (): void =>
|
||||||
|
void track
|
||||||
|
.restartTrack({
|
||||||
|
facingMode: facingMode === "user" ? "environment" : "user",
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// Inform the MediaDevices which camera was chosen
|
||||||
|
const deviceId =
|
||||||
|
track.mediaStreamTrack.getSettings().deviceId;
|
||||||
|
if (deviceId !== undefined)
|
||||||
|
mediaDevices.videoInput.select(deviceId);
|
||||||
|
})
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error("Failed to switch camera", facingMode, e),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,22 +13,24 @@ import {
|
|||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { observeParticipantEvents } from "@livekit/components-core";
|
import { observeParticipantEvents } from "@livekit/components-core";
|
||||||
|
|
||||||
import { type ObservableScope } from "./ObservableScope.ts";
|
import { type ObservableScope } from "../ObservableScope.ts";
|
||||||
import {
|
import type { Behavior } from "../Behavior.ts";
|
||||||
createLocalScreenShare,
|
import type { MediaDevices } from "../MediaDevices.ts";
|
||||||
createLocalUserMedia,
|
|
||||||
createRemoteScreenShare,
|
|
||||||
createRemoteUserMedia,
|
|
||||||
type ScreenShareViewModel,
|
|
||||||
type UserMediaViewModel,
|
|
||||||
type LocalUserMediaInputs,
|
|
||||||
type RemoteUserMediaInputs,
|
|
||||||
} from "./MediaViewModel.ts";
|
|
||||||
import type { Behavior } from "./Behavior.ts";
|
|
||||||
import type { MediaDevices } from "./MediaDevices.ts";
|
|
||||||
import { observeSpeaker$ } from "./observeSpeaker.ts";
|
import { observeSpeaker$ } from "./observeSpeaker.ts";
|
||||||
import { generateItems } from "../utils/observable.ts";
|
import { generateItems } from "../../utils/observable.ts";
|
||||||
import { type TaggedParticipant } from "./CallViewModel/remoteMembers/MatrixLivekitMembers.ts";
|
import { type TaggedParticipant } from "../CallViewModel/remoteMembers/MatrixLivekitMembers.ts";
|
||||||
|
import { type UserMediaViewModel } from "./UserMediaViewModel.ts";
|
||||||
|
import { type ScreenShareViewModel } from "./ScreenShareViewModel.ts";
|
||||||
|
import {
|
||||||
|
createLocalUserMedia,
|
||||||
|
type LocalUserMediaInputs,
|
||||||
|
} from "./LocalUserMediaViewModel.ts";
|
||||||
|
import {
|
||||||
|
createRemoteUserMedia,
|
||||||
|
type RemoteUserMediaInputs,
|
||||||
|
} from "./RemoteUserMediaViewModel.ts";
|
||||||
|
import { createLocalScreenShare } from "./LocalScreenShareViewModel.ts";
|
||||||
|
import { createRemoteScreenShare } from "./RemoteScreenShareViewModel.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorting bins defining the order in which media tiles appear in the layout.
|
* Sorting bins defining the order in which media tiles appear in the layout.
|
||||||
@@ -21,9 +21,8 @@ import {
|
|||||||
mockRemoteMedia,
|
mockRemoteMedia,
|
||||||
withTestScheduler,
|
withTestScheduler,
|
||||||
mockRemoteParticipant,
|
mockRemoteParticipant,
|
||||||
} from "../utils/test";
|
} from "../../utils/test";
|
||||||
import { getValue } from "../utils/observable";
|
import { constant } from "../Behavior";
|
||||||
import { constant } from "./Behavior";
|
|
||||||
|
|
||||||
global.MediaStreamTrack = class {} as unknown as {
|
global.MediaStreamTrack = class {} as unknown as {
|
||||||
new (): MediaStreamTrack;
|
new (): MediaStreamTrack;
|
||||||
@@ -35,7 +34,7 @@ global.MediaStream = class {} as unknown as {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||||
vi.mock("../Platform", () => ({
|
vi.mock("../../Platform", () => ({
|
||||||
get platform(): string {
|
get platform(): string {
|
||||||
return platformMock();
|
return platformMock();
|
||||||
},
|
},
|
||||||
@@ -184,7 +183,7 @@ test("switch cameras", async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Switch to back camera
|
// Switch to back camera
|
||||||
getValue(vm.switchCamera$)!();
|
vm.switchCamera$.value!();
|
||||||
expect(restartTrack).toHaveBeenCalledExactlyOnceWith({
|
expect(restartTrack).toHaveBeenCalledExactlyOnceWith({
|
||||||
facingMode: "environment",
|
facingMode: "environment",
|
||||||
});
|
});
|
||||||
@@ -195,7 +194,7 @@ test("switch cameras", async () => {
|
|||||||
expect(deviceId).toBe("back camera");
|
expect(deviceId).toBe("back camera");
|
||||||
|
|
||||||
// Switch to front camera
|
// Switch to front camera
|
||||||
getValue(vm.switchCamera$)!();
|
vm.switchCamera$.value!();
|
||||||
expect(restartTrack).toHaveBeenCalledTimes(2);
|
expect(restartTrack).toHaveBeenCalledTimes(2);
|
||||||
expect(restartTrack).toHaveBeenLastCalledWith({ facingMode: "user" });
|
expect(restartTrack).toHaveBeenLastCalledWith({ facingMode: "user" });
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
44
src/state/media/MediaViewModel.ts
Normal file
44
src/state/media/MediaViewModel.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Behavior } from "../Behavior";
|
||||||
|
import { type ScreenShareViewModel } from "./ScreenShareViewModel";
|
||||||
|
import { type UserMediaViewModel } from "./UserMediaViewModel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A participant's media.
|
||||||
|
*/
|
||||||
|
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properties which are common to all MediaViewModels.
|
||||||
|
*/
|
||||||
|
export interface BaseMediaViewModel {
|
||||||
|
/**
|
||||||
|
* An opaque identifier for this media.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* The Matrix user to which this media belongs.
|
||||||
|
*/
|
||||||
|
userId: string;
|
||||||
|
displayName$: Behavior<string>;
|
||||||
|
mxcAvatarUrl$: Behavior<string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BaseMediaInputs = BaseMediaViewModel;
|
||||||
|
|
||||||
|
// All this function does is strip out superfluous data from the input object
|
||||||
|
export function createBaseMedia({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
displayName$,
|
||||||
|
mxcAvatarUrl$,
|
||||||
|
}: BaseMediaInputs): BaseMediaViewModel {
|
||||||
|
return { id, userId, displayName$, mxcAvatarUrl$ };
|
||||||
|
}
|
||||||
280
src/state/media/MemberMediaViewModel.ts
Normal file
280
src/state/media/MemberMediaViewModel.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
RoomEvent as LivekitRoomEvent,
|
||||||
|
type Participant,
|
||||||
|
type Track,
|
||||||
|
} from "livekit-client";
|
||||||
|
import {
|
||||||
|
type AudioSource,
|
||||||
|
roomEventSelector,
|
||||||
|
type TrackReference,
|
||||||
|
type VideoSource,
|
||||||
|
} from "@livekit/components-core";
|
||||||
|
import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
|
||||||
|
import {
|
||||||
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
|
filter,
|
||||||
|
map,
|
||||||
|
type Observable,
|
||||||
|
of,
|
||||||
|
startWith,
|
||||||
|
switchMap,
|
||||||
|
throttleTime,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { type Behavior } from "../Behavior";
|
||||||
|
import { type BaseMediaViewModel, createBaseMedia } from "./MediaViewModel";
|
||||||
|
import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement";
|
||||||
|
import { type ObservableScope } from "../ObservableScope";
|
||||||
|
import { observeTrackReference$ } from "../observeTrackReference";
|
||||||
|
import { E2eeType } from "../../e2ee/e2eeType";
|
||||||
|
import { observeRtpStreamStats$ } from "./observeRtpStreamStats";
|
||||||
|
|
||||||
|
export enum EncryptionStatus {
|
||||||
|
Connecting,
|
||||||
|
Okay,
|
||||||
|
KeyMissing,
|
||||||
|
KeyInvalid,
|
||||||
|
PasswordInvalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media belonging to an active member of the RTC session.
|
||||||
|
*/
|
||||||
|
export interface MemberMediaViewModel extends BaseMediaViewModel {
|
||||||
|
/**
|
||||||
|
* The LiveKit video track for this media.
|
||||||
|
*/
|
||||||
|
video$: Behavior<TrackReference | undefined>;
|
||||||
|
/**
|
||||||
|
* The URL of the LiveKit focus on which this member should be publishing.
|
||||||
|
* Exposed for debugging.
|
||||||
|
*/
|
||||||
|
focusUrl$: Behavior<string | undefined>;
|
||||||
|
/**
|
||||||
|
* Whether there should be a warning that this media is unencrypted.
|
||||||
|
*/
|
||||||
|
unencryptedWarning$: Behavior<boolean>;
|
||||||
|
encryptionStatus$: Behavior<EncryptionStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberMediaInputs extends BaseMediaViewModel {
|
||||||
|
participant$: Behavior<LocalParticipant | RemoteParticipant | null>;
|
||||||
|
livekitRoom$: Behavior<LivekitRoom | undefined>;
|
||||||
|
audioSource: AudioSource;
|
||||||
|
videoSource: VideoSource;
|
||||||
|
focusUrl$: Behavior<string | undefined>;
|
||||||
|
encryptionSystem: EncryptionSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMemberMedia(
|
||||||
|
scope: ObservableScope,
|
||||||
|
{
|
||||||
|
participant$,
|
||||||
|
livekitRoom$,
|
||||||
|
audioSource,
|
||||||
|
videoSource,
|
||||||
|
focusUrl$,
|
||||||
|
encryptionSystem,
|
||||||
|
...inputs
|
||||||
|
}: MemberMediaInputs,
|
||||||
|
): MemberMediaViewModel {
|
||||||
|
const trackBehavior$ = (
|
||||||
|
source: Track.Source,
|
||||||
|
): Behavior<TrackReference | undefined> =>
|
||||||
|
scope.behavior(
|
||||||
|
participant$.pipe(
|
||||||
|
switchMap((p) =>
|
||||||
|
!p ? of(undefined) : observeTrackReference$(p, source),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const audio$ = trackBehavior$(audioSource);
|
||||||
|
const video$ = trackBehavior$(videoSource);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...createBaseMedia(inputs),
|
||||||
|
video$,
|
||||||
|
focusUrl$,
|
||||||
|
unencryptedWarning$: scope.behavior(
|
||||||
|
combineLatest(
|
||||||
|
[audio$, video$],
|
||||||
|
(a, v) =>
|
||||||
|
encryptionSystem.kind !== E2eeType.NONE &&
|
||||||
|
(a?.publication.isEncrypted === false ||
|
||||||
|
v?.publication.isEncrypted === false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
encryptionStatus$: scope.behavior(
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeInboundRtpStreamStats$(
|
||||||
|
participant: Participant,
|
||||||
|
source: Track.Source,
|
||||||
|
): Observable<RTCInboundRtpStreamStats | undefined> {
|
||||||
|
return observeRtpStreamStats$(participant, source, "inbound-rtp").pipe(
|
||||||
|
map((x) => x as RTCInboundRtpStreamStats | undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptionErrorObservable$(
|
||||||
|
room$: Behavior<LivekitRoom | undefined>,
|
||||||
|
participant: Participant,
|
||||||
|
encryptionSystem: EncryptionSystem,
|
||||||
|
criteria: string,
|
||||||
|
): Observable<boolean> {
|
||||||
|
return room$.pipe(
|
||||||
|
switchMap((room) => {
|
||||||
|
if (room === undefined) return of(false);
|
||||||
|
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;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
throttleTime(1000), // Throttle to avoid spamming the UI
|
||||||
|
startWith(false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/state/media/RemoteScreenShareViewModel.ts
Normal file
44
src/state/media/RemoteScreenShareViewModel.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type RemoteParticipant } from "livekit-client";
|
||||||
|
import { map } from "rxjs";
|
||||||
|
|
||||||
|
import { type Behavior } from "../Behavior";
|
||||||
|
import {
|
||||||
|
type BaseScreenShareInputs,
|
||||||
|
type BaseScreenShareViewModel,
|
||||||
|
createBaseScreenShare,
|
||||||
|
} from "./ScreenShareViewModel";
|
||||||
|
import { type ObservableScope } from "../ObservableScope";
|
||||||
|
|
||||||
|
export interface RemoteScreenShareViewModel extends BaseScreenShareViewModel {
|
||||||
|
local: false;
|
||||||
|
/**
|
||||||
|
* Whether this screen share's video should be displayed.
|
||||||
|
*/
|
||||||
|
videoEnabled$: Behavior<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteScreenShareInputs extends BaseScreenShareInputs {
|
||||||
|
participant$: Behavior<RemoteParticipant | null>;
|
||||||
|
pretendToBeDisconnected$: Behavior<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRemoteScreenShare(
|
||||||
|
scope: ObservableScope,
|
||||||
|
{ pretendToBeDisconnected$, ...inputs }: RemoteScreenShareInputs,
|
||||||
|
): RemoteScreenShareViewModel {
|
||||||
|
return {
|
||||||
|
...createBaseScreenShare(scope, inputs),
|
||||||
|
local: false,
|
||||||
|
videoEnabled$: scope.behavior(
|
||||||
|
pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
82
src/state/media/RemoteUserMediaViewModel.ts
Normal file
82
src/state/media/RemoteUserMediaViewModel.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type RemoteParticipant } from "livekit-client";
|
||||||
|
import { combineLatest, map, of, switchMap } from "rxjs";
|
||||||
|
|
||||||
|
import { type Behavior } from "../Behavior";
|
||||||
|
import { createVolumeControls, type VolumeControls } from "../VolumeControls";
|
||||||
|
import {
|
||||||
|
type BaseUserMediaInputs,
|
||||||
|
type BaseUserMediaViewModel,
|
||||||
|
createBaseUserMedia,
|
||||||
|
} from "./UserMediaViewModel";
|
||||||
|
import { type ObservableScope } from "../ObservableScope";
|
||||||
|
|
||||||
|
export interface RemoteUserMediaViewModel
|
||||||
|
extends BaseUserMediaViewModel, VolumeControls {
|
||||||
|
local: false;
|
||||||
|
/**
|
||||||
|
* Whether we are waiting for this user's LiveKit participant to exist. This
|
||||||
|
* could be because either we or the remote party are still connecting.
|
||||||
|
*/
|
||||||
|
waitingForMedia$: Behavior<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteUserMediaInputs extends Omit<
|
||||||
|
BaseUserMediaInputs,
|
||||||
|
"statsType"
|
||||||
|
> {
|
||||||
|
participant$: Behavior<RemoteParticipant | null>;
|
||||||
|
pretendToBeDisconnected$: Behavior<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRemoteUserMedia(
|
||||||
|
scope: ObservableScope,
|
||||||
|
{ pretendToBeDisconnected$, ...inputs }: RemoteUserMediaInputs,
|
||||||
|
): RemoteUserMediaViewModel {
|
||||||
|
const baseUserMedia = createBaseUserMedia(scope, {
|
||||||
|
...inputs,
|
||||||
|
statsType: "inbound-rtp",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseUserMedia,
|
||||||
|
...createVolumeControls(scope, {
|
||||||
|
pretendToBeDisconnected$,
|
||||||
|
sink$: scope.behavior(
|
||||||
|
inputs.participant$.pipe(map((p) => (volume) => p?.setVolume(volume))),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
local: false,
|
||||||
|
speaking$: scope.behavior(
|
||||||
|
pretendToBeDisconnected$.pipe(
|
||||||
|
switchMap((disconnected) =>
|
||||||
|
disconnected ? of(false) : baseUserMedia.speaking$,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
videoEnabled$: scope.behavior(
|
||||||
|
pretendToBeDisconnected$.pipe(
|
||||||
|
switchMap((disconnected) =>
|
||||||
|
disconnected ? of(false) : baseUserMedia.videoEnabled$,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
waitingForMedia$: scope.behavior(
|
||||||
|
combineLatest(
|
||||||
|
[inputs.livekitRoom$, inputs.participant$],
|
||||||
|
(livekitRoom, participant) =>
|
||||||
|
// If livekitRoom is undefined, the user is not attempting to publish on
|
||||||
|
// any transport and so we shouldn't expect a participant. (They might
|
||||||
|
// be a subscribe-only bot for example.)
|
||||||
|
livekitRoom !== undefined && participant === null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
51
src/state/media/ScreenShareViewModel.ts
Normal file
51
src/state/media/ScreenShareViewModel.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Track } from "livekit-client";
|
||||||
|
|
||||||
|
import { type ObservableScope } from "../ObservableScope";
|
||||||
|
import { type LocalScreenShareViewModel } from "./LocalScreenShareViewModel";
|
||||||
|
import {
|
||||||
|
createMemberMedia,
|
||||||
|
type MemberMediaInputs,
|
||||||
|
type MemberMediaViewModel,
|
||||||
|
} from "./MemberMediaViewModel";
|
||||||
|
import { type RemoteScreenShareViewModel } from "./RemoteScreenShareViewModel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A participant's screen share media.
|
||||||
|
*/
|
||||||
|
export type ScreenShareViewModel =
|
||||||
|
| LocalScreenShareViewModel
|
||||||
|
| RemoteScreenShareViewModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properties which are common to all ScreenShareViewModels.
|
||||||
|
*/
|
||||||
|
export interface BaseScreenShareViewModel extends MemberMediaViewModel {
|
||||||
|
type: "screen share";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BaseScreenShareInputs = Omit<
|
||||||
|
MemberMediaInputs,
|
||||||
|
"audioSource" | "videoSource"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createBaseScreenShare(
|
||||||
|
scope: ObservableScope,
|
||||||
|
inputs: BaseScreenShareInputs,
|
||||||
|
): BaseScreenShareViewModel {
|
||||||
|
return {
|
||||||
|
...createMemberMedia(scope, {
|
||||||
|
...inputs,
|
||||||
|
audioSource: Track.Source.ScreenShareAudio,
|
||||||
|
videoSource: Track.Source.ScreenShare,
|
||||||
|
}),
|
||||||
|
type: "screen share",
|
||||||
|
};
|
||||||
|
}
|
||||||
143
src/state/media/UserMediaViewModel.ts
Normal file
143
src/state/media/UserMediaViewModel.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
combineLatest,
|
||||||
|
map,
|
||||||
|
type Observable,
|
||||||
|
of,
|
||||||
|
Subject,
|
||||||
|
switchMap,
|
||||||
|
} from "rxjs";
|
||||||
|
import {
|
||||||
|
observeParticipantEvents,
|
||||||
|
observeParticipantMedia,
|
||||||
|
} from "@livekit/components-core";
|
||||||
|
import { ParticipantEvent, Track } from "livekit-client";
|
||||||
|
|
||||||
|
import { type ReactionOption } from "../../reactions";
|
||||||
|
import { type Behavior } from "../Behavior";
|
||||||
|
import { type LocalUserMediaViewModel } from "./LocalUserMediaViewModel";
|
||||||
|
import {
|
||||||
|
createMemberMedia,
|
||||||
|
type MemberMediaInputs,
|
||||||
|
type MemberMediaViewModel,
|
||||||
|
} from "./MemberMediaViewModel";
|
||||||
|
import { type RemoteUserMediaViewModel } from "./RemoteUserMediaViewModel";
|
||||||
|
import { type ObservableScope } from "../ObservableScope";
|
||||||
|
import { createToggle$ } from "../../utils/observable";
|
||||||
|
import { showConnectionStats } from "../../settings/settings";
|
||||||
|
import { observeRtpStreamStats$ } from "./observeRtpStreamStats";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A participant's user media (i.e. their microphone and camera feed).
|
||||||
|
*/
|
||||||
|
export type UserMediaViewModel =
|
||||||
|
| LocalUserMediaViewModel
|
||||||
|
| RemoteUserMediaViewModel;
|
||||||
|
|
||||||
|
export interface BaseUserMediaViewModel extends MemberMediaViewModel {
|
||||||
|
type: "user";
|
||||||
|
speaking$: Behavior<boolean>;
|
||||||
|
audioEnabled$: Behavior<boolean>;
|
||||||
|
videoEnabled$: Behavior<boolean>;
|
||||||
|
cropVideo$: Behavior<boolean>;
|
||||||
|
toggleCropVideo: () => void;
|
||||||
|
/**
|
||||||
|
* The expected identity of the LiveKit participant. Exposed for debugging.
|
||||||
|
*/
|
||||||
|
rtcBackendIdentity: string;
|
||||||
|
handRaised$: Behavior<Date | null>;
|
||||||
|
reaction$: Behavior<ReactionOption | null>;
|
||||||
|
audioStreamStats$: Observable<
|
||||||
|
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||||
|
>;
|
||||||
|
videoStreamStats$: Observable<
|
||||||
|
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseUserMediaInputs extends Omit<
|
||||||
|
MemberMediaInputs,
|
||||||
|
"audioSource" | "videoSource"
|
||||||
|
> {
|
||||||
|
rtcBackendIdentity: string;
|
||||||
|
handRaised$: Behavior<Date | null>;
|
||||||
|
reaction$: Behavior<ReactionOption | null>;
|
||||||
|
statsType: "inbound-rtp" | "outbound-rtp";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBaseUserMedia(
|
||||||
|
scope: ObservableScope,
|
||||||
|
{
|
||||||
|
rtcBackendIdentity,
|
||||||
|
handRaised$,
|
||||||
|
reaction$,
|
||||||
|
statsType,
|
||||||
|
...inputs
|
||||||
|
}: BaseUserMediaInputs,
|
||||||
|
): BaseUserMediaViewModel {
|
||||||
|
const { participant$ } = inputs;
|
||||||
|
const media$ = scope.behavior(
|
||||||
|
participant$.pipe(
|
||||||
|
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const toggleCropVideo$ = new Subject<void>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...createMemberMedia(scope, {
|
||||||
|
...inputs,
|
||||||
|
audioSource: Track.Source.Microphone,
|
||||||
|
videoSource: Track.Source.Camera,
|
||||||
|
}),
|
||||||
|
type: "user",
|
||||||
|
speaking$: scope.behavior(
|
||||||
|
participant$.pipe(
|
||||||
|
switchMap((p) =>
|
||||||
|
p
|
||||||
|
? observeParticipantEvents(
|
||||||
|
p,
|
||||||
|
ParticipantEvent.IsSpeakingChanged,
|
||||||
|
).pipe(map((p) => p.isSpeaking))
|
||||||
|
: of(false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
audioEnabled$: scope.behavior(
|
||||||
|
media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
|
||||||
|
),
|
||||||
|
videoEnabled$: scope.behavior(
|
||||||
|
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
||||||
|
),
|
||||||
|
cropVideo$: createToggle$(scope, true, toggleCropVideo$),
|
||||||
|
toggleCropVideo: () => toggleCropVideo$.next(),
|
||||||
|
rtcBackendIdentity,
|
||||||
|
handRaised$,
|
||||||
|
reaction$,
|
||||||
|
audioStreamStats$: combineLatest([
|
||||||
|
participant$,
|
||||||
|
showConnectionStats.value$,
|
||||||
|
]).pipe(
|
||||||
|
switchMap(([p, showConnectionStats]) => {
|
||||||
|
//
|
||||||
|
if (!p || !showConnectionStats) return of(undefined);
|
||||||
|
return observeRtpStreamStats$(p, Track.Source.Microphone, statsType);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
videoStreamStats$: combineLatest([
|
||||||
|
participant$,
|
||||||
|
showConnectionStats.value$,
|
||||||
|
]).pipe(
|
||||||
|
switchMap(([p, showConnectionStats]) => {
|
||||||
|
if (!p || !showConnectionStats) return of(undefined);
|
||||||
|
return observeRtpStreamStats$(p, Track.Source.Camera, statsType);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
59
src/state/media/observeRtpStreamStats.ts
Normal file
59
src/state/media/observeRtpStreamStats.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
LocalTrack,
|
||||||
|
type Participant,
|
||||||
|
RemoteTrack,
|
||||||
|
type Track,
|
||||||
|
} from "livekit-client";
|
||||||
|
import {
|
||||||
|
combineLatest,
|
||||||
|
interval,
|
||||||
|
type Observable,
|
||||||
|
startWith,
|
||||||
|
switchMap,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { observeTrackReference$ } from "../observeTrackReference";
|
||||||
|
|
||||||
|
export function observeRtpStreamStats$(
|
||||||
|
participant: Participant,
|
||||||
|
source: Track.Source,
|
||||||
|
type: "inbound-rtp" | "outbound-rtp",
|
||||||
|
): Observable<
|
||||||
|
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||||
|
> {
|
||||||
|
return combineLatest([
|
||||||
|
observeTrackReference$(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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { describe, test } from "vitest";
|
import { describe, test } from "vitest";
|
||||||
|
|
||||||
import { withTestScheduler } from "../utils/test";
|
import { withTestScheduler } from "../../utils/test";
|
||||||
import { observeSpeaker$ } from "./observeSpeaker";
|
import { observeSpeaker$ } from "./observeSpeaker";
|
||||||
|
|
||||||
const yesNo = {
|
const yesNo = {
|
||||||
28
src/state/observeTrackReference.ts
Normal file
28
src/state/observeTrackReference.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
observeParticipantMedia,
|
||||||
|
type TrackReference,
|
||||||
|
} from "@livekit/components-core";
|
||||||
|
import { type Participant, type Track } from "livekit-client";
|
||||||
|
import { distinctUntilChanged, map, type Observable } from "rxjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactively reads a participant's track reference for a given media source.
|
||||||
|
*/
|
||||||
|
export function observeTrackReference$(
|
||||||
|
participant: Participant,
|
||||||
|
source: Track.Source,
|
||||||
|
): Observable<TrackReference | undefined> {
|
||||||
|
return observeParticipantMedia(participant).pipe(
|
||||||
|
map(() => participant.getTrackPublication(source)),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
map((publication) => publication && { participant, publication, source }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,11 +39,6 @@ import {
|
|||||||
import { useObservableEagerState } from "observable-hooks";
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
|
||||||
import styles from "./GridTile.module.css";
|
import styles from "./GridTile.module.css";
|
||||||
import {
|
|
||||||
type UserMediaViewModel,
|
|
||||||
type LocalUserMediaViewModel,
|
|
||||||
type RemoteUserMediaViewModel,
|
|
||||||
} from "../state/MediaViewModel";
|
|
||||||
import { Slider } from "../Slider";
|
import { Slider } from "../Slider";
|
||||||
import { MediaView } from "./MediaView";
|
import { MediaView } from "./MediaView";
|
||||||
import { useLatest } from "../useLatest";
|
import { useLatest } from "../useLatest";
|
||||||
@@ -51,6 +46,9 @@ import { type GridTileViewModel } from "../state/TileViewModel";
|
|||||||
import { useMergedRefs } from "../useMergedRefs";
|
import { useMergedRefs } from "../useMergedRefs";
|
||||||
import { useReactionsSender } from "../reactions/useReactionsSender";
|
import { useReactionsSender } from "../reactions/useReactionsSender";
|
||||||
import { useBehavior } from "../useBehavior";
|
import { useBehavior } from "../useBehavior";
|
||||||
|
import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel";
|
||||||
|
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
|
||||||
|
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
||||||
|
|
||||||
interface TileProps {
|
interface TileProps {
|
||||||
ref?: Ref<HTMLDivElement>;
|
ref?: Ref<HTMLDivElement>;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { TrackInfo } from "@livekit/protocol";
|
|||||||
import { type ComponentProps } from "react";
|
import { type ComponentProps } from "react";
|
||||||
|
|
||||||
import { MediaView } from "./MediaView";
|
import { MediaView } from "./MediaView";
|
||||||
import { EncryptionStatus } from "../state/MediaViewModel";
|
import { EncryptionStatus } from "../state/media/MemberMediaViewModel";
|
||||||
import { mockLocalParticipant } from "../utils/test";
|
import { mockLocalParticipant } from "../utils/test";
|
||||||
|
|
||||||
describe("MediaView", () => {
|
describe("MediaView", () => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/ico
|
|||||||
|
|
||||||
import styles from "./MediaView.module.css";
|
import styles from "./MediaView.module.css";
|
||||||
import { Avatar } from "../Avatar";
|
import { Avatar } from "../Avatar";
|
||||||
import { type EncryptionStatus } from "../state/MediaViewModel";
|
import { type EncryptionStatus } from "../state/media/MemberMediaViewModel";
|
||||||
import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator";
|
import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator";
|
||||||
import {
|
import {
|
||||||
showConnectionStats as showConnectionStatsSetting,
|
showConnectionStats as showConnectionStatsSetting,
|
||||||
|
|||||||
@@ -32,21 +32,19 @@ import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
|
|||||||
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
|
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
|
||||||
import { MediaView } from "./MediaView";
|
import { MediaView } from "./MediaView";
|
||||||
import styles from "./SpotlightTile.module.css";
|
import styles from "./SpotlightTile.module.css";
|
||||||
import {
|
|
||||||
type EncryptionStatus,
|
|
||||||
type LocalUserMediaViewModel,
|
|
||||||
type MediaViewModel,
|
|
||||||
type UserMediaViewModel,
|
|
||||||
type RemoteUserMediaViewModel,
|
|
||||||
type ScreenShareViewModel,
|
|
||||||
type RemoteScreenShareViewModel,
|
|
||||||
} from "../state/MediaViewModel";
|
|
||||||
import { useInitial } from "../useInitial";
|
import { useInitial } from "../useInitial";
|
||||||
import { useMergedRefs } from "../useMergedRefs";
|
import { useMergedRefs } from "../useMergedRefs";
|
||||||
import { useReactiveState } from "../useReactiveState";
|
import { useReactiveState } from "../useReactiveState";
|
||||||
import { useLatest } from "../useLatest";
|
import { useLatest } from "../useLatest";
|
||||||
import { type SpotlightTileViewModel } from "../state/TileViewModel";
|
import { type SpotlightTileViewModel } from "../state/TileViewModel";
|
||||||
import { useBehavior } from "../useBehavior";
|
import { useBehavior } from "../useBehavior";
|
||||||
|
import { type EncryptionStatus } from "../state/media/MemberMediaViewModel";
|
||||||
|
import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel";
|
||||||
|
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
|
||||||
|
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
||||||
|
import { type ScreenShareViewModel } from "../state/media/ScreenShareViewModel";
|
||||||
|
import { type RemoteScreenShareViewModel } from "../state/media/RemoteScreenShareViewModel";
|
||||||
|
import { type MediaViewModel } from "../state/media/MediaViewModel";
|
||||||
|
|
||||||
interface SpotlightItemBaseProps {
|
interface SpotlightItemBaseProps {
|
||||||
ref?: Ref<HTMLDivElement>;
|
ref?: Ref<HTMLDivElement>;
|
||||||
|
|||||||
@@ -52,12 +52,6 @@ import {
|
|||||||
} from "matrix-js-sdk/lib/matrixrtc/IKeyTransport";
|
} from "matrix-js-sdk/lib/matrixrtc/IKeyTransport";
|
||||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
|
|
||||||
import {
|
|
||||||
createLocalUserMedia,
|
|
||||||
createRemoteUserMedia,
|
|
||||||
type LocalUserMediaViewModel,
|
|
||||||
type RemoteUserMediaViewModel,
|
|
||||||
} from "../state/MediaViewModel";
|
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
@@ -68,6 +62,14 @@ import { type MediaDevices } from "../state/MediaDevices";
|
|||||||
import { type Behavior, constant } from "../state/Behavior";
|
import { type Behavior, constant } from "../state/Behavior";
|
||||||
import { ObservableScope } from "../state/ObservableScope";
|
import { ObservableScope } from "../state/ObservableScope";
|
||||||
import { MuteStates } from "../state/MuteStates";
|
import { MuteStates } from "../state/MuteStates";
|
||||||
|
import {
|
||||||
|
createLocalUserMedia,
|
||||||
|
type LocalUserMediaViewModel,
|
||||||
|
} from "../state/media/LocalUserMediaViewModel";
|
||||||
|
import {
|
||||||
|
createRemoteUserMedia,
|
||||||
|
type RemoteUserMediaViewModel,
|
||||||
|
} from "../state/media/RemoteUserMediaViewModel";
|
||||||
|
|
||||||
export function withFakeTimers(continuation: () => void): void {
|
export function withFakeTimers(continuation: () => void): void {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|||||||
Reference in New Issue
Block a user