Add data models for the new call layouts
This is a start at implementing the call layouts from the new designs. I've added data types to model the contents of each possible layout, and begun implementing the business logic to produce these layouts in the call view model.
This commit is contained in:
241
src/state/MediaViewModel.ts
Normal file
241
src/state/MediaViewModel.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
Copyright 2023-2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
AudioSource,
|
||||
TrackReferenceOrPlaceholder,
|
||||
VideoSource,
|
||||
observeParticipantEvents,
|
||||
observeParticipantMedia,
|
||||
} from "@livekit/components-core";
|
||||
import { StateObservable, state } from "@react-rxjs/core";
|
||||
import {
|
||||
LocalParticipant,
|
||||
LocalTrack,
|
||||
Participant,
|
||||
ParticipantEvent,
|
||||
RemoteParticipant,
|
||||
Track,
|
||||
TrackEvent,
|
||||
facingModeFromLocalTrack,
|
||||
} from "livekit-client";
|
||||
import { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
distinctUntilKeyChanged,
|
||||
fromEvent,
|
||||
map,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
|
||||
function observeTrackReference(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): StateObservable<TrackReferenceOrPlaceholder> {
|
||||
return state(
|
||||
observeParticipantMedia(participant).pipe(
|
||||
map(() => ({
|
||||
participant,
|
||||
publication: participant.getTrack(source),
|
||||
source,
|
||||
})),
|
||||
distinctUntilKeyChanged("publication"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
abstract class BaseMediaViewModel extends ViewModel {
|
||||
/**
|
||||
* Whether the media belongs to the local user.
|
||||
*/
|
||||
public readonly local = this.participant.isLocal;
|
||||
/**
|
||||
* The LiveKit video track for this media.
|
||||
*/
|
||||
public readonly video: StateObservable<TrackReferenceOrPlaceholder>;
|
||||
/**
|
||||
* Whether there should be a warning that this media is unencrypted.
|
||||
*/
|
||||
public readonly unencryptedWarning: StateObservable<boolean>;
|
||||
|
||||
public constructor(
|
||||
// TODO: This is only needed for full screen toggling and can be removed as
|
||||
// soon as that code is moved into the view models
|
||||
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,
|
||||
protected readonly participant: LocalParticipant | RemoteParticipant,
|
||||
callEncrypted: boolean,
|
||||
audioSource: AudioSource,
|
||||
videoSource: VideoSource,
|
||||
) {
|
||||
super();
|
||||
const audio = observeTrackReference(participant, audioSource);
|
||||
this.video = observeTrackReference(participant, videoSource);
|
||||
this.unencryptedWarning = state(
|
||||
combineLatest(
|
||||
[audio, this.video],
|
||||
(a, v) =>
|
||||
callEncrypted &&
|
||||
(a.publication?.isEncrypted === false ||
|
||||
v.publication?.isEncrypted === false),
|
||||
).pipe(distinctUntilChanged()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some participant's media.
|
||||
*/
|
||||
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
|
||||
|
||||
/**
|
||||
* Some participant's user media.
|
||||
*/
|
||||
export class UserMediaViewModel extends BaseMediaViewModel {
|
||||
/**
|
||||
* Whether the video should be mirrored.
|
||||
*/
|
||||
public readonly mirror = state(
|
||||
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"),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether the participant is speaking.
|
||||
*/
|
||||
public readonly speaking = state(
|
||||
observeParticipantEvents(
|
||||
this.participant,
|
||||
ParticipantEvent.IsSpeakingChanged,
|
||||
).pipe(map((p) => p.isSpeaking)),
|
||||
);
|
||||
|
||||
private readonly _locallyMuted = new BehaviorSubject(false);
|
||||
/**
|
||||
* Whether we've disabled this participant's audio.
|
||||
*/
|
||||
public readonly locallyMuted = state(this._locallyMuted);
|
||||
|
||||
private readonly _localVolume = new BehaviorSubject(1);
|
||||
/**
|
||||
* The volume to which we've set this participant's audio, as a scalar
|
||||
* multiplier.
|
||||
*/
|
||||
public readonly localVolume = state(this._localVolume);
|
||||
|
||||
/**
|
||||
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
||||
*/
|
||||
public readonly audioEnabled: StateObservable<boolean>;
|
||||
/**
|
||||
* Whether this participant is sending video.
|
||||
*/
|
||||
public readonly videoEnabled: StateObservable<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 = state(this._cropVideo);
|
||||
|
||||
public constructor(
|
||||
id: string,
|
||||
member: RoomMember | undefined,
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
callEncrypted: boolean,
|
||||
) {
|
||||
super(
|
||||
id,
|
||||
member,
|
||||
participant,
|
||||
callEncrypted,
|
||||
Track.Source.Microphone,
|
||||
Track.Source.Camera,
|
||||
);
|
||||
|
||||
const media = observeParticipantMedia(participant);
|
||||
this.audioEnabled = state(
|
||||
media.pipe(map((m) => m.microphoneTrack?.isMuted === false)),
|
||||
);
|
||||
this.videoEnabled = state(
|
||||
media.pipe(map((m) => m.cameraTrack?.isMuted === false)),
|
||||
);
|
||||
|
||||
// Sync the local mute state and volume with LiveKit
|
||||
if (!this.local)
|
||||
combineLatest([this._locallyMuted, this._localVolume], (muted, volume) =>
|
||||
muted ? 0 : volume,
|
||||
)
|
||||
.pipe(this.scope.bind())
|
||||
.subscribe((volume) => {
|
||||
(this.participant as RemoteParticipant).setVolume(volume);
|
||||
});
|
||||
}
|
||||
|
||||
public toggleLocallyMuted(): void {
|
||||
this._locallyMuted.next(!this._locallyMuted.value);
|
||||
}
|
||||
|
||||
public toggleFitContain(): void {
|
||||
this._cropVideo.next(!this._cropVideo.value);
|
||||
}
|
||||
|
||||
public setLocalVolume(value: number): void {
|
||||
this._localVolume.next(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some participant's screen share media.
|
||||
*/
|
||||
export class ScreenShareViewModel extends BaseMediaViewModel {
|
||||
public constructor(
|
||||
id: string,
|
||||
member: RoomMember | undefined,
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
callEncrypted: boolean,
|
||||
) {
|
||||
super(
|
||||
id,
|
||||
member,
|
||||
participant,
|
||||
callEncrypted,
|
||||
Track.Source.ScreenShareAudio,
|
||||
Track.Source.ScreenShare,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user