Merge branch 'livekit' into toger5/track-processor-blur

This commit is contained in:
Hugh Nimmo-Smith
2024-12-18 09:41:38 +00:00
71 changed files with 1337 additions and 1056 deletions

View File

@@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "vitest";
import { expect, test, afterEach } from "vitest";
import { render } from "@testing-library/react";
import { type ReactNode, useState } from "react";
import { afterEach } from "node:test";
import userEvent from "@testing-library/user-event";
import { Modal } from "./Modal";

View File

@@ -415,7 +415,7 @@ export class PosthogAnalytics {
// * When the user changes their preferences on this device
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
// won't be called (i.e. this.anonymity will be left as the default, until the setting changes)
optInAnalytics.value.subscribe((optIn) => {
optInAnalytics.value$.subscribe((optIn) => {
this.setAnonymity(optIn ? Anonymity.Pseudonymous : Anonymity.Disabled);
this.maybeIdentifyUser().catch(() =>
logger.log("Could not identify user"),

View File

@@ -13,18 +13,18 @@ export interface Controls {
disablePip: () => void;
}
export const setPipEnabled = new Subject<boolean>();
export const setPipEnabled$ = new Subject<boolean>();
window.controls = {
canEnterPip(): boolean {
return setPipEnabled.observed;
return setPipEnabled$.observed;
},
enablePip(): void {
if (!setPipEnabled.observed) throw new Error("No call is running");
setPipEnabled.next(true);
if (!setPipEnabled$.observed) throw new Error("No call is running");
setPipEnabled$.next(true);
},
disablePip(): void {
if (!setPipEnabled.observed) throw new Error("No call is running");
setPipEnabled.next(false);
if (!setPipEnabled$.observed) throw new Error("No call is running");
setPipEnabled$.next(false);
},
};

View File

@@ -31,15 +31,15 @@ export interface CallLayoutInputs {
/**
* The minimum bounds of the layout area.
*/
minBounds: Observable<Bounds>;
minBounds$: Observable<Bounds>;
/**
* The alignment of the floating spotlight tile, if present.
*/
spotlightAlignment: BehaviorSubject<Alignment>;
spotlightAlignment$: BehaviorSubject<Alignment>;
/**
* The alignment of the small picture-in-picture tile, if present.
*/
pipAlignment: BehaviorSubject<Alignment>;
pipAlignment$: BehaviorSubject<Alignment>;
}
export interface CallLayoutOutputs<Model> {

View File

@@ -24,7 +24,6 @@ import {
createContext,
forwardRef,
memo,
useCallback,
useContext,
useEffect,
useMemo,
@@ -54,7 +53,6 @@ interface Tile<Model> {
id: string;
model: Model;
onDrag: DragCallback | undefined;
setVisible: (visible: boolean) => void;
}
type PlacedTile<Model> = Tile<Model> & Rect;
@@ -88,7 +86,6 @@ interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
id: string;
model: Model;
onDrag?: DragCallback;
onVisibilityChange?: (visible: boolean) => void;
style?: CSSProperties;
className?: string;
}
@@ -115,28 +112,51 @@ function offset(element: HTMLElement, relativeTo: Element): Offset {
}
}
export type VisibleTilesCallback = (visibleTiles: number) => void;
interface LayoutContext {
setGeneration: Dispatch<SetStateAction<number | null>>;
setVisibleTilesCallback: Dispatch<
SetStateAction<VisibleTilesCallback | null>
>;
}
const LayoutContext = createContext<LayoutContext | null>(null);
function useLayoutContext(): LayoutContext {
const context = useContext(LayoutContext);
if (context === null)
throw new Error("useUpdateLayout called outside a Grid layout context");
return context;
}
/**
* Enables Grid to react to layout changes. You must call this in your Layout
* component or else Grid will not be reactive.
*/
export function useUpdateLayout(): void {
const context = useContext(LayoutContext);
if (context === null)
throw new Error("useUpdateLayout called outside a Grid layout context");
const { setGeneration } = useLayoutContext();
// On every render, tell Grid that the layout may have changed
useEffect(() =>
context.setGeneration((prev) => (prev === null ? 0 : prev + 1)),
useEffect(() => setGeneration((prev) => (prev === null ? 0 : prev + 1)));
}
/**
* Asks Grid to call a callback whenever the number of visible tiles may have
* changed.
*/
export function useVisibleTiles(callback: VisibleTilesCallback): void {
const { setVisibleTilesCallback } = useLayoutContext();
useEffect(
() => setVisibleTilesCallback(() => callback),
[callback, setVisibleTilesCallback],
);
useEffect(
() => (): void => setVisibleTilesCallback(null),
[setVisibleTilesCallback],
);
}
const windowHeightObservable = fromEvent(window, "resize").pipe(
const windowHeightObservable$ = fromEvent(window, "resize").pipe(
startWith(null),
map(() => window.innerHeight),
);
@@ -242,42 +262,23 @@ export function Grid<
const [gridRoot, gridRef2] = useState<HTMLElement | null>(null);
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
const windowHeight = useObservableEagerState(windowHeightObservable);
const windowHeight = useObservableEagerState(windowHeightObservable$);
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
const [generation, setGeneration] = useState<number | null>(null);
const [visibleTilesCallback, setVisibleTilesCallback] =
useState<VisibleTilesCallback | null>(null);
const tiles = useInitial(() => new Map<string, Tile<TileModel>>());
const prefersReducedMotion = usePrefersReducedMotion();
const Slot: FC<SlotProps<TileModel>> = useMemo(
() =>
function Slot({
id,
model,
onDrag,
onVisibilityChange,
style,
className,
...props
}) {
function Slot({ id, model, onDrag, style, className, ...props }) {
const ref = useRef<HTMLDivElement | null>(null);
const prevVisible = useRef<boolean | null>(null);
const setVisible = useCallback(
(visible: boolean) => {
if (
onVisibilityChange !== undefined &&
visible !== prevVisible.current
) {
onVisibilityChange(visible);
prevVisible.current = visible;
}
},
[onVisibilityChange],
);
useEffect(() => {
tiles.set(id, { id, model, onDrag, setVisible });
tiles.set(id, { id, model, onDrag });
return (): void => void tiles.delete(id);
}, [id, model, onDrag, setVisible]);
}, [id, model, onDrag]);
return (
<div
@@ -307,7 +308,10 @@ export function Grid<
[],
);
const context: LayoutContext = useMemo(() => ({ setGeneration }), []);
const context: LayoutContext = useMemo(
() => ({ setGeneration, setVisibleTilesCallback }),
[setVisibleTilesCallback],
);
// Combine the tile definitions and slots together to create placed tiles
const placedTiles = useMemo(() => {
@@ -342,9 +346,11 @@ export function Grid<
);
useEffect(() => {
for (const tile of placedTiles)
tile.setVisible(tile.y + tile.height <= visibleHeight);
}, [placedTiles, visibleHeight]);
visibleTilesCallback?.(
placedTiles.filter((tile) => tile.y + tile.height <= visibleHeight)
.length,
);
}, [placedTiles, visibleTilesCallback, visibleHeight]);
// Drag state is stored in a ref rather than component state, because we use
// react-spring's imperative API during gestures to improve responsiveness

View File

@@ -13,7 +13,7 @@ import { type GridLayout as GridLayoutModel } from "../state/CallViewModel";
import styles from "./GridLayout.module.css";
import { useInitial } from "../useInitial";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
interface GridCSSProperties extends CSSProperties {
"--gap": string;
@@ -26,8 +26,8 @@ interface GridCSSProperties extends CSSProperties {
* together in a scrolling grid.
*/
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
minBounds,
spotlightAlignment,
minBounds$,
spotlightAlignment$,
}) => ({
scrollingOnTop: false,
@@ -37,7 +37,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
useUpdateLayout();
const alignment = useObservableEagerState(
useInitial(() =>
spotlightAlignment.pipe(
spotlightAlignment$.pipe(
distinctUntilChanged(
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
),
@@ -47,7 +47,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) =>
spotlightAlignment.next({
spotlightAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
@@ -73,7 +73,8 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// The scrolling part of the layout is where all the grid tiles live
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
useUpdateLayout();
const { width, height: minHeight } = useObservableEagerState(minBounds);
useVisibleTiles(model.setVisibleTiles);
const { width, height: minHeight } = useObservableEagerState(minBounds$);
const { gap, tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, minHeight, model.grid.length),
[width, minHeight, model.grid.length],
@@ -93,13 +94,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
}
>
{model.grid.map((m) => (
<Slot
key={m.id}
className={styles.slot}
id={m.id}
model={m}
onVisibilityChange={m.setVisible}
/>
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
))}
</div>
);

View File

@@ -19,8 +19,8 @@ import { type DragCallback, useUpdateLayout } from "./Grid";
* is shown at maximum size, overlaid by a small view of the local participant.
*/
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
minBounds,
pipAlignment,
minBounds$,
pipAlignment$,
}) => ({
scrollingOnTop: false,
@@ -31,8 +31,8 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
useUpdateLayout();
const { width, height } = useObservableEagerState(minBounds);
const pipAlignmentValue = useObservableEagerState(pipAlignment);
const { width, height } = useObservableEagerState(minBounds$);
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1),
[width, height],
@@ -40,7 +40,7 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
pipAlignment.next({
pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
@@ -52,7 +52,6 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
<Slot
id={model.remote.id}
model={model.remote}
onVisibilityChange={model.remote.setVisible}
className={styles.container}
style={{ width: tileWidth, height: tileHeight }}
>
@@ -61,7 +60,6 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
id={model.local.id}
model={model.local}
onDrag={onDragLocalTile}
onVisibilityChange={model.local.setVisible}
data-block-alignment={pipAlignmentValue.block}
data-inline-alignment={pipAlignmentValue.inline}
/>

View File

@@ -19,7 +19,7 @@ import styles from "./SpotlightExpandedLayout.module.css";
*/
export const makeSpotlightExpandedLayout: CallLayout<
SpotlightExpandedLayoutModel
> = ({ pipAlignment }) => ({
> = ({ pipAlignment$ }) => ({
scrollingOnTop: true,
fixed: forwardRef(function SpotlightExpandedLayoutFixed(
@@ -44,11 +44,11 @@ export const makeSpotlightExpandedLayout: CallLayout<
ref,
) {
useUpdateLayout();
const pipAlignmentValue = useObservableEagerState(pipAlignment);
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) =>
pipAlignment.next({
pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
@@ -63,7 +63,6 @@ export const makeSpotlightExpandedLayout: CallLayout<
id={model.pip.id}
model={model.pip}
onDrag={onDragPip}
onVisibilityChange={model.pip.setVisible}
data-block-alignment={pipAlignmentValue.block}
data-inline-alignment={pipAlignmentValue.inline}
/>

View File

@@ -12,7 +12,7 @@ import classNames from "classnames";
import { type CallLayout } from "./CallLayout";
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel";
import styles from "./SpotlightLandscapeLayout.module.css";
import { useUpdateLayout } from "./Grid";
import { useUpdateLayout, useVisibleTiles } from "./Grid";
/**
* An implementation of the "spotlight landscape" layout, in which the spotlight
@@ -21,7 +21,7 @@ import { useUpdateLayout } from "./Grid";
*/
export const makeSpotlightLandscapeLayout: CallLayout<
SpotlightLandscapeLayoutModel
> = ({ minBounds }) => ({
> = ({ minBounds$ }) => ({
scrollingOnTop: false,
fixed: forwardRef(function SpotlightLandscapeLayoutFixed(
@@ -29,7 +29,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
ref,
) {
useUpdateLayout();
useObservableEagerState(minBounds);
useObservableEagerState(minBounds$);
return (
<div ref={ref} className={styles.layer}>
@@ -50,9 +50,10 @@ export const makeSpotlightLandscapeLayout: CallLayout<
ref,
) {
useUpdateLayout();
useObservableEagerState(minBounds);
useVisibleTiles(model.setVisibleTiles);
useObservableEagerState(minBounds$);
const withIndicators =
useObservableEagerState(model.spotlight.media).length > 1;
useObservableEagerState(model.spotlight.media$).length > 1;
return (
<div ref={ref} className={styles.layer}>
@@ -63,13 +64,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
/>
<div className={styles.grid}>
{model.grid.map((m) => (
<Slot
key={m.id}
className={styles.slot}
id={m.id}
model={m}
onVisibilityChange={m.setVisible}
/>
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
))}
</div>
</div>

View File

@@ -12,7 +12,7 @@ import classNames from "classnames";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
import styles from "./SpotlightPortraitLayout.module.css";
import { useUpdateLayout } from "./Grid";
import { useUpdateLayout, useVisibleTiles } from "./Grid";
interface GridCSSProperties extends CSSProperties {
"--grid-gap": string;
@@ -27,7 +27,7 @@ interface GridCSSProperties extends CSSProperties {
*/
export const makeSpotlightPortraitLayout: CallLayout<
SpotlightPortraitLayoutModel
> = ({ minBounds }) => ({
> = ({ minBounds$ }) => ({
scrollingOnTop: false,
fixed: forwardRef(function SpotlightPortraitLayoutFixed(
@@ -54,7 +54,8 @@ export const makeSpotlightPortraitLayout: CallLayout<
ref,
) {
useUpdateLayout();
const { width } = useObservableEagerState(minBounds);
useVisibleTiles(model.setVisibleTiles);
const { width } = useObservableEagerState(minBounds$);
const { gap, tileWidth, tileHeight } = arrangeTiles(
width,
// TODO: We pretend that the minimum height is the width, because the
@@ -63,7 +64,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
model.grid.length,
);
const withIndicators =
useObservableEagerState(model.spotlight.media).length > 1;
useObservableEagerState(model.spotlight.media$).length > 1;
return (
<div
@@ -84,13 +85,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
/>
<div className={styles.grid}>
{model.grid.map((m) => (
<Slot
key={m.id}
className={styles.slot}
id={m.id}
model={m}
onVisibilityChange={m.setVisible}
/>
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
))}
</div>
</div>

View File

@@ -16,7 +16,8 @@ import {
useState,
} from "react";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { type Observable } from "rxjs";
import { map, startWith } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/src/logger";
import {
@@ -26,11 +27,25 @@ import {
videoInput as videoInputSetting,
type Setting,
} from "../settings/settings";
import { isFirefox } from "../Platform";
export type DeviceLabel =
| { type: "name"; name: string }
| { type: "number"; number: number }
| { type: "default"; name: string | null };
export interface MediaDevice {
available: MediaDeviceInfo[];
/**
* A map from available device IDs to labels.
*/
available: Map<string, DeviceLabel>;
selectedId: string | undefined;
/**
* The group ID of the selected device.
*/
// This is exposed sort of ad-hoc because it's only needed for knowing when to
// restart the tracks of default input devices, and ideally this behavior
// would be encapsulated somehow…
selectedGroupId: string | undefined;
select: (deviceId: string) => void;
}
@@ -42,26 +57,10 @@ export interface MediaDevices {
stopUsingDeviceNames: () => void;
}
// Cargo-culted from @livekit/components-react
function useObservableState<T>(
observable: Observable<T> | undefined,
startWith: T,
): T {
const [state, setState] = useState<T>(startWith);
useEffect(() => {
// observable state doesn't run in SSR
if (typeof window === "undefined" || !observable) return;
const subscription = observable.subscribe(setState);
return (): void => subscription.unsubscribe();
}, [observable]);
return state;
}
function useMediaDevice(
kind: MediaDeviceKind,
setting: Setting<string | undefined>,
usingNames: boolean,
alwaysDefault: boolean = false,
): MediaDevice {
// Make sure we don't needlessly reset to a device observer without names,
// once permissions are already given
@@ -75,49 +74,97 @@ function useMediaDevice(
// useMediaDevices provides no way to request device names.
// Tragically, the only way to get device names out of LiveKit is to specify a
// kind, which then results in multiple permissions requests.
const deviceObserver = useMemo(
const deviceObserver$ = useMemo(
() =>
createMediaDeviceObserver(
kind,
() => logger.error("Error creating MediaDeviceObserver"),
requestPermissions,
),
).pipe(startWith([])),
[kind, requestPermissions],
);
const available = useObservableState(deviceObserver, []);
const [preferredId, select] = useSetting(setting);
const available = useObservableEagerState(
useMemo(
() =>
deviceObserver$.pipe(
map((availableRaw) => {
// Sometimes browsers (particularly Firefox) can return multiple device
// entries for the exact same device ID; using a map deduplicates them
let available = new Map<string, DeviceLabel>(
availableRaw.map((d, i) => [
d.deviceId,
d.label
? { type: "name", name: d.label }
: { type: "number", number: i + 1 },
]),
);
// Create a virtual default audio output for browsers that don't have one.
// Its device ID must be the empty string because that's what setSinkId
// recognizes.
if (
kind === "audiooutput" &&
available.size &&
!available.has("") &&
!available.has("default")
)
available = new Map([
["", { type: "default", name: availableRaw[0]?.label || null }],
...available,
]);
// Note: creating virtual default input devices would be another problem
// entirely, because requesting a media stream from deviceId "" won't
// automatically track the default device.
return available;
}),
),
[kind, deviceObserver$],
),
);
return useMemo(() => {
let selectedId: string | undefined = undefined;
if (!alwaysDefault && available) {
const [preferredId, select] = useSetting(setting);
const selectedId = useMemo(() => {
if (available.size) {
// If the preferred device is available, use it. Or if every available
// device ID is falsy, the browser is probably just being paranoid about
// fingerprinting and we should still try using the preferred device.
// Worst case it is not available and the browser will gracefully fall
// back to some other device for us when requesting the media stream.
// Otherwise, select the first available device.
selectedId =
available.some((d) => d.deviceId === preferredId) ||
available.every((d) => d.deviceId === "")
? preferredId
: available.at(0)?.deviceId;
return (preferredId !== undefined && available.has(preferredId)) ||
(available.size === 1 && available.has(""))
? preferredId
: available.keys().next().value;
}
return undefined;
}, [available, preferredId]);
const selectedGroupId = useObservableEagerState(
useMemo(
() =>
deviceObserver$.pipe(
map(
(availableRaw) =>
availableRaw.find((d) => d.deviceId === selectedId)?.groupId,
),
),
[deviceObserver$, selectedId],
),
);
return {
available: available
? // Sometimes browsers (particularly Firefox) can return multiple
// device entries for the exact same device ID; deduplicate them
[...new Map(available.map((d) => [d.deviceId, d])).values()]
: [],
return useMemo(
() => ({
available,
selectedId,
selectedGroupId,
select,
};
}, [available, preferredId, select, alwaysDefault]);
}),
[available, selectedId, selectedGroupId, select],
);
}
export const deviceStub: MediaDevice = {
available: [],
available: new Map(),
selectedId: undefined,
selectedGroupId: undefined,
select: () => {},
};
export const devicesStub: MediaDevices = {
@@ -139,15 +186,6 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
const [numCallersUsingNames, setNumCallersUsingNames] = useState(0);
const usingNames = numCallersUsingNames > 0;
// Setting the audio device to something other than 'undefined' breaks echo-cancellation
// and even can introduce multiple different output devices for one call.
const alwaysUseDefaultAudio = isFirefox();
// On FF we dont need to query the names
// (call enumerateDevices + create meadia stream to trigger permissions)
// for ouput devices because the selector wont be shown on FF.
const useOutputNames = usingNames && !isFirefox();
const audioInput = useMediaDevice(
"audioinput",
audioInputSetting,
@@ -156,8 +194,7 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
const audioOutput = useMediaDevice(
"audiooutput",
audioOutputSetting,
useOutputNames,
alwaysUseDefaultAudio,
usingNames,
);
const videoInput = useMediaDevice(
"videoinput",

View File

@@ -310,18 +310,14 @@ export function useLiveKit(
room.localParticipant.audioTrackPublications.values(),
).find((d) => d.source === Track.Source.Microphone)?.track;
const defaultDevice = device.available.find(
(d) => d.deviceId === "default",
);
if (
defaultDevice &&
activeMicTrack &&
// only restart if the stream is still running: LiveKit will detect
// when a track stops & restart appropriately, so this is not our job.
// Plus, we need to avoid restarting again if the track is already in
// the process of being restarted.
activeMicTrack.mediaStreamTrack.readyState !== "ended" &&
defaultDevice.groupId !==
device.selectedGroupId !==
activeMicTrack.mediaStreamTrack.getSettings().groupId
) {
// It's different, so restart the track, ie. cause Livekit to do another

View File

@@ -13,11 +13,11 @@ import {
type MockedFunction,
test,
vitest,
afterEach,
} from "vitest";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { ConnectionState } from "livekit-client";
import { BehaviorSubject, of } from "rxjs";
import { afterEach } from "node:test";
import { act, type ReactNode } from "react";
import {
type CallMembership,
@@ -100,13 +100,13 @@ function getMockEnv(
): {
vm: CallViewModel;
session: MockRTCSession;
remoteRtcMemberships: BehaviorSubject<CallMembership[]>;
remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
} {
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
const remoteParticipants = of([aliceParticipant]);
const remoteParticipants$ = of([aliceParticipant]);
const liveKitRoom = mockLivekitRoom(
{ localParticipant },
{ remoteParticipants },
{ remoteParticipants$ },
);
const matrixRoom = mockMatrixRoom({
client: {
@@ -118,14 +118,14 @@ function getMockEnv(
getMember: (userId) => matrixRoomMembers.get(userId) ?? null,
});
const remoteRtcMemberships = new BehaviorSubject<CallMembership[]>(
const remoteRtcMemberships$ = new BehaviorSubject<CallMembership[]>(
initialRemoteRtcMemberships,
);
const session = new MockRTCSession(
matrixRoom,
localRtcMember,
).withMemberships(remoteRtcMemberships);
).withMemberships(remoteRtcMemberships$);
const vm = new CallViewModel(
session as unknown as MatrixRTCSession,
@@ -135,7 +135,7 @@ function getMockEnv(
},
of(ConnectionState.Connected),
);
return { vm, session, remoteRtcMemberships };
return { vm, session, remoteRtcMemberships$ };
}
/**
@@ -146,33 +146,33 @@ function getMockEnv(
* a noise every time.
*/
test("plays one sound when entering a call", () => {
const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]);
const { session, vm, remoteRtcMemberships$ } = getMockEnv([local, alice]);
render(<TestComponent rtcSession={session} vm={vm} />);
// Joining a call usually means remote participants are added later.
act(() => {
remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]);
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
});
expect(playSound).toHaveBeenCalledOnce();
});
// TODO: Same test?
test("plays a sound when a user joins", () => {
const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]);
const { session, vm, remoteRtcMemberships$ } = getMockEnv([local, alice]);
render(<TestComponent rtcSession={session} vm={vm} />);
act(() => {
remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]);
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
});
// Play a sound when joining a call.
expect(playSound).toBeCalledWith("join");
});
test("plays a sound when a user leaves", () => {
const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]);
const { session, vm, remoteRtcMemberships$ } = getMockEnv([local, alice]);
render(<TestComponent rtcSession={session} vm={vm} />);
act(() => {
remoteRtcMemberships.next([]);
remoteRtcMemberships$.next([]);
});
expect(playSound).toBeCalledWith("left");
});
@@ -185,7 +185,7 @@ test("plays no sound when the participant list is more than the maximum size", (
);
}
const { session, vm, remoteRtcMemberships } = getMockEnv(
const { session, vm, remoteRtcMemberships$ } = getMockEnv(
[local, alice],
mockRtcMemberships,
);
@@ -193,7 +193,7 @@ test("plays no sound when the participant list is more than the maximum size", (
render(<TestComponent rtcSession={session} vm={vm} />);
expect(playSound).not.toBeCalled();
act(() => {
remoteRtcMemberships.next(
remoteRtcMemberships$.next(
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1),
);
});

View File

@@ -65,7 +65,7 @@ export function CallEventAudioRenderer({
}, [audioEngineRef, previousRaisedHandCount, raisedHandCount]);
useEffect(() => {
const joinSub = vm.memberChanges
const joinSub = vm.memberChanges$
.pipe(
filter(
({ joined, ids }) =>
@@ -77,7 +77,7 @@ export function CallEventAudioRenderer({
void audioEngineRef.current?.playSound("join");
});
const leftSub = vm.memberChanges
const leftSub = vm.memberChanges$
.pipe(
filter(
({ ids, left }) =>

View File

@@ -110,8 +110,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
sfuConfig,
props.e2eeSystem,
);
const connStateObservable = useObservable(
(inputs) => inputs.pipe(map(([connState]) => connState)),
const connStateObservable$ = useObservable(
(inputs$) => inputs$.pipe(map(([connState]) => connState)),
[connState],
);
const [vm, setVm] = useState<CallViewModel | null>(null);
@@ -131,12 +131,12 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.rtcSession,
livekitRoom,
props.e2eeSystem,
connStateObservable,
connStateObservable$,
);
setVm(vm);
return (): void => vm.destroy();
}
}, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable]);
}, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable$]);
if (livekitRoom === undefined || vm === null) return null;
@@ -225,14 +225,14 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(),
);
const windowMode = useObservableEagerState(vm.windowMode);
const layout = useObservableEagerState(vm.layout);
const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration);
const windowMode = useObservableEagerState(vm.windowMode$);
const layout = useObservableEagerState(vm.layout$);
const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration$);
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
const gridMode = useObservableEagerState(vm.gridMode);
const showHeader = useObservableEagerState(vm.showHeader);
const showFooter = useObservableEagerState(vm.showFooter);
const switchCamera = useSwitchCamera(vm.localVideo);
const gridMode = useObservableEagerState(vm.gridMode$);
const showHeader = useObservableEagerState(vm.showHeader$);
const showFooter = useObservableEagerState(vm.showFooter$);
const switchCamera = useSwitchCamera(vm.localVideo$);
// Ideally we could detect taps by listening for click events and checking
// that the pointerType of the event is "touch", but this isn't yet supported
@@ -317,15 +317,15 @@ export const InCallView: FC<InCallViewProps> = ({
windowMode,
],
);
const gridBoundsObservable = useObservable(
(inputs) => inputs.pipe(map(([gridBounds]) => gridBounds)),
const gridBoundsObservable$ = useObservable(
(inputs$) => inputs$.pipe(map(([gridBounds]) => gridBounds)),
[gridBounds],
);
const spotlightAlignment = useInitial(
const spotlightAlignment$ = useInitial(
() => new BehaviorSubject(defaultSpotlightAlignment),
);
const pipAlignment = useInitial(
const pipAlignment$ = useInitial(
() => new BehaviorSubject(defaultPipAlignment),
);
@@ -383,15 +383,17 @@ export const InCallView: FC<InCallViewProps> = ({
{ className, style, targetWidth, targetHeight, model },
ref,
) {
const spotlightExpanded = useObservableEagerState(vm.spotlightExpanded);
const spotlightExpanded = useObservableEagerState(
vm.spotlightExpanded$,
);
const onToggleExpanded = useObservableEagerState(
vm.toggleSpotlightExpanded,
vm.toggleSpotlightExpanded$,
);
const showSpeakingIndicatorsValue = useObservableEagerState(
vm.showSpeakingIndicators,
vm.showSpeakingIndicators$,
);
const showSpotlightIndicatorsValue = useObservableEagerState(
vm.showSpotlightIndicators,
vm.showSpotlightIndicators$,
);
return model instanceof GridTileViewModel ? (
@@ -424,9 +426,9 @@ export const InCallView: FC<InCallViewProps> = ({
const layouts = useMemo(() => {
const inputs = {
minBounds: gridBoundsObservable,
spotlightAlignment,
pipAlignment,
minBounds$: gridBoundsObservable$,
spotlightAlignment$,
pipAlignment$,
};
return {
grid: makeGridLayout(inputs),
@@ -435,7 +437,7 @@ export const InCallView: FC<InCallViewProps> = ({
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
"one-on-one": makeOneOnOneLayout(inputs),
};
}, [gridBoundsObservable, spotlightAlignment, pipAlignment]);
}, [gridBoundsObservable$, spotlightAlignment$, pipAlignment$]);
const renderContent = (): JSX.Element => {
if (layout.type === "pip") {

View File

@@ -159,7 +159,7 @@ export const LobbyView: FC<Props> = ({
useTrackProcessorSync(videoTrack);
const showSwitchCamera = useShowSwitchCamera(
useObservable(
(inputs) => inputs.pipe(map(([video]) => video)),
(inputs$) => inputs$.pipe(map(([video]) => video)),
[videoTrack],
),
);

View File

@@ -6,13 +6,13 @@ Please see LICENSE in the repository root for full details.
*/
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import React, { type ReactNode } from "react";
import { beforeEach } from "vitest";
import { type ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { useMuteStates } from "./MuteStates";
import {
type DeviceLabel,
type MediaDevice,
type MediaDevices,
MediaDevicesContext,
@@ -63,10 +63,11 @@ const mockCamera: MediaDeviceInfo = {
},
};
function mockDevices(available: MediaDeviceInfo[]): MediaDevice {
function mockDevices(available: Map<string, DeviceLabel>): MediaDevice {
return {
available,
selectedId: "",
selectedGroupId: "",
select: (): void => {},
};
}
@@ -83,25 +84,29 @@ function mockMediaDevices(
} = { microphone: true, speaker: true, camera: true },
): MediaDevices {
return {
audioInput: mockDevices(microphone ? [mockMicrophone] : []),
audioOutput: mockDevices(speaker ? [mockSpeaker] : []),
videoInput: mockDevices(camera ? [mockCamera] : []),
audioInput: mockDevices(
microphone
? new Map([[mockMicrophone.deviceId, mockMicrophone]])
: new Map(),
),
audioOutput: mockDevices(
speaker ? new Map([[mockSpeaker.deviceId, mockSpeaker]]) : new Map(),
),
videoInput: mockDevices(
camera ? new Map([[mockCamera.deviceId, mockCamera]]) : new Map(),
),
startUsingDeviceNames: (): void => {},
stopUsingDeviceNames: (): void => {},
};
}
describe("useMuteStates", () => {
beforeEach(() => {
vi.spyOn(React, "useContext").mockReturnValue({});
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
afterAll(() => {
vi.clearAllMocks();
vi.resetAllMocks();
});
it("disabled when no input devices", () => {

View File

@@ -58,12 +58,12 @@ function useMuteState(
): MuteState {
const [enabled, setEnabled] = useReactiveState<boolean | undefined>(
(prev) =>
device.available.length > 0 ? (prev ?? enabledByDefault()) : undefined,
device.available.size > 0 ? (prev ?? enabledByDefault()) : undefined,
[device],
);
return useMemo(
() =>
device.available.length === 0
device.available.size === 0
? deviceUnavailable
: {
enabled: enabled ?? false,

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { render } from "@testing-library/react";
import {
afterAll,
afterEach,
beforeEach,
expect,
test,
@@ -17,7 +18,6 @@ import {
} from "vitest";
import { TooltipProvider } from "@vector-im/compound-web";
import { act, type ReactNode } from "react";
import { afterEach } from "node:test";
import {
MockRoom,

View File

@@ -6,10 +6,9 @@ Please see LICENSE in the repository root for full details.
*/
import { render } from "@testing-library/react";
import { expect, test } from "vitest";
import { expect, test, afterEach } from "vitest";
import { TooltipProvider } from "@vector-im/compound-web";
import { act, type ReactNode } from "react";
import { afterEach } from "node:test";
import {
MockRoom,

View File

@@ -31,17 +31,17 @@ import { useLatest } from "../useLatest";
* producing a callback if so.
*/
export function useSwitchCamera(
video: Observable<LocalVideoTrack | null>,
video$: Observable<LocalVideoTrack | null>,
): (() => void) | null {
const mediaDevices = useMediaDevices();
const setVideoInput = useLatest(mediaDevices.videoInput.select);
// Produce an observable like the input 'video' observable, except make it
// emit whenever the track is muted or the device changes
const videoTrack: Observable<LocalVideoTrack | null> = useObservable(
(inputs) =>
inputs.pipe(
switchMap(([video]) => video),
const videoTrack$: Observable<LocalVideoTrack | null> = useObservable(
(inputs$) =>
inputs$.pipe(
switchMap(([video$]) => video$),
switchMap((video) => {
if (video === null) return of(null);
return merge(
@@ -53,15 +53,15 @@ export function useSwitchCamera(
);
}),
),
[video],
[video$],
);
const switchCamera: Observable<(() => void) | null> = useObservable(
(inputs) =>
const switchCamera$: Observable<(() => void) | null> = useObservable(
(inputs$) =>
platform === "desktop"
? of(null)
: inputs.pipe(
switchMap(([track]) => track),
: inputs$.pipe(
switchMap(([track$]) => track$),
map((track) => {
if (track === null) return null;
const facingMode = facingModeFromLocalTrack(track).facingMode;
@@ -86,8 +86,8 @@ export function useSwitchCamera(
);
}),
),
[videoTrack],
[videoTrack$],
);
return useObservableEagerState(switchCamera);
return useObservableEagerState(switchCamera$);
}

View File

@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { expect, test, vi } from "vitest";
import { AutoDiscovery } from "matrix-js-sdk/src/autodiscovery";
import { enterRTCSession } from "../src/rtcSessionHelpers";
import { mockConfig } from "./utils/test";
@@ -36,11 +37,21 @@ test("It joins the correct Session", async () => {
mockConfig({
livekit: { livekit_service_url: "http://my-default-service-url.com" },
});
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockImplementation(
async (domain) => {
if (domain === "example.org") {
return Promise.resolve(clientWellKnown);
}
return Promise.resolve({});
},
);
const mockedSession = vi.mocked({
room: {
roomId: "roomId",
client: {
getClientWellKnown: vi.fn().mockReturnValue(clientWellKnown),
getDomain: vi.fn().mockReturnValue("example.org"),
},
},
memberships: [],

View File

@@ -13,6 +13,7 @@ import {
isLivekitFocus,
isLivekitFocusConfig,
} from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
import { AutoDiscovery } from "matrix-js-sdk/src/autodiscovery";
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
import { Config } from "./config/Config";
@@ -43,19 +44,28 @@ async function makePreferredLivekitFoci(
preferredFoci.push(focusInUse);
}
// Prioritize the client well known over the configured sfu.
const wellKnownFoci =
rtcSession.room.client.getClientWellKnown()?.[FOCI_WK_KEY];
if (Array.isArray(wellKnownFoci)) {
preferredFoci.push(
...wellKnownFoci
.filter((f) => !!f)
.filter(isLivekitFocusConfig)
.map((wellKnownFocus) => {
logger.log("Adding livekit focus from well known: ", wellKnownFocus);
return { ...wellKnownFocus, livekit_alias: livekitAlias };
}),
);
// Prioritize the .well-known/matrix/client, if available, over the configured SFU
const domain = rtcSession.room.client.getDomain();
if (domain) {
// we use AutoDiscovery instead of relying on the MatrixClient having already
// been fully configured and started
const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[
FOCI_WK_KEY
];
if (Array.isArray(wellKnownFoci)) {
preferredFoci.push(
...wellKnownFoci
.filter((f) => !!f)
.filter(isLivekitFocusConfig)
.map((wellKnownFocus) => {
logger.log(
"Adding livekit focus from well known: ",
wellKnownFocus,
);
return { ...wellKnownFocus, livekit_alias: livekitAlias };
}),
);
}
}
const urlFromConf = Config.get().livekit?.livekit_service_url;

View File

@@ -13,6 +13,7 @@ import {
useSetting,
duplicateTiles as duplicateTilesSetting,
debugTileLayout as debugTileLayoutSetting,
showNonMemberTiles as showNonMemberTilesSetting,
} from "./settings";
import type { MatrixClient } from "matrix-js-sdk/src/client";
@@ -26,6 +27,9 @@ export const DeveloperSettingsTab: FC<Props> = ({ client }) => {
const [debugTileLayout, setDebugTileLayout] = useSetting(
debugTileLayoutSetting,
);
const [showNonMemberTiles, setShowNonMemberTiles] = useSetting(
showNonMemberTilesSetting,
);
return (
<>
@@ -85,6 +89,20 @@ export const DeveloperSettingsTab: FC<Props> = ({ client }) => {
}
/>
</FieldRow>
<FieldRow>
<InputField
id="showNonMemberTiles"
type="checkbox"
label={t("developer_mode.show_non_member_tiles")}
checked={!!showNonMemberTiles}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
setShowNonMemberTiles(event.target.checked);
},
[setShowNonMemberTiles],
)}
/>
</FieldRow>
</>
);
};

View File

@@ -16,3 +16,7 @@
flex-direction: column;
gap: var(--cpd-space-4x);
}
.secondary {
color: var(--cpd-color-text-secondary);
}

View File

@@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { type ChangeEvent, type FC, useCallback, useId } from "react";
import {
type ChangeEvent,
type FC,
type ReactElement,
type ReactNode,
useCallback,
useId,
} from "react";
import {
Heading,
InlineField,
@@ -13,16 +20,23 @@ import {
RadioControl,
Separator,
} from "@vector-im/compound-web";
import { Trans, useTranslation } from "react-i18next";
import { type MediaDevice } from "../livekit/MediaDevicesContext";
import styles from "./DeviceSelection.module.css";
interface Props {
devices: MediaDevice;
caption: string;
title: string;
numberedLabel: (number: number) => string;
}
export const DeviceSelection: FC<Props> = ({ devices, caption }) => {
export const DeviceSelection: FC<Props> = ({
devices,
title,
numberedLabel,
}) => {
const { t } = useTranslation();
const groupId = useId();
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
@@ -31,7 +45,7 @@ export const DeviceSelection: FC<Props> = ({ devices, caption }) => {
[devices],
);
if (devices.available.length == 0) return null;
if (devices.available.size == 0) return null;
return (
<div className={styles.selection}>
@@ -42,29 +56,53 @@ export const DeviceSelection: FC<Props> = ({ devices, caption }) => {
as="h4"
className={styles.title}
>
{caption}
{title}
</Heading>
<Separator className={styles.separator} />
<div className={styles.options}>
{devices.available.map(({ deviceId, label }, index) => (
<InlineField
key={deviceId}
name={groupId}
control={
<RadioControl
checked={deviceId === devices.selectedId}
onChange={onChange}
value={deviceId}
/>
}
>
<Label>
{!!label && label.trim().length > 0
? label
: `${caption} ${index + 1}`}
</Label>
</InlineField>
))}
{[...devices.available].map(([id, label]) => {
let labelText: ReactNode;
switch (label.type) {
case "name":
labelText = label.name;
break;
case "number":
labelText = numberedLabel(label.number);
break;
case "default":
labelText =
label.name === null ? (
t("settings.devices.default")
) : (
<Trans
i18nKey="settings.devices.default_named"
name={label.name}
>
Default{" "}
<span className={styles.secondary}>
({{ name: label.name } as unknown as ReactElement})
</span>
</Trans>
);
break;
}
return (
<InlineField
key={id}
name={groupId}
control={
<RadioControl
checked={id === devices.selectedId}
onChange={onChange}
value={id}
/>
}
>
<Label>{labelText}</Label>
</InlineField>
);
})}
</div>
</div>
);

View File

@@ -41,13 +41,13 @@ export const PreferencesSettingsTab: FC = () => {
return (
<div>
<Text>{t("settings.preferences_tab_body")}</Text>
<Text>{t("settings.preferences_tab.introduction")}</Text>
<FieldRow>
<InputField
id="showHandRaisedTimer"
label={t("settings.preferences_tab_show_hand_raised_timer_label")}
label={t("settings.preferences_tab.show_hand_raised_timer_label")}
description={t(
"settings.preferences_tab_show_hand_raised_timer_description",
"settings.preferences_tab.show_hand_raised_timer_description",
)}
type="checkbox"
checked={showHandRaisedTimer}

View File

@@ -26,7 +26,6 @@ import {
backgroundBlur as backgroundBlurSetting,
developerMode,
} from "./settings";
import { isFirefox } from "../Platform";
import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
import { Slider } from "../Slider";
import { DeviceSelection } from "./DeviceSelection";
@@ -107,14 +106,16 @@ export const SettingsModal: FC<Props> = ({
<Form>
<DeviceSelection
devices={devices.audioInput}
caption={t("common.microphone")}
title={t("settings.devices.microphone")}
numberedLabel={(n) =>
t("settings.devices.microphone_numbered", { n })
}
/>
<DeviceSelection
devices={devices.audioOutput}
title={t("settings.devices.speaker")}
numberedLabel={(n) => t("settings.devices.speaker_numbered", { n })}
/>
{!isFirefox() && (
<DeviceSelection
devices={devices.audioOutput}
caption={t("settings.speaker_device_selection_label")}
/>
)}
<div className={styles.volumeSlider}>
<label>{t("settings.audio_tab.effect_volume_label")}</label>
<p>{t("settings.audio_tab.effect_volume_description")}</p>
@@ -141,7 +142,8 @@ export const SettingsModal: FC<Props> = ({
<Form>
<DeviceSelection
devices={devices.videoInput}
caption={t("common.camera")}
title={t("settings.devices.camera")}
numberedLabel={(n) => t("settings.devices.camera_numbered", { n })}
/>
</Form>
<Separator />

View File

@@ -31,17 +31,17 @@ export class Setting<T> {
}
}
this._value = new BehaviorSubject(initialValue);
this.value = this._value;
this._value$ = new BehaviorSubject(initialValue);
this.value$ = this._value$;
}
private readonly key: string;
private readonly _value: BehaviorSubject<T>;
public readonly value: Observable<T>;
private readonly _value$: BehaviorSubject<T>;
public readonly value$: Observable<T>;
public readonly setValue = (value: T): void => {
this._value.next(value);
this._value$.next(value);
localStorage.setItem(this.key, JSON.stringify(value));
};
}
@@ -50,7 +50,7 @@ export class Setting<T> {
* React hook that returns a settings's current value and a setter.
*/
export function useSetting<T>(setting: Setting<T>): [T, (value: T) => void] {
return [useObservableEagerState(setting.value), setting.setValue];
return [useObservableEagerState(setting.value$), setting.setValue];
}
// null = undecided
@@ -72,6 +72,10 @@ export const developerMode = new Setting("developer-settings-tab", false);
export const duplicateTiles = new Setting("duplicate-tiles", 0);
export const showNonMemberTiles = new Setting<boolean>(
"show-non-member-tiles",
false,
);
export const debugTileLayout = new Setting("debug-tile-layout", false);
export const audioInput = new Setting<string | undefined>(

View File

@@ -46,6 +46,7 @@ import {
type ECConnectionState,
} from "../livekit/useECConnectionState";
import { E2eeType } from "../e2ee/e2eeType";
import { showNonMemberTiles } from "../settings/settings";
vi.mock("@livekit/components-core");
@@ -123,15 +124,15 @@ export type LayoutSummary =
| OneOnOneLayoutSummary
| PipLayoutSummary;
function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
return l.pipe(
function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
return l$.pipe(
switchMap((l) => {
switch (l.type) {
case "grid":
return combineLatest(
[
l.spotlight?.media ?? of(undefined),
...l.grid.map((vm) => vm.media),
l.spotlight?.media$ ?? of(undefined),
...l.grid.map((vm) => vm.media$),
],
(spotlight, ...grid) => ({
type: l.type,
@@ -142,7 +143,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
case "spotlight-landscape":
case "spotlight-portrait":
return combineLatest(
[l.spotlight.media, ...l.grid.map((vm) => vm.media)],
[l.spotlight.media$, ...l.grid.map((vm) => vm.media$)],
(spotlight, ...grid) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -151,7 +152,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
);
case "spotlight-expanded":
return combineLatest(
[l.spotlight.media, l.pip?.media ?? of(undefined)],
[l.spotlight.media$, l.pip?.media$ ?? of(undefined)],
(spotlight, pip) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -160,7 +161,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
);
case "one-on-one":
return combineLatest(
[l.local.media, l.remote.media],
[l.local.media$, l.remote.media$],
(local, remote) => ({
type: l.type,
local: local.id,
@@ -168,7 +169,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
}),
);
case "pip":
return l.spotlight.media.pipe(
return l.spotlight.media$.pipe(
map((spotlight) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -185,9 +186,9 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
}
function withCallViewModel(
remoteParticipants: Observable<RemoteParticipant[]>,
rtcMembers: Observable<Partial<CallMembership>[]>,
connectionState: Observable<ECConnectionState>,
remoteParticipants$: Observable<RemoteParticipant[]>,
rtcMembers$: Observable<Partial<CallMembership>[]>,
connectionState$: Observable<ECConnectionState>,
speaking: Map<Participant, Observable<boolean>>,
continuation: (vm: CallViewModel) => void,
): void {
@@ -202,10 +203,10 @@ function withCallViewModel(
room,
localRtcMember,
[],
).withMemberships(rtcMembers);
).withMemberships(rtcMembers$);
const participantsSpy = vi
.spyOn(ComponentsCore, "connectedParticipantsObserver")
.mockReturnValue(remoteParticipants);
.mockReturnValue(remoteParticipants$);
const mediaSpy = vi
.spyOn(ComponentsCore, "observeParticipantMedia")
.mockImplementation((p) =>
@@ -231,7 +232,7 @@ function withCallViewModel(
const liveKitRoom = mockLivekitRoom(
{ localParticipant },
{ remoteParticipants },
{ remoteParticipants$ },
);
const vm = new CallViewModel(
@@ -240,7 +241,7 @@ function withCallViewModel(
{
kind: E2eeType.PER_PARTICIPANT,
},
connectionState,
connectionState$,
);
onTestFinished(() => {
@@ -275,7 +276,7 @@ test("participants are retained during a focus switch", () => {
}),
new Map(),
(vm) => {
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -319,7 +320,7 @@ test("screen sharing activates spotlight layout", () => {
g: () => vm.setGridMode("grid"),
});
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -362,7 +363,7 @@ test("screen sharing activates spotlight layout", () => {
},
},
);
expectObservable(vm.showSpeakingIndicators).toBe(
expectObservable(vm.showSpeakingIndicators$).toBe(
expectedShowSpeakingMarbles,
{
y: true,
@@ -376,16 +377,16 @@ test("screen sharing activates spotlight layout", () => {
test("participants stay in the same order unless to appear/disappear", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => {
const modeInputMarbles = " a";
const visibilityInputMarbles = "a";
// First Bob speaks, then Dave, then Alice
const aSpeakingInputMarbles = "n- 1998ms - 1999ms y";
const bSpeakingInputMarbles = "ny 1998ms n 1999ms -";
const dSpeakingInputMarbles = "n- 1998ms y 1999ms n";
const aSpeakingInputMarbles = " n- 1998ms - 1999ms y";
const bSpeakingInputMarbles = " ny 1998ms n 1999ms -";
const dSpeakingInputMarbles = " n- 1998ms y 1999ms n";
// Nothing should change when Bob speaks, because Bob is already on screen.
// When Dave speaks he should switch with Alice because she's the one who
// hasn't spoken at all. Then when Alice speaks, she should return to her
// place at the top.
const expectedLayoutMarbles = "a 1999ms b 1999ms a 57999ms c 1999ms a";
const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a";
withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]),
@@ -397,20 +398,17 @@ test("participants stay in the same order unless to appear/disappear", () => {
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
]),
(vm) => {
schedule(modeInputMarbles, {
schedule(visibilityInputMarbles, {
a: () => {
// We imagine that only three tiles (the first three) will be visible
// on screen at a time
vm.layout.subscribe((layout) => {
if (layout.type === "grid") {
for (let i = 0; i < layout.grid.length; i++)
layout.grid[i].setVisible(i < 3);
}
vm.layout$.subscribe((layout) => {
if (layout.type === "grid") layout.setVisibleTiles(3);
});
},
});
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -435,6 +433,56 @@ test("participants stay in the same order unless to appear/disappear", () => {
});
});
test("participants adjust order when space becomes constrained", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => {
// Start with all tiles on screen then shrink to 3
const visibilityInputMarbles = "a-b";
// Bob and Dave speak
const bSpeakingInputMarbles = " ny";
const dSpeakingInputMarbles = " ny";
// Nothing should change when Bob or Dave initially speak, because they are
// on screen. When the screen becomes smaller Alice should move off screen
// to make way for the speakers (specifically, she should swap with Dave).
const expectedLayoutMarbles = " a-b";
withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]),
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
of(ConnectionState.Connected),
new Map([
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
]),
(vm) => {
let setVisibleTiles: ((value: number) => void) | null = null;
vm.layout$.subscribe((layout) => {
if (layout.type === "grid") setVisibleTiles = layout.setVisibleTiles;
});
schedule(visibilityInputMarbles, {
a: () => setVisibleTiles!(Infinity),
b: () => setVisibleTiles!(3),
});
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`, `${daveId}:0`],
},
b: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${daveId}:0`, `${bobId}:0`, `${aliceId}:0`],
},
},
);
},
);
});
});
test("spotlight speakers swap places", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => {
// Go immediately into spotlight mode for the test
@@ -461,7 +509,7 @@ test("spotlight speakers swap places", () => {
(vm) => {
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -509,7 +557,7 @@ test("layout enters picture-in-picture mode when requested", () => {
d: () => window.controls.disablePip(),
});
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -552,12 +600,12 @@ test("spotlight remembers whether it's expanded", () => {
schedule(expandInputMarbles, {
a: () => {
let toggle: () => void;
vm.toggleSpotlightExpanded.subscribe((val) => (toggle = val!));
vm.toggleSpotlightExpanded$.subscribe((val) => (toggle = val!));
toggle!();
},
});
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -614,7 +662,7 @@ test("participants must have a MatrixRTCSession to be visible", () => {
new Map(),
(vm) => {
vm.setGridMode("grid");
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -639,6 +687,53 @@ test("participants must have a MatrixRTCSession to be visible", () => {
});
});
test("shows participants without MatrixRTCSession when enabled in settings", () => {
try {
// enable the setting:
showNonMemberTiles.setValue(true);
withTestScheduler(({ hot, expectObservable }) => {
const scenarioInputMarbles = " abc";
const expectedLayoutMarbles = "abc";
withCallViewModel(
hot(scenarioInputMarbles, {
a: [],
b: [aliceParticipant],
c: [aliceParticipant, bobParticipant],
}),
of([]), // No one joins the MatrixRTC session
of(ConnectionState.Connected),
new Map(),
(vm) => {
vm.setGridMode("grid");
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: ["local:0"],
},
b: {
type: "one-on-one",
local: "local:0",
remote: `${aliceId}:0`,
},
c: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
},
);
},
);
});
} finally {
showNonMemberTiles.setValue(showNonMemberTiles.defaultValue);
}
});
it("should show at least one tile per MatrixRTCSession", () => {
withTestScheduler(({ hot, expectObservable }) => {
// iterate through some combinations of MatrixRTC memberships
@@ -658,7 +753,7 @@ it("should show at least one tile per MatrixRTCSession", () => {
new Map(),
(vm) => {
vm.setGridMode("grid");
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {

View File

@@ -62,16 +62,16 @@ import {
import {
LocalUserMediaViewModel,
type MediaViewModel,
observeTrackReference,
observeTrackReference$,
RemoteUserMediaViewModel,
ScreenShareViewModel,
type UserMediaViewModel,
} from "./MediaViewModel";
import { accumulate, finalizeValue } from "../utils/observable";
import { ObservableScope } from "./ObservableScope";
import { duplicateTiles } from "../settings/settings";
import { duplicateTiles, showNonMemberTiles } from "../settings/settings";
import { isFirefox } from "../Platform";
import { setPipEnabled } from "../controls";
import { setPipEnabled$ } from "../controls";
import {
type GridTileViewModel,
type SpotlightTileViewModel,
@@ -82,7 +82,8 @@ import { spotlightExpandedLayout } from "./SpotlightExpandedLayout";
import { oneOnOneLayout } from "./OneOnOneLayout";
import { pipLayout } from "./PipLayout";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { observeSpeaker } from "./observeSpeaker";
import { observeSpeaker$ } from "./observeSpeaker";
import { shallowEquals } from "../utils/array";
// How long we wait after a focus switch before showing the real participant
// list again
@@ -143,18 +144,21 @@ export interface GridLayout {
type: "grid";
spotlight?: SpotlightTileViewModel;
grid: GridTileViewModel[];
setVisibleTiles: (value: number) => void;
}
export interface SpotlightLandscapeLayout {
type: "spotlight-landscape";
spotlight: SpotlightTileViewModel;
grid: GridTileViewModel[];
setVisibleTiles: (value: number) => void;
}
export interface SpotlightPortraitLayout {
type: "spotlight-portrait";
spotlight: SpotlightTileViewModel;
grid: GridTileViewModel[];
setVisibleTiles: (value: number) => void;
}
export interface SpotlightExpandedLayout {
@@ -223,18 +227,17 @@ enum SortingBin {
interface LayoutScanState {
layout: Layout | null;
tiles: TileStore;
visibleTiles: Set<GridTileViewModel>;
}
class UserMedia {
private readonly scope = new ObservableScope();
public readonly vm: UserMediaViewModel;
private readonly participant: BehaviorSubject<
private readonly participant$: BehaviorSubject<
LocalParticipant | RemoteParticipant | undefined
>;
public readonly speaker: Observable<boolean>;
public readonly presenter: Observable<boolean>;
public readonly speaker$: Observable<boolean>;
public readonly presenter$: Observable<boolean>;
public constructor(
public readonly id: string,
member: RoomMember | undefined,
@@ -242,13 +245,13 @@ class UserMedia {
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
) {
this.participant = new BehaviorSubject(participant);
this.participant$ = new BehaviorSubject(participant);
if (participant?.isLocal) {
this.vm = new LocalUserMediaViewModel(
this.id,
member,
this.participant.asObservable() as Observable<LocalParticipant>,
this.participant$.asObservable() as Observable<LocalParticipant>,
encryptionSystem,
livekitRoom,
);
@@ -256,7 +259,7 @@ class UserMedia {
this.vm = new RemoteUserMediaViewModel(
id,
member,
this.participant.asObservable() as Observable<
this.participant$.asObservable() as Observable<
RemoteParticipant | undefined
>,
encryptionSystem,
@@ -264,9 +267,9 @@ class UserMedia {
);
}
this.speaker = observeSpeaker(this.vm.speaking).pipe(this.scope.state());
this.speaker$ = observeSpeaker$(this.vm.speaking$).pipe(this.scope.state());
this.presenter = this.participant.pipe(
this.presenter$ = this.participant$.pipe(
switchMap(
(p) =>
(p &&
@@ -286,9 +289,9 @@ class UserMedia {
public updateParticipant(
newParticipant: LocalParticipant | RemoteParticipant | undefined,
): void {
if (this.participant.value !== newParticipant) {
if (this.participant$.value !== newParticipant) {
// Update the BehaviourSubject in the UserMedia.
this.participant.next(newParticipant);
this.participant$.next(newParticipant);
}
}
@@ -300,7 +303,7 @@ class UserMedia {
class ScreenShare {
public readonly vm: ScreenShareViewModel;
private readonly participant: BehaviorSubject<
private readonly participant$: BehaviorSubject<
LocalParticipant | RemoteParticipant
>;
@@ -311,12 +314,12 @@ class ScreenShare {
encryptionSystem: EncryptionSystem,
liveKitRoom: LivekitRoom,
) {
this.participant = new BehaviorSubject(participant);
this.participant$ = new BehaviorSubject(participant);
this.vm = new ScreenShareViewModel(
id,
member,
this.participant.asObservable(),
this.participant$.asObservable(),
encryptionSystem,
liveKitRoom,
participant.isLocal,
@@ -354,8 +357,8 @@ function findMatrixRoomMember(
// TODO: Move wayyyy more business logic from the call and lobby views into here
export class CallViewModel extends ViewModel {
public readonly localVideo: Observable<LocalVideoTrack | null> =
observeTrackReference(
public readonly localVideo$: Observable<LocalVideoTrack | null> =
observeTrackReference$(
of(this.livekitRoom.localParticipant),
Track.Source.Camera,
).pipe(
@@ -368,16 +371,16 @@ export class CallViewModel extends ViewModel {
/**
* The raw list of RemoteParticipants as reported by LiveKit
*/
private readonly rawRemoteParticipants: Observable<RemoteParticipant[]> =
private readonly rawRemoteParticipants$: Observable<RemoteParticipant[]> =
connectedParticipantsObserver(this.livekitRoom).pipe(this.scope.state());
/**
* Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that
* they've left
*/
private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> =
this.connectionState.pipe(
withLatestFrom(this.rawRemoteParticipants),
private readonly remoteParticipantHolds$: Observable<RemoteParticipant[][]> =
this.connectionState$.pipe(
withLatestFrom(this.rawRemoteParticipants$),
mergeMap(([s, ps]) => {
// Whenever we switch focuses, we should retain all the previous
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
@@ -389,7 +392,7 @@ export class CallViewModel extends ViewModel {
// Wait for time to pass and the connection state to have changed
forkJoin([
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
this.connectionState.pipe(
this.connectionState$.pipe(
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
take(1),
),
@@ -412,9 +415,9 @@ export class CallViewModel extends ViewModel {
/**
* The RemoteParticipants including those that are being "held" on the screen
*/
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
private readonly remoteParticipants$: Observable<RemoteParticipant[]> =
combineLatest(
[this.rawRemoteParticipants, this.remoteParticipantHolds],
[this.rawRemoteParticipants$, this.remoteParticipantHolds$],
(raw, holds) => {
const result = [...raw];
const resultIds = new Set(result.map((p) => p.identity));
@@ -436,10 +439,10 @@ export class CallViewModel extends ViewModel {
/**
* List of MediaItems that we want to display
*/
private readonly mediaItems: Observable<MediaItem[]> = combineLatest([
this.remoteParticipants,
private readonly mediaItems$: Observable<MediaItem[]> = combineLatest([
this.remoteParticipants$,
observeParticipantMedia(this.livekitRoom.localParticipant),
duplicateTiles.value,
duplicateTiles.value$,
// 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.
@@ -447,6 +450,7 @@ export class CallViewModel extends ViewModel {
this.matrixRTCSession,
MatrixRTCSessionEvent.MembershipsChanged,
).pipe(startWith(null)),
showNonMemberTiles.value$,
]).pipe(
scan(
(
@@ -456,6 +460,7 @@ export class CallViewModel extends ViewModel {
{ participant: localParticipant },
duplicateTiles,
_membershipsChanged,
showNonMemberTiles,
],
) => {
const newItems = new Map(
@@ -493,9 +498,17 @@ export class CallViewModel extends ViewModel {
}
for (let i = 0; i < 1 + duplicateTiles; i++) {
const indexedMediaId = `${livekitParticipantId}:${i}`;
const prevMedia = prevItems.get(indexedMediaId);
let prevMedia = prevItems.get(indexedMediaId);
if (prevMedia && prevMedia instanceof UserMedia) {
prevMedia.updateParticipant(participant);
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;
}
}
yield [
indexedMediaId,
@@ -531,7 +544,55 @@ export class CallViewModel extends ViewModel {
}.bind(this)(),
);
return newItems;
// 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,
),
];
}
}
}
}.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;
},
new Map<string, MediaItem>(),
),
@@ -545,13 +606,13 @@ export class CallViewModel extends ViewModel {
/**
* List of MediaItems that we want to display, that are of type UserMedia
*/
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
private readonly userMedia$: Observable<UserMedia[]> = this.mediaItems$.pipe(
map((mediaItems) =>
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
),
);
public readonly memberChanges = this.userMedia
public readonly memberChanges$ = this.userMedia$
.pipe(map((mediaItems) => mediaItems.map((m) => m.id)))
.pipe(
scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
@@ -567,22 +628,22 @@ export class CallViewModel extends ViewModel {
/**
* List of MediaItems that we want to display, that are of type ScreenShare
*/
private readonly screenShares: Observable<ScreenShare[]> =
this.mediaItems.pipe(
private readonly screenShares$: Observable<ScreenShare[]> =
this.mediaItems$.pipe(
map((mediaItems) =>
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
),
this.scope.state(),
);
private readonly spotlightSpeaker: Observable<UserMediaViewModel | null> =
this.userMedia.pipe(
private readonly spotlightSpeaker$: Observable<UserMediaViewModel | null> =
this.userMedia$.pipe(
switchMap((mediaItems) =>
mediaItems.length === 0
? of([])
: combineLatest(
mediaItems.map((m) =>
m.vm.speaking.pipe(map((s) => [m, s] as const)),
m.vm.speaking$.pipe(map((s) => [m, s] as const)),
),
),
),
@@ -611,64 +672,68 @@ export class CallViewModel extends ViewModel {
this.scope.state(),
);
private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe(
switchMap((mediaItems) => {
const bins = mediaItems.map((m) =>
combineLatest(
[
m.speaker,
m.presenter,
m.vm.videoEnabled,
m.vm instanceof LocalUserMediaViewModel
? m.vm.alwaysShow
: of(false),
],
(speaker, presenter, video, alwaysShow) => {
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;
else if (video) bin = SortingBin.Video;
else bin = SortingBin.NoVideo;
private readonly grid$: Observable<UserMediaViewModel[]> =
this.userMedia$.pipe(
switchMap((mediaItems) => {
const bins = mediaItems.map((m) =>
combineLatest(
[
m.speaker$,
m.presenter$,
m.vm.videoEnabled$,
m.vm instanceof LocalUserMediaViewModel
? m.vm.alwaysShow$
: of(false),
],
(speaker, presenter, video, alwaysShow) => {
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;
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),
);
}),
);
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(),
);
private readonly spotlight: Observable<MediaViewModel[]> =
this.screenShares.pipe(
private readonly spotlight$: Observable<MediaViewModel[]> =
this.screenShares$.pipe(
switchMap((screenShares) => {
if (screenShares.length > 0) {
return of(screenShares.map((m) => m.vm));
}
return this.spotlightSpeaker.pipe(
return this.spotlightSpeaker$.pipe(
map((speaker) => (speaker ? [speaker] : [])),
);
}),
distinctUntilChanged(shallowEquals),
this.scope.state(),
);
private readonly pip: Observable<UserMediaViewModel | null> = combineLatest([
this.screenShares,
this.spotlightSpeaker,
this.mediaItems,
private readonly pip$: Observable<UserMediaViewModel | null> = combineLatest([
this.screenShares$,
this.spotlightSpeaker$,
this.mediaItems$,
]).pipe(
switchMap(([screenShares, spotlight, mediaItems]) => {
if (screenShares.length > 0) {
return this.spotlightSpeaker;
return this.spotlightSpeaker$;
}
if (!spotlight || spotlight.local) {
return of(null);
@@ -685,7 +750,7 @@ export class CallViewModel extends ViewModel {
if (!localUserMediaViewModel) {
return of(null);
}
return localUserMediaViewModel.alwaysShow.pipe(
return localUserMediaViewModel.alwaysShow$.pipe(
map((alwaysShow) => {
if (alwaysShow) {
return localUserMediaViewModel;
@@ -698,19 +763,19 @@ export class CallViewModel extends ViewModel {
this.scope.state(),
);
private readonly hasRemoteScreenShares: Observable<boolean> =
this.spotlight.pipe(
private readonly hasRemoteScreenShares$: Observable<boolean> =
this.spotlight$.pipe(
map((spotlight) =>
spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel),
),
distinctUntilChanged(),
);
private readonly pipEnabled: Observable<boolean> = setPipEnabled.pipe(
private readonly pipEnabled$: Observable<boolean> = setPipEnabled$.pipe(
startWith(false),
);
private readonly naturalWindowMode: Observable<WindowMode> = fromEvent(
private readonly naturalWindowMode$: Observable<WindowMode> = fromEvent(
window,
"resize",
).pipe(
@@ -732,30 +797,30 @@ export class CallViewModel extends ViewModel {
/**
* The general shape of the window.
*/
public readonly windowMode: Observable<WindowMode> = this.pipEnabled.pipe(
switchMap((pip) => (pip ? of<WindowMode>("pip") : this.naturalWindowMode)),
public readonly windowMode$: Observable<WindowMode> = this.pipEnabled$.pipe(
switchMap((pip) => (pip ? of<WindowMode>("pip") : this.naturalWindowMode$)),
);
private readonly spotlightExpandedToggle = new Subject<void>();
public readonly spotlightExpanded: Observable<boolean> =
this.spotlightExpandedToggle.pipe(
private readonly spotlightExpandedToggle$ = new Subject<void>();
public readonly spotlightExpanded$: Observable<boolean> =
this.spotlightExpandedToggle$.pipe(
accumulate(false, (expanded) => !expanded),
this.scope.state(),
);
private readonly gridModeUserSelection = new Subject<GridMode>();
private readonly gridModeUserSelection$ = new Subject<GridMode>();
/**
* The layout mode of the media tile grid.
*/
public readonly gridMode: Observable<GridMode> =
public readonly gridMode$: Observable<GridMode> =
// If the user hasn't selected spotlight and somebody starts screen sharing,
// automatically switch to spotlight mode and reset when screen sharing ends
this.gridModeUserSelection.pipe(
this.gridModeUserSelection$.pipe(
startWith(null),
switchMap((userSelection) =>
(userSelection === "spotlight"
? EMPTY
: combineLatest([this.hasRemoteScreenShares, this.windowMode]).pipe(
: combineLatest([this.hasRemoteScreenShares$, this.windowMode$]).pipe(
skip(userSelection === null ? 0 : 1),
map(
([hasScreenShares, windowMode]): GridMode =>
@@ -770,43 +835,41 @@ export class CallViewModel extends ViewModel {
);
public setGridMode(value: GridMode): void {
this.gridModeUserSelection.next(value);
this.gridModeUserSelection$.next(value);
}
private readonly gridLayoutMedia: Observable<GridLayoutMedia> = combineLatest(
[this.grid, this.spotlight],
(grid, spotlight) => ({
private readonly gridLayoutMedia$: Observable<GridLayoutMedia> =
combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({
type: "grid",
spotlight: spotlight.some((vm) => vm instanceof ScreenShareViewModel)
? spotlight
: undefined,
grid,
}),
);
}));
private readonly spotlightLandscapeLayoutMedia: Observable<SpotlightLandscapeLayoutMedia> =
combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({
private readonly spotlightLandscapeLayoutMedia$: Observable<SpotlightLandscapeLayoutMedia> =
combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({
type: "spotlight-landscape",
spotlight,
grid,
}));
private readonly spotlightPortraitLayoutMedia: Observable<SpotlightPortraitLayoutMedia> =
combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({
private readonly spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({
type: "spotlight-portrait",
spotlight,
grid,
}));
private readonly spotlightExpandedLayoutMedia: Observable<SpotlightExpandedLayoutMedia> =
combineLatest([this.spotlight, this.pip], (spotlight, pip) => ({
private readonly spotlightExpandedLayoutMedia$: Observable<SpotlightExpandedLayoutMedia> =
combineLatest([this.spotlight$, this.pip$], (spotlight, pip) => ({
type: "spotlight-expanded",
spotlight,
pip: pip ?? undefined,
}));
private readonly oneOnOneLayoutMedia: Observable<OneOnOneLayoutMedia | null> =
this.mediaItems.pipe(
private readonly oneOnOneLayoutMedia$: Observable<OneOnOneLayoutMedia | null> =
this.mediaItems$.pipe(
map((mediaItems) => {
if (mediaItems.length !== 2) return null;
const local = mediaItems.find((vm) => vm.vm.local)?.vm as
@@ -824,129 +887,125 @@ export class CallViewModel extends ViewModel {
}),
);
private readonly pipLayoutMedia: Observable<LayoutMedia> =
this.spotlight.pipe(map((spotlight) => ({ type: "pip", spotlight })));
private readonly pipLayoutMedia$: Observable<LayoutMedia> =
this.spotlight$.pipe(map((spotlight) => ({ type: "pip", spotlight })));
/**
* The media to be used to produce a layout.
*/
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(),
);
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(),
);
public readonly layoutInternals: Observable<
// 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.
private readonly visibleTiles$ = new Subject<number>();
private readonly setVisibleTiles = (value: number): void =>
this.visibleTiles$.next(value);
public readonly layoutInternals$: Observable<
LayoutScanState & { layout: Layout }
> = this.layoutMedia.pipe(
// Each layout will produce a set of tiles, and these tiles have an
// observable indicating whether they're visible. We loop this information
// back into the layout process by using switchScan.
switchScan<
LayoutMedia,
LayoutScanState,
Observable<LayoutScanState & { layout: Layout }>
> = combineLatest([
this.layoutMedia$,
this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()),
]).pipe(
scan<
[LayoutMedia, number],
LayoutScanState & { layout: Layout },
LayoutScanState
>(
({ tiles: prevTiles, visibleTiles }, media) => {
({ tiles: prevTiles }, [media, visibleTiles]) => {
let layout: Layout;
let newTiles: TileStore;
switch (media.type) {
case "grid":
case "spotlight-landscape":
case "spotlight-portrait":
[layout, newTiles] = gridLikeLayout(media, visibleTiles, prevTiles);
break;
case "spotlight-expanded":
[layout, newTiles] = spotlightExpandedLayout(
[layout, newTiles] = gridLikeLayout(
media,
visibleTiles,
this.setVisibleTiles,
prevTiles,
);
break;
case "spotlight-expanded":
[layout, newTiles] = spotlightExpandedLayout(media, prevTiles);
break;
case "one-on-one":
[layout, newTiles] = oneOnOneLayout(media, visibleTiles, prevTiles);
[layout, newTiles] = oneOnOneLayout(media, prevTiles);
break;
case "pip":
[layout, newTiles] = pipLayout(media, visibleTiles, prevTiles);
[layout, newTiles] = pipLayout(media, prevTiles);
break;
}
// Take all of the 'visible' observables and combine them into one big
// observable array
const visibilities =
newTiles.gridTiles.length === 0
? of([])
: combineLatest(newTiles.gridTiles.map((tile) => tile.visible));
return visibilities.pipe(
map((visibilities) => ({
layout: layout,
tiles: newTiles,
visibleTiles: new Set(
newTiles.gridTiles.filter((_tile, i) => visibilities[i]),
),
})),
);
},
{
layout: null,
tiles: TileStore.empty(),
visibleTiles: new Set(),
return { layout, tiles: newTiles };
},
{ layout: null, tiles: TileStore.empty() },
),
this.scope.state(),
);
@@ -954,7 +1013,7 @@ export class CallViewModel extends ViewModel {
/**
* The layout of tiles in the call interface.
*/
public readonly layout: Observable<Layout> = this.layoutInternals.pipe(
public readonly layout$: Observable<Layout> = this.layoutInternals$.pipe(
map(({ layout }) => layout),
this.scope.state(),
);
@@ -962,18 +1021,18 @@ export class CallViewModel extends ViewModel {
/**
* The current generation of the tile store, exposed for debugging purposes.
*/
public readonly tileStoreGeneration: Observable<number> =
this.layoutInternals.pipe(
public readonly tileStoreGeneration$: Observable<number> =
this.layoutInternals$.pipe(
map(({ tiles }) => tiles.generation),
this.scope.state(),
);
public showSpotlightIndicators: Observable<boolean> = this.layout.pipe(
public showSpotlightIndicators$: Observable<boolean> = this.layout$.pipe(
map((l) => l.type !== "grid"),
this.scope.state(),
);
public showSpeakingIndicators: Observable<boolean> = this.layout.pipe(
public showSpeakingIndicators$: Observable<boolean> = this.layout$.pipe(
switchMap((l) => {
switch (l.type) {
case "spotlight-landscape":
@@ -981,7 +1040,7 @@ export class CallViewModel extends ViewModel {
// 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.
return l.spotlight.media.pipe(
return l.spotlight.media$.pipe(
map((models: MediaViewModel[]) =>
models.some((m) => m instanceof ScreenShareViewModel),
),
@@ -1000,11 +1059,11 @@ export class CallViewModel extends ViewModel {
this.scope.state(),
);
public readonly toggleSpotlightExpanded: Observable<(() => void) | null> =
this.windowMode.pipe(
public readonly toggleSpotlightExpanded$: Observable<(() => void) | null> =
this.windowMode$.pipe(
switchMap((mode) =>
mode === "normal"
? this.layout.pipe(
? this.layout$.pipe(
map(
(l) =>
l.type === "spotlight-landscape" ||
@@ -1015,50 +1074,50 @@ export class CallViewModel extends ViewModel {
),
distinctUntilChanged(),
map((enabled) =>
enabled ? (): void => this.spotlightExpandedToggle.next() : null,
enabled ? (): void => this.spotlightExpandedToggle$.next() : null,
),
this.scope.state(),
);
private readonly screenTap = new Subject<void>();
private readonly controlsTap = new Subject<void>();
private readonly screenHover = new Subject<void>();
private readonly screenUnhover = new Subject<void>();
private readonly screenTap$ = new Subject<void>();
private readonly controlsTap$ = new Subject<void>();
private readonly screenHover$ = new Subject<void>();
private readonly screenUnhover$ = new Subject<void>();
/**
* Callback for when the user taps the call view.
*/
public tapScreen(): void {
this.screenTap.next();
this.screenTap$.next();
}
/**
* Callback for when the user taps the call's controls.
*/
public tapControls(): void {
this.controlsTap.next();
this.controlsTap$.next();
}
/**
* Callback for when the user hovers over the call view.
*/
public hoverScreen(): void {
this.screenHover.next();
this.screenHover$.next();
}
/**
* Callback for when the user stops hovering over the call view.
*/
public unhoverScreen(): void {
this.screenUnhover.next();
this.screenUnhover$.next();
}
public readonly showHeader: Observable<boolean> = this.windowMode.pipe(
public readonly showHeader$: Observable<boolean> = this.windowMode$.pipe(
map((mode) => mode !== "pip" && mode !== "flat"),
this.scope.state(),
);
public readonly showFooter: Observable<boolean> = this.windowMode.pipe(
public readonly showFooter$: Observable<boolean> = this.windowMode$.pipe(
switchMap((mode) => {
switch (mode) {
case "pip":
@@ -1073,9 +1132,9 @@ export class CallViewModel extends ViewModel {
if (isFirefox()) return of(true);
// Show/hide the footer in response to interactions
return merge(
this.screenTap.pipe(map(() => "tap screen" as const)),
this.controlsTap.pipe(map(() => "tap controls" as const)),
this.screenHover.pipe(map(() => "hover" as const)),
this.screenTap$.pipe(map(() => "tap screen" as const)),
this.controlsTap$.pipe(map(() => "tap controls" as const)),
this.screenHover$.pipe(map(() => "hover" as const)),
).pipe(
switchScan((state, interaction) => {
switch (interaction) {
@@ -1098,7 +1157,7 @@ export class CallViewModel extends ViewModel {
// Show on hover and hide after a timeout
return race(
timer(showFooterMs),
this.screenUnhover.pipe(take(1)),
this.screenUnhover$.pipe(take(1)),
).pipe(
map(() => false),
startWith(true),
@@ -1117,7 +1176,7 @@ export class CallViewModel extends ViewModel {
private readonly matrixRTCSession: MatrixRTCSession,
private readonly livekitRoom: LivekitRoom,
private readonly encryptionSystem: EncryptionSystem,
private readonly connectionState: Observable<ECConnectionState>,
private readonly connectionState$: Observable<ECConnectionState>,
) {
super();
}

View File

@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
import { type Layout, type LayoutMedia } from "./CallViewModel";
import { type TileStore } from "./TileStore";
import { type GridTileViewModel } from "./TileViewModel";
export type GridLikeLayoutType =
| "grid"
@@ -20,7 +19,8 @@ export type GridLikeLayoutType =
*/
export function gridLikeLayout(
media: LayoutMedia & { type: GridLikeLayoutType },
visibleTiles: Set<GridTileViewModel>,
visibleTiles: number,
setVisibleTiles: (value: number) => void,
prevTiles: TileStore,
): [Layout & { type: GridLikeLayoutType }, TileStore] {
const update = prevTiles.from(visibleTiles);
@@ -37,6 +37,7 @@ export function gridLikeLayout(
type: media.type,
spotlight: tiles.spotlightTile,
grid: tiles.gridTiles,
setVisibleTiles,
} as Layout & { type: GridLikeLayoutType },
tiles,
];

View File

@@ -49,7 +49,7 @@ test("control a participant's volume", async () => {
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
},
});
expectObservable(vm.localVolume).toBe("ab(cd)(ef)g", {
expectObservable(vm.localVolume$).toBe("ab(cd)(ef)g", {
a: 1,
b: 0,
c: 0.6,
@@ -69,7 +69,7 @@ test("toggle fit/contain for a participant's video", async () => {
a: () => vm.toggleFitContain(),
b: () => vm.toggleFitContain(),
});
expectObservable(vm.cropVideo).toBe("abc", {
expectObservable(vm.cropVideo$).toBe("abc", {
a: true,
b: false,
c: true,
@@ -82,7 +82,7 @@ test("local media remembers whether it should always be shown", async () => {
await withLocalMedia(rtcMembership, {}, (vm) =>
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
expectObservable(vm.alwaysShow).toBe("ab", { a: true, b: false });
expectObservable(vm.alwaysShow$).toBe("ab", { a: true, b: false });
}),
);
// Next local media should start out *not* always shown
@@ -93,7 +93,7 @@ test("local media remembers whether it should always be shown", async () => {
(vm) =>
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true });
expectObservable(vm.alwaysShow$).toBe("ab", { a: false, b: true });
}),
);
});

View File

@@ -74,11 +74,11 @@ export function useDisplayName(vm: MediaViewModel): string {
return displayName;
}
export function observeTrackReference(
participant: Observable<Participant | undefined>,
export function observeTrackReference$(
participant$: Observable<Participant | undefined>,
source: Track.Source,
): Observable<TrackReferenceOrPlaceholder | undefined> {
return participant.pipe(
return participant$.pipe(
switchMap((p) => {
if (p) {
return observeParticipantMedia(p).pipe(
@@ -96,7 +96,7 @@ export function observeTrackReference(
);
}
function observeRemoteTrackReceivingOkay(
function observeRemoteTrackReceivingOkay$(
participant: Participant,
source: Track.Source,
): Observable<boolean | undefined> {
@@ -111,7 +111,7 @@ function observeRemoteTrackReceivingOkay(
};
return combineLatest([
observeTrackReference(of(participant), source),
observeTrackReference$(of(participant), source),
interval(1000).pipe(startWith(0)),
]).pipe(
switchMap(async ([trackReference]) => {
@@ -168,7 +168,7 @@ function observeRemoteTrackReceivingOkay(
);
}
function encryptionErrorObservable(
function encryptionErrorObservable$(
room: LivekitRoom,
participant: Participant,
encryptionSystem: EncryptionSystem,
@@ -209,13 +209,13 @@ abstract class BaseMediaViewModel extends ViewModel {
/**
* The LiveKit video track for this media.
*/
public readonly video: Observable<TrackReferenceOrPlaceholder | undefined>;
public readonly video$: Observable<TrackReferenceOrPlaceholder | undefined>;
/**
* Whether there should be a warning that this media is unencrypted.
*/
public readonly unencryptedWarning: Observable<boolean>;
public readonly unencryptedWarning$: Observable<boolean>;
public readonly encryptionStatus: Observable<EncryptionStatus>;
public readonly encryptionStatus$: Observable<EncryptionStatus>;
/**
* Whether this media corresponds to the local participant.
@@ -235,7 +235,7 @@ abstract class BaseMediaViewModel extends ViewModel {
public readonly member: RoomMember | undefined,
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
// livekit.
protected readonly participant: Observable<
protected readonly participant$: Observable<
LocalParticipant | RemoteParticipant | undefined
>,
@@ -245,21 +245,21 @@ abstract class BaseMediaViewModel extends ViewModel {
livekitRoom: LivekitRoom,
) {
super();
const audio = observeTrackReference(participant, audioSource).pipe(
const audio$ = observeTrackReference$(participant$, audioSource).pipe(
this.scope.state(),
);
this.video = observeTrackReference(participant, videoSource).pipe(
this.video$ = observeTrackReference$(participant$, videoSource).pipe(
this.scope.state(),
);
this.unencryptedWarning = combineLatest(
[audio, this.video],
this.unencryptedWarning$ = combineLatest(
[audio$, this.video$],
(a, v) =>
encryptionSystem.kind !== E2eeType.NONE &&
(a?.publication?.isEncrypted === false ||
v?.publication?.isEncrypted === false),
).pipe(this.scope.state());
this.encryptionStatus = this.participant.pipe(
this.encryptionStatus$ = this.participant$.pipe(
switchMap((participant): Observable<EncryptionStatus> => {
if (!participant) {
return of(EncryptionStatus.Connecting);
@@ -270,20 +270,20 @@ abstract class BaseMediaViewModel extends ViewModel {
return of(EncryptionStatus.Okay);
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
return combineLatest([
encryptionErrorObservable(
encryptionErrorObservable$(
livekitRoom,
participant,
encryptionSystem,
"MissingKey",
),
encryptionErrorObservable(
encryptionErrorObservable$(
livekitRoom,
participant,
encryptionSystem,
"InvalidKey",
),
observeRemoteTrackReceivingOkay(participant, audioSource),
observeRemoteTrackReceivingOkay(participant, videoSource),
observeRemoteTrackReceivingOkay$(participant, audioSource),
observeRemoteTrackReceivingOkay$(participant, videoSource),
]).pipe(
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
if (keyMissing) return EncryptionStatus.KeyMissing;
@@ -296,14 +296,14 @@ abstract class BaseMediaViewModel extends ViewModel {
);
} else {
return combineLatest([
encryptionErrorObservable(
encryptionErrorObservable$(
livekitRoom,
participant,
encryptionSystem,
"InvalidKey",
),
observeRemoteTrackReceivingOkay(participant, audioSource),
observeRemoteTrackReceivingOkay(participant, videoSource),
observeRemoteTrackReceivingOkay$(participant, audioSource),
observeRemoteTrackReceivingOkay$(participant, videoSource),
]).pipe(
map(
([keyInvalid, audioOkay, videoOkay]):
@@ -339,7 +339,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/**
* Whether the participant is speaking.
*/
public readonly speaking = this.participant.pipe(
public readonly speaking$ = this.participant$.pipe(
switchMap((p) =>
p
? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe(
@@ -353,49 +353,49 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/**
* Whether this participant is sending audio (i.e. is unmuted on their side).
*/
public readonly audioEnabled: Observable<boolean>;
public readonly audioEnabled$: Observable<boolean>;
/**
* Whether this participant is sending video.
*/
public readonly videoEnabled: Observable<boolean>;
public readonly videoEnabled$: Observable<boolean>;
private readonly _cropVideo = new BehaviorSubject(true);
private readonly _cropVideo$ = new BehaviorSubject(true);
/**
* Whether the tile video should be contained inside the tile or be cropped to fit.
*/
public readonly cropVideo: Observable<boolean> = this._cropVideo;
public readonly cropVideo$: Observable<boolean> = this._cropVideo$;
public constructor(
id: string,
member: RoomMember | undefined,
participant: Observable<LocalParticipant | RemoteParticipant | undefined>,
participant$: Observable<LocalParticipant | RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
) {
super(
id,
member,
participant,
participant$,
encryptionSystem,
Track.Source.Microphone,
Track.Source.Camera,
livekitRoom,
);
const media = participant.pipe(
const media$ = participant$.pipe(
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
this.scope.state(),
);
this.audioEnabled = media.pipe(
this.audioEnabled$ = media$.pipe(
map((m) => m?.microphoneTrack?.isMuted === false),
);
this.videoEnabled = media.pipe(
this.videoEnabled$ = media$.pipe(
map((m) => m?.cameraTrack?.isMuted === false),
);
}
public toggleFitContain(): void {
this._cropVideo.next(!this._cropVideo.value);
this._cropVideo$.next(!this._cropVideo$.value);
}
public get local(): boolean {
@@ -410,7 +410,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/**
* Whether the video should be mirrored.
*/
public readonly mirror = this.video.pipe(
public readonly mirror$ = this.video$.pipe(
switchMap((v) => {
const track = v?.publication?.track;
if (!(track instanceof LocalTrack)) return of(false);
@@ -428,17 +428,17 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
* Whether to show this tile in a highly visible location near the start of
* the grid.
*/
public readonly alwaysShow = alwaysShowSelf.value;
public readonly alwaysShow$ = alwaysShowSelf.value$;
public readonly setAlwaysShow = alwaysShowSelf.setValue;
public constructor(
id: string,
member: RoomMember | undefined,
participant: Observable<LocalParticipant | undefined>,
participant$: Observable<LocalParticipant | undefined>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
) {
super(id, member, participant, encryptionSystem, livekitRoom);
super(id, member, participant$, encryptionSystem, livekitRoom);
}
}
@@ -446,18 +446,18 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
* A remote participant's user media.
*/
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
private readonly locallyMutedToggle = new Subject<void>();
private readonly localVolumeAdjustment = new Subject<number>();
private readonly localVolumeCommit = new Subject<void>();
private readonly locallyMutedToggle$ = new Subject<void>();
private readonly localVolumeAdjustment$ = new Subject<number>();
private readonly localVolumeCommit$ = new Subject<void>();
/**
* The volume to which this participant's audio is set, as a scalar
* multiplier.
*/
public readonly localVolume: Observable<number> = merge(
this.locallyMutedToggle.pipe(map(() => "toggle mute" as const)),
this.localVolumeAdjustment,
this.localVolumeCommit.pipe(map(() => "commit" as const)),
public readonly localVolume$: Observable<number> = merge(
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
this.localVolumeAdjustment$,
this.localVolumeCommit$.pipe(map(() => "commit" as const)),
).pipe(
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
switch (event) {
@@ -487,7 +487,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
/**
* Whether this participant's audio is disabled.
*/
public readonly locallyMuted: Observable<boolean> = this.localVolume.pipe(
public readonly locallyMuted$: Observable<boolean> = this.localVolume$.pipe(
map((volume) => volume === 0),
this.scope.state(),
);
@@ -495,29 +495,29 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
public constructor(
id: string,
member: RoomMember | undefined,
participant: Observable<RemoteParticipant | undefined>,
participant$: Observable<RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
) {
super(id, member, participant, encryptionSystem, livekitRoom);
super(id, member, participant$, encryptionSystem, livekitRoom);
// Sync the local volume with LiveKit
combineLatest([
participant,
this.localVolume.pipe(this.scope.bind()),
participant$,
this.localVolume$.pipe(this.scope.bind()),
]).subscribe(([p, volume]) => p && p.setVolume(volume));
}
public toggleLocallyMuted(): void {
this.locallyMutedToggle.next();
this.locallyMutedToggle$.next();
}
public setLocalVolume(value: number): void {
this.localVolumeAdjustment.next(value);
this.localVolumeAdjustment$.next(value);
}
public commitLocalVolume(): void {
this.localVolumeCommit.next();
this.localVolumeCommit$.next();
}
}
@@ -528,7 +528,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
public constructor(
id: string,
member: RoomMember | undefined,
participant: Observable<LocalParticipant | RemoteParticipant>,
participant$: Observable<LocalParticipant | RemoteParticipant>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
public readonly local: boolean,
@@ -536,7 +536,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
super(
id,
member,
participant,
participant$,
encryptionSystem,
Track.Source.ScreenShareAudio,
Track.Source.ScreenShare,

View File

@@ -19,9 +19,9 @@ type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
* A scope which limits the execution lifetime of its bound Observables.
*/
export class ObservableScope {
private readonly ended = new Subject<void>();
private readonly ended$ = new Subject<void>();
private readonly bindImpl: MonoTypeOperator = takeUntil(this.ended);
private readonly bindImpl: MonoTypeOperator = takeUntil(this.ended$);
/**
* Binds an Observable to this scope, so that it completes when the scope
@@ -31,8 +31,8 @@ export class ObservableScope {
return this.bindImpl;
}
private readonly stateImpl: MonoTypeOperator = (o) =>
o.pipe(
private readonly stateImpl: MonoTypeOperator = (o$) =>
o$.pipe(
this.bind(),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: false }),
@@ -51,7 +51,7 @@ export class ObservableScope {
* Ends the scope, causing any bound Observables to complete.
*/
public end(): void {
this.ended.next();
this.ended.complete();
this.ended$.next();
this.ended$.complete();
}
}

View File

@@ -7,17 +7,15 @@ Please see LICENSE in the repository root for full details.
import { type OneOnOneLayout, type OneOnOneLayoutMedia } from "./CallViewModel";
import { type TileStore } from "./TileStore";
import { type GridTileViewModel } from "./TileViewModel";
/**
* Produces a one-on-one layout with the given media.
*/
export function oneOnOneLayout(
media: OneOnOneLayoutMedia,
visibleTiles: Set<GridTileViewModel>,
prevTiles: TileStore,
): [OneOnOneLayout, TileStore] {
const update = prevTiles.from(visibleTiles);
const update = prevTiles.from(2);
update.registerGridTile(media.local);
update.registerGridTile(media.remote);
const tiles = update.build();

View File

@@ -7,17 +7,15 @@ Please see LICENSE in the repository root for full details.
import { type PipLayout, type PipLayoutMedia } from "./CallViewModel";
import { type TileStore } from "./TileStore";
import { type GridTileViewModel } from "./TileViewModel";
/**
* Produces a picture-in-picture layout with the given media.
*/
export function pipLayout(
media: PipLayoutMedia,
visibleTiles: Set<GridTileViewModel>,
prevTiles: TileStore,
): [PipLayout, TileStore] {
const update = prevTiles.from(visibleTiles);
const update = prevTiles.from(0);
update.registerSpotlight(media.spotlight, true);
const tiles = update.build();
return [

View File

@@ -10,17 +10,15 @@ import {
type SpotlightExpandedLayoutMedia,
} from "./CallViewModel";
import { type TileStore } from "./TileStore";
import { type GridTileViewModel } from "./TileViewModel";
/**
* Produces an expanded spotlight layout with the given media.
*/
export function spotlightExpandedLayout(
media: SpotlightExpandedLayoutMedia,
visibleTiles: Set<GridTileViewModel>,
prevTiles: TileStore,
): [SpotlightExpandedLayout, TileStore] {
const update = prevTiles.from(visibleTiles);
const update = prevTiles.from(1);
update.registerSpotlight(media.spotlight, true);
if (media.pip !== undefined) update.registerGridTile(media.pip);
const tiles = update.build();

View File

@@ -18,31 +18,31 @@ function debugEntries(entries: GridTileData[]): string[] {
}
let DEBUG_ENABLED = false;
debugTileLayout.value.subscribe((value) => (DEBUG_ENABLED = value));
debugTileLayout.value$.subscribe((value) => (DEBUG_ENABLED = value));
class SpotlightTileData {
private readonly media_: BehaviorSubject<MediaViewModel[]>;
private readonly media$: BehaviorSubject<MediaViewModel[]>;
public get media(): MediaViewModel[] {
return this.media_.value;
return this.media$.value;
}
public set media(value: MediaViewModel[]) {
this.media_.next(value);
this.media$.next(value);
}
private readonly maximised_: BehaviorSubject<boolean>;
private readonly maximised$: BehaviorSubject<boolean>;
public get maximised(): boolean {
return this.maximised_.value;
return this.maximised$.value;
}
public set maximised(value: boolean) {
this.maximised_.next(value);
this.maximised$.next(value);
}
public readonly vm: SpotlightTileViewModel;
public constructor(media: MediaViewModel[], maximised: boolean) {
this.media_ = new BehaviorSubject(media);
this.maximised_ = new BehaviorSubject(maximised);
this.vm = new SpotlightTileViewModel(this.media_, this.maximised_);
this.media$ = new BehaviorSubject(media);
this.maximised$ = new BehaviorSubject(maximised);
this.vm = new SpotlightTileViewModel(this.media$, this.maximised$);
}
public destroy(): void {
@@ -51,19 +51,19 @@ class SpotlightTileData {
}
class GridTileData {
private readonly media_: BehaviorSubject<UserMediaViewModel>;
private readonly media$: BehaviorSubject<UserMediaViewModel>;
public get media(): UserMediaViewModel {
return this.media_.value;
return this.media$.value;
}
public set media(value: UserMediaViewModel) {
this.media_.next(value);
this.media$.next(value);
}
public readonly vm: GridTileViewModel;
public constructor(media: UserMediaViewModel) {
this.media_ = new BehaviorSubject(media);
this.vm = new GridTileViewModel(this.media_);
this.media$ = new BehaviorSubject(media);
this.vm = new GridTileViewModel(this.media$);
}
public destroy(): void {
@@ -101,7 +101,7 @@ export class TileStore {
* Creates a builder which can be used to update the collection, passing
* ownership of the tiles to the updated collection.
*/
public from(visibleTiles: Set<GridTileViewModel>): TileStoreBuilder {
public from(visibleTiles: number): TileStoreBuilder {
return new TileStoreBuilder(
this.spotlight,
this.grid,
@@ -123,7 +123,10 @@ export class TileStoreBuilder {
"speaking" in this.prevSpotlight.media[0] &&
this.prevSpotlight.media[0];
private readonly prevGridByMedia = new Map(
private readonly prevGridByMedia: Map<
MediaViewModel,
[GridTileData, number]
> = new Map(
this.prevGrid.map((entry, i) => [entry.media, [entry, i]] as const),
);
@@ -146,7 +149,7 @@ export class TileStoreBuilder {
spotlight: SpotlightTileData | null,
grid: GridTileData[],
) => TileStore,
private readonly visibleTiles: Set<GridTileViewModel>,
private readonly visibleTiles: number,
/**
* A number incremented on each update, just for debugging purposes.
*/
@@ -204,10 +207,8 @@ export class TileStoreBuilder {
const prev = this.prevGridByMedia.get(this.spotlight.media[0]);
if (prev !== undefined) {
const [entry, prevIndex] = prev;
const previouslyVisible = this.visibleTiles.has(entry.vm);
const nowVisible = this.visibleTiles.has(
this.prevGrid[this.numGridEntries]?.vm,
);
const previouslyVisible = prevIndex < this.visibleTiles;
const nowVisible = this.numGridEntries < this.visibleTiles;
// If it doesn't need to move between the visible/invisible sections of
// the grid, then we can keep it where it was and swap the media
@@ -236,17 +237,15 @@ export class TileStoreBuilder {
const prev = this.prevGridByMedia.get(media);
if (prev === undefined) {
// Create a new tile
(this.visibleTiles.has(this.prevGrid[this.numGridEntries]?.vm)
(this.numGridEntries < this.visibleTiles
? this.visibleGridEntries
: this.invisibleGridEntries
).push(new GridTileData(media));
} else {
// Reuse the existing tile
const [entry, prevIndex] = prev;
const previouslyVisible = this.visibleTiles.has(entry.vm);
const nowVisible = this.visibleTiles.has(
this.prevGrid[this.numGridEntries]?.vm,
);
const previouslyVisible = prevIndex < this.visibleTiles;
const nowVisible = this.numGridEntries < this.visibleTiles;
// If it doesn't need to move between the visible/invisible sections of
// the grid, then we can keep it exactly where it was previously
if (previouslyVisible === nowVisible)

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { BehaviorSubject, type Observable } from "rxjs";
import { type Observable } from "rxjs";
import { ViewModel } from "./ViewModel";
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
@@ -18,23 +18,15 @@ function createId(): string {
export class GridTileViewModel extends ViewModel {
public readonly id = createId();
private readonly visible_ = new BehaviorSubject(false);
/**
* Whether the tile is visible within the current viewport.
*/
public readonly visible: Observable<boolean> = this.visible_;
public setVisible = (value: boolean): void => this.visible_.next(value);
public constructor(public readonly media: Observable<UserMediaViewModel>) {
public constructor(public readonly media$: Observable<UserMediaViewModel>) {
super();
}
}
export class SpotlightTileViewModel extends ViewModel {
public constructor(
public readonly media: Observable<MediaViewModel[]>,
public readonly maximised: Observable<boolean>,
public readonly media$: Observable<MediaViewModel[]>,
public readonly maximised$: Observable<boolean>,
) {
super();
}

View File

@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { describe, test } from "vitest";
import { withTestScheduler } from "../utils/test";
import { observeSpeaker } from "./observeSpeaker";
import { observeSpeaker$ } from "./observeSpeaker";
const yesNo = {
y: true,
@@ -22,40 +22,36 @@ describe("observeSpeaker", () => {
// should default to false when no input is given
const speakingInputMarbles = "";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
test("after no speaking", () => {
const speakingInputMarbles = "n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
test("with speaking for 1ms", () => {
const speakingInputMarbles = "y n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
test("with speaking for 999ms", () => {
const speakingInputMarbles = "y 999ms n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
@@ -63,20 +59,18 @@ describe("observeSpeaker", () => {
const speakingInputMarbles =
"y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
test("with consecutive speaking then stops speaking", () => {
const speakingInputMarbles = "y y y y y y y y y y n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
});
@@ -87,10 +81,9 @@ describe("observeSpeaker", () => {
const speakingInputMarbles = " y";
const expectedOutputMarbles = "n 999ms y";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
@@ -98,10 +91,9 @@ describe("observeSpeaker", () => {
const speakingInputMarbles = " y 1s n ";
const expectedOutputMarbles = "n 999ms y 60s n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
@@ -109,10 +101,9 @@ describe("observeSpeaker", () => {
const speakingInputMarbles = " y 5s n ";
const expectedOutputMarbles = "n 999ms y 64s n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
});

View File

@@ -18,16 +18,16 @@ import {
* Require 1 second of continuous speaking to become a speaker, and 60 second of
* continuous silence to stop being considered a speaker
*/
export function observeSpeaker(
isSpeakingObservable: Observable<boolean>,
export function observeSpeaker$(
isSpeakingObservable$: Observable<boolean>,
): Observable<boolean> {
const distinct = isSpeakingObservable.pipe(distinctUntilChanged());
const distinct$ = isSpeakingObservable$.pipe(distinctUntilChanged());
return distinct.pipe(
return distinct$.pipe(
// Either change to the new value after the timer or re-emit the same value if it toggles back
// (audit will return the latest (toggled back) value) before the timeout.
audit((s) =>
merge(timer(s ? 1000 : 60000), distinct.pipe(filter((s1) => s1 !== s))),
merge(timer(s ? 1000 : 60000), distinct$.pipe(filter((s1) => s1 !== s))),
),
// Filter the re-emissions (marked as: | ) that happen if we toggle quickly (<1s) from false->true->false|->..
startWith(false),

View File

@@ -83,13 +83,13 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
ref,
) => {
const { t } = useTranslation();
const video = useObservableEagerState(vm.video);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus);
const audioEnabled = useObservableEagerState(vm.audioEnabled);
const videoEnabled = useObservableEagerState(vm.videoEnabled);
const speaking = useObservableEagerState(vm.speaking);
const cropVideo = useObservableEagerState(vm.cropVideo);
const video = useObservableEagerState(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
const audioEnabled = useObservableEagerState(vm.audioEnabled$);
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
const speaking = useObservableEagerState(vm.speaking$);
const cropVideo = useObservableEagerState(vm.cropVideo$);
const onSelectFitContain = useCallback(
(e: Event) => {
e.preventDefault();
@@ -198,8 +198,8 @@ interface LocalUserMediaTileProps extends TileProps {
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
({ vm, onOpenProfile, ...props }, ref) => {
const { t } = useTranslation();
const mirror = useObservableEagerState(vm.mirror);
const alwaysShow = useObservableEagerState(vm.alwaysShow);
const mirror = useObservableEagerState(vm.mirror$);
const alwaysShow = useObservableEagerState(vm.alwaysShow$);
const latestAlwaysShow = useLatest(alwaysShow);
const onSelectAlwaysShow = useCallback(
(e: Event) => {
@@ -249,8 +249,8 @@ const RemoteUserMediaTile = forwardRef<
RemoteUserMediaTileProps
>(({ vm, ...props }, ref) => {
const { t } = useTranslation();
const locallyMuted = useObservableEagerState(vm.locallyMuted);
const localVolume = useObservableEagerState(vm.localVolume);
const locallyMuted = useObservableEagerState(vm.locallyMuted$);
const localVolume = useObservableEagerState(vm.localVolume$);
const onSelectMute = useCallback(
(e: Event) => {
e.preventDefault();
@@ -316,7 +316,7 @@ export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
({ vm, onOpenProfile, ...props }, theirRef) => {
const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const media = useObservableEagerState(vm.media);
const media = useObservableEagerState(vm.media$);
const displayName = useDisplayName(media);
if (media instanceof LocalUserMediaViewModel) {

View File

@@ -72,7 +72,7 @@ const SpotlightLocalUserMediaItem = forwardRef<
HTMLDivElement,
SpotlightLocalUserMediaItemProps
>(({ vm, ...props }, ref) => {
const mirror = useObservableEagerState(vm.mirror);
const mirror = useObservableEagerState(vm.mirror$);
return <MediaView ref={ref} mirror={mirror} {...props} />;
});
@@ -86,8 +86,8 @@ const SpotlightUserMediaItem = forwardRef<
HTMLDivElement,
SpotlightUserMediaItemProps
>(({ vm, ...props }, ref) => {
const videoEnabled = useObservableEagerState(vm.videoEnabled);
const cropVideo = useObservableEagerState(vm.cropVideo);
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
const cropVideo = useObservableEagerState(vm.cropVideo$);
const baseProps: SpotlightUserMediaItemBaseProps &
RefAttributes<HTMLDivElement> = {
@@ -110,7 +110,7 @@ interface SpotlightItemProps {
vm: MediaViewModel;
targetWidth: number;
targetHeight: number;
intersectionObserver: Observable<IntersectionObserver>;
intersectionObserver$: Observable<IntersectionObserver>;
/**
* Whether this item should act as a scroll snapping point.
*/
@@ -124,7 +124,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
vm,
targetWidth,
targetHeight,
intersectionObserver,
intersectionObserver$,
snap,
"aria-hidden": ariaHidden,
},
@@ -133,15 +133,15 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const displayName = useDisplayName(vm);
const video = useObservableEagerState(vm.video);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus);
const video = useObservableEagerState(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
// Hook this item up to the intersection observer
useEffect(() => {
const element = ourRef.current!;
let prevIo: IntersectionObserver | null = null;
const subscription = intersectionObserver.subscribe((io) => {
const subscription = intersectionObserver$.subscribe((io) => {
prevIo?.unobserve(element);
io.observe(element);
prevIo = io;
@@ -150,7 +150,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
subscription.unsubscribe();
prevIo?.unobserve(element);
};
}, [intersectionObserver]);
}, [intersectionObserver$]);
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
ref,
@@ -208,10 +208,10 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
theirRef,
) => {
const { t } = useTranslation();
const [ourRef, root] = useObservableRef<HTMLDivElement | null>(null);
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const maximised = useObservableEagerState(vm.maximised);
const media = useObservableEagerState(vm.media);
const maximised = useObservableEagerState(vm.maximised$);
const media = useObservableEagerState(vm.media$);
const [visibleId, setVisibleId] = useState<string | undefined>(
media[0]?.id,
);
@@ -225,9 +225,9 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
// hooked up to the root element and the items. Because the items will run
// their effects before their parent does, we need to do this dance with an
// Observable to actually give them the intersection observer.
const intersectionObserver = useInitial<Observable<IntersectionObserver>>(
const intersectionObserver$ = useInitial<Observable<IntersectionObserver>>(
() =>
root.pipe(
root$.pipe(
map(
(r) =>
new IntersectionObserver(
@@ -295,7 +295,7 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
intersectionObserver={intersectionObserver}
intersectionObserver$={intersectionObserver$}
// This is how we get the container to scroll to the right media
// when the previous/next buttons are clicked: we temporarily
// remove all scroll snap points except for just the one media

View File

@@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { expect, test, vitest } from "vitest";
import { expect, test, vitest, afterEach } from "vitest";
import { type FC } from "react";
import { render } from "@testing-library/react";
import { afterEach } from "node:test";
import userEvent from "@testing-library/user-event";
import { deviceStub, MediaDevicesContext } from "./livekit/MediaDevicesContext";
@@ -101,7 +100,8 @@ test("will use the correct device", () => {
audioInput: deviceStub,
audioOutput: {
selectedId: "chosen-device",
available: [],
selectedGroupId: "",
available: new Map(),
select: () => {},
},
videoInput: deviceStub,

16
src/utils/array.ts Normal file
View File

@@ -0,0 +1,16 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
/**
* Determine whether two arrays are equal by shallow comparison.
*/
export function shallowEquals<A>(first: A[], second: A[]): boolean {
if (first.length !== second.length) return false;
for (let i = 0; i < first.length; i++)
if (first[i] !== second[i]) return false;
return true;
}

View File

@@ -15,10 +15,10 @@ const nothing = Symbol("nothing");
* callback will not be invoked.
*/
export function finalizeValue<T>(callback: (finalValue: T) => void) {
return (source: Observable<T>): Observable<T> =>
return (source$: Observable<T>): Observable<T> =>
defer(() => {
let finalValue: T | typeof nothing = nothing;
return source.pipe(
return source$.pipe(
tap((value) => (finalValue = value)),
finalize(() => {
if (finalValue !== nothing) callback(finalValue);
@@ -35,6 +35,6 @@ export function accumulate<State, Event>(
initial: State,
update: (state: State, event: Event) => State,
) {
return (events: Observable<Event>): Observable<State> =>
events.pipe(scan(update, initial), startWith(initial));
return (events$: Observable<Event>): Observable<State> =>
events$.pipe(scan(update, initial), startWith(initial));
}

View File

@@ -77,14 +77,14 @@ export function withTestScheduler(
continuation({
...helpers,
schedule(marbles, actions) {
const actionsObservable = helpers
const actionsObservable$ = helpers
.cold(marbles)
.pipe(map((value) => actions[value]()));
const results = Object.fromEntries(
Object.keys(actions).map((value) => [value, undefined] as const),
);
// Run the actions and verify that none of them error
helpers.expectObservable(actionsObservable).toBe(marbles, results);
helpers.expectObservable(actionsObservable$).toBe(marbles, results);
},
}),
);
@@ -157,16 +157,16 @@ export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom {
export function mockLivekitRoom(
room: Partial<LivekitRoom>,
{
remoteParticipants,
}: { remoteParticipants?: Observable<RemoteParticipant[]> } = {},
remoteParticipants$,
}: { remoteParticipants$?: Observable<RemoteParticipant[]> } = {},
): LivekitRoom {
const livekitRoom = {
...mockEmitter(),
...room,
} as Partial<LivekitRoom> as LivekitRoom;
if (remoteParticipants) {
if (remoteParticipants$) {
livekitRoom.remoteParticipants = new Map();
remoteParticipants.subscribe((newRemoteParticipants) => {
remoteParticipants$.subscribe((newRemoteParticipants) => {
livekitRoom.remoteParticipants.clear();
newRemoteParticipants.forEach((p) => {
livekitRoom.remoteParticipants.set(p.identity, p);
@@ -238,7 +238,7 @@ export async function withRemoteMedia(
{
kind: E2eeType.PER_PARTICIPANT,
},
mockLivekitRoom({}, { remoteParticipants: of([remoteParticipant]) }),
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
);
try {
await continuation(vm);
@@ -277,9 +277,9 @@ export class MockRTCSession extends TypedEventEmitter<
}
public withMemberships(
rtcMembers: Observable<Partial<CallMembership>[]>,
rtcMembers$: Observable<Partial<CallMembership>[]>,
): MockRTCSession {
rtcMembers.subscribe((m) => {
rtcMembers$.subscribe((m) => {
const old = this.memberships;
// always prepend the local participant
const updated = [this.localMembership, ...(m as CallMembership[])];