mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
Merge remote-tracking branch 'upstream/livekit' into livekit
This commit is contained in:
@@ -29,7 +29,6 @@ import {
|
||||
pairwise,
|
||||
race,
|
||||
scan,
|
||||
skipWhile,
|
||||
startWith,
|
||||
Subject,
|
||||
switchAll,
|
||||
@@ -39,8 +38,9 @@ import {
|
||||
tap,
|
||||
throttleTime,
|
||||
timer,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
type LivekitTransportConfig,
|
||||
@@ -70,8 +70,8 @@ import { setPipEnabled$ } from "../../controls";
|
||||
import { TileStore } from "../TileStore";
|
||||
import { gridLikeLayout } from "../GridLikeLayout";
|
||||
import { spotlightExpandedLayout } from "../SpotlightExpandedLayout";
|
||||
import { oneOnOneLandscapeLayout } from "../OneOnOneLandscapeLayout";
|
||||
import { oneOnOnePortraitLayout } from "../OneOnOnePortraitLayout";
|
||||
import { oneOnOneDesktopLayout } from "../OneOnOneDesktopLayout";
|
||||
import { oneOnOneMobileLayout } from "../OneOnOneMobileLayout";
|
||||
import { pipLayout } from "../PipLayout";
|
||||
import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement";
|
||||
import {
|
||||
@@ -93,8 +93,8 @@ import {
|
||||
type GridLayoutMedia,
|
||||
type Layout,
|
||||
type LayoutMedia,
|
||||
type OneOnOneLandscapeLayoutMedia,
|
||||
type OneOnOnePortraitLayoutMedia,
|
||||
type OneOnOneDesktopLayoutMedia,
|
||||
type OneOnOneMobileLayoutMedia,
|
||||
type SpotlightExpandedLayoutMedia,
|
||||
type SpotlightLandscapeLayoutMedia,
|
||||
type SpotlightPortraitLayoutMedia,
|
||||
@@ -125,10 +125,9 @@ import {
|
||||
createConnectionManager$,
|
||||
} from "./remoteMembers/ConnectionManager.ts";
|
||||
import {
|
||||
createMatrixLivekitMembers$,
|
||||
createRemoteMatrixLivekitMembers$,
|
||||
type LocalMatrixLivekitMember,
|
||||
type RemoteMatrixLivekitMember,
|
||||
type MatrixLivekitMember,
|
||||
} from "./remoteMembers/MatrixLivekitMembers.ts";
|
||||
import {
|
||||
type AutoLeaveReason,
|
||||
@@ -142,7 +141,10 @@ import {
|
||||
} from "./remoteMembers/MatrixMemberMetadata.ts";
|
||||
import { Publisher } from "./localMember/Publisher.ts";
|
||||
import { type Connection } from "./remoteMembers/Connection.ts";
|
||||
import { createLayoutModeSwitch } from "./LayoutSwitch.ts";
|
||||
import {
|
||||
type LayoutSwitchViewModel,
|
||||
createLayoutSwitchViewModel,
|
||||
} from "../LayoutSwitchViewModel.ts";
|
||||
import {
|
||||
createWrappedUserMedia,
|
||||
type WrappedUserMediaViewModel,
|
||||
@@ -156,8 +158,8 @@ import {
|
||||
createRingingMedia,
|
||||
type RingingMediaViewModel,
|
||||
} from "../media/RingingMediaViewModel.ts";
|
||||
import { type GridTileViewModel } from "../TileViewModel.ts";
|
||||
|
||||
const logger = rootLogger.getChild("[CallViewModel]");
|
||||
//TODO
|
||||
// Larger rename
|
||||
// member,membership -> rtcMember
|
||||
@@ -202,12 +204,11 @@ const smallMobileCallThreshold = 3;
|
||||
// with the interface
|
||||
const showFooterMs = 4000;
|
||||
|
||||
export type GridMode = "grid" | "spotlight";
|
||||
|
||||
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
|
||||
|
||||
interface LayoutScanState {
|
||||
layout: Layout | null;
|
||||
overflowing: boolean;
|
||||
tiles: TileStore;
|
||||
}
|
||||
|
||||
@@ -230,9 +231,13 @@ export interface CallViewModel {
|
||||
// lifecycle
|
||||
autoLeave$: Observable<AutoLeaveReason>;
|
||||
/**
|
||||
* Whether we are ringing a call recipient.
|
||||
* View model for info relating to ringing, timing out, calling back, etc.
|
||||
*/
|
||||
ringing$: Behavior<boolean>;
|
||||
ringingVm$: Behavior<RingingMediaViewModel | null>;
|
||||
/**
|
||||
* Which visual element the ringing status should be shown in.
|
||||
*/
|
||||
ringingStatusLocation: "app_bar" | "tile";
|
||||
/** Observable that emits when the user should leave the call (hangup pressed, widget action, error).
|
||||
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is
|
||||
* - by ending the scope
|
||||
@@ -300,7 +305,7 @@ export interface CallViewModel {
|
||||
/** Participants sorted by livekit room so they can be used in the audio rendering */
|
||||
livekitRoomItems$: Behavior<LivekitRoomItem[]>;
|
||||
/** use the layout instead, this is just for the sdk export. */
|
||||
matrixLivekitMembers$: Behavior<RemoteMatrixLivekitMember[]>;
|
||||
remoteMatrixLivekitMembers$: Behavior<RemoteMatrixLivekitMember[]>;
|
||||
localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null>;
|
||||
/** List of participants raising their hand */
|
||||
handsRaised$: Behavior<Record<string, RaisedHandInfo>>;
|
||||
@@ -345,8 +350,7 @@ export interface CallViewModel {
|
||||
showNameTags$: Behavior<boolean>;
|
||||
spotlightExpanded$: Behavior<boolean>;
|
||||
toggleSpotlightExpanded$: Behavior<(() => void) | null>;
|
||||
gridMode$: Behavior<GridMode>;
|
||||
setGridMode: (value: GridMode) => void;
|
||||
layoutSwitchVm$: Behavior<LayoutSwitchViewModel | null>;
|
||||
|
||||
// header/footer visibility
|
||||
showHeader$: Behavior<boolean>;
|
||||
@@ -356,6 +360,10 @@ export interface CallViewModel {
|
||||
* and header as overlays.
|
||||
*/
|
||||
edgeToEdge$: Behavior<boolean>;
|
||||
/**
|
||||
* Whether the call layout is overflowing the interface (causing it to scroll).
|
||||
*/
|
||||
overflowing$: Behavior<boolean>;
|
||||
|
||||
settingsOpen$: Behavior<boolean>;
|
||||
setSettingsOpen$: Behavior<(open: boolean) => void>;
|
||||
@@ -407,6 +415,7 @@ export function createCallViewModel$(
|
||||
reactionsSubject$: Observable<Record<string, ReactionInfo>>,
|
||||
trackProcessorState$: Behavior<ProcessorState>,
|
||||
): CallViewModel {
|
||||
const logger = rootLogger.getChild("[CallViewModel]");
|
||||
const client = matrixRoom.client;
|
||||
const userId = client.getUserId();
|
||||
const deviceId = client.getDeviceId();
|
||||
@@ -416,6 +425,7 @@ export function createCallViewModel$(
|
||||
const livekitKeyProvider = getE2eeKeyProvider(
|
||||
options.encryptionSystem,
|
||||
matrixRTCSession,
|
||||
logger,
|
||||
);
|
||||
// matrix_rtc_mode in config.json overrides the user's Developer Settings choice.
|
||||
// It is validated at config load (src/config/Config.ts) so the cast is safe.
|
||||
@@ -525,13 +535,15 @@ export function createCallViewModel$(
|
||||
ownMembershipIdentity,
|
||||
});
|
||||
|
||||
const matrixLivekitMembers$: Behavior<Epoch<RemoteMatrixLivekitMember[]>> =
|
||||
createMatrixLivekitMembers$({
|
||||
scope: scope,
|
||||
membershipsWithTransport$:
|
||||
membershipsAndTransports.membershipsWithTransport$,
|
||||
connectionManager: connectionManager,
|
||||
});
|
||||
const remoteMatrixLivekitMembers$: Behavior<
|
||||
Epoch<RemoteMatrixLivekitMember[]>
|
||||
> = createRemoteMatrixLivekitMembers$({
|
||||
scope: scope,
|
||||
membershipsWithTransport$:
|
||||
membershipsAndTransports.membershipsWithTransport$,
|
||||
connectionManager: connectionManager,
|
||||
localUser: { userId, deviceId },
|
||||
});
|
||||
|
||||
const connectOptions$ = scope.behavior(
|
||||
matrixRTCMode$.pipe(
|
||||
@@ -608,19 +620,12 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// callLifecycle
|
||||
|
||||
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
|
||||
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
|
||||
const { callPickupState$, autoLeave$ } = createCallNotificationLifecycle$({
|
||||
scope: scope,
|
||||
memberships$: memberships$,
|
||||
sentCallNotification$: createSentCallNotification$(scope, matrixRTCSession),
|
||||
receivedDecline$: createReceivedDecline$(matrixRoom),
|
||||
options: options,
|
||||
localUser: { userId: userId, deviceId: deviceId },
|
||||
});
|
||||
const matrixLivekitMembers$ = scope.behavior(
|
||||
combineLatest(
|
||||
[localMatrixLivekitMember$, remoteMatrixLivekitMembers$],
|
||||
(local, remote) => [...(local === null ? [] : [local]), ...remote.value],
|
||||
),
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// matrixMemberMetadataStore
|
||||
@@ -632,11 +637,26 @@ export function createCallViewModel$(
|
||||
matrixRoomMembers$,
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// callLifecycle
|
||||
|
||||
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
|
||||
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
|
||||
const { ringAttempts$, autoLeave$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
memberships$,
|
||||
matrixRoomMembers$,
|
||||
sentCallNotification$: createSentCallNotification$(scope, matrixRTCSession),
|
||||
receivedDecline$: createReceivedDecline$(matrixRoom),
|
||||
options,
|
||||
localUser: { userId, deviceId },
|
||||
});
|
||||
|
||||
const allConnections$ = scope.behavior(
|
||||
connectionManager.connectionManagerData$.pipe(map((d) => d.value)),
|
||||
);
|
||||
const livekitRoomItems$ = scope.behavior(
|
||||
matrixLivekitMembers$.pipe(
|
||||
remoteMatrixLivekitMembers$.pipe(
|
||||
switchMap((members) => {
|
||||
const a$ = combineLatest(
|
||||
members.value.map((member) =>
|
||||
@@ -702,43 +722,20 @@ export function createCallViewModel$(
|
||||
* List of user media (camera feeds) that we want tiles for.
|
||||
*/
|
||||
const userMedia$ = scope.behavior<WrappedUserMediaViewModel[]>(
|
||||
combineLatest([
|
||||
localMatrixLivekitMember$,
|
||||
matrixLivekitMembers$,
|
||||
duplicateTiles.value$,
|
||||
]).pipe(
|
||||
combineLatest([matrixLivekitMembers$, duplicateTiles.value$]).pipe(
|
||||
// Generate a collection of user media from the list of expected (whether
|
||||
// present or missing) LiveKit participants.
|
||||
generateItems(
|
||||
"CallViewModel userMedia$",
|
||||
function* ([
|
||||
localMatrixLivekitMember,
|
||||
matrixLivekitMembers,
|
||||
duplicateTiles,
|
||||
]) {
|
||||
const computeMediaId = (m: MatrixLivekitMember): string =>
|
||||
`${m.userId}:${m.membership$.value.deviceId}`;
|
||||
|
||||
const localUserMediaId = localMatrixLivekitMember
|
||||
? computeMediaId(localMatrixLivekitMember)
|
||||
: undefined;
|
||||
|
||||
const localAsArray = localMatrixLivekitMember
|
||||
? [localMatrixLivekitMember]
|
||||
: [];
|
||||
const remoteWithoutLocal = matrixLivekitMembers.value.filter(
|
||||
(m) => computeMediaId(m) !== localUserMediaId,
|
||||
);
|
||||
const allMatrixLivekitMembers = [
|
||||
...localAsArray,
|
||||
...remoteWithoutLocal,
|
||||
];
|
||||
|
||||
for (const matrixLivekitMember of allMatrixLivekitMembers) {
|
||||
const { userId, participant, connection$, membership$ } =
|
||||
matrixLivekitMember;
|
||||
const rtcId = membership$.value.rtcBackendIdentity; // rtcBackendIdentity
|
||||
const mediaId = computeMediaId(matrixLivekitMember);
|
||||
function* ([members, duplicateTiles]) {
|
||||
for (const {
|
||||
userId,
|
||||
participant,
|
||||
connection$,
|
||||
membership$,
|
||||
} of members) {
|
||||
const rtcId = membership$.value.rtcBackendIdentity;
|
||||
const mediaId = `${userId}:${membership$.value.deviceId}`;
|
||||
for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
|
||||
yield {
|
||||
keys: [dup, mediaId, userId, participant, connection$, rtcId],
|
||||
@@ -764,11 +761,13 @@ export function createCallViewModel$(
|
||||
pretendToBeDisconnected$: localMembership.reconnecting$,
|
||||
displayName$: scope.behavior(
|
||||
matrixMemberMetadataStore
|
||||
.createDisplayNameBehavior$(userId)
|
||||
.createDisplayNameBehavior$(scope, userId)
|
||||
.pipe(map((name) => name ?? userId)),
|
||||
),
|
||||
mxcAvatarUrl$:
|
||||
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
|
||||
mxcAvatarUrl$: matrixMemberMetadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
userId,
|
||||
),
|
||||
handRaised$: scope.behavior(
|
||||
handsRaised$.pipe(map((v) => v[mediaId]?.time ?? null)),
|
||||
),
|
||||
@@ -780,49 +779,42 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
const ringingMedia$ = scope.behavior<RingingMediaViewModel[]>(
|
||||
combineLatest([userMedia$, matrixRoomMembers$, callPickupState$]).pipe(
|
||||
generateItems(
|
||||
"CallViewModel ringingMedia$",
|
||||
function* ([userMedia, roomMembers, callPickupState]) {
|
||||
if (
|
||||
callPickupState === "ringing" ||
|
||||
callPickupState === "timeout" ||
|
||||
callPickupState === "decline"
|
||||
) {
|
||||
// TODO: Respect io.element.functional_members
|
||||
for (const member of roomMembers.values()) {
|
||||
if (!userMedia.some((vm) => vm.userId === member.userId))
|
||||
yield {
|
||||
keys: [member.userId],
|
||||
data: callPickupState,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
(scope, pickupState$, userId) =>
|
||||
createRingingMedia({
|
||||
id: `ringing:${userId}`,
|
||||
userId,
|
||||
displayName$: scope.behavior(
|
||||
matrixRoomMembers$.pipe(
|
||||
map((members) => members.get(userId)?.rawDisplayName || userId),
|
||||
),
|
||||
),
|
||||
mxcAvatarUrl$:
|
||||
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
|
||||
pickupState$,
|
||||
muteStates,
|
||||
}),
|
||||
const ringingMedia$ = scope.behavior<RingingMediaViewModel | null>(
|
||||
ringAttempts$.pipe(
|
||||
switchMap(({ intent, recipient, outcome$ }) =>
|
||||
outcome$.pipe(
|
||||
startWith("ringing" as const),
|
||||
generateItems(
|
||||
"CallViewModel ringingMedia$",
|
||||
function* (pickupState) {
|
||||
if (pickupState !== "accept")
|
||||
yield { keys: [intent, recipient], data: pickupState };
|
||||
},
|
||||
(scope, pickupState$, intent, userId) =>
|
||||
createRingingMedia({
|
||||
id: `ringing:${userId}`,
|
||||
userId,
|
||||
displayName$: scope.behavior(
|
||||
matrixRoomMembers$.pipe(
|
||||
map(
|
||||
(members) =>
|
||||
members.get(userId)?.rawDisplayName || userId,
|
||||
),
|
||||
),
|
||||
),
|
||||
mxcAvatarUrl$:
|
||||
matrixMemberMetadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
userId,
|
||||
),
|
||||
pickupState$,
|
||||
intent,
|
||||
}),
|
||||
),
|
||||
map(([media]) => media ?? null),
|
||||
),
|
||||
),
|
||||
distinctUntilChanged(shallowEquals),
|
||||
tap((ringingMedia) => {
|
||||
if (ringingMedia.length > 1)
|
||||
// Warn that UI may do something unexpected in this case
|
||||
logger.warn(
|
||||
`Ringing more than one participant is not supported (ringing ${ringingMedia.map((vm) => vm.userId).join(", ")})`,
|
||||
);
|
||||
}),
|
||||
startWith(null),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -861,14 +853,10 @@ export function createCallViewModel$(
|
||||
* multiple devices.
|
||||
*/
|
||||
const participantCount$ = scope.behavior(
|
||||
matrixLivekitMembers$.pipe(map((ms) => ms.value.length)),
|
||||
matrixLivekitMembers$.pipe(map((ms) => ms.length)),
|
||||
);
|
||||
|
||||
const leaveSoundEffect$ = combineLatest([callPickupState$, userMedia$]).pipe(
|
||||
// Until the call is successful, do not play a leave sound.
|
||||
// If callPickupState$ is null, then we always play the sound as it will not conflict with a decline sound.
|
||||
skipWhile(([c]) => c !== null && c !== "success"),
|
||||
map(([, userMedia]) => userMedia),
|
||||
const leaveSoundEffect$ = userMedia$.pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
([prev, current]) =>
|
||||
@@ -877,6 +865,9 @@ export function createCallViewModel$(
|
||||
),
|
||||
map(() => {}),
|
||||
throttleTime(THROTTLE_SOUND_EFFECT_MS),
|
||||
// Avoid doubling up on any auto-leave sounds (e.g. the decline sound),
|
||||
// which are handled elsewhere
|
||||
takeUntil(autoLeave$),
|
||||
);
|
||||
|
||||
const userHangup$ = new Subject<void>();
|
||||
@@ -955,8 +946,8 @@ export function createCallViewModel$(
|
||||
);
|
||||
|
||||
/**
|
||||
* Local user media suitable for displaying in a PiP (undefined if not found
|
||||
* or if user prefers to not see themselves).
|
||||
* Local user media suitable for displaying in a PiP (undefined if not found,
|
||||
* video is muted, or if user prefers to not see themselves).
|
||||
*/
|
||||
const localUserMediaForPip$ = scope.behavior<
|
||||
LocalUserMediaViewModel | undefined
|
||||
@@ -968,8 +959,10 @@ export function createCallViewModel$(
|
||||
m.type === "user" && m.local,
|
||||
);
|
||||
if (!localUserMedia) return of(undefined);
|
||||
return localUserMedia.alwaysShow$.pipe(
|
||||
map((alwaysShow) => (alwaysShow ? localUserMedia : undefined)),
|
||||
return combineLatest(
|
||||
[localUserMedia.videoEnabled$, localUserMedia.alwaysShow$],
|
||||
(videoEnabled, alwaysShow) =>
|
||||
videoEnabled && alwaysShow ? localUserMedia : undefined,
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -981,8 +974,8 @@ export function createCallViewModel$(
|
||||
}>(
|
||||
ringingMedia$.pipe(
|
||||
switchMap((ringingMedia) => {
|
||||
if (ringingMedia.length > 0)
|
||||
return of({ spotlight: ringingMedia, pip$: localUserMediaForPip$ });
|
||||
if (ringingMedia !== null)
|
||||
return of({ spotlight: [ringingMedia], pip$: localUserMediaForPip$ });
|
||||
|
||||
return screenShares$.pipe(
|
||||
switchMap((screenShares) => {
|
||||
@@ -1061,7 +1054,7 @@ export function createCallViewModel$(
|
||||
spotlightExpandedToggle$,
|
||||
);
|
||||
|
||||
const { setGridMode, gridMode$ } = createLayoutModeSwitch(
|
||||
const layoutSwitchVm = createLayoutSwitchViewModel(
|
||||
scope,
|
||||
windowMode$,
|
||||
hasRemoteScreenShares$,
|
||||
@@ -1113,60 +1106,60 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
const oneOnOneLayoutMedia$: Observable<{
|
||||
const oneOnOneLayoutMedia$: Behavior<{
|
||||
local: LocalUserMediaViewModel;
|
||||
remote: UserMediaViewModel | RingingMediaViewModel;
|
||||
} | null> = combineLatest([userMedia$, screenShares$]).pipe(
|
||||
switchMap(([userMedia, screenShares]) => {
|
||||
// One-on-one layout only supports 2 user media, no screen shares
|
||||
if (userMedia.length <= 2 && screenShares.length === 0) {
|
||||
const local = userMedia.find(
|
||||
(vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
|
||||
vm.type === "user" && vm.local,
|
||||
);
|
||||
|
||||
if (local !== undefined) {
|
||||
const remote = userMedia.find(
|
||||
(vm): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel =>
|
||||
vm.type === "user" && !vm.local,
|
||||
} | null> = scope.behavior(
|
||||
combineLatest([userMedia$, screenShares$]).pipe(
|
||||
switchMap(([userMedia, screenShares]) => {
|
||||
// One-on-one layout only supports 2 user media, no screen shares
|
||||
if (userMedia.length <= 2 && screenShares.length === 0) {
|
||||
const local = userMedia.find(
|
||||
(vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
|
||||
vm.type === "user" && vm.local,
|
||||
);
|
||||
|
||||
if (remote !== undefined) return of({ local, remote });
|
||||
|
||||
// If there's no other user media in the call (could still happen in
|
||||
// this branch due to the duplicate tiles option), we could possibly
|
||||
// show ringing media instead
|
||||
if (userMedia.length === 1)
|
||||
return ringingMedia$.pipe(
|
||||
map((ringingMedia) => {
|
||||
return ringingMedia.length === 1
|
||||
? {
|
||||
local,
|
||||
remote: ringingMedia[0],
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
if (local !== undefined) {
|
||||
const remote = userMedia.find(
|
||||
(
|
||||
vm,
|
||||
): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel =>
|
||||
vm.type === "user" && !vm.local,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return of(null);
|
||||
}),
|
||||
if (remote !== undefined) return of({ local, remote });
|
||||
|
||||
// If there's no other user media in the call (could still happen in
|
||||
// this branch due to the duplicate tiles option), we could possibly
|
||||
// show ringing media instead
|
||||
if (userMedia.length === 1)
|
||||
return ringingMedia$.pipe(
|
||||
map(
|
||||
(ringingMedia) =>
|
||||
ringingMedia && { local, remote: ringingMedia },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return of(null);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const oneOnOneLandscapeLayoutMedia$: Observable<OneOnOneLandscapeLayoutMedia | null> =
|
||||
const oneOnOneDesktopLayoutMedia$: Observable<OneOnOneDesktopLayoutMedia | null> =
|
||||
oneOnOneLayoutMedia$.pipe(
|
||||
map((media) => {
|
||||
if (media === null) return null;
|
||||
return media.remote.type === "ringing"
|
||||
? {
|
||||
type: "one-on-one-landscape" as const,
|
||||
type: "one-on-one-desktop" as const,
|
||||
edgeToEdge: false,
|
||||
spotlight: media.local,
|
||||
pip: media.remote,
|
||||
}
|
||||
: {
|
||||
type: "one-on-one-landscape" as const,
|
||||
type: "one-on-one-desktop" as const,
|
||||
edgeToEdge: false,
|
||||
spotlight: media.remote,
|
||||
pip: media.local,
|
||||
@@ -1174,13 +1167,13 @@ export function createCallViewModel$(
|
||||
}),
|
||||
);
|
||||
|
||||
const oneOnOnePortraitLayoutMedia$: Observable<OneOnOnePortraitLayoutMedia | null> =
|
||||
const oneOnOneMobileLayoutMedia$: Observable<OneOnOneMobileLayoutMedia | null> =
|
||||
oneOnOneLayoutMedia$.pipe(
|
||||
switchMap((media) => {
|
||||
if (media === null) return of(null);
|
||||
return media.local.videoEnabled$.pipe(
|
||||
map((videoEnabled) => ({
|
||||
type: "one-on-one-portrait" as const,
|
||||
type: "one-on-one-mobile" as const,
|
||||
edgeToEdge: true as const,
|
||||
spotlight: media.remote,
|
||||
pip: videoEnabled ? media.local : undefined,
|
||||
@@ -1197,6 +1190,33 @@ export function createCallViewModel$(
|
||||
})),
|
||||
);
|
||||
|
||||
spotlight$
|
||||
.pipe(
|
||||
switchMap((media) => {
|
||||
let layout;
|
||||
const pipMedia = media[0];
|
||||
if (pipMedia === undefined) return of(undefined);
|
||||
switch (pipMedia.type) {
|
||||
case "user":
|
||||
layout = pipMedia.videoOrientation$;
|
||||
break;
|
||||
case "ringing":
|
||||
layout = of("landscape" as const);
|
||||
break;
|
||||
case "screen share":
|
||||
layout = of("landscape" as const);
|
||||
break;
|
||||
}
|
||||
return layout;
|
||||
}),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe((orientation) => {
|
||||
if (orientation === undefined) return;
|
||||
logger.info("controls api pip orientation updated:", orientation);
|
||||
window.controls.onPipMediaOrientationUpdate?.(orientation);
|
||||
});
|
||||
|
||||
/**
|
||||
* The media to be used to produce a layout.
|
||||
*/
|
||||
@@ -1205,11 +1225,11 @@ export function createCallViewModel$(
|
||||
switchMap((windowMode) => {
|
||||
switch (windowMode) {
|
||||
case "normal":
|
||||
return gridMode$.pipe(
|
||||
switchMap((gridMode) => {
|
||||
switch (gridMode) {
|
||||
return layoutSwitchVm.layout$.pipe(
|
||||
switchMap((layout) => {
|
||||
switch (layout) {
|
||||
case "grid":
|
||||
return oneOnOneLandscapeLayoutMedia$.pipe(
|
||||
return oneOnOneDesktopLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne),
|
||||
),
|
||||
@@ -1226,7 +1246,7 @@ export function createCallViewModel$(
|
||||
}),
|
||||
);
|
||||
case "narrow":
|
||||
return oneOnOnePortraitLayoutMedia$.pipe(
|
||||
return oneOnOneMobileLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null
|
||||
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
|
||||
@@ -1239,17 +1259,23 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
case "flat":
|
||||
return gridMode$.pipe(
|
||||
switchMap((gridMode) => {
|
||||
switch (gridMode) {
|
||||
case "grid":
|
||||
// Yes, grid mode actually gets you a "spotlight" layout in
|
||||
// this window mode.
|
||||
return spotlightLandscapeLayoutMedia$(true);
|
||||
case "spotlight":
|
||||
return spotlightExpandedLayoutMedia$(true);
|
||||
}
|
||||
}),
|
||||
return oneOnOneMobileLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null
|
||||
? layoutSwitchVm.layout$.pipe(
|
||||
switchMap((layout) => {
|
||||
switch (layout) {
|
||||
case "grid":
|
||||
// Yes, grid mode actually gets you a "spotlight" layout in
|
||||
// this window mode.
|
||||
return spotlightLandscapeLayoutMedia$(true);
|
||||
case "spotlight":
|
||||
return spotlightExpandedLayoutMedia$(true);
|
||||
}
|
||||
}),
|
||||
)
|
||||
: of(oneOnOne),
|
||||
),
|
||||
);
|
||||
case "pip":
|
||||
return pipLayoutMedia$;
|
||||
@@ -1277,8 +1303,8 @@ export function createCallViewModel$(
|
||||
// indicators. And in one-on-one layout there's no question as to who is
|
||||
// speaking.
|
||||
case "spotlight-expanded":
|
||||
case "one-on-one-landscape":
|
||||
case "one-on-one-portrait":
|
||||
case "one-on-one-desktop":
|
||||
case "one-on-one-mobile":
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
@@ -1290,7 +1316,7 @@ export function createCallViewModel$(
|
||||
const showNameTags$ = scope.behavior<boolean>(
|
||||
layoutMedia$.pipe(
|
||||
switchMap((l) =>
|
||||
l.type === "pip" || l.type === "one-on-one-portrait"
|
||||
l.type === "pip" || l.type === "one-on-one-mobile"
|
||||
? matrixRoomMembers$.pipe(
|
||||
map(
|
||||
(members) =>
|
||||
@@ -1334,6 +1360,22 @@ export function createCallViewModel$(
|
||||
layoutMedia$.pipe(map(({ edgeToEdge }) => edgeToEdge)),
|
||||
);
|
||||
|
||||
// Only show the layout switch in cases where it has an effect on the layout
|
||||
const showLayoutSwitch$ = windowMode$.pipe(
|
||||
switchMap((windowMode) => {
|
||||
switch (windowMode) {
|
||||
case "normal":
|
||||
return of(true);
|
||||
case "flat":
|
||||
return oneOnOneLayoutMedia$.pipe(
|
||||
map((oneOnOne) => oneOnOne === null),
|
||||
);
|
||||
default:
|
||||
return of(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const screenTap$ = new Subject<void>();
|
||||
const controlsTap$ = new Subject<void>();
|
||||
const screenHover$ = new Subject<void>();
|
||||
@@ -1415,7 +1457,7 @@ export function createCallViewModel$(
|
||||
windowMode$.pipe(
|
||||
switchMap((mode) => {
|
||||
// In small windows the header would be too obstructive
|
||||
if (mode === "pip" || mode === "flat") return of(false);
|
||||
if (mode === "pip") return of(false);
|
||||
// In edge-to-edge layouts, couple the visibility of the header
|
||||
// to that of the footer
|
||||
return edgeToEdge$.pipe(
|
||||
@@ -1455,7 +1497,7 @@ export function createCallViewModel$(
|
||||
|
||||
// There is a cyclical dependency here: the layout algorithms want to know
|
||||
// which tiles are on screen, but to know which tiles are on screen we have to
|
||||
// first render a layout. To deal with this we assume initially that no tiles
|
||||
// first render a layout. To deal with this we assume initially that all tiles
|
||||
// are visible, and loop the data back into the layouts with a Subject.
|
||||
const visibleTiles$ = new Subject<number>();
|
||||
const setVisibleTiles = (value: number): void => visibleTiles$.next(value);
|
||||
@@ -1463,7 +1505,7 @@ export function createCallViewModel$(
|
||||
const layoutInternals$ = scope.behavior<LayoutScanState & { layout: Layout }>(
|
||||
combineLatest([
|
||||
layoutMedia$,
|
||||
visibleTiles$.pipe(startWith(0), distinctUntilChanged()),
|
||||
visibleTiles$.pipe(startWith(Infinity), distinctUntilChanged()),
|
||||
]).pipe(
|
||||
scan<
|
||||
[LayoutMedia, number],
|
||||
@@ -1473,6 +1515,8 @@ export function createCallViewModel$(
|
||||
({ tiles: prevTiles }, [media, visibleTiles]) => {
|
||||
let layout: Layout;
|
||||
let newTiles: TileStore;
|
||||
let pip: GridTileViewModel | undefined;
|
||||
let overflowing = false;
|
||||
switch (media.type) {
|
||||
case "grid":
|
||||
case "spotlight-landscape":
|
||||
@@ -1484,6 +1528,7 @@ export function createCallViewModel$(
|
||||
setVisibleTiles,
|
||||
prevTiles,
|
||||
);
|
||||
overflowing = newTiles.gridTiles.length > visibleTiles;
|
||||
break;
|
||||
case "spotlight-expanded":
|
||||
[layout, newTiles] = spotlightExpandedLayout(
|
||||
@@ -1492,29 +1537,35 @@ export function createCallViewModel$(
|
||||
prevTiles,
|
||||
);
|
||||
break;
|
||||
case "one-on-one-landscape":
|
||||
[layout, newTiles] = oneOnOneLandscapeLayout(
|
||||
case "one-on-one-desktop":
|
||||
[layout, newTiles] = oneOnOneDesktopLayout(
|
||||
media,
|
||||
landscapePipAlignment$,
|
||||
prevTiles,
|
||||
);
|
||||
pip = layout.pip;
|
||||
break;
|
||||
case "one-on-one-portrait":
|
||||
[layout, newTiles] = oneOnOnePortraitLayout(
|
||||
case "one-on-one-mobile":
|
||||
[layout, newTiles] = oneOnOneMobileLayout(
|
||||
media,
|
||||
portraitPipSize$,
|
||||
portraitPipAlignment$,
|
||||
prevTiles,
|
||||
);
|
||||
pip = layout.pip;
|
||||
break;
|
||||
case "pip":
|
||||
[layout, newTiles] = pipLayout(media, prevTiles);
|
||||
break;
|
||||
}
|
||||
|
||||
return { layout, tiles: newTiles };
|
||||
for (const tile of newTiles.gridTiles) {
|
||||
tile.setShowOutline(tile === pip);
|
||||
}
|
||||
|
||||
return { layout, overflowing, tiles: newTiles };
|
||||
},
|
||||
{ layout: null, tiles: TileStore.empty() },
|
||||
{ layout: null, overflowing: false, tiles: TileStore.empty() },
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1526,6 +1577,10 @@ export function createCallViewModel$(
|
||||
layoutInternals$.pipe(map(({ layout }) => layout)),
|
||||
);
|
||||
|
||||
const overflowing$ = scope.behavior<boolean>(
|
||||
layoutInternals$.pipe(map(({ overflowing }) => overflowing)),
|
||||
);
|
||||
|
||||
/**
|
||||
* The current generation of the tile store, exposed for debugging purposes.
|
||||
*/
|
||||
@@ -1695,9 +1750,9 @@ export function createCallViewModel$(
|
||||
|
||||
return {
|
||||
autoLeave$: autoLeave$,
|
||||
ringing$: scope.behavior(
|
||||
callPickupState$.pipe(map((state) => state === "ringing")),
|
||||
),
|
||||
ringingVm$: ringingMedia$,
|
||||
ringingStatusLocation:
|
||||
urlParams.header === HeaderStyle.AppBar ? "app_bar" : "tile",
|
||||
leave$: leave$,
|
||||
hangup: (): void => userHangup$.next(),
|
||||
join: localMembership.requestJoinAndPublish,
|
||||
@@ -1738,12 +1793,13 @@ export function createCallViewModel$(
|
||||
|
||||
spotlightExpanded$: spotlightExpanded$,
|
||||
toggleSpotlightExpanded$: toggleSpotlightExpanded$,
|
||||
gridMode$: gridMode$,
|
||||
setGridMode: setGridMode,
|
||||
layoutSwitchVm$: scope.behavior(
|
||||
showLayoutSwitch$.pipe(map((show) => (show ? layoutSwitchVm : null))),
|
||||
),
|
||||
layout$: layout$,
|
||||
localMatrixLivekitMember$,
|
||||
matrixLivekitMembers$: scope.behavior(
|
||||
matrixLivekitMembers$.pipe(
|
||||
remoteMatrixLivekitMembers$: scope.behavior(
|
||||
remoteMatrixLivekitMembers$.pipe(
|
||||
map((members) => members.value),
|
||||
tap((v) => {
|
||||
const listForLogs = v
|
||||
@@ -1767,6 +1823,7 @@ export function createCallViewModel$(
|
||||
settingsOpen$: settingsOpen$,
|
||||
setSettingsOpen$: setSettingsOpen$,
|
||||
edgeToEdge$,
|
||||
overflowing$,
|
||||
earpieceMode$: earpieceMode$,
|
||||
audioOutputSwitcher$: audioOutputSwitcher$,
|
||||
reconnecting$: localMembership.reconnecting$,
|
||||
@@ -1778,6 +1835,7 @@ export function createCallViewModel$(
|
||||
function getE2eeKeyProvider(
|
||||
e2eeSystem: EncryptionSystem,
|
||||
rtcSession: MatrixRTCSession,
|
||||
logger: Logger,
|
||||
): BaseKeyProvider | undefined {
|
||||
if (e2eeSystem.kind === E2eeType.NONE) return undefined;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user