Files
element-call/src/state/MediaViewModel.ts
T

335 lines
8.9 KiB
TypeScript
Raw Normal View History

/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
2023-12-01 17:43:09 -05:00
import {
AudioSource,
TrackReferenceOrPlaceholder,
VideoSource,
observeParticipantEvents,
observeParticipantMedia,
} from "@livekit/components-core";
import {
LocalParticipant,
LocalTrack,
Participant,
ParticipantEvent,
RemoteParticipant,
Track,
TrackEvent,
facingModeFromLocalTrack,
} from "livekit-client";
2024-05-02 18:44:36 -04:00
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
2023-12-01 17:43:09 -05:00
import {
BehaviorSubject,
2024-05-16 15:23:10 -04:00
Observable,
2024-10-18 17:51:37 -04:00
Subject,
2023-12-01 17:43:09 -05:00
combineLatest,
distinctUntilKeyChanged,
fromEvent,
map,
2024-10-18 17:51:37 -04:00
merge,
2023-12-01 17:43:09 -05:00
of,
startWith,
switchMap,
} from "rxjs";
2024-05-02 18:44:36 -04:00
import { useEffect } from "react";
2023-12-01 17:43:09 -05:00
import { ViewModel } from "./ViewModel";
2024-05-02 18:44:36 -04:00
import { useReactiveState } from "../useReactiveState";
2024-05-16 13:33:02 -04:00
import { alwaysShowSelf } from "../settings/settings";
2024-10-18 17:51:37 -04:00
import { accumulate } from "../utils/observable";
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
2024-05-02 18:44:36 -04:00
// TODO: Move this naming logic into the view model
2024-08-01 15:46:14 -04:00
export function useDisplayName(vm: MediaViewModel): string {
2024-05-02 18:44:36 -04:00
const [displayName, setDisplayName] = useReactiveState(
() => vm.member?.rawDisplayName ?? "[👻]",
[vm.member],
);
useEffect(() => {
if (vm.member) {
const updateName = (): void => {
setDisplayName(vm.member!.rawDisplayName);
};
vm.member!.on(RoomMemberEvent.Name, updateName);
return (): void => {
vm.member!.removeListener(RoomMemberEvent.Name, updateName);
};
}
}, [vm.member, setDisplayName]);
2024-08-01 15:46:14 -04:00
return displayName;
2024-05-02 18:44:36 -04:00
}
2023-12-01 17:43:09 -05:00
2024-10-28 17:29:26 -04:00
export function observeTrackReference(
2023-12-01 17:43:09 -05:00
participant: Participant,
source: Track.Source,
2024-05-16 15:23:10 -04:00
): Observable<TrackReferenceOrPlaceholder> {
return observeParticipantMedia(participant).pipe(
map(() => ({
participant,
publication: participant.getTrackPublication(source),
source,
})),
distinctUntilKeyChanged("publication"),
2023-12-01 17:43:09 -05:00
);
}
2024-01-20 20:39:12 -05:00
abstract class BaseMediaViewModel extends ViewModel {
2023-12-01 17:43:09 -05:00
/**
2024-01-20 20:39:12 -05:00
* Whether the media belongs to the local user.
2023-12-01 17:43:09 -05:00
*/
public readonly local = this.participant.isLocal;
/**
2024-01-20 20:39:12 -05:00
* The LiveKit video track for this media.
2023-12-01 17:43:09 -05:00
*/
2024-05-16 15:23:10 -04:00
public readonly video: Observable<TrackReferenceOrPlaceholder>;
2023-12-01 17:43:09 -05:00
/**
* Whether there should be a warning that this media is unencrypted.
*/
2024-05-16 15:23:10 -04:00
public readonly unencryptedWarning: Observable<boolean>;
public constructor(
2024-05-02 18:44:36 -04:00
/**
* An opaque identifier for this media.
*/
public readonly id: string,
2023-12-01 17:43:09 -05:00
/**
2024-01-20 20:39:12 -05:00
* The Matrix room member to which this media belongs.
2023-12-01 17:43:09 -05:00
*/
// TODO: Fully separate the data layer from the UI layer by keeping the
// member object internal
public readonly member: RoomMember | undefined,
2023-12-01 17:43:09 -05:00
protected readonly participant: LocalParticipant | RemoteParticipant,
encryptionSystem: EncryptionSystem,
2023-12-01 17:43:09 -05:00
audioSource: AudioSource,
videoSource: VideoSource,
) {
super();
const audio = observeTrackReference(participant, audioSource).pipe(
this.scope.state(),
);
this.video = observeTrackReference(participant, videoSource).pipe(
this.scope.state(),
);
2024-05-16 15:23:10 -04:00
this.unencryptedWarning = combineLatest(
[audio, this.video],
(a, v) =>
encryptionSystem.kind !== E2eeType.NONE &&
2024-05-16 15:23:10 -04:00
(a.publication?.isEncrypted === false ||
v.publication?.isEncrypted === false),
).pipe(this.scope.state());
}
}
2023-12-01 17:43:09 -05:00
/**
2024-01-20 20:39:12 -05:00
* Some participant's media.
2023-12-01 17:43:09 -05:00
*/
2024-01-20 20:39:12 -05:00
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
export type UserMediaViewModel =
| LocalUserMediaViewModel
| RemoteUserMediaViewModel;
2023-12-01 17:43:09 -05:00
/**
2024-01-20 20:39:12 -05:00
* Some participant's user media.
2023-12-01 17:43:09 -05:00
*/
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
2023-12-01 17:43:09 -05:00
/**
* Whether the participant is speaking.
*/
2024-05-16 15:23:10 -04:00
public readonly speaking = observeParticipantEvents(
this.participant,
ParticipantEvent.IsSpeakingChanged,
).pipe(
map((p) => p.isSpeaking),
this.scope.state(),
2023-12-01 17:43:09 -05:00
);
/**
* Whether this participant is sending audio (i.e. is unmuted on their side).
*/
2024-05-16 15:23:10 -04:00
public readonly audioEnabled: Observable<boolean>;
2023-12-01 17:43:09 -05:00
/**
* Whether this participant is sending video.
*/
2024-05-16 15:23:10 -04:00
public readonly videoEnabled: Observable<boolean>;
2023-12-01 17:43:09 -05:00
private readonly _cropVideo = new BehaviorSubject(true);
/**
* Whether the tile video should be contained inside the tile or be cropped to fit.
*/
2024-05-16 15:23:10 -04:00
public readonly cropVideo: Observable<boolean> = this._cropVideo;
public constructor(
2023-12-01 17:43:09 -05:00
id: string,
member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant,
encryptionSystem: EncryptionSystem,
) {
2023-12-01 17:43:09 -05:00
super(
id,
member,
participant,
encryptionSystem,
2023-12-01 17:43:09 -05:00
Track.Source.Microphone,
Track.Source.Camera,
);
const media = observeParticipantMedia(participant).pipe(this.scope.state());
2024-05-16 15:23:10 -04:00
this.audioEnabled = media.pipe(
map((m) => m.microphoneTrack?.isMuted === false),
2023-12-01 17:43:09 -05:00
);
2024-05-16 15:23:10 -04:00
this.videoEnabled = media.pipe(
map((m) => m.cameraTrack?.isMuted === false),
2023-12-01 17:43:09 -05:00
);
}
public toggleFitContain(): void {
this._cropVideo.next(!this._cropVideo.value);
}
}
/**
* The local participant's user media.
*/
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/**
* Whether the video should be mirrored.
*/
2024-05-16 15:23:10 -04:00
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(),
);
2024-05-16 13:33:02 -04:00
/**
* 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: LocalParticipant,
encryptionSystem: EncryptionSystem,
) {
super(id, member, participant, encryptionSystem);
}
}
/**
* A remote participant's user media.
*/
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
2024-10-18 17:51:37 -04:00
private readonly locallyMutedToggle = new Subject<void>();
private readonly localVolumeAdjustment = new Subject<number>();
private readonly localVolumeCommit = new Subject<void>();
/**
2024-10-18 17:51:37 -04:00
* The volume to which this participant's audio is set, as a scalar
* multiplier.
*/
2024-10-18 17:51:37 -04:00
public readonly localVolume: Observable<number> = merge(
this.locallyMutedToggle.pipe(map(() => "toggle mute" as const)),
this.localVolumeAdjustment,
this.localVolumeCommit.pipe(map(() => "commit" as const)),
).pipe(
2024-10-22 17:23:40 -04:00
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),
2024-10-18 17:51:37 -04:00
this.scope.state(),
);
/**
2024-10-18 17:51:37 -04:00
* Whether this participant's audio is disabled.
*/
2024-10-18 17:51:37 -04:00
public readonly locallyMuted: Observable<boolean> = this.localVolume.pipe(
map((volume) => volume === 0),
this.scope.state(),
);
public constructor(
id: string,
member: RoomMember | undefined,
participant: RemoteParticipant,
encryptionSystem: EncryptionSystem,
) {
super(id, member, participant, encryptionSystem);
2023-12-01 17:43:09 -05:00
2024-10-18 17:51:37 -04:00
// Sync the local volume with LiveKit
this.localVolume
.pipe(this.scope.bind())
2024-10-18 17:51:37 -04:00
.subscribe((volume) =>
(this.participant as RemoteParticipant).setVolume(volume),
);
2023-12-01 17:43:09 -05:00
}
public toggleLocallyMuted(): void {
2024-10-18 17:51:37 -04:00
this.locallyMutedToggle.next();
2023-12-01 17:43:09 -05:00
}
public setLocalVolume(value: number): void {
2024-10-18 17:51:37 -04:00
this.localVolumeAdjustment.next(value);
}
public commitLocalVolume(): void {
this.localVolumeCommit.next();
2023-12-01 17:43:09 -05:00
}
}
/**
2024-01-20 20:39:12 -05:00
* Some participant's screen share media.
2023-12-01 17:43:09 -05:00
*/
2024-01-20 20:39:12 -05:00
export class ScreenShareViewModel extends BaseMediaViewModel {
2023-12-01 17:43:09 -05:00
public constructor(
id: string,
member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant,
encryptionSystem: EncryptionSystem,
2023-12-01 17:43:09 -05:00
) {
super(
id,
member,
participant,
encryptionSystem,
2023-12-01 17:43:09 -05:00
Track.Source.ScreenShareAudio,
Track.Source.ScreenShare,
);
}
}