2023-11-30 22:59:19 -05:00
|
|
|
/*
|
2024-09-06 10:22:13 +02:00
|
|
|
Copyright 2023, 2024 New Vector Ltd.
|
2023-11-30 22:59:19 -05:00
|
|
|
|
2024-09-06 10:22:13 +02:00
|
|
|
SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
Please see LICENSE in the repository root for full details.
|
2023-11-30 22:59:19 -05:00
|
|
|
*/
|
|
|
|
|
|
2023-12-01 17:43:09 -05:00
|
|
|
import {
|
2024-12-11 09:27:55 +00:00
|
|
|
type AudioSource,
|
|
|
|
|
type TrackReferenceOrPlaceholder,
|
|
|
|
|
type VideoSource,
|
2023-12-01 17:43:09 -05:00
|
|
|
observeParticipantEvents,
|
|
|
|
|
observeParticipantMedia,
|
2024-11-06 11:12:46 +00:00
|
|
|
roomEventSelector,
|
2023-12-01 17:43:09 -05:00
|
|
|
} from "@livekit/components-core";
|
|
|
|
|
import {
|
2024-12-11 09:27:55 +00:00
|
|
|
type LocalParticipant,
|
2023-12-01 17:43:09 -05:00
|
|
|
LocalTrack,
|
2024-12-11 09:27:55 +00:00
|
|
|
type Participant,
|
2023-12-01 17:43:09 -05:00
|
|
|
ParticipantEvent,
|
2024-12-11 09:27:55 +00:00
|
|
|
type RemoteParticipant,
|
2023-12-01 17:43:09 -05:00
|
|
|
Track,
|
|
|
|
|
TrackEvent,
|
|
|
|
|
facingModeFromLocalTrack,
|
2024-12-11 09:27:55 +00:00
|
|
|
type Room as LivekitRoom,
|
2024-11-06 11:12:46 +00:00
|
|
|
RoomEvent as LivekitRoomEvent,
|
|
|
|
|
RemoteTrack,
|
2023-12-01 17:43:09 -05:00
|
|
|
} from "livekit-client";
|
2024-12-11 09:27:55 +00:00
|
|
|
import { type RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
|
2023-12-01 17:43:09 -05:00
|
|
|
import {
|
|
|
|
|
BehaviorSubject,
|
2024-12-11 09:27:55 +00:00
|
|
|
type Observable,
|
2024-10-18 17:51:37 -04:00
|
|
|
Subject,
|
2023-12-01 17:43:09 -05:00
|
|
|
combineLatest,
|
|
|
|
|
distinctUntilKeyChanged,
|
2024-11-06 11:12:46 +00:00
|
|
|
filter,
|
2023-12-01 17:43:09 -05:00
|
|
|
fromEvent,
|
2024-11-06 11:12:46 +00:00
|
|
|
interval,
|
2023-12-01 17:43:09 -05:00
|
|
|
map,
|
2024-10-18 17:51:37 -04:00
|
|
|
merge,
|
2023-12-01 17:43:09 -05:00
|
|
|
of,
|
|
|
|
|
startWith,
|
|
|
|
|
switchMap,
|
2024-11-06 11:12:46 +00:00
|
|
|
throttleTime,
|
2023-12-01 17:43:09 -05:00
|
|
|
} from "rxjs";
|
2024-05-02 18:44:36 -04:00
|
|
|
import { useEffect } from "react";
|
2023-11-30 22:59:19 -05:00
|
|
|
|
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";
|
2024-12-11 09:27:55 +00:00
|
|
|
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
2024-11-04 09:11:44 +00:00
|
|
|
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-12-17 04:01:56 +00:00
|
|
|
export function observeTrackReference$(
|
|
|
|
|
participant$: Observable<Participant | undefined>,
|
2023-12-01 17:43:09 -05:00
|
|
|
source: Track.Source,
|
2024-12-06 12:28:37 +01:00
|
|
|
): Observable<TrackReferenceOrPlaceholder | undefined> {
|
2024-12-17 04:01:56 +00:00
|
|
|
return participant$.pipe(
|
2024-12-06 12:28:37 +01:00
|
|
|
switchMap((p) => {
|
|
|
|
|
if (p) {
|
|
|
|
|
return observeParticipantMedia(p).pipe(
|
|
|
|
|
map(() => ({
|
|
|
|
|
participant: p,
|
|
|
|
|
publication: p.getTrackPublication(source),
|
|
|
|
|
source,
|
|
|
|
|
})),
|
|
|
|
|
distinctUntilKeyChanged("publication"),
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
return of(undefined);
|
|
|
|
|
}
|
|
|
|
|
}),
|
2023-12-01 17:43:09 -05:00
|
|
|
);
|
2023-11-30 22:59:19 -05:00
|
|
|
}
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
function observeRemoteTrackReceivingOkay$(
|
2024-11-06 11:12:46 +00:00
|
|
|
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 combineLatest([
|
2024-12-17 04:01:56 +00:00
|
|
|
observeTrackReference$(of(participant), source),
|
2024-11-06 11:12:46 +00:00
|
|
|
interval(1000).pipe(startWith(0)),
|
|
|
|
|
]).pipe(
|
|
|
|
|
switchMap(async ([trackReference]) => {
|
2024-12-06 12:28:37 +01:00
|
|
|
const track = trackReference?.publication?.track;
|
2024-11-06 11:12:46 +00:00
|
|
|
if (!track || !(track instanceof RemoteTrack)) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
const report = await track.getRTCStatsReport();
|
|
|
|
|
if (!report) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const v of report.values()) {
|
|
|
|
|
if (v.type === "inbound-rtp") {
|
|
|
|
|
const { framesDecoded, framesDropped, framesReceived } =
|
|
|
|
|
v as RTCInboundRtpStreamStats;
|
|
|
|
|
return {
|
|
|
|
|
framesDecoded,
|
|
|
|
|
framesDropped,
|
|
|
|
|
framesReceived,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return undefined;
|
|
|
|
|
}),
|
|
|
|
|
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),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
function encryptionErrorObservable$(
|
2024-11-06 11:12:46 +00:00
|
|
|
room: LivekitRoom,
|
|
|
|
|
participant: Participant,
|
|
|
|
|
encryptionSystem: EncryptionSystem,
|
|
|
|
|
criteria: string,
|
|
|
|
|
): Observable<boolean> {
|
|
|
|
|
return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe(
|
|
|
|
|
map((e) => {
|
|
|
|
|
const [err] = e;
|
|
|
|
|
if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
|
|
|
|
return (
|
|
|
|
|
// Ideally we would pull the participant identity from the field on the error.
|
|
|
|
|
// However, it gets lost in the serialization process between workers.
|
|
|
|
|
// So, instead we do a string match
|
|
|
|
|
(err?.message.includes(participant.identity) &&
|
|
|
|
|
err?.message.includes(criteria)) ??
|
|
|
|
|
false
|
|
|
|
|
);
|
|
|
|
|
} else if (encryptionSystem.kind === E2eeType.SHARED_KEY) {
|
|
|
|
|
return !!err?.message.includes(criteria);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}),
|
|
|
|
|
throttleTime(1000), // Throttle to avoid spamming the UI
|
|
|
|
|
startWith(false),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export enum EncryptionStatus {
|
|
|
|
|
Connecting,
|
|
|
|
|
Okay,
|
|
|
|
|
KeyMissing,
|
|
|
|
|
KeyInvalid,
|
|
|
|
|
PasswordInvalid,
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
* The LiveKit video track for this media.
|
2023-12-01 17:43:09 -05:00
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly video$: Observable<TrackReferenceOrPlaceholder | undefined>;
|
2023-12-01 17:43:09 -05:00
|
|
|
/**
|
|
|
|
|
* Whether there should be a warning that this media is unencrypted.
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly unencryptedWarning$: Observable<boolean>;
|
2023-11-30 22:59:19 -05:00
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly encryptionStatus$: Observable<EncryptionStatus>;
|
2024-11-06 11:12:46 +00:00
|
|
|
|
2024-12-06 12:28:37 +01:00
|
|
|
/**
|
|
|
|
|
* Whether this media corresponds to the local participant.
|
|
|
|
|
*/
|
|
|
|
|
public abstract readonly local: boolean;
|
|
|
|
|
|
2023-11-30 22:59:19 -05:00
|
|
|
public constructor(
|
2024-05-02 18:44:36 -04:00
|
|
|
/**
|
|
|
|
|
* An opaque identifier for this media.
|
|
|
|
|
*/
|
2023-11-30 22:59:19 -05:00
|
|
|
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
|
2023-11-30 22:59:19 -05:00
|
|
|
public readonly member: RoomMember | undefined,
|
2024-12-06 12:28:37 +01:00
|
|
|
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
|
|
|
|
|
// livekit.
|
2024-12-17 04:01:56 +00:00
|
|
|
protected readonly participant$: Observable<
|
2024-12-06 12:28:37 +01:00
|
|
|
LocalParticipant | RemoteParticipant | undefined
|
|
|
|
|
>,
|
|
|
|
|
|
2024-11-04 09:11:44 +00:00
|
|
|
encryptionSystem: EncryptionSystem,
|
2023-12-01 17:43:09 -05:00
|
|
|
audioSource: AudioSource,
|
|
|
|
|
videoSource: VideoSource,
|
2024-11-06 11:12:46 +00:00
|
|
|
livekitRoom: LivekitRoom,
|
2023-11-30 22:59:19 -05:00
|
|
|
) {
|
|
|
|
|
super();
|
2024-12-17 04:01:56 +00:00
|
|
|
const audio$ = observeTrackReference$(participant$, audioSource).pipe(
|
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-12-17 04:01:56 +00:00
|
|
|
this.video$ = observeTrackReference$(participant$, videoSource).pipe(
|
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-12-17 04:01:56 +00:00
|
|
|
this.unencryptedWarning$ = combineLatest(
|
|
|
|
|
[audio$, this.video$],
|
2024-05-16 15:23:10 -04:00
|
|
|
(a, v) =>
|
2024-11-04 09:11:44 +00:00
|
|
|
encryptionSystem.kind !== E2eeType.NONE &&
|
2024-12-06 12:28:37 +01:00
|
|
|
(a?.publication?.isEncrypted === false ||
|
|
|
|
|
v?.publication?.isEncrypted === false),
|
|
|
|
|
).pipe(this.scope.state());
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
this.encryptionStatus$ = this.participant$.pipe(
|
2024-12-06 12:28:37 +01:00
|
|
|
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([
|
2024-12-17 04:01:56 +00:00
|
|
|
encryptionErrorObservable$(
|
2024-12-06 12:28:37 +01:00
|
|
|
livekitRoom,
|
|
|
|
|
participant,
|
|
|
|
|
encryptionSystem,
|
|
|
|
|
"MissingKey",
|
|
|
|
|
),
|
2024-12-17 04:01:56 +00:00
|
|
|
encryptionErrorObservable$(
|
2024-12-06 12:28:37 +01:00
|
|
|
livekitRoom,
|
|
|
|
|
participant,
|
|
|
|
|
encryptionSystem,
|
|
|
|
|
"InvalidKey",
|
|
|
|
|
),
|
2024-12-17 04:01:56 +00:00
|
|
|
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
|
|
|
|
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
2024-12-06 12:28:37 +01:00
|
|
|
]).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([
|
2024-12-17 04:01:56 +00:00
|
|
|
encryptionErrorObservable$(
|
2024-12-06 12:28:37 +01:00
|
|
|
livekitRoom,
|
|
|
|
|
participant,
|
|
|
|
|
encryptionSystem,
|
|
|
|
|
"InvalidKey",
|
|
|
|
|
),
|
2024-12-17 04:01:56 +00:00
|
|
|
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
|
|
|
|
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
2024-12-06 12:28:37 +01:00
|
|
|
]).pipe(
|
|
|
|
|
map(
|
|
|
|
|
([keyInvalid, audioOkay, videoOkay]):
|
|
|
|
|
| EncryptionStatus
|
|
|
|
|
| undefined => {
|
|
|
|
|
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
|
|
|
|
|
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
|
|
|
|
return undefined; // no change
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
filter((x) => !!x),
|
|
|
|
|
startWith(EncryptionStatus.Connecting),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
this.scope.state(),
|
2024-11-06 14:33:06 +00:00
|
|
|
);
|
2023-11-30 22:59:19 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
2024-05-16 12:32:18 -04:00
|
|
|
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
|
|
|
*/
|
2024-05-16 12:32:18 -04:00
|
|
|
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
2023-12-01 17:43:09 -05:00
|
|
|
/**
|
|
|
|
|
* Whether the participant is speaking.
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly speaking$ = this.participant$.pipe(
|
2024-12-06 12:28:37 +01:00
|
|
|
switchMap((p) =>
|
|
|
|
|
p
|
|
|
|
|
? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe(
|
|
|
|
|
map((p) => p.isSpeaking),
|
|
|
|
|
)
|
|
|
|
|
: of(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
|
|
|
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-12-17 04:01:56 +00:00
|
|
|
public readonly audioEnabled$: Observable<boolean>;
|
2023-12-01 17:43:09 -05:00
|
|
|
/**
|
|
|
|
|
* Whether this participant is sending video.
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly videoEnabled$: Observable<boolean>;
|
2023-12-01 17:43:09 -05:00
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly _cropVideo$ = new BehaviorSubject(true);
|
2024-02-12 16:49:32 +01:00
|
|
|
/**
|
|
|
|
|
* Whether the tile video should be contained inside the tile or be cropped to fit.
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly cropVideo$: Observable<boolean> = this._cropVideo$;
|
2024-02-12 16:49:32 +01:00
|
|
|
|
2023-11-30 22:59:19 -05:00
|
|
|
public constructor(
|
2023-12-01 17:43:09 -05:00
|
|
|
id: string,
|
|
|
|
|
member: RoomMember | undefined,
|
2024-12-17 04:01:56 +00:00
|
|
|
participant$: Observable<LocalParticipant | RemoteParticipant | undefined>,
|
2024-11-04 09:11:44 +00:00
|
|
|
encryptionSystem: EncryptionSystem,
|
2024-11-06 11:12:46 +00:00
|
|
|
livekitRoom: LivekitRoom,
|
2023-11-30 22:59:19 -05:00
|
|
|
) {
|
2023-12-01 17:43:09 -05:00
|
|
|
super(
|
|
|
|
|
id,
|
|
|
|
|
member,
|
2024-12-17 04:01:56 +00:00
|
|
|
participant$,
|
2024-11-04 09:11:44 +00:00
|
|
|
encryptionSystem,
|
2023-12-01 17:43:09 -05:00
|
|
|
Track.Source.Microphone,
|
|
|
|
|
Track.Source.Camera,
|
2024-11-06 11:12:46 +00:00
|
|
|
livekitRoom,
|
2023-12-01 17:43:09 -05:00
|
|
|
);
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
const media$ = participant$.pipe(
|
2024-12-06 12:28:37 +01:00
|
|
|
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
|
|
|
|
this.scope.state(),
|
|
|
|
|
);
|
2024-12-17 04:01:56 +00:00
|
|
|
this.audioEnabled$ = media$.pipe(
|
2024-12-06 12:28:37 +01:00
|
|
|
map((m) => m?.microphoneTrack?.isMuted === false),
|
2023-12-01 17:43:09 -05:00
|
|
|
);
|
2024-12-17 04:01:56 +00:00
|
|
|
this.videoEnabled$ = media$.pipe(
|
2024-12-06 12:28:37 +01:00
|
|
|
map((m) => m?.cameraTrack?.isMuted === false),
|
2023-12-01 17:43:09 -05:00
|
|
|
);
|
2024-05-16 12:32:18 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public toggleFitContain(): void {
|
2024-12-17 04:01:56 +00:00
|
|
|
this._cropVideo$.next(!this._cropVideo$.value);
|
2024-05-16 12:32:18 -04:00
|
|
|
}
|
2024-12-06 12:28:37 +01:00
|
|
|
|
|
|
|
|
public get local(): boolean {
|
|
|
|
|
return this instanceof LocalUserMediaViewModel;
|
|
|
|
|
}
|
2024-05-16 12:32:18 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The local participant's user media.
|
|
|
|
|
*/
|
|
|
|
|
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|
|
|
|
/**
|
|
|
|
|
* Whether the video should be mirrored.
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly mirror$ = this.video$.pipe(
|
2024-05-16 15:23:10 -04:00
|
|
|
switchMap((v) => {
|
2024-12-06 12:28:37 +01:00
|
|
|
const track = v?.publication?.track;
|
2024-05-16 15:23:10 -04:00
|
|
|
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 12:32:18 -04:00
|
|
|
);
|
|
|
|
|
|
2024-05-16 13:33:02 -04:00
|
|
|
/**
|
|
|
|
|
* Whether to show this tile in a highly visible location near the start of
|
|
|
|
|
* the grid.
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly alwaysShow$ = alwaysShowSelf.value$;
|
2024-05-16 13:33:02 -04:00
|
|
|
public readonly setAlwaysShow = alwaysShowSelf.setValue;
|
|
|
|
|
|
2024-05-16 12:32:18 -04:00
|
|
|
public constructor(
|
|
|
|
|
id: string,
|
|
|
|
|
member: RoomMember | undefined,
|
2024-12-17 04:01:56 +00:00
|
|
|
participant$: Observable<LocalParticipant | undefined>,
|
2024-11-04 09:11:44 +00:00
|
|
|
encryptionSystem: EncryptionSystem,
|
2024-11-06 11:12:46 +00:00
|
|
|
livekitRoom: LivekitRoom,
|
2024-05-16 12:32:18 -04:00
|
|
|
) {
|
2024-12-17 04:01:56 +00:00
|
|
|
super(id, member, participant$, encryptionSystem, livekitRoom);
|
2024-05-16 12:32:18 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A remote participant's user media.
|
|
|
|
|
*/
|
|
|
|
|
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
2024-12-17 04:01:56 +00: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
|
|
|
|
2024-05-16 12:32:18 -04:00
|
|
|
/**
|
2024-10-18 17:51:37 -04:00
|
|
|
* The volume to which this participant's audio is set, as a scalar
|
|
|
|
|
* multiplier.
|
2024-05-16 12:32:18 -04:00
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly localVolume$: Observable<number> = merge(
|
|
|
|
|
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
|
|
|
|
|
this.localVolumeAdjustment$,
|
|
|
|
|
this.localVolumeCommit$.pipe(map(() => "commit" as const)),
|
2024-10-18 17:51:37 -04:00
|
|
|
).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-05-16 12:32:18 -04:00
|
|
|
|
|
|
|
|
/**
|
2024-10-18 17:51:37 -04:00
|
|
|
* Whether this participant's audio is disabled.
|
2024-05-16 12:32:18 -04:00
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly locallyMuted$: Observable<boolean> = this.localVolume$.pipe(
|
2024-10-18 17:51:37 -04:00
|
|
|
map((volume) => volume === 0),
|
|
|
|
|
this.scope.state(),
|
|
|
|
|
);
|
2024-05-16 12:32:18 -04:00
|
|
|
|
|
|
|
|
public constructor(
|
|
|
|
|
id: string,
|
|
|
|
|
member: RoomMember | undefined,
|
2024-12-17 04:01:56 +00:00
|
|
|
participant$: Observable<RemoteParticipant | undefined>,
|
2024-11-04 09:11:44 +00:00
|
|
|
encryptionSystem: EncryptionSystem,
|
2024-11-06 11:12:46 +00:00
|
|
|
livekitRoom: LivekitRoom,
|
2024-05-16 12:32:18 -04:00
|
|
|
) {
|
2024-12-17 04:01:56 +00:00
|
|
|
super(id, member, participant$, encryptionSystem, livekitRoom);
|
2023-12-01 17:43:09 -05:00
|
|
|
|
2024-10-18 17:51:37 -04:00
|
|
|
// Sync the local volume with LiveKit
|
2024-12-06 12:28:37 +01:00
|
|
|
combineLatest([
|
2024-12-17 04:01:56 +00:00
|
|
|
participant$,
|
|
|
|
|
this.localVolume$.pipe(this.scope.bind()),
|
2024-12-06 12:28:37 +01:00
|
|
|
]).subscribe(([p, volume]) => p && p.setVolume(volume));
|
2023-12-01 17:43:09 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public toggleLocallyMuted(): void {
|
2024-12-17 04:01:56 +00:00
|
|
|
this.locallyMutedToggle$.next();
|
2023-12-01 17:43:09 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public setLocalVolume(value: number): void {
|
2024-12-17 04:01:56 +00:00
|
|
|
this.localVolumeAdjustment$.next(value);
|
2024-10-18 17:51:37 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public commitLocalVolume(): void {
|
2024-12-17 04:01:56 +00:00
|
|
|
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,
|
2024-12-17 04:01:56 +00:00
|
|
|
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
2024-11-04 09:11:44 +00:00
|
|
|
encryptionSystem: EncryptionSystem,
|
2024-11-06 11:12:46 +00:00
|
|
|
livekitRoom: LivekitRoom,
|
2024-12-06 12:28:37 +01:00
|
|
|
public readonly local: boolean,
|
2023-12-01 17:43:09 -05:00
|
|
|
) {
|
|
|
|
|
super(
|
|
|
|
|
id,
|
|
|
|
|
member,
|
2024-12-17 04:01:56 +00:00
|
|
|
participant$,
|
2024-11-04 09:11:44 +00:00
|
|
|
encryptionSystem,
|
2023-12-01 17:43:09 -05:00
|
|
|
Track.Source.ScreenShareAudio,
|
|
|
|
|
Track.Source.ScreenShare,
|
2024-11-06 11:12:46 +00:00
|
|
|
livekitRoom,
|
2023-12-01 17:43:09 -05:00
|
|
|
);
|
2023-11-30 22:59:19 -05:00
|
|
|
}
|
|
|
|
|
}
|