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
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
connectedParticipantsObserver,
|
2024-01-20 20:39:12 -05:00
|
|
|
observeParticipantEvents,
|
2023-11-30 22:59:19 -05:00
|
|
|
observeParticipantMedia,
|
|
|
|
|
} from "@livekit/components-core";
|
2024-01-20 20:39:12 -05:00
|
|
|
import {
|
2024-12-11 09:27:55 +00:00
|
|
|
type Room as LivekitRoom,
|
|
|
|
|
type LocalParticipant,
|
2024-10-28 17:29:26 -04:00
|
|
|
LocalVideoTrack,
|
2024-01-20 20:39:12 -05:00
|
|
|
ParticipantEvent,
|
2024-12-11 09:27:55 +00:00
|
|
|
type RemoteParticipant,
|
2024-10-28 17:29:26 -04:00
|
|
|
Track,
|
2024-01-20 20:39:12 -05:00
|
|
|
} from "livekit-client";
|
2024-12-11 09:27:55 +00:00
|
|
|
import {
|
|
|
|
|
type Room as MatrixRoom,
|
|
|
|
|
type RoomMember,
|
|
|
|
|
} from "matrix-js-sdk/src/matrix";
|
2024-09-11 01:27:24 -04:00
|
|
|
import {
|
2024-12-06 12:28:37 +01:00
|
|
|
BehaviorSubject,
|
2023-11-30 22:59:19 -05:00
|
|
|
EMPTY,
|
2024-12-11 09:27:55 +00:00
|
|
|
type Observable,
|
2024-05-17 16:38:00 -04:00
|
|
|
Subject,
|
2023-11-30 22:59:19 -05:00
|
|
|
combineLatest,
|
|
|
|
|
concat,
|
2024-01-20 20:39:12 -05:00
|
|
|
distinctUntilChanged,
|
|
|
|
|
filter,
|
2024-09-11 01:03:23 -04:00
|
|
|
forkJoin,
|
2024-07-03 15:08:30 -04:00
|
|
|
fromEvent,
|
2024-01-20 20:39:12 -05:00
|
|
|
map,
|
|
|
|
|
merge,
|
2024-09-11 01:03:23 -04:00
|
|
|
mergeMap,
|
2023-11-30 22:59:19 -05:00
|
|
|
of,
|
2024-08-08 17:21:47 -04:00
|
|
|
race,
|
2023-11-30 22:59:19 -05:00
|
|
|
scan,
|
2024-07-03 15:08:30 -04:00
|
|
|
skip,
|
2023-11-30 22:59:19 -05:00
|
|
|
startWith,
|
2024-07-25 17:52:23 -04:00
|
|
|
switchAll,
|
2024-01-20 20:39:12 -05:00
|
|
|
switchMap,
|
2024-08-08 17:21:47 -04:00
|
|
|
switchScan,
|
|
|
|
|
take,
|
2024-01-20 20:39:12 -05:00
|
|
|
timer,
|
2024-09-11 01:03:23 -04:00
|
|
|
withLatestFrom,
|
2023-11-30 22:59:19 -05:00
|
|
|
} from "rxjs";
|
|
|
|
|
import { logger } from "matrix-js-sdk/src/logger";
|
2024-12-06 12:28:37 +01:00
|
|
|
import {
|
2024-12-11 09:27:55 +00:00
|
|
|
type MatrixRTCSession,
|
2024-12-06 12:28:37 +01:00
|
|
|
MatrixRTCSessionEvent,
|
|
|
|
|
} from "matrix-js-sdk/src/matrixrtc";
|
2023-11-30 22:59:19 -05:00
|
|
|
|
|
|
|
|
import { ViewModel } from "./ViewModel";
|
|
|
|
|
import {
|
|
|
|
|
ECAddonConnectionState,
|
2024-12-11 09:27:55 +00:00
|
|
|
type ECConnectionState,
|
2023-11-30 22:59:19 -05:00
|
|
|
} from "../livekit/useECConnectionState";
|
|
|
|
|
import {
|
2024-05-16 12:32:18 -04:00
|
|
|
LocalUserMediaViewModel,
|
2024-12-11 09:27:55 +00:00
|
|
|
type MediaViewModel,
|
2024-12-17 04:01:56 +00:00
|
|
|
observeTrackReference$,
|
2024-05-16 12:32:18 -04:00
|
|
|
RemoteUserMediaViewModel,
|
2024-01-20 20:39:12 -05:00
|
|
|
ScreenShareViewModel,
|
2024-12-11 09:27:55 +00:00
|
|
|
type UserMediaViewModel,
|
2024-01-20 20:39:12 -05:00
|
|
|
} from "./MediaViewModel";
|
2024-08-27 09:45:39 -04:00
|
|
|
import { accumulate, finalizeValue } from "../utils/observable";
|
2024-01-20 20:39:12 -05:00
|
|
|
import { ObservableScope } from "./ObservableScope";
|
2024-12-19 15:54:28 +00:00
|
|
|
import {
|
|
|
|
|
duplicateTiles,
|
|
|
|
|
playReactionsSound,
|
|
|
|
|
showReactions,
|
|
|
|
|
showNonMemberTiles,
|
|
|
|
|
} from "../settings/settings";
|
2024-08-08 17:21:47 -04:00
|
|
|
import { isFirefox } from "../Platform";
|
2024-12-17 04:01:56 +00:00
|
|
|
import { setPipEnabled$ } from "../controls";
|
2024-12-11 09:27:55 +00:00
|
|
|
import {
|
|
|
|
|
type GridTileViewModel,
|
|
|
|
|
type SpotlightTileViewModel,
|
|
|
|
|
} from "./TileViewModel";
|
2024-11-06 04:36:48 -05:00
|
|
|
import { TileStore } from "./TileStore";
|
|
|
|
|
import { gridLikeLayout } from "./GridLikeLayout";
|
|
|
|
|
import { spotlightExpandedLayout } from "./SpotlightExpandedLayout";
|
|
|
|
|
import { oneOnOneLayout } from "./OneOnOneLayout";
|
|
|
|
|
import { pipLayout } from "./PipLayout";
|
2024-12-11 09:27:55 +00:00
|
|
|
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
2024-12-19 15:54:28 +00:00
|
|
|
import {
|
|
|
|
|
type RaisedHandInfo,
|
|
|
|
|
type ReactionInfo,
|
|
|
|
|
type ReactionOption,
|
|
|
|
|
} from "../reactions";
|
2024-12-17 04:01:56 +00:00
|
|
|
import { observeSpeaker$ } from "./observeSpeaker";
|
2024-12-13 16:40:20 -05:00
|
|
|
import { shallowEquals } from "../utils/array";
|
2024-01-20 20:39:12 -05:00
|
|
|
|
|
|
|
|
// How long we wait after a focus switch before showing the real participant
|
|
|
|
|
// list again
|
|
|
|
|
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
|
2023-11-30 22:59:19 -05:00
|
|
|
|
2024-07-25 17:52:23 -04:00
|
|
|
// This is the number of participants that we think constitutes a "small" call
|
|
|
|
|
// on mobile. No spotlight tile should be shown below this threshold.
|
|
|
|
|
const smallMobileCallThreshold = 3;
|
|
|
|
|
|
2024-11-08 10:23:19 -05:00
|
|
|
// How long the footer should be shown for when hovering over or interacting
|
|
|
|
|
// with the interface
|
|
|
|
|
const showFooterMs = 4000;
|
|
|
|
|
|
2024-11-06 04:36:48 -05:00
|
|
|
export interface GridLayoutMedia {
|
2024-01-20 20:39:12 -05:00
|
|
|
type: "grid";
|
2024-05-02 16:00:05 -04:00
|
|
|
spotlight?: MediaViewModel[];
|
|
|
|
|
grid: UserMediaViewModel[];
|
2024-01-20 20:39:12 -05:00
|
|
|
}
|
|
|
|
|
|
2024-11-06 04:36:48 -05:00
|
|
|
export interface SpotlightLandscapeLayoutMedia {
|
2024-07-18 11:24:18 -04:00
|
|
|
type: "spotlight-landscape";
|
2024-05-02 16:00:05 -04:00
|
|
|
spotlight: MediaViewModel[];
|
|
|
|
|
grid: UserMediaViewModel[];
|
2024-01-20 20:39:12 -05:00
|
|
|
}
|
|
|
|
|
|
2024-11-06 04:36:48 -05:00
|
|
|
export interface SpotlightPortraitLayoutMedia {
|
2024-07-18 11:24:18 -04:00
|
|
|
type: "spotlight-portrait";
|
2024-07-03 15:08:30 -04:00
|
|
|
spotlight: MediaViewModel[];
|
|
|
|
|
grid: UserMediaViewModel[];
|
2024-06-07 12:27:13 -04:00
|
|
|
}
|
|
|
|
|
|
2024-11-06 04:36:48 -05:00
|
|
|
export interface SpotlightExpandedLayoutMedia {
|
2024-07-18 11:24:18 -04:00
|
|
|
type: "spotlight-expanded";
|
2024-05-02 16:00:05 -04:00
|
|
|
spotlight: MediaViewModel[];
|
|
|
|
|
pip?: UserMediaViewModel;
|
2024-01-20 20:39:12 -05:00
|
|
|
}
|
|
|
|
|
|
2024-11-06 04:36:48 -05:00
|
|
|
export interface OneOnOneLayoutMedia {
|
|
|
|
|
type: "one-on-one";
|
|
|
|
|
local: UserMediaViewModel;
|
|
|
|
|
remote: UserMediaViewModel;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface PipLayoutMedia {
|
|
|
|
|
type: "pip";
|
|
|
|
|
spotlight: MediaViewModel[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type LayoutMedia =
|
|
|
|
|
| GridLayoutMedia
|
|
|
|
|
| SpotlightLandscapeLayoutMedia
|
|
|
|
|
| SpotlightPortraitLayoutMedia
|
|
|
|
|
| SpotlightExpandedLayoutMedia
|
|
|
|
|
| OneOnOneLayoutMedia
|
|
|
|
|
| PipLayoutMedia;
|
|
|
|
|
|
|
|
|
|
export interface GridLayout {
|
|
|
|
|
type: "grid";
|
|
|
|
|
spotlight?: SpotlightTileViewModel;
|
|
|
|
|
grid: GridTileViewModel[];
|
2024-12-12 17:32:13 -05:00
|
|
|
setVisibleTiles: (value: number) => void;
|
2024-11-06 04:36:48 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SpotlightLandscapeLayout {
|
|
|
|
|
type: "spotlight-landscape";
|
|
|
|
|
spotlight: SpotlightTileViewModel;
|
|
|
|
|
grid: GridTileViewModel[];
|
2024-12-12 17:32:13 -05:00
|
|
|
setVisibleTiles: (value: number) => void;
|
2024-11-06 04:36:48 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SpotlightPortraitLayout {
|
|
|
|
|
type: "spotlight-portrait";
|
|
|
|
|
spotlight: SpotlightTileViewModel;
|
|
|
|
|
grid: GridTileViewModel[];
|
2024-12-12 17:32:13 -05:00
|
|
|
setVisibleTiles: (value: number) => void;
|
2024-11-06 04:36:48 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SpotlightExpandedLayout {
|
|
|
|
|
type: "spotlight-expanded";
|
|
|
|
|
spotlight: SpotlightTileViewModel;
|
|
|
|
|
pip?: GridTileViewModel;
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-03 15:08:30 -04:00
|
|
|
export interface OneOnOneLayout {
|
|
|
|
|
type: "one-on-one";
|
2024-11-06 04:36:48 -05:00
|
|
|
local: GridTileViewModel;
|
|
|
|
|
remote: GridTileViewModel;
|
2024-07-03 15:08:30 -04:00
|
|
|
}
|
|
|
|
|
|
2024-01-20 20:39:12 -05:00
|
|
|
export interface PipLayout {
|
|
|
|
|
type: "pip";
|
2024-11-06 04:36:48 -05:00
|
|
|
spotlight: SpotlightTileViewModel;
|
2024-01-20 20:39:12 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A layout defining the media tiles present on screen and their visual
|
|
|
|
|
* arrangement.
|
|
|
|
|
*/
|
|
|
|
|
export type Layout =
|
|
|
|
|
| GridLayout
|
2024-07-03 15:08:30 -04:00
|
|
|
| SpotlightLandscapeLayout
|
|
|
|
|
| SpotlightPortraitLayout
|
|
|
|
|
| SpotlightExpandedLayout
|
2024-06-07 12:27:13 -04:00
|
|
|
| OneOnOneLayout
|
2024-01-20 20:39:12 -05:00
|
|
|
| PipLayout;
|
|
|
|
|
|
|
|
|
|
export type GridMode = "grid" | "spotlight";
|
|
|
|
|
|
2024-07-03 15:08:30 -04:00
|
|
|
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
|
2024-01-20 20:39:12 -05:00
|
|
|
|
|
|
|
|
/**
|
2024-05-02 16:32:48 -04:00
|
|
|
* Sorting bins defining the order in which media tiles appear in the layout.
|
2024-01-20 20:39:12 -05:00
|
|
|
*/
|
2024-05-02 16:32:48 -04:00
|
|
|
enum SortingBin {
|
2024-07-17 15:37:41 -04:00
|
|
|
/**
|
|
|
|
|
* Yourself, when the "always show self" option is on.
|
|
|
|
|
*/
|
2024-05-16 13:55:31 -04:00
|
|
|
SelfAlwaysShown,
|
2024-07-17 15:37:41 -04:00
|
|
|
/**
|
|
|
|
|
* Participants that are sharing their screen.
|
|
|
|
|
*/
|
2024-01-20 20:39:12 -05:00
|
|
|
Presenters,
|
2024-07-17 15:37:41 -04:00
|
|
|
/**
|
|
|
|
|
* Participants that have been speaking recently.
|
|
|
|
|
*/
|
2024-01-20 20:39:12 -05:00
|
|
|
Speakers,
|
2024-12-19 15:54:28 +00:00
|
|
|
/**
|
|
|
|
|
* Participants that have their hand raised.
|
|
|
|
|
*/
|
|
|
|
|
HandRaised,
|
2024-07-17 15:37:41 -04:00
|
|
|
/**
|
2024-07-26 05:27:22 -04:00
|
|
|
* Participants with video.
|
2024-07-17 15:37:41 -04:00
|
|
|
*/
|
2024-01-20 20:39:12 -05:00
|
|
|
Video,
|
2024-07-17 15:37:41 -04:00
|
|
|
/**
|
2024-07-26 05:27:22 -04:00
|
|
|
* Participants not sharing any video.
|
2024-07-17 15:37:41 -04:00
|
|
|
*/
|
2024-07-26 05:27:22 -04:00
|
|
|
NoVideo,
|
2024-07-17 15:37:41 -04:00
|
|
|
/**
|
|
|
|
|
* Yourself, when the "always show self" option is off.
|
|
|
|
|
*/
|
2024-05-16 13:55:31 -04:00
|
|
|
SelfNotAlwaysShown,
|
2024-01-20 20:39:12 -05:00
|
|
|
}
|
|
|
|
|
|
2024-11-06 04:36:48 -05:00
|
|
|
interface LayoutScanState {
|
|
|
|
|
layout: Layout | null;
|
|
|
|
|
tiles: TileStore;
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-20 20:39:12 -05:00
|
|
|
class UserMedia {
|
|
|
|
|
private readonly scope = new ObservableScope();
|
|
|
|
|
public readonly vm: UserMediaViewModel;
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly participant$: BehaviorSubject<
|
2024-12-06 12:28:37 +01:00
|
|
|
LocalParticipant | RemoteParticipant | undefined
|
|
|
|
|
>;
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly speaker$: Observable<boolean>;
|
|
|
|
|
public readonly presenter$: Observable<boolean>;
|
2024-01-20 20:39:12 -05:00
|
|
|
public constructor(
|
|
|
|
|
public readonly id: string,
|
|
|
|
|
member: RoomMember | undefined,
|
2024-12-06 12:28:37 +01:00
|
|
|
participant: LocalParticipant | RemoteParticipant | undefined,
|
2024-11-04 09:11:44 +00:00
|
|
|
encryptionSystem: EncryptionSystem,
|
2024-11-06 11:12:46 +00:00
|
|
|
livekitRoom: LivekitRoom,
|
2024-12-19 15:54:28 +00:00
|
|
|
handRaised$: Observable<Date | null>,
|
|
|
|
|
reaction$: Observable<ReactionOption | null>,
|
2024-01-20 20:39:12 -05:00
|
|
|
) {
|
2024-12-17 04:01:56 +00:00
|
|
|
this.participant$ = new BehaviorSubject(participant);
|
2024-12-06 12:28:37 +01:00
|
|
|
|
|
|
|
|
if (participant?.isLocal) {
|
|
|
|
|
this.vm = new LocalUserMediaViewModel(
|
|
|
|
|
this.id,
|
|
|
|
|
member,
|
2024-12-17 04:01:56 +00:00
|
|
|
this.participant$.asObservable() as Observable<LocalParticipant>,
|
2024-12-06 12:28:37 +01:00
|
|
|
encryptionSystem,
|
|
|
|
|
livekitRoom,
|
2024-12-19 15:54:28 +00:00
|
|
|
handRaised$,
|
|
|
|
|
reaction$,
|
2024-12-06 12:28:37 +01:00
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
this.vm = new RemoteUserMediaViewModel(
|
|
|
|
|
id,
|
|
|
|
|
member,
|
2024-12-17 04:01:56 +00:00
|
|
|
this.participant$.asObservable() as Observable<
|
2024-12-06 12:28:37 +01:00
|
|
|
RemoteParticipant | undefined
|
|
|
|
|
>,
|
|
|
|
|
encryptionSystem,
|
|
|
|
|
livekitRoom,
|
2024-12-19 15:54:28 +00:00
|
|
|
handRaised$,
|
|
|
|
|
reaction$,
|
2024-12-06 12:28:37 +01:00
|
|
|
);
|
|
|
|
|
}
|
2024-01-20 20:39:12 -05:00
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
this.speaker$ = observeSpeaker$(this.vm.speaking$).pipe(this.scope.state());
|
2024-01-20 20:39:12 -05:00
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
this.presenter$ = this.participant$.pipe(
|
2024-12-06 12:28:37 +01:00
|
|
|
switchMap(
|
|
|
|
|
(p) =>
|
|
|
|
|
(p &&
|
|
|
|
|
observeParticipantEvents(
|
|
|
|
|
p,
|
|
|
|
|
ParticipantEvent.TrackPublished,
|
|
|
|
|
ParticipantEvent.TrackUnpublished,
|
|
|
|
|
ParticipantEvent.LocalTrackPublished,
|
|
|
|
|
ParticipantEvent.LocalTrackUnpublished,
|
|
|
|
|
).pipe(map((p) => p.isScreenShareEnabled))) ??
|
|
|
|
|
of(false),
|
|
|
|
|
),
|
|
|
|
|
this.scope.state(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public updateParticipant(
|
|
|
|
|
newParticipant: LocalParticipant | RemoteParticipant | undefined,
|
|
|
|
|
): void {
|
2024-12-17 04:01:56 +00:00
|
|
|
if (this.participant$.value !== newParticipant) {
|
2024-12-06 12:28:37 +01:00
|
|
|
// Update the BehaviourSubject in the UserMedia.
|
2024-12-17 04:01:56 +00:00
|
|
|
this.participant$.next(newParticipant);
|
2024-12-06 12:28:37 +01:00
|
|
|
}
|
2024-01-20 20:39:12 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public destroy(): void {
|
|
|
|
|
this.scope.end();
|
|
|
|
|
this.vm.destroy();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class ScreenShare {
|
|
|
|
|
public readonly vm: ScreenShareViewModel;
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly participant$: BehaviorSubject<
|
2024-12-06 12:28:37 +01:00
|
|
|
LocalParticipant | RemoteParticipant
|
|
|
|
|
>;
|
2024-01-20 20:39:12 -05:00
|
|
|
|
|
|
|
|
public constructor(
|
|
|
|
|
id: string,
|
|
|
|
|
member: RoomMember | undefined,
|
|
|
|
|
participant: LocalParticipant | RemoteParticipant,
|
2024-11-04 09:11:44 +00:00
|
|
|
encryptionSystem: EncryptionSystem,
|
2024-11-06 11:12:46 +00:00
|
|
|
liveKitRoom: LivekitRoom,
|
2024-01-20 20:39:12 -05:00
|
|
|
) {
|
2024-12-17 04:01:56 +00:00
|
|
|
this.participant$ = new BehaviorSubject(participant);
|
2024-12-06 12:28:37 +01:00
|
|
|
|
2024-11-04 09:11:44 +00:00
|
|
|
this.vm = new ScreenShareViewModel(
|
|
|
|
|
id,
|
|
|
|
|
member,
|
2024-12-17 04:01:56 +00:00
|
|
|
this.participant$.asObservable(),
|
2024-11-04 09:11:44 +00:00
|
|
|
encryptionSystem,
|
2024-11-06 11:12:46 +00:00
|
|
|
liveKitRoom,
|
2024-12-06 12:28:37 +01:00
|
|
|
participant.isLocal,
|
2024-11-04 09:11:44 +00:00
|
|
|
);
|
2024-01-20 20:39:12 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public destroy(): void {
|
|
|
|
|
this.vm.destroy();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type MediaItem = UserMedia | ScreenShare;
|
2023-11-30 22:59:19 -05:00
|
|
|
|
2024-11-21 11:02:05 +00:00
|
|
|
function findMatrixRoomMember(
|
2023-11-30 22:59:19 -05:00
|
|
|
room: MatrixRoom,
|
|
|
|
|
id: string,
|
|
|
|
|
): RoomMember | undefined {
|
2024-06-12 15:26:00 -04:00
|
|
|
if (id === "local")
|
|
|
|
|
return room.getMember(room.client.getUserId()!) ?? undefined;
|
2023-11-30 22:59:19 -05:00
|
|
|
|
|
|
|
|
const parts = id.split(":");
|
|
|
|
|
// must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon
|
|
|
|
|
if (parts.length < 3) {
|
|
|
|
|
logger.warn(
|
2024-12-09 11:39:16 +00:00
|
|
|
`Livekit participants ID (${id}) doesn't look like a userId:deviceId combination`,
|
2023-11-30 22:59:19 -05:00
|
|
|
);
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
parts.pop();
|
|
|
|
|
const userId = parts.join(":");
|
|
|
|
|
|
|
|
|
|
return room.getMember(userId) ?? undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
|
|
|
|
export class CallViewModel extends ViewModel {
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly localVideo$: Observable<LocalVideoTrack | null> =
|
|
|
|
|
observeTrackReference$(
|
2024-12-06 12:28:37 +01:00
|
|
|
of(this.livekitRoom.localParticipant),
|
2024-10-28 17:29:26 -04:00
|
|
|
Track.Source.Camera,
|
|
|
|
|
).pipe(
|
|
|
|
|
map((trackRef) => {
|
2024-12-06 12:28:37 +01:00
|
|
|
const track = trackRef?.publication?.track;
|
2024-10-28 17:29:26 -04:00
|
|
|
return track instanceof LocalVideoTrack ? track : null;
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
2024-11-21 11:01:43 +00:00
|
|
|
/**
|
|
|
|
|
* The raw list of RemoteParticipants as reported by LiveKit
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly rawRemoteParticipants$: Observable<RemoteParticipant[]> =
|
2024-11-21 11:01:43 +00:00
|
|
|
connectedParticipantsObserver(this.livekitRoom).pipe(this.scope.state());
|
2023-11-30 22:59:19 -05:00
|
|
|
|
2024-11-21 11:01:43 +00:00
|
|
|
/**
|
|
|
|
|
* Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that
|
|
|
|
|
* they've left
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly remoteParticipantHolds$: Observable<RemoteParticipant[][]> =
|
|
|
|
|
this.connectionState$.pipe(
|
|
|
|
|
withLatestFrom(this.rawRemoteParticipants$),
|
2024-09-11 01:03:23 -04:00
|
|
|
mergeMap(([s, ps]) => {
|
2024-05-02 16:32:48 -04:00
|
|
|
// Whenever we switch focuses, we should retain all the previous
|
|
|
|
|
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
|
|
|
|
|
// give their clients time to switch over and avoid jarring layout shifts
|
|
|
|
|
if (s === ECAddonConnectionState.ECSwitchingFocus) {
|
|
|
|
|
return concat(
|
|
|
|
|
// Hold these participants
|
|
|
|
|
of({ hold: ps }),
|
|
|
|
|
// Wait for time to pass and the connection state to have changed
|
2024-09-11 01:03:23 -04:00
|
|
|
forkJoin([
|
|
|
|
|
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
2024-12-17 04:01:56 +00:00
|
|
|
this.connectionState$.pipe(
|
2024-09-11 01:03:23 -04:00
|
|
|
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
|
|
|
|
|
take(1),
|
2024-05-02 16:32:48 -04:00
|
|
|
),
|
|
|
|
|
// Then unhold them
|
2024-09-11 01:03:23 -04:00
|
|
|
]).pipe(map(() => ({ unhold: ps }))),
|
2024-05-02 16:32:48 -04:00
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
return EMPTY;
|
|
|
|
|
}
|
2024-09-11 01:03:23 -04:00
|
|
|
}),
|
2024-07-03 15:08:30 -04:00
|
|
|
// Accumulate the hold instructions into a single list showing which
|
2024-05-02 16:32:48 -04:00
|
|
|
// participants are being held
|
2024-07-03 15:08:30 -04:00
|
|
|
accumulate([] as RemoteParticipant[][], (holds, instruction) =>
|
|
|
|
|
"hold" in instruction
|
|
|
|
|
? [instruction.hold, ...holds]
|
|
|
|
|
: holds.filter((h) => h !== instruction.unhold),
|
2024-05-02 16:32:48 -04:00
|
|
|
),
|
|
|
|
|
);
|
2023-11-30 22:59:19 -05:00
|
|
|
|
2024-11-21 11:01:43 +00:00
|
|
|
/**
|
|
|
|
|
* The RemoteParticipants including those that are being "held" on the screen
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly remoteParticipants$: Observable<RemoteParticipant[]> =
|
2024-05-02 16:32:48 -04:00
|
|
|
combineLatest(
|
2024-12-17 04:01:56 +00:00
|
|
|
[this.rawRemoteParticipants$, this.remoteParticipantHolds$],
|
2024-05-02 16:32:48 -04:00
|
|
|
(raw, holds) => {
|
|
|
|
|
const result = [...raw];
|
|
|
|
|
const resultIds = new Set(result.map((p) => p.identity));
|
|
|
|
|
|
|
|
|
|
// Incorporate the held participants into the list
|
|
|
|
|
for (const hold of holds) {
|
|
|
|
|
for (const p of hold) {
|
|
|
|
|
if (!resultIds.has(p.identity)) {
|
|
|
|
|
result.push(p);
|
|
|
|
|
resultIds.add(p.identity);
|
|
|
|
|
}
|
2023-11-30 22:59:19 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-02 16:32:48 -04:00
|
|
|
return result;
|
|
|
|
|
},
|
|
|
|
|
);
|
2023-11-30 22:59:19 -05:00
|
|
|
|
2024-11-21 11:01:43 +00:00
|
|
|
/**
|
|
|
|
|
* List of MediaItems that we want to display
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly mediaItems$: Observable<MediaItem[]> = combineLatest([
|
|
|
|
|
this.remoteParticipants$,
|
2024-05-16 15:23:10 -04:00
|
|
|
observeParticipantMedia(this.livekitRoom.localParticipant),
|
2024-12-17 04:01:56 +00:00
|
|
|
duplicateTiles.value$,
|
2024-12-06 12:28:37 +01:00
|
|
|
// Also react to changes in the MatrixRTC session list.
|
|
|
|
|
// The session list will also be update if a room membership changes.
|
|
|
|
|
// No additional RoomState event listener needs to be set up.
|
|
|
|
|
fromEvent(
|
|
|
|
|
this.matrixRTCSession,
|
|
|
|
|
MatrixRTCSessionEvent.MembershipsChanged,
|
|
|
|
|
).pipe(startWith(null)),
|
2024-12-17 04:01:56 +00:00
|
|
|
showNonMemberTiles.value$,
|
2024-05-16 15:23:10 -04:00
|
|
|
]).pipe(
|
|
|
|
|
scan(
|
2024-07-17 15:55:50 -04:00
|
|
|
(
|
|
|
|
|
prevItems,
|
2024-12-06 12:28:37 +01:00
|
|
|
[
|
|
|
|
|
remoteParticipants,
|
|
|
|
|
{ participant: localParticipant },
|
|
|
|
|
duplicateTiles,
|
|
|
|
|
_membershipsChanged,
|
2024-12-13 14:53:08 +00:00
|
|
|
showNonMemberTiles,
|
2024-12-06 12:28:37 +01:00
|
|
|
],
|
2024-07-17 15:55:50 -04:00
|
|
|
) => {
|
2024-05-16 15:23:10 -04:00
|
|
|
const newItems = new Map(
|
|
|
|
|
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
2024-12-06 12:28:37 +01:00
|
|
|
// m.rtc.members are the basis for calculating what is visible in the call
|
|
|
|
|
for (const rtcMember of this.matrixRTCSession.memberships) {
|
|
|
|
|
const room = this.matrixRTCSession.room;
|
|
|
|
|
// WARN! This is not exactly the sender but the user defined in the state key.
|
|
|
|
|
// This will be available once we change to the new "member as object" format in the MatrixRTC object.
|
|
|
|
|
let livekitParticipantId =
|
|
|
|
|
rtcMember.sender + ":" + rtcMember.deviceId;
|
|
|
|
|
|
2024-12-19 15:54:28 +00:00
|
|
|
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
|
|
|
|
|
|
2024-12-06 12:28:37 +01:00
|
|
|
let participant:
|
|
|
|
|
| LocalParticipant
|
|
|
|
|
| RemoteParticipant
|
|
|
|
|
| undefined = undefined;
|
|
|
|
|
if (
|
|
|
|
|
rtcMember.sender === room.client.getUserId()! &&
|
|
|
|
|
rtcMember.deviceId === room.client.getDeviceId()
|
|
|
|
|
) {
|
|
|
|
|
livekitParticipantId = "local";
|
|
|
|
|
participant = localParticipant;
|
|
|
|
|
} else {
|
|
|
|
|
participant = remoteParticipants.find(
|
|
|
|
|
(p) => p.identity === livekitParticipantId,
|
2024-05-16 15:23:10 -04:00
|
|
|
);
|
2024-12-06 12:28:37 +01:00
|
|
|
}
|
2024-05-16 15:23:10 -04:00
|
|
|
|
2024-12-06 12:28:37 +01:00
|
|
|
const member = findMatrixRoomMember(room, livekitParticipantId);
|
|
|
|
|
if (!member) {
|
|
|
|
|
logger.error(
|
|
|
|
|
"Could not find member for media id: ",
|
|
|
|
|
livekitParticipantId,
|
|
|
|
|
);
|
|
|
|
|
}
|
2024-07-17 15:55:50 -04:00
|
|
|
for (let i = 0; i < 1 + duplicateTiles; i++) {
|
2024-12-06 12:28:37 +01:00
|
|
|
const indexedMediaId = `${livekitParticipantId}:${i}`;
|
2024-12-13 14:53:08 +00:00
|
|
|
let prevMedia = prevItems.get(indexedMediaId);
|
2024-12-06 12:28:37 +01:00
|
|
|
if (prevMedia && prevMedia instanceof UserMedia) {
|
|
|
|
|
prevMedia.updateParticipant(participant);
|
2024-12-13 14:53:08 +00:00
|
|
|
if (prevMedia.vm.member === undefined) {
|
|
|
|
|
// We have a previous media created because of the `debugShowNonMember` flag.
|
|
|
|
|
// In this case we actually replace the media item.
|
|
|
|
|
// This "hack" never occurs if we do not use the `debugShowNonMember` debugging
|
|
|
|
|
// option and if we always find a room member for each rtc member (which also
|
|
|
|
|
// only fails if we have a fundamental problem)
|
|
|
|
|
prevMedia = undefined;
|
|
|
|
|
}
|
2024-12-06 12:28:37 +01:00
|
|
|
}
|
2024-01-20 20:39:12 -05:00
|
|
|
yield [
|
2024-12-06 12:28:37 +01:00
|
|
|
indexedMediaId,
|
|
|
|
|
// We create UserMedia with or without a participant.
|
|
|
|
|
// This will be the initial value of a BehaviourSubject.
|
|
|
|
|
// Once a participant appears we will update the BehaviourSubject. (see above)
|
|
|
|
|
prevMedia ??
|
2024-11-04 09:11:44 +00:00
|
|
|
new UserMedia(
|
2024-12-06 12:28:37 +01:00
|
|
|
indexedMediaId,
|
2024-11-04 09:11:44 +00:00
|
|
|
member,
|
2024-12-06 12:28:37 +01:00
|
|
|
participant,
|
2024-11-04 09:11:44 +00:00
|
|
|
this.encryptionSystem,
|
2024-11-06 11:12:46 +00:00
|
|
|
this.livekitRoom,
|
2024-12-19 15:54:28 +00:00
|
|
|
this.handsRaised$.pipe(
|
|
|
|
|
map((v) => v[matrixIdentifier]?.time ?? null),
|
|
|
|
|
),
|
|
|
|
|
this.reactions$.pipe(
|
|
|
|
|
map((v) => v[matrixIdentifier] ?? undefined),
|
|
|
|
|
),
|
2024-11-04 09:11:44 +00:00
|
|
|
),
|
2024-01-20 20:39:12 -05:00
|
|
|
];
|
2024-07-17 15:55:50 -04:00
|
|
|
|
2024-12-06 12:28:37 +01:00
|
|
|
if (participant?.isScreenShareEnabled) {
|
|
|
|
|
const screenShareId = `${indexedMediaId}:screen-share`;
|
2024-01-20 20:39:12 -05:00
|
|
|
yield [
|
2024-07-17 15:55:50 -04:00
|
|
|
screenShareId,
|
|
|
|
|
prevItems.get(screenShareId) ??
|
2024-11-04 09:11:44 +00:00
|
|
|
new ScreenShare(
|
|
|
|
|
screenShareId,
|
|
|
|
|
member,
|
2024-12-06 12:28:37 +01:00
|
|
|
participant,
|
2024-11-04 09:11:44 +00:00
|
|
|
this.encryptionSystem,
|
2024-11-06 11:12:46 +00:00
|
|
|
this.livekitRoom,
|
2024-11-04 09:11:44 +00:00
|
|
|
),
|
2024-01-20 20:39:12 -05:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-05-16 15:23:10 -04:00
|
|
|
}
|
|
|
|
|
}.bind(this)(),
|
|
|
|
|
);
|
2024-01-20 20:39:12 -05:00
|
|
|
|
2024-12-13 14:53:08 +00:00
|
|
|
// Generate non member items (items without a corresponding MatrixRTC member)
|
|
|
|
|
// Those items should not be rendered, they are participants in LiveKit that do not have a corresponding
|
|
|
|
|
// MatrixRTC members. This cannot be any good:
|
|
|
|
|
// - A malicious user impersonates someone
|
|
|
|
|
// - Someone injects abusive content
|
|
|
|
|
// - The user cannot have encryption keys so it makes no sense to participate
|
|
|
|
|
// We can only trust users that have a MatrixRTC member event.
|
|
|
|
|
//
|
|
|
|
|
// This is still available as a debug option. This can be useful
|
|
|
|
|
// - If one wants to test scalability using the LiveKit CLI.
|
|
|
|
|
// - If an experimental project does not yet do the MatrixRTC bits.
|
|
|
|
|
// - If someone wants to debug if the LiveKit connection works but MatrixRTC room state failed to arrive.
|
|
|
|
|
const newNonMemberItems = showNonMemberTiles
|
|
|
|
|
? new Map(
|
|
|
|
|
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
|
|
|
|
for (const participant of remoteParticipants) {
|
|
|
|
|
for (let i = 0; i < 1 + duplicateTiles; i++) {
|
|
|
|
|
const maybeNonMemberParticipantId =
|
|
|
|
|
participant.identity + ":" + i;
|
|
|
|
|
if (!newItems.has(maybeNonMemberParticipantId)) {
|
|
|
|
|
const nonMemberId = maybeNonMemberParticipantId;
|
|
|
|
|
yield [
|
|
|
|
|
nonMemberId,
|
|
|
|
|
prevItems.get(nonMemberId) ??
|
|
|
|
|
new UserMedia(
|
|
|
|
|
nonMemberId,
|
|
|
|
|
undefined,
|
|
|
|
|
participant,
|
|
|
|
|
this.encryptionSystem,
|
|
|
|
|
this.livekitRoom,
|
2024-12-19 15:54:28 +00:00
|
|
|
of(null),
|
|
|
|
|
of(null),
|
2024-12-13 14:53:08 +00:00
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}.bind(this)(),
|
|
|
|
|
)
|
|
|
|
|
: new Map();
|
|
|
|
|
if (newNonMemberItems.size > 0) {
|
|
|
|
|
logger.debug("Added NonMember items: ", newNonMemberItems);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const combinedNew = new Map([
|
|
|
|
|
...newNonMemberItems.entries(),
|
|
|
|
|
...newItems.entries(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
for (const [id, t] of prevItems) if (!combinedNew.has(id)) t.destroy();
|
|
|
|
|
return combinedNew;
|
2024-05-16 15:23:10 -04:00
|
|
|
},
|
|
|
|
|
new Map<string, MediaItem>(),
|
2024-01-20 20:39:12 -05:00
|
|
|
),
|
2024-07-17 15:55:50 -04:00
|
|
|
map((mediaItems) => [...mediaItems.values()]),
|
2024-05-16 15:23:10 -04:00
|
|
|
finalizeValue((ts) => {
|
|
|
|
|
for (const t of ts) t.destroy();
|
|
|
|
|
}),
|
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-01-20 20:39:12 -05:00
|
|
|
);
|
|
|
|
|
|
2024-11-21 11:01:43 +00:00
|
|
|
/**
|
|
|
|
|
* List of MediaItems that we want to display, that are of type UserMedia
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly userMedia$: Observable<UserMedia[]> = this.mediaItems$.pipe(
|
2024-07-17 15:37:55 -04:00
|
|
|
map((mediaItems) =>
|
|
|
|
|
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
|
|
|
|
|
),
|
2024-01-20 20:39:12 -05:00
|
|
|
);
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly memberChanges$ = this.userMedia$
|
2024-12-02 15:16:58 +00:00
|
|
|
.pipe(map((mediaItems) => mediaItems.map((m) => m.id)))
|
|
|
|
|
.pipe(
|
|
|
|
|
scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
|
|
|
|
|
(prev, ids) => {
|
|
|
|
|
const left = prev.ids.filter((id) => !ids.includes(id));
|
|
|
|
|
const joined = ids.filter((id) => !prev.ids.includes(id));
|
|
|
|
|
return { ids, joined, left };
|
|
|
|
|
},
|
|
|
|
|
{ ids: [], joined: [], left: [] },
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
2024-11-21 11:01:43 +00:00
|
|
|
/**
|
|
|
|
|
* List of MediaItems that we want to display, that are of type ScreenShare
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly screenShares$: Observable<ScreenShare[]> =
|
|
|
|
|
this.mediaItems$.pipe(
|
2024-07-17 15:37:55 -04:00
|
|
|
map((mediaItems) =>
|
|
|
|
|
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
|
|
|
|
|
),
|
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-17 16:38:00 -04:00
|
|
|
);
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly spotlightSpeaker$: Observable<UserMediaViewModel | null> =
|
|
|
|
|
this.userMedia$.pipe(
|
2024-07-17 15:37:55 -04:00
|
|
|
switchMap((mediaItems) =>
|
|
|
|
|
mediaItems.length === 0
|
2024-05-02 16:32:48 -04:00
|
|
|
? of([])
|
|
|
|
|
: combineLatest(
|
2024-07-17 15:37:55 -04:00
|
|
|
mediaItems.map((m) =>
|
2024-12-17 04:01:56 +00:00
|
|
|
m.vm.speaking$.pipe(map((s) => [m, s] as const)),
|
2024-07-17 15:37:55 -04:00
|
|
|
),
|
2024-05-02 16:32:48 -04:00
|
|
|
),
|
|
|
|
|
),
|
2024-12-06 12:28:37 +01:00
|
|
|
scan<(readonly [UserMedia, boolean])[], UserMedia | undefined, null>(
|
2024-08-01 12:48:47 -04:00
|
|
|
(prev, mediaItems) => {
|
2024-08-09 13:06:33 -04:00
|
|
|
// Only remote users that are still in the call should be sticky
|
2024-08-09 13:38:59 -04:00
|
|
|
const [stickyMedia, stickySpeaking] =
|
|
|
|
|
(!prev?.vm.local && mediaItems.find(([m]) => m === prev)) || [];
|
2024-05-02 16:32:48 -04:00
|
|
|
// Decide who to spotlight:
|
2024-08-09 13:06:33 -04:00
|
|
|
// If the previous speaker is still speaking, stick with them rather
|
|
|
|
|
// than switching eagerly to someone else
|
2024-08-09 13:38:59 -04:00
|
|
|
return stickySpeaking
|
|
|
|
|
? stickyMedia!
|
2024-08-09 13:06:33 -04:00
|
|
|
: // Otherwise, select any remote user who is speaking
|
|
|
|
|
(mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ??
|
|
|
|
|
// Otherwise, stick with the person who was last speaking
|
2024-08-09 13:38:59 -04:00
|
|
|
stickyMedia ??
|
2024-08-09 13:06:33 -04:00
|
|
|
// Otherwise, spotlight an arbitrary remote user
|
|
|
|
|
mediaItems.find(([m]) => !m.vm.local)?.[0] ??
|
|
|
|
|
// Otherwise, spotlight the local user
|
2024-12-06 12:28:37 +01:00
|
|
|
mediaItems.find(([m]) => m.vm.local)?.[0]);
|
2024-08-01 12:48:47 -04:00
|
|
|
},
|
2024-01-20 20:39:12 -05:00
|
|
|
null,
|
2024-05-02 16:32:48 -04:00
|
|
|
),
|
2024-12-06 12:28:37 +01:00
|
|
|
map((speaker) => speaker?.vm ?? null),
|
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-02 16:32:48 -04:00
|
|
|
);
|
2024-01-20 20:39:12 -05:00
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly grid$: Observable<UserMediaViewModel[]> =
|
|
|
|
|
this.userMedia$.pipe(
|
|
|
|
|
switchMap((mediaItems) => {
|
|
|
|
|
const bins = mediaItems.map((m) =>
|
|
|
|
|
combineLatest(
|
|
|
|
|
[
|
|
|
|
|
m.speaker$,
|
|
|
|
|
m.presenter$,
|
|
|
|
|
m.vm.videoEnabled$,
|
2024-12-19 15:54:28 +00:00
|
|
|
m.vm.handRaised$,
|
2024-12-17 04:01:56 +00:00
|
|
|
m.vm instanceof LocalUserMediaViewModel
|
|
|
|
|
? m.vm.alwaysShow$
|
|
|
|
|
: of(false),
|
|
|
|
|
],
|
2024-12-19 15:54:28 +00:00
|
|
|
(speaker, presenter, video, handRaised, alwaysShow) => {
|
2024-12-17 04:01:56 +00:00
|
|
|
let bin: SortingBin;
|
|
|
|
|
if (m.vm.local)
|
|
|
|
|
bin = alwaysShow
|
|
|
|
|
? SortingBin.SelfAlwaysShown
|
|
|
|
|
: SortingBin.SelfNotAlwaysShown;
|
|
|
|
|
else if (presenter) bin = SortingBin.Presenters;
|
|
|
|
|
else if (speaker) bin = SortingBin.Speakers;
|
2024-12-19 15:54:28 +00:00
|
|
|
else if (handRaised) bin = SortingBin.HandRaised;
|
2024-12-17 04:01:56 +00:00
|
|
|
else if (video) bin = SortingBin.Video;
|
|
|
|
|
else bin = SortingBin.NoVideo;
|
|
|
|
|
|
|
|
|
|
return [m, bin] as const;
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
// Sort the media by bin order and generate a tile for each one
|
|
|
|
|
return bins.length === 0
|
|
|
|
|
? of([])
|
|
|
|
|
: combineLatest(bins, (...bins) =>
|
|
|
|
|
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm),
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
distinctUntilChanged(shallowEquals),
|
|
|
|
|
this.scope.state(),
|
|
|
|
|
);
|
2024-01-20 20:39:12 -05:00
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly spotlight$: Observable<MediaViewModel[]> =
|
|
|
|
|
this.screenShares$.pipe(
|
2024-12-06 12:28:37 +01:00
|
|
|
switchMap((screenShares) => {
|
|
|
|
|
if (screenShares.length > 0) {
|
|
|
|
|
return of(screenShares.map((m) => m.vm));
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
return this.spotlightSpeaker$.pipe(
|
2024-12-06 12:28:37 +01:00
|
|
|
map((speaker) => (speaker ? [speaker] : [])),
|
|
|
|
|
);
|
|
|
|
|
}),
|
2024-12-13 16:40:20 -05:00
|
|
|
distinctUntilChanged(shallowEquals),
|
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-07-03 15:08:30 -04:00
|
|
|
);
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly pip$: Observable<UserMediaViewModel | null> = combineLatest([
|
|
|
|
|
this.screenShares$,
|
|
|
|
|
this.spotlightSpeaker$,
|
|
|
|
|
this.mediaItems$,
|
2024-12-06 12:28:37 +01:00
|
|
|
]).pipe(
|
|
|
|
|
switchMap(([screenShares, spotlight, mediaItems]) => {
|
|
|
|
|
if (screenShares.length > 0) {
|
2024-12-17 04:01:56 +00:00
|
|
|
return this.spotlightSpeaker$;
|
2024-12-06 12:28:37 +01:00
|
|
|
}
|
|
|
|
|
if (!spotlight || spotlight.local) {
|
|
|
|
|
return of(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const localUserMedia = mediaItems.find(
|
|
|
|
|
(m) => m.vm instanceof LocalUserMediaViewModel,
|
|
|
|
|
) as UserMedia | undefined;
|
|
|
|
|
|
|
|
|
|
const localUserMediaViewModel = localUserMedia?.vm as
|
|
|
|
|
| LocalUserMediaViewModel
|
|
|
|
|
| undefined;
|
|
|
|
|
|
|
|
|
|
if (!localUserMediaViewModel) {
|
|
|
|
|
return of(null);
|
|
|
|
|
}
|
2024-12-17 04:01:56 +00:00
|
|
|
return localUserMediaViewModel.alwaysShow$.pipe(
|
2024-12-06 12:28:37 +01:00
|
|
|
map((alwaysShow) => {
|
|
|
|
|
if (alwaysShow) {
|
|
|
|
|
return localUserMediaViewModel;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
this.scope.state(),
|
|
|
|
|
);
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly hasRemoteScreenShares$: Observable<boolean> =
|
|
|
|
|
this.spotlight$.pipe(
|
2024-11-06 04:36:48 -05:00
|
|
|
map((spotlight) =>
|
|
|
|
|
spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel),
|
|
|
|
|
),
|
|
|
|
|
distinctUntilChanged(),
|
|
|
|
|
);
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly pipEnabled$: Observable<boolean> = setPipEnabled$.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
|
|
|
startWith(false),
|
|
|
|
|
);
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly naturalWindowMode$: Observable<WindowMode> = fromEvent(
|
2024-07-03 15:08:30 -04:00
|
|
|
window,
|
|
|
|
|
"resize",
|
|
|
|
|
).pipe(
|
|
|
|
|
startWith(null),
|
|
|
|
|
map(() => {
|
|
|
|
|
const height = window.innerHeight;
|
|
|
|
|
const width = window.innerWidth;
|
|
|
|
|
if (height <= 400 && width <= 340) return "pip";
|
2024-08-08 11:27:16 -04:00
|
|
|
// Our layouts for flat windows are better at adapting to a small width
|
|
|
|
|
// than our layouts for narrow windows are at adapting to a small height,
|
|
|
|
|
// so we give "flat" precedence here
|
2024-08-09 10:07:50 -04:00
|
|
|
if (height <= 600) return "flat";
|
2024-08-08 11:27:16 -04:00
|
|
|
if (width <= 600) return "narrow";
|
2024-07-03 15:08:30 -04:00
|
|
|
return "normal";
|
|
|
|
|
}),
|
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(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The general shape of the window.
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly windowMode$: Observable<WindowMode> = this.pipEnabled$.pipe(
|
|
|
|
|
switchMap((pip) => (pip ? of<WindowMode>("pip") : this.naturalWindowMode$)),
|
2024-07-03 15:08:30 -04:00
|
|
|
);
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly spotlightExpandedToggle$ = new Subject<void>();
|
|
|
|
|
public readonly spotlightExpanded$: Observable<boolean> =
|
|
|
|
|
this.spotlightExpandedToggle$.pipe(
|
2024-07-03 15:08:30 -04:00
|
|
|
accumulate(false, (expanded) => !expanded),
|
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-07-03 15:08:30 -04:00
|
|
|
);
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly gridModeUserSelection$ = new Subject<GridMode>();
|
2024-05-02 16:00:05 -04:00
|
|
|
/**
|
|
|
|
|
* The layout mode of the media tile grid.
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly gridMode$: Observable<GridMode> =
|
2024-05-17 16:38:00 -04:00
|
|
|
// If the user hasn't selected spotlight and somebody starts screen sharing,
|
|
|
|
|
// automatically switch to spotlight mode and reset when screen sharing ends
|
2024-12-17 04:01:56 +00:00
|
|
|
this.gridModeUserSelection$.pipe(
|
2024-07-03 15:08:30 -04:00
|
|
|
startWith(null),
|
|
|
|
|
switchMap((userSelection) =>
|
|
|
|
|
(userSelection === "spotlight"
|
2024-05-17 16:38:00 -04:00
|
|
|
? EMPTY
|
2024-12-17 04:01:56 +00:00
|
|
|
: combineLatest([this.hasRemoteScreenShares$, this.windowMode$]).pipe(
|
2024-07-03 15:08:30 -04:00
|
|
|
skip(userSelection === null ? 0 : 1),
|
|
|
|
|
map(
|
|
|
|
|
([hasScreenShares, windowMode]): GridMode =>
|
|
|
|
|
hasScreenShares || windowMode === "flat"
|
|
|
|
|
? "spotlight"
|
|
|
|
|
: "grid",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
).pipe(startWith(userSelection ?? "grid")),
|
2024-05-17 16:38:00 -04:00
|
|
|
),
|
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-07-03 15:08:30 -04:00
|
|
|
);
|
2024-01-20 20:39:12 -05:00
|
|
|
|
|
|
|
|
public setGridMode(value: GridMode): void {
|
2024-12-17 04:01:56 +00:00
|
|
|
this.gridModeUserSelection$.next(value);
|
2024-01-20 20:39:12 -05:00
|
|
|
}
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly gridLayoutMedia$: Observable<GridLayoutMedia> =
|
|
|
|
|
combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({
|
2024-07-25 17:51:00 -04:00
|
|
|
type: "grid",
|
|
|
|
|
spotlight: spotlight.some((vm) => vm instanceof ScreenShareViewModel)
|
|
|
|
|
? spotlight
|
|
|
|
|
: undefined,
|
|
|
|
|
grid,
|
2024-12-17 04:01:56 +00:00
|
|
|
}));
|
2024-07-25 17:51:00 -04:00
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly spotlightLandscapeLayoutMedia$: Observable<SpotlightLandscapeLayoutMedia> =
|
|
|
|
|
combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({
|
2024-11-06 04:36:48 -05:00
|
|
|
type: "spotlight-landscape",
|
|
|
|
|
spotlight,
|
|
|
|
|
grid,
|
|
|
|
|
}));
|
2024-07-25 17:51:00 -04:00
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
|
|
|
|
|
combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({
|
2024-11-06 04:36:48 -05:00
|
|
|
type: "spotlight-portrait",
|
|
|
|
|
spotlight,
|
|
|
|
|
grid,
|
|
|
|
|
}));
|
2024-07-25 17:51:00 -04:00
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly spotlightExpandedLayoutMedia$: Observable<SpotlightExpandedLayoutMedia> =
|
|
|
|
|
combineLatest([this.spotlight$, this.pip$], (spotlight, pip) => ({
|
2024-07-25 17:51:00 -04:00
|
|
|
type: "spotlight-expanded",
|
|
|
|
|
spotlight,
|
|
|
|
|
pip: pip ?? undefined,
|
2024-11-06 04:36:48 -05:00
|
|
|
}));
|
2024-07-25 17:51:00 -04:00
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly oneOnOneLayoutMedia$: Observable<OneOnOneLayoutMedia | null> =
|
|
|
|
|
this.mediaItems$.pipe(
|
2024-11-11 08:25:16 -05:00
|
|
|
map((mediaItems) => {
|
|
|
|
|
if (mediaItems.length !== 2) return null;
|
2024-12-06 12:28:37 +01:00
|
|
|
const local = mediaItems.find((vm) => vm.vm.local)?.vm as
|
|
|
|
|
| LocalUserMediaViewModel
|
|
|
|
|
| undefined;
|
2024-11-11 08:25:16 -05:00
|
|
|
const remote = mediaItems.find((vm) => !vm.vm.local)?.vm as
|
|
|
|
|
| RemoteUserMediaViewModel
|
|
|
|
|
| undefined;
|
|
|
|
|
// There might not be a remote tile if there are screen shares, or if
|
|
|
|
|
// only the local user is in the call and they're using the duplicate
|
|
|
|
|
// tiles option
|
2024-12-06 12:28:37 +01:00
|
|
|
if (!remote || !local) return null;
|
2024-11-11 08:25:16 -05:00
|
|
|
|
|
|
|
|
return { type: "one-on-one", local, remote };
|
|
|
|
|
}),
|
2024-11-06 04:36:48 -05:00
|
|
|
);
|
2024-07-25 17:51:00 -04:00
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly pipLayoutMedia$: Observable<LayoutMedia> =
|
|
|
|
|
this.spotlight$.pipe(map((spotlight) => ({ type: "pip", spotlight })));
|
2024-07-25 17:51:00 -04:00
|
|
|
|
2024-11-06 04:36:48 -05:00
|
|
|
/**
|
|
|
|
|
* The media to be used to produce a layout.
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly layoutMedia$: Observable<LayoutMedia> =
|
|
|
|
|
this.windowMode$.pipe(
|
|
|
|
|
switchMap((windowMode) => {
|
|
|
|
|
switch (windowMode) {
|
|
|
|
|
case "normal":
|
|
|
|
|
return this.gridMode$.pipe(
|
|
|
|
|
switchMap((gridMode) => {
|
|
|
|
|
switch (gridMode) {
|
|
|
|
|
case "grid":
|
|
|
|
|
return this.oneOnOneLayoutMedia$.pipe(
|
|
|
|
|
switchMap((oneOnOne) =>
|
|
|
|
|
oneOnOne === null
|
|
|
|
|
? this.gridLayoutMedia$
|
|
|
|
|
: of(oneOnOne),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
case "spotlight":
|
|
|
|
|
return this.spotlightExpanded$.pipe(
|
|
|
|
|
switchMap((expanded) =>
|
|
|
|
|
expanded
|
|
|
|
|
? this.spotlightExpandedLayoutMedia$
|
|
|
|
|
: this.spotlightLandscapeLayoutMedia$,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
case "narrow":
|
|
|
|
|
return this.oneOnOneLayoutMedia$.pipe(
|
|
|
|
|
switchMap((oneOnOne) =>
|
|
|
|
|
oneOnOne === null
|
|
|
|
|
? combineLatest(
|
|
|
|
|
[this.grid$, this.spotlight$],
|
|
|
|
|
(grid, spotlight) =>
|
|
|
|
|
grid.length > smallMobileCallThreshold ||
|
|
|
|
|
spotlight.some(
|
|
|
|
|
(vm) => vm instanceof ScreenShareViewModel,
|
|
|
|
|
)
|
|
|
|
|
? this.spotlightPortraitLayoutMedia$
|
|
|
|
|
: this.gridLayoutMedia$,
|
|
|
|
|
).pipe(switchAll())
|
|
|
|
|
: // The expanded spotlight layout makes for a better one-on-one
|
|
|
|
|
// experience in narrow windows
|
|
|
|
|
this.spotlightExpandedLayoutMedia$,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
case "flat":
|
|
|
|
|
return this.gridMode$.pipe(
|
|
|
|
|
switchMap((gridMode) => {
|
|
|
|
|
switch (gridMode) {
|
|
|
|
|
case "grid":
|
|
|
|
|
// Yes, grid mode actually gets you a "spotlight" layout in
|
|
|
|
|
// this window mode.
|
|
|
|
|
return this.spotlightLandscapeLayoutMedia$;
|
|
|
|
|
case "spotlight":
|
|
|
|
|
return this.spotlightExpandedLayoutMedia$;
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
case "pip":
|
|
|
|
|
return this.pipLayoutMedia$;
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
this.scope.state(),
|
|
|
|
|
);
|
2024-01-20 20:39:12 -05:00
|
|
|
|
2024-12-12 17:32:13 -05:00
|
|
|
// 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
|
|
|
|
|
// are visible, and loop the data back into the layouts with a Subject.
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly visibleTiles$ = new Subject<number>();
|
2024-12-12 17:32:13 -05:00
|
|
|
private readonly setVisibleTiles = (value: number): void =>
|
2024-12-17 04:01:56 +00:00
|
|
|
this.visibleTiles$.next(value);
|
2024-12-12 17:32:13 -05:00
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly layoutInternals$: Observable<
|
2024-12-11 05:23:42 -05:00
|
|
|
LayoutScanState & { layout: Layout }
|
2024-12-12 17:32:13 -05:00
|
|
|
> = combineLatest([
|
2024-12-17 04:01:56 +00:00
|
|
|
this.layoutMedia$,
|
|
|
|
|
this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()),
|
2024-12-12 17:32:13 -05:00
|
|
|
]).pipe(
|
|
|
|
|
scan<
|
|
|
|
|
[LayoutMedia, number],
|
|
|
|
|
LayoutScanState & { layout: Layout },
|
|
|
|
|
LayoutScanState
|
2024-11-06 04:36:48 -05:00
|
|
|
>(
|
2024-12-12 17:32:13 -05:00
|
|
|
({ tiles: prevTiles }, [media, visibleTiles]) => {
|
2024-11-06 04:36:48 -05:00
|
|
|
let layout: Layout;
|
|
|
|
|
let newTiles: TileStore;
|
|
|
|
|
switch (media.type) {
|
|
|
|
|
case "grid":
|
|
|
|
|
case "spotlight-landscape":
|
|
|
|
|
case "spotlight-portrait":
|
2024-12-12 17:32:13 -05:00
|
|
|
[layout, newTiles] = gridLikeLayout(
|
2024-11-06 04:36:48 -05:00
|
|
|
media,
|
|
|
|
|
visibleTiles,
|
2024-12-12 17:32:13 -05:00
|
|
|
this.setVisibleTiles,
|
2024-11-06 04:36:48 -05:00
|
|
|
prevTiles,
|
|
|
|
|
);
|
|
|
|
|
break;
|
2024-12-12 17:32:13 -05:00
|
|
|
case "spotlight-expanded":
|
|
|
|
|
[layout, newTiles] = spotlightExpandedLayout(media, prevTiles);
|
|
|
|
|
break;
|
2024-11-06 04:36:48 -05:00
|
|
|
case "one-on-one":
|
2024-12-12 17:32:13 -05:00
|
|
|
[layout, newTiles] = oneOnOneLayout(media, prevTiles);
|
2024-11-06 04:36:48 -05:00
|
|
|
break;
|
|
|
|
|
case "pip":
|
2024-12-12 17:32:13 -05:00
|
|
|
[layout, newTiles] = pipLayout(media, prevTiles);
|
2024-11-06 04:36:48 -05:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-12 17:32:13 -05:00
|
|
|
return { layout, tiles: newTiles };
|
2024-11-06 04:36:48 -05:00
|
|
|
},
|
2024-12-12 17:32:13 -05:00
|
|
|
{ layout: null, tiles: TileStore.empty() },
|
2024-11-06 04:36:48 -05:00
|
|
|
),
|
2024-12-11 05:23:42 -05:00
|
|
|
this.scope.state(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The layout of tiles in the call interface.
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly layout$: Observable<Layout> = this.layoutInternals$.pipe(
|
2024-11-06 04:36:48 -05:00
|
|
|
map(({ layout }) => layout),
|
|
|
|
|
this.scope.state(),
|
|
|
|
|
);
|
|
|
|
|
|
2024-12-11 05:23:42 -05:00
|
|
|
/**
|
|
|
|
|
* The current generation of the tile store, exposed for debugging purposes.
|
|
|
|
|
*/
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly tileStoreGeneration$: Observable<number> =
|
|
|
|
|
this.layoutInternals$.pipe(
|
2024-12-11 05:23:42 -05:00
|
|
|
map(({ tiles }) => tiles.generation),
|
|
|
|
|
this.scope.state(),
|
|
|
|
|
);
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
public showSpotlightIndicators$: Observable<boolean> = this.layout$.pipe(
|
2024-07-03 15:08:30 -04:00
|
|
|
map((l) => l.type !== "grid"),
|
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-07-03 15:08:30 -04:00
|
|
|
);
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
public showSpeakingIndicators$: Observable<boolean> = this.layout$.pipe(
|
2024-11-06 04:36:48 -05:00
|
|
|
switchMap((l) => {
|
2024-10-28 14:45:06 -04:00
|
|
|
switch (l.type) {
|
|
|
|
|
case "spotlight-landscape":
|
|
|
|
|
case "spotlight-portrait":
|
|
|
|
|
// If the spotlight is showing the active speaker, we can do without
|
|
|
|
|
// speaking indicators as they're a redundant visual cue. But if
|
|
|
|
|
// screen sharing feeds are in the spotlight we still need them.
|
2024-12-17 04:01:56 +00:00
|
|
|
return l.spotlight.media$.pipe(
|
2024-11-06 04:36:48 -05:00
|
|
|
map((models: MediaViewModel[]) =>
|
|
|
|
|
models.some((m) => m instanceof ScreenShareViewModel),
|
|
|
|
|
),
|
|
|
|
|
);
|
2024-11-01 11:25:55 -04:00
|
|
|
// In expanded spotlight layout, the active speaker is always shown in
|
|
|
|
|
// the picture-in-picture tile so there is no need for speaking
|
2024-11-04 10:56:29 -05:00
|
|
|
// indicators. And in one-on-one layout there's no question as to who is
|
|
|
|
|
// speaking.
|
2024-10-28 14:45:06 -04:00
|
|
|
case "spotlight-expanded":
|
|
|
|
|
case "one-on-one":
|
2024-11-06 04:36:48 -05:00
|
|
|
return of(false);
|
2024-10-28 14:45:06 -04:00
|
|
|
default:
|
2024-11-06 04:36:48 -05:00
|
|
|
return of(true);
|
2024-10-28 14:45:06 -04:00
|
|
|
}
|
|
|
|
|
}),
|
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-07-03 15:08:30 -04:00
|
|
|
);
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly toggleSpotlightExpanded$: Observable<(() => void) | null> =
|
|
|
|
|
this.windowMode$.pipe(
|
2024-08-01 13:49:09 -04:00
|
|
|
switchMap((mode) =>
|
|
|
|
|
mode === "normal"
|
2024-12-17 04:01:56 +00:00
|
|
|
? this.layout$.pipe(
|
2024-08-01 13:49:09 -04:00
|
|
|
map(
|
|
|
|
|
(l) =>
|
|
|
|
|
l.type === "spotlight-landscape" ||
|
|
|
|
|
l.type === "spotlight-expanded",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
: of(false),
|
|
|
|
|
),
|
|
|
|
|
distinctUntilChanged(),
|
|
|
|
|
map((enabled) =>
|
2024-12-17 04:01:56 +00:00
|
|
|
enabled ? (): void => this.spotlightExpandedToggle$.next() : null,
|
2024-08-01 13:49:09 -04:00
|
|
|
),
|
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-08-01 13:49:09 -04:00
|
|
|
);
|
2024-07-26 06:57:49 -04:00
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly screenTap$ = new Subject<void>();
|
|
|
|
|
private readonly controlsTap$ = new Subject<void>();
|
|
|
|
|
private readonly screenHover$ = new Subject<void>();
|
|
|
|
|
private readonly screenUnhover$ = new Subject<void>();
|
2024-08-08 17:21:47 -04:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Callback for when the user taps the call view.
|
|
|
|
|
*/
|
|
|
|
|
public tapScreen(): void {
|
2024-12-17 04:01:56 +00:00
|
|
|
this.screenTap$.next();
|
2024-08-08 17:21:47 -04:00
|
|
|
}
|
|
|
|
|
|
2024-11-08 10:23:19 -05:00
|
|
|
/**
|
|
|
|
|
* Callback for when the user taps the call's controls.
|
|
|
|
|
*/
|
|
|
|
|
public tapControls(): void {
|
2024-12-17 04:01:56 +00:00
|
|
|
this.controlsTap$.next();
|
2024-11-08 10:23:19 -05:00
|
|
|
}
|
|
|
|
|
|
2024-08-08 17:21:47 -04:00
|
|
|
/**
|
|
|
|
|
* Callback for when the user hovers over the call view.
|
|
|
|
|
*/
|
|
|
|
|
public hoverScreen(): void {
|
2024-12-17 04:01:56 +00:00
|
|
|
this.screenHover$.next();
|
2024-08-08 17:21:47 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Callback for when the user stops hovering over the call view.
|
|
|
|
|
*/
|
|
|
|
|
public unhoverScreen(): void {
|
2024-12-17 04:01:56 +00:00
|
|
|
this.screenUnhover$.next();
|
2024-08-08 17:21:47 -04:00
|
|
|
}
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly showHeader$: Observable<boolean> = this.windowMode$.pipe(
|
2024-08-08 17:21:47 -04:00
|
|
|
map((mode) => mode !== "pip" && mode !== "flat"),
|
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-08-08 17:21:47 -04:00
|
|
|
);
|
|
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
public readonly showFooter$: Observable<boolean> = this.windowMode$.pipe(
|
2024-08-08 17:21:47 -04:00
|
|
|
switchMap((mode) => {
|
|
|
|
|
switch (mode) {
|
|
|
|
|
case "pip":
|
|
|
|
|
return of(false);
|
|
|
|
|
case "normal":
|
|
|
|
|
case "narrow":
|
|
|
|
|
return of(true);
|
|
|
|
|
case "flat":
|
|
|
|
|
// Sadly Firefox has some layering glitches that prevent the footer
|
|
|
|
|
// from appearing properly. They happen less often if we never hide
|
|
|
|
|
// the footer.
|
|
|
|
|
if (isFirefox()) return of(true);
|
|
|
|
|
// Show/hide the footer in response to interactions
|
|
|
|
|
return merge(
|
2024-12-17 04:01:56 +00:00
|
|
|
this.screenTap$.pipe(map(() => "tap screen" as const)),
|
|
|
|
|
this.controlsTap$.pipe(map(() => "tap controls" as const)),
|
|
|
|
|
this.screenHover$.pipe(map(() => "hover" as const)),
|
2024-08-08 17:21:47 -04:00
|
|
|
).pipe(
|
2024-11-08 10:23:19 -05:00
|
|
|
switchScan((state, interaction) => {
|
|
|
|
|
switch (interaction) {
|
|
|
|
|
case "tap screen":
|
|
|
|
|
return state
|
2024-08-08 17:21:47 -04:00
|
|
|
? // Toggle visibility on tap
|
|
|
|
|
of(false)
|
|
|
|
|
: // Hide after a timeout
|
2024-11-08 10:23:19 -05:00
|
|
|
timer(showFooterMs).pipe(
|
2024-08-08 17:21:47 -04:00
|
|
|
map(() => false),
|
|
|
|
|
startWith(true),
|
2024-11-08 10:23:19 -05:00
|
|
|
);
|
|
|
|
|
case "tap controls":
|
|
|
|
|
// The user is interacting with things, so reset the timeout
|
|
|
|
|
return timer(showFooterMs).pipe(
|
|
|
|
|
map(() => false),
|
|
|
|
|
startWith(true),
|
|
|
|
|
);
|
|
|
|
|
case "hover":
|
|
|
|
|
// Show on hover and hide after a timeout
|
|
|
|
|
return race(
|
|
|
|
|
timer(showFooterMs),
|
2024-12-17 04:01:56 +00:00
|
|
|
this.screenUnhover$.pipe(take(1)),
|
2024-11-08 10:23:19 -05:00
|
|
|
).pipe(
|
|
|
|
|
map(() => false),
|
|
|
|
|
startWith(true),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}, false),
|
2024-08-08 17:21:47 -04:00
|
|
|
startWith(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(),
|
2024-08-08 17:21:47 -04:00
|
|
|
);
|
2024-12-19 15:54:28 +00:00
|
|
|
|
|
|
|
|
public readonly reactions$ = this.reactionsSubject$.pipe(
|
|
|
|
|
map((v) =>
|
|
|
|
|
Object.fromEntries(
|
|
|
|
|
Object.entries(v).map(([a, { reactionOption }]) => [a, reactionOption]),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
public readonly handsRaised$ = this.handsRaisedSubject$.pipe();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Emits an array of reactions that should be visible on the screen.
|
|
|
|
|
*/
|
|
|
|
|
public readonly visibleReactions$ = showReactions.value$.pipe(
|
|
|
|
|
switchMap((show) => (show ? this.reactions$ : of({}))),
|
|
|
|
|
scan<
|
|
|
|
|
Record<string, ReactionOption>,
|
|
|
|
|
{ sender: string; emoji: string; startX: number }[]
|
|
|
|
|
>((acc, latest) => {
|
|
|
|
|
const newSet: { sender: string; emoji: string; startX: number }[] = [];
|
|
|
|
|
for (const [sender, reaction] of Object.entries(latest)) {
|
|
|
|
|
const startX =
|
|
|
|
|
acc.find((v) => v.sender === sender && v.emoji)?.startX ??
|
|
|
|
|
Math.ceil(Math.random() * 80) + 10;
|
|
|
|
|
newSet.push({ sender, emoji: reaction.emoji, startX });
|
|
|
|
|
}
|
|
|
|
|
return newSet;
|
|
|
|
|
}, []),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Emits an array of reactions that should be played.
|
|
|
|
|
*/
|
|
|
|
|
public readonly audibleReactions$ = playReactionsSound.value$.pipe(
|
|
|
|
|
switchMap((show) =>
|
|
|
|
|
show ? this.reactions$ : of<Record<string, ReactionOption>>({}),
|
|
|
|
|
),
|
|
|
|
|
map((reactions) => Object.values(reactions).map((v) => v.name)),
|
|
|
|
|
scan<string[], { playing: string[]; newSounds: string[] }>(
|
|
|
|
|
(acc, latest) => {
|
|
|
|
|
return {
|
|
|
|
|
playing: latest.filter(
|
|
|
|
|
(v) => acc.playing.includes(v) || acc.newSounds.includes(v),
|
|
|
|
|
),
|
|
|
|
|
newSounds: latest.filter(
|
|
|
|
|
(v) => !acc.playing.includes(v) && !acc.newSounds.includes(v),
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
{ playing: [], newSounds: [] },
|
|
|
|
|
),
|
|
|
|
|
map((v) => v.newSounds),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Emits an event every time a new hand is raised in
|
|
|
|
|
* the call.
|
|
|
|
|
*/
|
|
|
|
|
public readonly newHandRaised$ = this.handsRaised$.pipe(
|
|
|
|
|
map((v) => Object.keys(v).length),
|
|
|
|
|
scan(
|
|
|
|
|
(acc, newValue) => ({
|
|
|
|
|
value: newValue,
|
|
|
|
|
playSounds: newValue > acc.value,
|
|
|
|
|
}),
|
|
|
|
|
{ value: 0, playSounds: false },
|
|
|
|
|
),
|
|
|
|
|
filter((v) => v.playSounds),
|
|
|
|
|
);
|
|
|
|
|
|
2024-12-19 12:37:10 +00:00
|
|
|
/**
|
|
|
|
|
* Emits an event every time a new screenshare is started in
|
|
|
|
|
* the call.
|
|
|
|
|
*/
|
|
|
|
|
public readonly newScreenShare$ = this.screenShares$.pipe(
|
|
|
|
|
map((v) => v.length),
|
|
|
|
|
scan(
|
|
|
|
|
(acc, newValue) => ({
|
|
|
|
|
value: newValue,
|
|
|
|
|
playSounds: newValue > acc.value,
|
|
|
|
|
}),
|
|
|
|
|
{ value: 0, playSounds: false },
|
|
|
|
|
),
|
|
|
|
|
filter((v) => v.playSounds),
|
|
|
|
|
);
|
2024-08-08 17:21:47 -04:00
|
|
|
|
2023-11-30 22:59:19 -05:00
|
|
|
public constructor(
|
|
|
|
|
// A call is permanently tied to a single Matrix room and LiveKit room
|
2024-12-06 12:28:37 +01:00
|
|
|
private readonly matrixRTCSession: MatrixRTCSession,
|
2023-11-30 22:59:19 -05:00
|
|
|
private readonly livekitRoom: LivekitRoom,
|
2024-11-04 09:11:44 +00:00
|
|
|
private readonly encryptionSystem: EncryptionSystem,
|
2024-12-17 04:01:56 +00:00
|
|
|
private readonly connectionState$: Observable<ECConnectionState>,
|
2024-12-19 15:54:28 +00:00
|
|
|
private readonly handsRaisedSubject$: Observable<
|
|
|
|
|
Record<string, RaisedHandInfo>
|
|
|
|
|
>,
|
|
|
|
|
private readonly reactionsSubject$: Observable<
|
|
|
|
|
Record<string, ReactionInfo>
|
|
|
|
|
>,
|
2023-11-30 22:59:19 -05:00
|
|
|
) {
|
|
|
|
|
super();
|
|
|
|
|
}
|
|
|
|
|
}
|