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

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";
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
2023-12-01 17:43:09 -05:00
import {
BehaviorSubject,
Observable,
Subject,
2023-12-01 17:43:09 -05:00
combineLatest,
distinctUntilKeyChanged,
fromEvent,
map,
merge,
2023-12-01 17:43:09 -05:00
of,
startWith,
switchMap,
} from "rxjs";
import { useEffect } from "react";
2023-12-01 17:43:09 -05:00
import { ViewModel } from "./ViewModel";
import { useReactiveState } from "../useReactiveState";
2024-05-16 13:33:02 -04:00
import { alwaysShowSelf } from "../settings/settings";
import { accumulate } from "../utils/observable";
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
// TODO: Move this naming logic into the view model
export function useDisplayName(vm: MediaViewModel): string {
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]);
return displayName;
}
2023-12-01 17:43:09 -05:00
export function observeTrackReference(
2023-12-01 17:43:09 -05:00
participant: Participant,
source: Track.Source,
): Observable<TrackReferenceOrPlaceholder> {
return observeParticipantMedia(participant).pipe(
map(() => ({
participant,
publication: participant.getTrackPublication(source),
source,
})),
distinctUntilKeyChanged("publication"),
2023-12-01 17:43:09 -05:00
);
}
abstract class BaseMediaViewModel extends ViewModel {
2023-12-01 17:43:09 -05:00
/**
* Whether the media belongs to the local user.
2023-12-01 17:43:09 -05:00
*/
public readonly local = this.participant.isLocal;
/**
* The LiveKit video track for this media.
2023-12-01 17:43:09 -05: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.
*/
public readonly unencryptedWarning: Observable<boolean>;
public constructor(
/**
* An opaque identifier for this media.
*/
public readonly id: string,
2023-12-01 17:43:09 -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();
Add simple global controls to put the call in picture-in-picture mode (#2573) * Stop sharing state observables when the view model is destroyed By default, observables running with shareReplay will continue running forever even if there are no subscribers. We need to stop them when the view model is destroyed to avoid memory leaks and other unintuitive behavior. * Hydrate the call view model in a less hacky way This ensures that only a single view model is created per call, unlike the previous solution which would create extra view models in strict mode which it was unable to dispose of. The other way was invalid because React gives us no way to reliably dispose of a resource created in the render phase. This is essentially a memory leak fix. * Add simple global controls to put the call in picture-in-picture mode Our web and mobile apps (will) all support putting calls into a picture-in-picture mode. However, it'd be nice to have a way of doing this that's more explicit than a breakpoint, because PiP views could in theory get fairly large. Specifically, on mobile, we want a way to do this that can tell you whether the call is ongoing, and that works even without the widget API (because we support SPA calls in the Element X apps…) To this end, I've created a simple global "controls" API on the window. Right now it only has methods for controlling the picture-in-picture state, but in theory we can expand it to also control mute states, which is current possible via the widget API only. * Fix footer appearing in large PiP views * Add a method for whether you can enter picture-in-picture mode * Have the controls emit booleans directly
2024-08-27 07:47:20 -04:00
const audio = observeTrackReference(participant, audioSource).pipe(
this.scope.state(),
);
this.video = observeTrackReference(participant, videoSource).pipe(
this.scope.state(),
);
this.unencryptedWarning = combineLatest(
[audio, this.video],
(a, v) =>
encryptionSystem.kind !== E2eeType.NONE &&
(a.publication?.isEncrypted === false ||
v.publication?.isEncrypted === false),
Add simple global controls to put the call in picture-in-picture mode (#2573) * Stop sharing state observables when the view model is destroyed By default, observables running with shareReplay will continue running forever even if there are no subscribers. We need to stop them when the view model is destroyed to avoid memory leaks and other unintuitive behavior. * Hydrate the call view model in a less hacky way This ensures that only a single view model is created per call, unlike the previous solution which would create extra view models in strict mode which it was unable to dispose of. The other way was invalid because React gives us no way to reliably dispose of a resource created in the render phase. This is essentially a memory leak fix. * Add simple global controls to put the call in picture-in-picture mode Our web and mobile apps (will) all support putting calls into a picture-in-picture mode. However, it'd be nice to have a way of doing this that's more explicit than a breakpoint, because PiP views could in theory get fairly large. Specifically, on mobile, we want a way to do this that can tell you whether the call is ongoing, and that works even without the widget API (because we support SPA calls in the Element X apps…) To this end, I've created a simple global "controls" API on the window. Right now it only has methods for controlling the picture-in-picture state, but in theory we can expand it to also control mute states, which is current possible via the widget API only. * Fix footer appearing in large PiP views * Add a method for whether you can enter picture-in-picture mode * Have the controls emit booleans directly
2024-08-27 07:47:20 -04:00
).pipe(this.scope.state());
}
}
2023-12-01 17:43:09 -05:00
/**
* Some participant's media.
2023-12-01 17:43:09 -05:00
*/
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
export type UserMediaViewModel =
| LocalUserMediaViewModel
| RemoteUserMediaViewModel;
2023-12-01 17:43:09 -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.
*/
public readonly speaking = observeParticipantEvents(
this.participant,
ParticipantEvent.IsSpeakingChanged,
).pipe(
map((p) => p.isSpeaking),
Add simple global controls to put the call in picture-in-picture mode (#2573) * Stop sharing state observables when the view model is destroyed By default, observables running with shareReplay will continue running forever even if there are no subscribers. We need to stop them when the view model is destroyed to avoid memory leaks and other unintuitive behavior. * Hydrate the call view model in a less hacky way This ensures that only a single view model is created per call, unlike the previous solution which would create extra view models in strict mode which it was unable to dispose of. The other way was invalid because React gives us no way to reliably dispose of a resource created in the render phase. This is essentially a memory leak fix. * Add simple global controls to put the call in picture-in-picture mode Our web and mobile apps (will) all support putting calls into a picture-in-picture mode. However, it'd be nice to have a way of doing this that's more explicit than a breakpoint, because PiP views could in theory get fairly large. Specifically, on mobile, we want a way to do this that can tell you whether the call is ongoing, and that works even without the widget API (because we support SPA calls in the Element X apps…) To this end, I've created a simple global "controls" API on the window. Right now it only has methods for controlling the picture-in-picture state, but in theory we can expand it to also control mute states, which is current possible via the widget API only. * Fix footer appearing in large PiP views * Add a method for whether you can enter picture-in-picture mode * Have the controls emit booleans directly
2024-08-27 07:47:20 -04:00
this.scope.state(),
2023-12-01 17:43:09 -05:00
);
/**
* Whether this participant is sending audio (i.e. is unmuted on their side).
*/
public readonly audioEnabled: Observable<boolean>;
2023-12-01 17:43:09 -05:00
/**
* Whether this participant is sending video.
*/
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.
*/
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,
);
Add simple global controls to put the call in picture-in-picture mode (#2573) * Stop sharing state observables when the view model is destroyed By default, observables running with shareReplay will continue running forever even if there are no subscribers. We need to stop them when the view model is destroyed to avoid memory leaks and other unintuitive behavior. * Hydrate the call view model in a less hacky way This ensures that only a single view model is created per call, unlike the previous solution which would create extra view models in strict mode which it was unable to dispose of. The other way was invalid because React gives us no way to reliably dispose of a resource created in the render phase. This is essentially a memory leak fix. * Add simple global controls to put the call in picture-in-picture mode Our web and mobile apps (will) all support putting calls into a picture-in-picture mode. However, it'd be nice to have a way of doing this that's more explicit than a breakpoint, because PiP views could in theory get fairly large. Specifically, on mobile, we want a way to do this that can tell you whether the call is ongoing, and that works even without the widget API (because we support SPA calls in the Element X apps…) To this end, I've created a simple global "controls" API on the window. Right now it only has methods for controlling the picture-in-picture state, but in theory we can expand it to also control mute states, which is current possible via the widget API only. * Fix footer appearing in large PiP views * Add a method for whether you can enter picture-in-picture mode * Have the controls emit booleans directly
2024-08-27 07:47:20 -04:00
const media = observeParticipantMedia(participant).pipe(this.scope.state());
this.audioEnabled = media.pipe(
map((m) => m.microphoneTrack?.isMuted === false),
2023-12-01 17:43:09 -05: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.
*/
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"),
);
}),
Add simple global controls to put the call in picture-in-picture mode (#2573) * Stop sharing state observables when the view model is destroyed By default, observables running with shareReplay will continue running forever even if there are no subscribers. We need to stop them when the view model is destroyed to avoid memory leaks and other unintuitive behavior. * Hydrate the call view model in a less hacky way This ensures that only a single view model is created per call, unlike the previous solution which would create extra view models in strict mode which it was unable to dispose of. The other way was invalid because React gives us no way to reliably dispose of a resource created in the render phase. This is essentially a memory leak fix. * Add simple global controls to put the call in picture-in-picture mode Our web and mobile apps (will) all support putting calls into a picture-in-picture mode. However, it'd be nice to have a way of doing this that's more explicit than a breakpoint, because PiP views could in theory get fairly large. Specifically, on mobile, we want a way to do this that can tell you whether the call is ongoing, and that works even without the widget API (because we support SPA calls in the Element X apps…) To this end, I've created a simple global "controls" API on the window. Right now it only has methods for controlling the picture-in-picture state, but in theory we can expand it to also control mute states, which is current possible via the widget API only. * Fix footer appearing in large PiP views * Add a method for whether you can enter picture-in-picture mode * Have the controls emit booleans directly
2024-08-27 07:47:20 -04:00
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 {
private readonly locallyMutedToggle = new Subject<void>();
private readonly localVolumeAdjustment = new Subject<number>();
private readonly localVolumeCommit = new Subject<void>();
/**
* The volume to which this participant's audio is set, as a scalar
* multiplier.
*/
public readonly localVolume: Observable<number> = merge(
this.locallyMutedToggle.pipe(map(() => "toggle mute" as const)),
this.localVolumeAdjustment,
this.localVolumeCommit.pipe(map(() => "commit" as const)),
).pipe(
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),
this.scope.state(),
);
/**
* Whether this participant's audio is disabled.
*/
public readonly locallyMuted: Observable<boolean> = this.localVolume.pipe(
map((volume) => volume === 0),
this.scope.state(),
);
public constructor(
id: string,
member: RoomMember | undefined,
participant: RemoteParticipant,
encryptionSystem: EncryptionSystem,
) {
super(id, member, participant, encryptionSystem);
2023-12-01 17:43:09 -05:00
// Sync the local volume with LiveKit
this.localVolume
.pipe(this.scope.bind())
.subscribe((volume) =>
(this.participant as RemoteParticipant).setVolume(volume),
);
2023-12-01 17:43:09 -05:00
}
public toggleLocallyMuted(): void {
this.locallyMutedToggle.next();
2023-12-01 17:43:09 -05:00
}
public setLocalVolume(value: number): void {
this.localVolumeAdjustment.next(value);
}
public commitLocalVolume(): void {
this.localVolumeCommit.next();
2023-12-01 17:43:09 -05:00
}
}
/**
* Some participant's screen share media.
2023-12-01 17:43:09 -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,
);
}
}