Use finnish notation for observables (#2905)
To help make our usage of the observables more readable/intuitive.
This commit is contained in:
@@ -44,6 +44,7 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
// To encourage good usage of RxJS:
|
// To encourage good usage of RxJS:
|
||||||
"rxjs/no-exposed-subjects": "error",
|
"rxjs/no-exposed-subjects": "error",
|
||||||
|
"rxjs/finnish": "error",
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
react: {
|
react: {
|
||||||
|
|||||||
@@ -415,7 +415,7 @@ export class PosthogAnalytics {
|
|||||||
// * When the user changes their preferences on this device
|
// * When the user changes their preferences on this device
|
||||||
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
|
// 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)
|
// 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.setAnonymity(optIn ? Anonymity.Pseudonymous : Anonymity.Disabled);
|
||||||
this.maybeIdentifyUser().catch(() =>
|
this.maybeIdentifyUser().catch(() =>
|
||||||
logger.log("Could not identify user"),
|
logger.log("Could not identify user"),
|
||||||
|
|||||||
@@ -13,18 +13,18 @@ export interface Controls {
|
|||||||
disablePip: () => void;
|
disablePip: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setPipEnabled = new Subject<boolean>();
|
export const setPipEnabled$ = new Subject<boolean>();
|
||||||
|
|
||||||
window.controls = {
|
window.controls = {
|
||||||
canEnterPip(): boolean {
|
canEnterPip(): boolean {
|
||||||
return setPipEnabled.observed;
|
return setPipEnabled$.observed;
|
||||||
},
|
},
|
||||||
enablePip(): void {
|
enablePip(): void {
|
||||||
if (!setPipEnabled.observed) throw new Error("No call is running");
|
if (!setPipEnabled$.observed) throw new Error("No call is running");
|
||||||
setPipEnabled.next(true);
|
setPipEnabled$.next(true);
|
||||||
},
|
},
|
||||||
disablePip(): void {
|
disablePip(): void {
|
||||||
if (!setPipEnabled.observed) throw new Error("No call is running");
|
if (!setPipEnabled$.observed) throw new Error("No call is running");
|
||||||
setPipEnabled.next(false);
|
setPipEnabled$.next(false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,15 +31,15 @@ export interface CallLayoutInputs {
|
|||||||
/**
|
/**
|
||||||
* The minimum bounds of the layout area.
|
* The minimum bounds of the layout area.
|
||||||
*/
|
*/
|
||||||
minBounds: Observable<Bounds>;
|
minBounds$: Observable<Bounds>;
|
||||||
/**
|
/**
|
||||||
* The alignment of the floating spotlight tile, if present.
|
* 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.
|
* The alignment of the small picture-in-picture tile, if present.
|
||||||
*/
|
*/
|
||||||
pipAlignment: BehaviorSubject<Alignment>;
|
pipAlignment$: BehaviorSubject<Alignment>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CallLayoutOutputs<Model> {
|
export interface CallLayoutOutputs<Model> {
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export function useVisibleTiles(callback: VisibleTilesCallback): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const windowHeightObservable = fromEvent(window, "resize").pipe(
|
const windowHeightObservable$ = fromEvent(window, "resize").pipe(
|
||||||
startWith(null),
|
startWith(null),
|
||||||
map(() => window.innerHeight),
|
map(() => window.innerHeight),
|
||||||
);
|
);
|
||||||
@@ -262,7 +262,7 @@ export function Grid<
|
|||||||
const [gridRoot, gridRef2] = useState<HTMLElement | null>(null);
|
const [gridRoot, gridRef2] = useState<HTMLElement | null>(null);
|
||||||
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
|
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
|
||||||
|
|
||||||
const windowHeight = useObservableEagerState(windowHeightObservable);
|
const windowHeight = useObservableEagerState(windowHeightObservable$);
|
||||||
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
|
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
|
||||||
const [generation, setGeneration] = useState<number | null>(null);
|
const [generation, setGeneration] = useState<number | null>(null);
|
||||||
const [visibleTilesCallback, setVisibleTilesCallback] =
|
const [visibleTilesCallback, setVisibleTilesCallback] =
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ interface GridCSSProperties extends CSSProperties {
|
|||||||
* together in a scrolling grid.
|
* together in a scrolling grid.
|
||||||
*/
|
*/
|
||||||
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||||
minBounds,
|
minBounds$,
|
||||||
spotlightAlignment,
|
spotlightAlignment$,
|
||||||
}) => ({
|
}) => ({
|
||||||
scrollingOnTop: false,
|
scrollingOnTop: false,
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
|||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
const alignment = useObservableEagerState(
|
const alignment = useObservableEagerState(
|
||||||
useInitial(() =>
|
useInitial(() =>
|
||||||
spotlightAlignment.pipe(
|
spotlightAlignment$.pipe(
|
||||||
distinctUntilChanged(
|
distinctUntilChanged(
|
||||||
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
|
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
|
||||||
),
|
),
|
||||||
@@ -47,7 +47,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
|||||||
|
|
||||||
const onDragSpotlight: DragCallback = useCallback(
|
const onDragSpotlight: DragCallback = useCallback(
|
||||||
({ xRatio, yRatio }) =>
|
({ xRatio, yRatio }) =>
|
||||||
spotlightAlignment.next({
|
spotlightAlignment$.next({
|
||||||
block: yRatio < 0.5 ? "start" : "end",
|
block: yRatio < 0.5 ? "start" : "end",
|
||||||
inline: xRatio < 0.5 ? "start" : "end",
|
inline: xRatio < 0.5 ? "start" : "end",
|
||||||
}),
|
}),
|
||||||
@@ -74,7 +74,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
|||||||
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
|
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
|
||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
useVisibleTiles(model.setVisibleTiles);
|
useVisibleTiles(model.setVisibleTiles);
|
||||||
const { width, height: minHeight } = useObservableEagerState(minBounds);
|
const { width, height: minHeight } = useObservableEagerState(minBounds$);
|
||||||
const { gap, tileWidth, tileHeight } = useMemo(
|
const { gap, tileWidth, tileHeight } = useMemo(
|
||||||
() => arrangeTiles(width, minHeight, model.grid.length),
|
() => arrangeTiles(width, minHeight, model.grid.length),
|
||||||
[width, minHeight, model.grid.length],
|
[width, minHeight, model.grid.length],
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import { type DragCallback, useUpdateLayout } from "./Grid";
|
|||||||
* is shown at maximum size, overlaid by a small view of the local participant.
|
* is shown at maximum size, overlaid by a small view of the local participant.
|
||||||
*/
|
*/
|
||||||
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
||||||
minBounds,
|
minBounds$,
|
||||||
pipAlignment,
|
pipAlignment$,
|
||||||
}) => ({
|
}) => ({
|
||||||
scrollingOnTop: false,
|
scrollingOnTop: false,
|
||||||
|
|
||||||
@@ -31,8 +31,8 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
|||||||
|
|
||||||
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
|
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
|
||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
const { width, height } = useObservableEagerState(minBounds);
|
const { width, height } = useObservableEagerState(minBounds$);
|
||||||
const pipAlignmentValue = useObservableEagerState(pipAlignment);
|
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
|
||||||
const { tileWidth, tileHeight } = useMemo(
|
const { tileWidth, tileHeight } = useMemo(
|
||||||
() => arrangeTiles(width, height, 1),
|
() => arrangeTiles(width, height, 1),
|
||||||
[width, height],
|
[width, height],
|
||||||
@@ -40,7 +40,7 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
|||||||
|
|
||||||
const onDragLocalTile: DragCallback = useCallback(
|
const onDragLocalTile: DragCallback = useCallback(
|
||||||
({ xRatio, yRatio }) =>
|
({ xRatio, yRatio }) =>
|
||||||
pipAlignment.next({
|
pipAlignment$.next({
|
||||||
block: yRatio < 0.5 ? "start" : "end",
|
block: yRatio < 0.5 ? "start" : "end",
|
||||||
inline: xRatio < 0.5 ? "start" : "end",
|
inline: xRatio < 0.5 ? "start" : "end",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import styles from "./SpotlightExpandedLayout.module.css";
|
|||||||
*/
|
*/
|
||||||
export const makeSpotlightExpandedLayout: CallLayout<
|
export const makeSpotlightExpandedLayout: CallLayout<
|
||||||
SpotlightExpandedLayoutModel
|
SpotlightExpandedLayoutModel
|
||||||
> = ({ pipAlignment }) => ({
|
> = ({ pipAlignment$ }) => ({
|
||||||
scrollingOnTop: true,
|
scrollingOnTop: true,
|
||||||
|
|
||||||
fixed: forwardRef(function SpotlightExpandedLayoutFixed(
|
fixed: forwardRef(function SpotlightExpandedLayoutFixed(
|
||||||
@@ -44,11 +44,11 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
|||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
const pipAlignmentValue = useObservableEagerState(pipAlignment);
|
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
|
||||||
|
|
||||||
const onDragPip: DragCallback = useCallback(
|
const onDragPip: DragCallback = useCallback(
|
||||||
({ xRatio, yRatio }) =>
|
({ xRatio, yRatio }) =>
|
||||||
pipAlignment.next({
|
pipAlignment$.next({
|
||||||
block: yRatio < 0.5 ? "start" : "end",
|
block: yRatio < 0.5 ? "start" : "end",
|
||||||
inline: xRatio < 0.5 ? "start" : "end",
|
inline: xRatio < 0.5 ? "start" : "end",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { useUpdateLayout, useVisibleTiles } from "./Grid";
|
|||||||
*/
|
*/
|
||||||
export const makeSpotlightLandscapeLayout: CallLayout<
|
export const makeSpotlightLandscapeLayout: CallLayout<
|
||||||
SpotlightLandscapeLayoutModel
|
SpotlightLandscapeLayoutModel
|
||||||
> = ({ minBounds }) => ({
|
> = ({ minBounds$ }) => ({
|
||||||
scrollingOnTop: false,
|
scrollingOnTop: false,
|
||||||
|
|
||||||
fixed: forwardRef(function SpotlightLandscapeLayoutFixed(
|
fixed: forwardRef(function SpotlightLandscapeLayoutFixed(
|
||||||
@@ -29,7 +29,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
|||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
useObservableEagerState(minBounds);
|
useObservableEagerState(minBounds$);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={styles.layer}>
|
<div ref={ref} className={styles.layer}>
|
||||||
@@ -51,9 +51,9 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
|||||||
) {
|
) {
|
||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
useVisibleTiles(model.setVisibleTiles);
|
useVisibleTiles(model.setVisibleTiles);
|
||||||
useObservableEagerState(minBounds);
|
useObservableEagerState(minBounds$);
|
||||||
const withIndicators =
|
const withIndicators =
|
||||||
useObservableEagerState(model.spotlight.media).length > 1;
|
useObservableEagerState(model.spotlight.media$).length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={styles.layer}>
|
<div ref={ref} className={styles.layer}>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ interface GridCSSProperties extends CSSProperties {
|
|||||||
*/
|
*/
|
||||||
export const makeSpotlightPortraitLayout: CallLayout<
|
export const makeSpotlightPortraitLayout: CallLayout<
|
||||||
SpotlightPortraitLayoutModel
|
SpotlightPortraitLayoutModel
|
||||||
> = ({ minBounds }) => ({
|
> = ({ minBounds$ }) => ({
|
||||||
scrollingOnTop: false,
|
scrollingOnTop: false,
|
||||||
|
|
||||||
fixed: forwardRef(function SpotlightPortraitLayoutFixed(
|
fixed: forwardRef(function SpotlightPortraitLayoutFixed(
|
||||||
@@ -55,7 +55,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
|||||||
) {
|
) {
|
||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
useVisibleTiles(model.setVisibleTiles);
|
useVisibleTiles(model.setVisibleTiles);
|
||||||
const { width } = useObservableEagerState(minBounds);
|
const { width } = useObservableEagerState(minBounds$);
|
||||||
const { gap, tileWidth, tileHeight } = arrangeTiles(
|
const { gap, tileWidth, tileHeight } = arrangeTiles(
|
||||||
width,
|
width,
|
||||||
// TODO: We pretend that the minimum height is the width, because the
|
// TODO: We pretend that the minimum height is the width, because the
|
||||||
@@ -64,7 +64,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
|||||||
model.grid.length,
|
model.grid.length,
|
||||||
);
|
);
|
||||||
const withIndicators =
|
const withIndicators =
|
||||||
useObservableEagerState(model.spotlight.media).length > 1;
|
useObservableEagerState(model.spotlight.media$).length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ function useMediaDevice(
|
|||||||
// useMediaDevices provides no way to request device names.
|
// useMediaDevices provides no way to request device names.
|
||||||
// Tragically, the only way to get device names out of LiveKit is to specify a
|
// Tragically, the only way to get device names out of LiveKit is to specify a
|
||||||
// kind, which then results in multiple permissions requests.
|
// kind, which then results in multiple permissions requests.
|
||||||
const deviceObserver = useMemo(
|
const deviceObserver$ = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createMediaDeviceObserver(
|
createMediaDeviceObserver(
|
||||||
kind,
|
kind,
|
||||||
@@ -86,7 +86,7 @@ function useMediaDevice(
|
|||||||
const available = useObservableEagerState(
|
const available = useObservableEagerState(
|
||||||
useMemo(
|
useMemo(
|
||||||
() =>
|
() =>
|
||||||
deviceObserver.pipe(
|
deviceObserver$.pipe(
|
||||||
map((availableRaw) => {
|
map((availableRaw) => {
|
||||||
// Sometimes browsers (particularly Firefox) can return multiple device
|
// Sometimes browsers (particularly Firefox) can return multiple device
|
||||||
// entries for the exact same device ID; using a map deduplicates them
|
// entries for the exact same device ID; using a map deduplicates them
|
||||||
@@ -117,7 +117,7 @@ function useMediaDevice(
|
|||||||
return available;
|
return available;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
[kind, deviceObserver],
|
[kind, deviceObserver$],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -140,13 +140,13 @@ function useMediaDevice(
|
|||||||
const selectedGroupId = useObservableEagerState(
|
const selectedGroupId = useObservableEagerState(
|
||||||
useMemo(
|
useMemo(
|
||||||
() =>
|
() =>
|
||||||
deviceObserver.pipe(
|
deviceObserver$.pipe(
|
||||||
map(
|
map(
|
||||||
(availableRaw) =>
|
(availableRaw) =>
|
||||||
availableRaw.find((d) => d.deviceId === selectedId)?.groupId,
|
availableRaw.find((d) => d.deviceId === selectedId)?.groupId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
[deviceObserver, selectedId],
|
[deviceObserver$, selectedId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -100,13 +100,13 @@ function getMockEnv(
|
|||||||
): {
|
): {
|
||||||
vm: CallViewModel;
|
vm: CallViewModel;
|
||||||
session: MockRTCSession;
|
session: MockRTCSession;
|
||||||
remoteRtcMemberships: BehaviorSubject<CallMembership[]>;
|
remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||||
} {
|
} {
|
||||||
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
|
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
|
||||||
const remoteParticipants = of([aliceParticipant]);
|
const remoteParticipants$ = of([aliceParticipant]);
|
||||||
const liveKitRoom = mockLivekitRoom(
|
const liveKitRoom = mockLivekitRoom(
|
||||||
{ localParticipant },
|
{ localParticipant },
|
||||||
{ remoteParticipants },
|
{ remoteParticipants$ },
|
||||||
);
|
);
|
||||||
const matrixRoom = mockMatrixRoom({
|
const matrixRoom = mockMatrixRoom({
|
||||||
client: {
|
client: {
|
||||||
@@ -118,14 +118,14 @@ function getMockEnv(
|
|||||||
getMember: (userId) => matrixRoomMembers.get(userId) ?? null,
|
getMember: (userId) => matrixRoomMembers.get(userId) ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const remoteRtcMemberships = new BehaviorSubject<CallMembership[]>(
|
const remoteRtcMemberships$ = new BehaviorSubject<CallMembership[]>(
|
||||||
initialRemoteRtcMemberships,
|
initialRemoteRtcMemberships,
|
||||||
);
|
);
|
||||||
|
|
||||||
const session = new MockRTCSession(
|
const session = new MockRTCSession(
|
||||||
matrixRoom,
|
matrixRoom,
|
||||||
localRtcMember,
|
localRtcMember,
|
||||||
).withMemberships(remoteRtcMemberships);
|
).withMemberships(remoteRtcMemberships$);
|
||||||
|
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
session as unknown as MatrixRTCSession,
|
session as unknown as MatrixRTCSession,
|
||||||
@@ -135,7 +135,7 @@ function getMockEnv(
|
|||||||
},
|
},
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
);
|
);
|
||||||
return { vm, session, remoteRtcMemberships };
|
return { vm, session, remoteRtcMemberships$ };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,33 +146,33 @@ function getMockEnv(
|
|||||||
* a noise every time.
|
* a noise every time.
|
||||||
*/
|
*/
|
||||||
test("plays one sound when entering a call", () => {
|
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} />);
|
render(<TestComponent rtcSession={session} vm={vm} />);
|
||||||
// Joining a call usually means remote participants are added later.
|
// Joining a call usually means remote participants are added later.
|
||||||
act(() => {
|
act(() => {
|
||||||
remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]);
|
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
|
||||||
});
|
});
|
||||||
expect(playSound).toHaveBeenCalledOnce();
|
expect(playSound).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Same test?
|
// TODO: Same test?
|
||||||
test("plays a sound when a user joins", () => {
|
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} />);
|
render(<TestComponent rtcSession={session} vm={vm} />);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]);
|
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
|
||||||
});
|
});
|
||||||
// Play a sound when joining a call.
|
// Play a sound when joining a call.
|
||||||
expect(playSound).toBeCalledWith("join");
|
expect(playSound).toBeCalledWith("join");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plays a sound when a user leaves", () => {
|
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} />);
|
render(<TestComponent rtcSession={session} vm={vm} />);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
remoteRtcMemberships.next([]);
|
remoteRtcMemberships$.next([]);
|
||||||
});
|
});
|
||||||
expect(playSound).toBeCalledWith("left");
|
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],
|
[local, alice],
|
||||||
mockRtcMemberships,
|
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} />);
|
render(<TestComponent rtcSession={session} vm={vm} />);
|
||||||
expect(playSound).not.toBeCalled();
|
expect(playSound).not.toBeCalled();
|
||||||
act(() => {
|
act(() => {
|
||||||
remoteRtcMemberships.next(
|
remoteRtcMemberships$.next(
|
||||||
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1),
|
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function CallEventAudioRenderer({
|
|||||||
}, [audioEngineRef, previousRaisedHandCount, raisedHandCount]);
|
}, [audioEngineRef, previousRaisedHandCount, raisedHandCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const joinSub = vm.memberChanges
|
const joinSub = vm.memberChanges$
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(
|
filter(
|
||||||
({ joined, ids }) =>
|
({ joined, ids }) =>
|
||||||
@@ -77,7 +77,7 @@ export function CallEventAudioRenderer({
|
|||||||
void audioEngineRef.current?.playSound("join");
|
void audioEngineRef.current?.playSound("join");
|
||||||
});
|
});
|
||||||
|
|
||||||
const leftSub = vm.memberChanges
|
const leftSub = vm.memberChanges$
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(
|
filter(
|
||||||
({ ids, left }) =>
|
({ ids, left }) =>
|
||||||
|
|||||||
@@ -110,8 +110,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
sfuConfig,
|
sfuConfig,
|
||||||
props.e2eeSystem,
|
props.e2eeSystem,
|
||||||
);
|
);
|
||||||
const connStateObservable = useObservable(
|
const connStateObservable$ = useObservable(
|
||||||
(inputs) => inputs.pipe(map(([connState]) => connState)),
|
(inputs$) => inputs$.pipe(map(([connState]) => connState)),
|
||||||
[connState],
|
[connState],
|
||||||
);
|
);
|
||||||
const [vm, setVm] = useState<CallViewModel | null>(null);
|
const [vm, setVm] = useState<CallViewModel | null>(null);
|
||||||
@@ -131,12 +131,12 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
props.rtcSession,
|
props.rtcSession,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
props.e2eeSystem,
|
props.e2eeSystem,
|
||||||
connStateObservable,
|
connStateObservable$,
|
||||||
);
|
);
|
||||||
setVm(vm);
|
setVm(vm);
|
||||||
return (): void => vm.destroy();
|
return (): void => vm.destroy();
|
||||||
}
|
}
|
||||||
}, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable]);
|
}, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable$]);
|
||||||
|
|
||||||
if (livekitRoom === undefined || vm === null) return null;
|
if (livekitRoom === undefined || vm === null) return null;
|
||||||
|
|
||||||
@@ -225,14 +225,14 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
() => void toggleRaisedHand(),
|
() => void toggleRaisedHand(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const windowMode = useObservableEagerState(vm.windowMode);
|
const windowMode = useObservableEagerState(vm.windowMode$);
|
||||||
const layout = useObservableEagerState(vm.layout);
|
const layout = useObservableEagerState(vm.layout$);
|
||||||
const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration);
|
const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration$);
|
||||||
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
|
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
|
||||||
const gridMode = useObservableEagerState(vm.gridMode);
|
const gridMode = useObservableEagerState(vm.gridMode$);
|
||||||
const showHeader = useObservableEagerState(vm.showHeader);
|
const showHeader = useObservableEagerState(vm.showHeader$);
|
||||||
const showFooter = useObservableEagerState(vm.showFooter);
|
const showFooter = useObservableEagerState(vm.showFooter$);
|
||||||
const switchCamera = useSwitchCamera(vm.localVideo);
|
const switchCamera = useSwitchCamera(vm.localVideo$);
|
||||||
|
|
||||||
// Ideally we could detect taps by listening for click events and checking
|
// 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
|
// that the pointerType of the event is "touch", but this isn't yet supported
|
||||||
@@ -317,15 +317,15 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
windowMode,
|
windowMode,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const gridBoundsObservable = useObservable(
|
const gridBoundsObservable$ = useObservable(
|
||||||
(inputs) => inputs.pipe(map(([gridBounds]) => gridBounds)),
|
(inputs$) => inputs$.pipe(map(([gridBounds]) => gridBounds)),
|
||||||
[gridBounds],
|
[gridBounds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const spotlightAlignment = useInitial(
|
const spotlightAlignment$ = useInitial(
|
||||||
() => new BehaviorSubject(defaultSpotlightAlignment),
|
() => new BehaviorSubject(defaultSpotlightAlignment),
|
||||||
);
|
);
|
||||||
const pipAlignment = useInitial(
|
const pipAlignment$ = useInitial(
|
||||||
() => new BehaviorSubject(defaultPipAlignment),
|
() => new BehaviorSubject(defaultPipAlignment),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -383,15 +383,17 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
{ className, style, targetWidth, targetHeight, model },
|
{ className, style, targetWidth, targetHeight, model },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const spotlightExpanded = useObservableEagerState(vm.spotlightExpanded);
|
const spotlightExpanded = useObservableEagerState(
|
||||||
|
vm.spotlightExpanded$,
|
||||||
|
);
|
||||||
const onToggleExpanded = useObservableEagerState(
|
const onToggleExpanded = useObservableEagerState(
|
||||||
vm.toggleSpotlightExpanded,
|
vm.toggleSpotlightExpanded$,
|
||||||
);
|
);
|
||||||
const showSpeakingIndicatorsValue = useObservableEagerState(
|
const showSpeakingIndicatorsValue = useObservableEagerState(
|
||||||
vm.showSpeakingIndicators,
|
vm.showSpeakingIndicators$,
|
||||||
);
|
);
|
||||||
const showSpotlightIndicatorsValue = useObservableEagerState(
|
const showSpotlightIndicatorsValue = useObservableEagerState(
|
||||||
vm.showSpotlightIndicators,
|
vm.showSpotlightIndicators$,
|
||||||
);
|
);
|
||||||
|
|
||||||
return model instanceof GridTileViewModel ? (
|
return model instanceof GridTileViewModel ? (
|
||||||
@@ -424,9 +426,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
|
|
||||||
const layouts = useMemo(() => {
|
const layouts = useMemo(() => {
|
||||||
const inputs = {
|
const inputs = {
|
||||||
minBounds: gridBoundsObservable,
|
minBounds$: gridBoundsObservable$,
|
||||||
spotlightAlignment,
|
spotlightAlignment$,
|
||||||
pipAlignment,
|
pipAlignment$,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
grid: makeGridLayout(inputs),
|
grid: makeGridLayout(inputs),
|
||||||
@@ -435,7 +437,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
|
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
|
||||||
"one-on-one": makeOneOnOneLayout(inputs),
|
"one-on-one": makeOneOnOneLayout(inputs),
|
||||||
};
|
};
|
||||||
}, [gridBoundsObservable, spotlightAlignment, pipAlignment]);
|
}, [gridBoundsObservable$, spotlightAlignment$, pipAlignment$]);
|
||||||
|
|
||||||
const renderContent = (): JSX.Element => {
|
const renderContent = (): JSX.Element => {
|
||||||
if (layout.type === "pip") {
|
if (layout.type === "pip") {
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export const LobbyView: FC<Props> = ({
|
|||||||
|
|
||||||
const switchCamera = useSwitchCamera(
|
const switchCamera = useSwitchCamera(
|
||||||
useObservable(
|
useObservable(
|
||||||
(inputs) => inputs.pipe(map(([video]) => video)),
|
(inputs$) => inputs$.pipe(map(([video]) => video)),
|
||||||
[videoTrack],
|
[videoTrack],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,17 +31,17 @@ import { useLatest } from "../useLatest";
|
|||||||
* producing a callback if so.
|
* producing a callback if so.
|
||||||
*/
|
*/
|
||||||
export function useSwitchCamera(
|
export function useSwitchCamera(
|
||||||
video: Observable<LocalVideoTrack | null>,
|
video$: Observable<LocalVideoTrack | null>,
|
||||||
): (() => void) | null {
|
): (() => void) | null {
|
||||||
const mediaDevices = useMediaDevices();
|
const mediaDevices = useMediaDevices();
|
||||||
const setVideoInput = useLatest(mediaDevices.videoInput.select);
|
const setVideoInput = useLatest(mediaDevices.videoInput.select);
|
||||||
|
|
||||||
// Produce an observable like the input 'video' observable, except make it
|
// Produce an observable like the input 'video' observable, except make it
|
||||||
// emit whenever the track is muted or the device changes
|
// emit whenever the track is muted or the device changes
|
||||||
const videoTrack: Observable<LocalVideoTrack | null> = useObservable(
|
const videoTrack$: Observable<LocalVideoTrack | null> = useObservable(
|
||||||
(inputs) =>
|
(inputs$) =>
|
||||||
inputs.pipe(
|
inputs$.pipe(
|
||||||
switchMap(([video]) => video),
|
switchMap(([video$]) => video$),
|
||||||
switchMap((video) => {
|
switchMap((video) => {
|
||||||
if (video === null) return of(null);
|
if (video === null) return of(null);
|
||||||
return merge(
|
return merge(
|
||||||
@@ -53,15 +53,15 @@ export function useSwitchCamera(
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
[video],
|
[video$],
|
||||||
);
|
);
|
||||||
|
|
||||||
const switchCamera: Observable<(() => void) | null> = useObservable(
|
const switchCamera$: Observable<(() => void) | null> = useObservable(
|
||||||
(inputs) =>
|
(inputs$) =>
|
||||||
platform === "desktop"
|
platform === "desktop"
|
||||||
? of(null)
|
? of(null)
|
||||||
: inputs.pipe(
|
: inputs$.pipe(
|
||||||
switchMap(([track]) => track),
|
switchMap(([track$]) => track$),
|
||||||
map((track) => {
|
map((track) => {
|
||||||
if (track === null) return null;
|
if (track === null) return null;
|
||||||
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
||||||
@@ -86,8 +86,8 @@ export function useSwitchCamera(
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
[videoTrack],
|
[videoTrack$],
|
||||||
);
|
);
|
||||||
|
|
||||||
return useObservableEagerState(switchCamera);
|
return useObservableEagerState(switchCamera$);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,17 +31,17 @@ export class Setting<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._value = new BehaviorSubject(initialValue);
|
this._value$ = new BehaviorSubject(initialValue);
|
||||||
this.value = this._value;
|
this.value$ = this._value$;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly key: string;
|
private readonly key: string;
|
||||||
|
|
||||||
private readonly _value: BehaviorSubject<T>;
|
private readonly _value$: BehaviorSubject<T>;
|
||||||
public readonly value: Observable<T>;
|
public readonly value$: Observable<T>;
|
||||||
|
|
||||||
public readonly setValue = (value: T): void => {
|
public readonly setValue = (value: T): void => {
|
||||||
this._value.next(value);
|
this._value$.next(value);
|
||||||
localStorage.setItem(this.key, JSON.stringify(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.
|
* React hook that returns a settings's current value and a setter.
|
||||||
*/
|
*/
|
||||||
export function useSetting<T>(setting: Setting<T>): [T, (value: T) => void] {
|
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
|
// null = undecided
|
||||||
|
|||||||
@@ -124,15 +124,15 @@ export type LayoutSummary =
|
|||||||
| OneOnOneLayoutSummary
|
| OneOnOneLayoutSummary
|
||||||
| PipLayoutSummary;
|
| PipLayoutSummary;
|
||||||
|
|
||||||
function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
|
function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||||
return l.pipe(
|
return l$.pipe(
|
||||||
switchMap((l) => {
|
switchMap((l) => {
|
||||||
switch (l.type) {
|
switch (l.type) {
|
||||||
case "grid":
|
case "grid":
|
||||||
return combineLatest(
|
return combineLatest(
|
||||||
[
|
[
|
||||||
l.spotlight?.media ?? of(undefined),
|
l.spotlight?.media$ ?? of(undefined),
|
||||||
...l.grid.map((vm) => vm.media),
|
...l.grid.map((vm) => vm.media$),
|
||||||
],
|
],
|
||||||
(spotlight, ...grid) => ({
|
(spotlight, ...grid) => ({
|
||||||
type: l.type,
|
type: l.type,
|
||||||
@@ -143,7 +143,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
|
|||||||
case "spotlight-landscape":
|
case "spotlight-landscape":
|
||||||
case "spotlight-portrait":
|
case "spotlight-portrait":
|
||||||
return combineLatest(
|
return combineLatest(
|
||||||
[l.spotlight.media, ...l.grid.map((vm) => vm.media)],
|
[l.spotlight.media$, ...l.grid.map((vm) => vm.media$)],
|
||||||
(spotlight, ...grid) => ({
|
(spotlight, ...grid) => ({
|
||||||
type: l.type,
|
type: l.type,
|
||||||
spotlight: spotlight.map((vm) => vm.id),
|
spotlight: spotlight.map((vm) => vm.id),
|
||||||
@@ -152,7 +152,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
|
|||||||
);
|
);
|
||||||
case "spotlight-expanded":
|
case "spotlight-expanded":
|
||||||
return combineLatest(
|
return combineLatest(
|
||||||
[l.spotlight.media, l.pip?.media ?? of(undefined)],
|
[l.spotlight.media$, l.pip?.media$ ?? of(undefined)],
|
||||||
(spotlight, pip) => ({
|
(spotlight, pip) => ({
|
||||||
type: l.type,
|
type: l.type,
|
||||||
spotlight: spotlight.map((vm) => vm.id),
|
spotlight: spotlight.map((vm) => vm.id),
|
||||||
@@ -161,7 +161,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
|
|||||||
);
|
);
|
||||||
case "one-on-one":
|
case "one-on-one":
|
||||||
return combineLatest(
|
return combineLatest(
|
||||||
[l.local.media, l.remote.media],
|
[l.local.media$, l.remote.media$],
|
||||||
(local, remote) => ({
|
(local, remote) => ({
|
||||||
type: l.type,
|
type: l.type,
|
||||||
local: local.id,
|
local: local.id,
|
||||||
@@ -169,7 +169,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
case "pip":
|
case "pip":
|
||||||
return l.spotlight.media.pipe(
|
return l.spotlight.media$.pipe(
|
||||||
map((spotlight) => ({
|
map((spotlight) => ({
|
||||||
type: l.type,
|
type: l.type,
|
||||||
spotlight: spotlight.map((vm) => vm.id),
|
spotlight: spotlight.map((vm) => vm.id),
|
||||||
@@ -186,9 +186,9 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function withCallViewModel(
|
function withCallViewModel(
|
||||||
remoteParticipants: Observable<RemoteParticipant[]>,
|
remoteParticipants$: Observable<RemoteParticipant[]>,
|
||||||
rtcMembers: Observable<Partial<CallMembership>[]>,
|
rtcMembers$: Observable<Partial<CallMembership>[]>,
|
||||||
connectionState: Observable<ECConnectionState>,
|
connectionState$: Observable<ECConnectionState>,
|
||||||
speaking: Map<Participant, Observable<boolean>>,
|
speaking: Map<Participant, Observable<boolean>>,
|
||||||
continuation: (vm: CallViewModel) => void,
|
continuation: (vm: CallViewModel) => void,
|
||||||
): void {
|
): void {
|
||||||
@@ -203,10 +203,10 @@ function withCallViewModel(
|
|||||||
room,
|
room,
|
||||||
localRtcMember,
|
localRtcMember,
|
||||||
[],
|
[],
|
||||||
).withMemberships(rtcMembers);
|
).withMemberships(rtcMembers$);
|
||||||
const participantsSpy = vi
|
const participantsSpy = vi
|
||||||
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
||||||
.mockReturnValue(remoteParticipants);
|
.mockReturnValue(remoteParticipants$);
|
||||||
const mediaSpy = vi
|
const mediaSpy = vi
|
||||||
.spyOn(ComponentsCore, "observeParticipantMedia")
|
.spyOn(ComponentsCore, "observeParticipantMedia")
|
||||||
.mockImplementation((p) =>
|
.mockImplementation((p) =>
|
||||||
@@ -232,7 +232,7 @@ function withCallViewModel(
|
|||||||
|
|
||||||
const liveKitRoom = mockLivekitRoom(
|
const liveKitRoom = mockLivekitRoom(
|
||||||
{ localParticipant },
|
{ localParticipant },
|
||||||
{ remoteParticipants },
|
{ remoteParticipants$ },
|
||||||
);
|
);
|
||||||
|
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
@@ -241,7 +241,7 @@ function withCallViewModel(
|
|||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
connectionState,
|
connectionState$,
|
||||||
);
|
);
|
||||||
|
|
||||||
onTestFinished(() => {
|
onTestFinished(() => {
|
||||||
@@ -276,7 +276,7 @@ test("participants are retained during a focus switch", () => {
|
|||||||
}),
|
}),
|
||||||
new Map(),
|
new Map(),
|
||||||
(vm) => {
|
(vm) => {
|
||||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
expectedLayoutMarbles,
|
expectedLayoutMarbles,
|
||||||
{
|
{
|
||||||
a: {
|
a: {
|
||||||
@@ -320,7 +320,7 @@ test("screen sharing activates spotlight layout", () => {
|
|||||||
g: () => vm.setGridMode("grid"),
|
g: () => vm.setGridMode("grid"),
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
expectedLayoutMarbles,
|
expectedLayoutMarbles,
|
||||||
{
|
{
|
||||||
a: {
|
a: {
|
||||||
@@ -363,7 +363,7 @@ test("screen sharing activates spotlight layout", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expectObservable(vm.showSpeakingIndicators).toBe(
|
expectObservable(vm.showSpeakingIndicators$).toBe(
|
||||||
expectedShowSpeakingMarbles,
|
expectedShowSpeakingMarbles,
|
||||||
{
|
{
|
||||||
y: true,
|
y: true,
|
||||||
@@ -402,13 +402,13 @@ test("participants stay in the same order unless to appear/disappear", () => {
|
|||||||
a: () => {
|
a: () => {
|
||||||
// We imagine that only three tiles (the first three) will be visible
|
// We imagine that only three tiles (the first three) will be visible
|
||||||
// on screen at a time
|
// on screen at a time
|
||||||
vm.layout.subscribe((layout) => {
|
vm.layout$.subscribe((layout) => {
|
||||||
if (layout.type === "grid") layout.setVisibleTiles(3);
|
if (layout.type === "grid") layout.setVisibleTiles(3);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
expectedLayoutMarbles,
|
expectedLayoutMarbles,
|
||||||
{
|
{
|
||||||
a: {
|
a: {
|
||||||
@@ -455,7 +455,7 @@ test("participants adjust order when space becomes constrained", () => {
|
|||||||
]),
|
]),
|
||||||
(vm) => {
|
(vm) => {
|
||||||
let setVisibleTiles: ((value: number) => void) | null = null;
|
let setVisibleTiles: ((value: number) => void) | null = null;
|
||||||
vm.layout.subscribe((layout) => {
|
vm.layout$.subscribe((layout) => {
|
||||||
if (layout.type === "grid") setVisibleTiles = layout.setVisibleTiles;
|
if (layout.type === "grid") setVisibleTiles = layout.setVisibleTiles;
|
||||||
});
|
});
|
||||||
schedule(visibilityInputMarbles, {
|
schedule(visibilityInputMarbles, {
|
||||||
@@ -463,7 +463,7 @@ test("participants adjust order when space becomes constrained", () => {
|
|||||||
b: () => setVisibleTiles!(3),
|
b: () => setVisibleTiles!(3),
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
expectedLayoutMarbles,
|
expectedLayoutMarbles,
|
||||||
{
|
{
|
||||||
a: {
|
a: {
|
||||||
@@ -509,7 +509,7 @@ test("spotlight speakers swap places", () => {
|
|||||||
(vm) => {
|
(vm) => {
|
||||||
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
|
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
|
||||||
|
|
||||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
expectedLayoutMarbles,
|
expectedLayoutMarbles,
|
||||||
{
|
{
|
||||||
a: {
|
a: {
|
||||||
@@ -557,7 +557,7 @@ test("layout enters picture-in-picture mode when requested", () => {
|
|||||||
d: () => window.controls.disablePip(),
|
d: () => window.controls.disablePip(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
expectedLayoutMarbles,
|
expectedLayoutMarbles,
|
||||||
{
|
{
|
||||||
a: {
|
a: {
|
||||||
@@ -600,12 +600,12 @@ test("spotlight remembers whether it's expanded", () => {
|
|||||||
schedule(expandInputMarbles, {
|
schedule(expandInputMarbles, {
|
||||||
a: () => {
|
a: () => {
|
||||||
let toggle: () => void;
|
let toggle: () => void;
|
||||||
vm.toggleSpotlightExpanded.subscribe((val) => (toggle = val!));
|
vm.toggleSpotlightExpanded$.subscribe((val) => (toggle = val!));
|
||||||
toggle!();
|
toggle!();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
expectedLayoutMarbles,
|
expectedLayoutMarbles,
|
||||||
{
|
{
|
||||||
a: {
|
a: {
|
||||||
@@ -662,7 +662,7 @@ test("participants must have a MatrixRTCSession to be visible", () => {
|
|||||||
new Map(),
|
new Map(),
|
||||||
(vm) => {
|
(vm) => {
|
||||||
vm.setGridMode("grid");
|
vm.setGridMode("grid");
|
||||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
expectedLayoutMarbles,
|
expectedLayoutMarbles,
|
||||||
{
|
{
|
||||||
a: {
|
a: {
|
||||||
@@ -706,7 +706,7 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
|
|||||||
new Map(),
|
new Map(),
|
||||||
(vm) => {
|
(vm) => {
|
||||||
vm.setGridMode("grid");
|
vm.setGridMode("grid");
|
||||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
expectedLayoutMarbles,
|
expectedLayoutMarbles,
|
||||||
{
|
{
|
||||||
a: {
|
a: {
|
||||||
@@ -753,7 +753,7 @@ it("should show at least one tile per MatrixRTCSession", () => {
|
|||||||
new Map(),
|
new Map(),
|
||||||
(vm) => {
|
(vm) => {
|
||||||
vm.setGridMode("grid");
|
vm.setGridMode("grid");
|
||||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
expectedLayoutMarbles,
|
expectedLayoutMarbles,
|
||||||
{
|
{
|
||||||
a: {
|
a: {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
LocalUserMediaViewModel,
|
LocalUserMediaViewModel,
|
||||||
type MediaViewModel,
|
type MediaViewModel,
|
||||||
observeTrackReference,
|
observeTrackReference$,
|
||||||
RemoteUserMediaViewModel,
|
RemoteUserMediaViewModel,
|
||||||
ScreenShareViewModel,
|
ScreenShareViewModel,
|
||||||
type UserMediaViewModel,
|
type UserMediaViewModel,
|
||||||
@@ -71,7 +71,7 @@ import { accumulate, finalizeValue } from "../utils/observable";
|
|||||||
import { ObservableScope } from "./ObservableScope";
|
import { ObservableScope } from "./ObservableScope";
|
||||||
import { duplicateTiles, showNonMemberTiles } from "../settings/settings";
|
import { duplicateTiles, showNonMemberTiles } from "../settings/settings";
|
||||||
import { isFirefox } from "../Platform";
|
import { isFirefox } from "../Platform";
|
||||||
import { setPipEnabled } from "../controls";
|
import { setPipEnabled$ } from "../controls";
|
||||||
import {
|
import {
|
||||||
type GridTileViewModel,
|
type GridTileViewModel,
|
||||||
type SpotlightTileViewModel,
|
type SpotlightTileViewModel,
|
||||||
@@ -82,7 +82,7 @@ import { spotlightExpandedLayout } from "./SpotlightExpandedLayout";
|
|||||||
import { oneOnOneLayout } from "./OneOnOneLayout";
|
import { oneOnOneLayout } from "./OneOnOneLayout";
|
||||||
import { pipLayout } from "./PipLayout";
|
import { pipLayout } from "./PipLayout";
|
||||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
import { observeSpeaker } from "./observeSpeaker";
|
import { observeSpeaker$ } from "./observeSpeaker";
|
||||||
import { shallowEquals } from "../utils/array";
|
import { shallowEquals } from "../utils/array";
|
||||||
|
|
||||||
// How long we wait after a focus switch before showing the real participant
|
// How long we wait after a focus switch before showing the real participant
|
||||||
@@ -232,12 +232,12 @@ interface LayoutScanState {
|
|||||||
class UserMedia {
|
class UserMedia {
|
||||||
private readonly scope = new ObservableScope();
|
private readonly scope = new ObservableScope();
|
||||||
public readonly vm: UserMediaViewModel;
|
public readonly vm: UserMediaViewModel;
|
||||||
private readonly participant: BehaviorSubject<
|
private readonly participant$: BehaviorSubject<
|
||||||
LocalParticipant | RemoteParticipant | undefined
|
LocalParticipant | RemoteParticipant | undefined
|
||||||
>;
|
>;
|
||||||
|
|
||||||
public readonly speaker: Observable<boolean>;
|
public readonly speaker$: Observable<boolean>;
|
||||||
public readonly presenter: Observable<boolean>;
|
public readonly presenter$: Observable<boolean>;
|
||||||
public constructor(
|
public constructor(
|
||||||
public readonly id: string,
|
public readonly id: string,
|
||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
@@ -245,13 +245,13 @@ class UserMedia {
|
|||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
) {
|
) {
|
||||||
this.participant = new BehaviorSubject(participant);
|
this.participant$ = new BehaviorSubject(participant);
|
||||||
|
|
||||||
if (participant?.isLocal) {
|
if (participant?.isLocal) {
|
||||||
this.vm = new LocalUserMediaViewModel(
|
this.vm = new LocalUserMediaViewModel(
|
||||||
this.id,
|
this.id,
|
||||||
member,
|
member,
|
||||||
this.participant.asObservable() as Observable<LocalParticipant>,
|
this.participant$.asObservable() as Observable<LocalParticipant>,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
);
|
);
|
||||||
@@ -259,7 +259,7 @@ class UserMedia {
|
|||||||
this.vm = new RemoteUserMediaViewModel(
|
this.vm = new RemoteUserMediaViewModel(
|
||||||
id,
|
id,
|
||||||
member,
|
member,
|
||||||
this.participant.asObservable() as Observable<
|
this.participant$.asObservable() as Observable<
|
||||||
RemoteParticipant | undefined
|
RemoteParticipant | undefined
|
||||||
>,
|
>,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
@@ -267,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(
|
switchMap(
|
||||||
(p) =>
|
(p) =>
|
||||||
(p &&
|
(p &&
|
||||||
@@ -289,9 +289,9 @@ class UserMedia {
|
|||||||
public updateParticipant(
|
public updateParticipant(
|
||||||
newParticipant: LocalParticipant | RemoteParticipant | undefined,
|
newParticipant: LocalParticipant | RemoteParticipant | undefined,
|
||||||
): void {
|
): void {
|
||||||
if (this.participant.value !== newParticipant) {
|
if (this.participant$.value !== newParticipant) {
|
||||||
// Update the BehaviourSubject in the UserMedia.
|
// Update the BehaviourSubject in the UserMedia.
|
||||||
this.participant.next(newParticipant);
|
this.participant$.next(newParticipant);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +303,7 @@ class UserMedia {
|
|||||||
|
|
||||||
class ScreenShare {
|
class ScreenShare {
|
||||||
public readonly vm: ScreenShareViewModel;
|
public readonly vm: ScreenShareViewModel;
|
||||||
private readonly participant: BehaviorSubject<
|
private readonly participant$: BehaviorSubject<
|
||||||
LocalParticipant | RemoteParticipant
|
LocalParticipant | RemoteParticipant
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@@ -314,12 +314,12 @@ class ScreenShare {
|
|||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
liveKitRoom: LivekitRoom,
|
liveKitRoom: LivekitRoom,
|
||||||
) {
|
) {
|
||||||
this.participant = new BehaviorSubject(participant);
|
this.participant$ = new BehaviorSubject(participant);
|
||||||
|
|
||||||
this.vm = new ScreenShareViewModel(
|
this.vm = new ScreenShareViewModel(
|
||||||
id,
|
id,
|
||||||
member,
|
member,
|
||||||
this.participant.asObservable(),
|
this.participant$.asObservable(),
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
liveKitRoom,
|
liveKitRoom,
|
||||||
participant.isLocal,
|
participant.isLocal,
|
||||||
@@ -357,8 +357,8 @@ function findMatrixRoomMember(
|
|||||||
|
|
||||||
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
||||||
export class CallViewModel extends ViewModel {
|
export class CallViewModel extends ViewModel {
|
||||||
public readonly localVideo: Observable<LocalVideoTrack | null> =
|
public readonly localVideo$: Observable<LocalVideoTrack | null> =
|
||||||
observeTrackReference(
|
observeTrackReference$(
|
||||||
of(this.livekitRoom.localParticipant),
|
of(this.livekitRoom.localParticipant),
|
||||||
Track.Source.Camera,
|
Track.Source.Camera,
|
||||||
).pipe(
|
).pipe(
|
||||||
@@ -371,16 +371,16 @@ export class CallViewModel extends ViewModel {
|
|||||||
/**
|
/**
|
||||||
* The raw list of RemoteParticipants as reported by LiveKit
|
* 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());
|
connectedParticipantsObserver(this.livekitRoom).pipe(this.scope.state());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that
|
* Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that
|
||||||
* they've left
|
* they've left
|
||||||
*/
|
*/
|
||||||
private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> =
|
private readonly remoteParticipantHolds$: Observable<RemoteParticipant[][]> =
|
||||||
this.connectionState.pipe(
|
this.connectionState$.pipe(
|
||||||
withLatestFrom(this.rawRemoteParticipants),
|
withLatestFrom(this.rawRemoteParticipants$),
|
||||||
mergeMap(([s, ps]) => {
|
mergeMap(([s, ps]) => {
|
||||||
// Whenever we switch focuses, we should retain all the previous
|
// Whenever we switch focuses, we should retain all the previous
|
||||||
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
|
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
|
||||||
@@ -392,7 +392,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
// Wait for time to pass and the connection state to have changed
|
// Wait for time to pass and the connection state to have changed
|
||||||
forkJoin([
|
forkJoin([
|
||||||
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
||||||
this.connectionState.pipe(
|
this.connectionState$.pipe(
|
||||||
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
|
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
|
||||||
take(1),
|
take(1),
|
||||||
),
|
),
|
||||||
@@ -415,9 +415,9 @@ export class CallViewModel extends ViewModel {
|
|||||||
/**
|
/**
|
||||||
* The RemoteParticipants including those that are being "held" on the screen
|
* The RemoteParticipants including those that are being "held" on the screen
|
||||||
*/
|
*/
|
||||||
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
|
private readonly remoteParticipants$: Observable<RemoteParticipant[]> =
|
||||||
combineLatest(
|
combineLatest(
|
||||||
[this.rawRemoteParticipants, this.remoteParticipantHolds],
|
[this.rawRemoteParticipants$, this.remoteParticipantHolds$],
|
||||||
(raw, holds) => {
|
(raw, holds) => {
|
||||||
const result = [...raw];
|
const result = [...raw];
|
||||||
const resultIds = new Set(result.map((p) => p.identity));
|
const resultIds = new Set(result.map((p) => p.identity));
|
||||||
@@ -439,10 +439,10 @@ export class CallViewModel extends ViewModel {
|
|||||||
/**
|
/**
|
||||||
* List of MediaItems that we want to display
|
* List of MediaItems that we want to display
|
||||||
*/
|
*/
|
||||||
private readonly mediaItems: Observable<MediaItem[]> = combineLatest([
|
private readonly mediaItems$: Observable<MediaItem[]> = combineLatest([
|
||||||
this.remoteParticipants,
|
this.remoteParticipants$,
|
||||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||||
duplicateTiles.value,
|
duplicateTiles.value$,
|
||||||
// Also react to changes in the MatrixRTC session list.
|
// Also react to changes in the MatrixRTC session list.
|
||||||
// The session list will also be update if a room membership changes.
|
// The session list will also be update if a room membership changes.
|
||||||
// No additional RoomState event listener needs to be set up.
|
// No additional RoomState event listener needs to be set up.
|
||||||
@@ -450,7 +450,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
this.matrixRTCSession,
|
this.matrixRTCSession,
|
||||||
MatrixRTCSessionEvent.MembershipsChanged,
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
).pipe(startWith(null)),
|
).pipe(startWith(null)),
|
||||||
showNonMemberTiles.value,
|
showNonMemberTiles.value$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
scan(
|
scan(
|
||||||
(
|
(
|
||||||
@@ -606,13 +606,13 @@ export class CallViewModel extends ViewModel {
|
|||||||
/**
|
/**
|
||||||
* List of MediaItems that we want to display, that are of type UserMedia
|
* 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) =>
|
map((mediaItems) =>
|
||||||
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
|
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(map((mediaItems) => mediaItems.map((m) => m.id)))
|
||||||
.pipe(
|
.pipe(
|
||||||
scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
|
scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
|
||||||
@@ -628,22 +628,22 @@ export class CallViewModel extends ViewModel {
|
|||||||
/**
|
/**
|
||||||
* List of MediaItems that we want to display, that are of type ScreenShare
|
* List of MediaItems that we want to display, that are of type ScreenShare
|
||||||
*/
|
*/
|
||||||
private readonly screenShares: Observable<ScreenShare[]> =
|
private readonly screenShares$: Observable<ScreenShare[]> =
|
||||||
this.mediaItems.pipe(
|
this.mediaItems$.pipe(
|
||||||
map((mediaItems) =>
|
map((mediaItems) =>
|
||||||
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
|
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
|
||||||
),
|
),
|
||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly spotlightSpeaker: Observable<UserMediaViewModel | null> =
|
private readonly spotlightSpeaker$: Observable<UserMediaViewModel | null> =
|
||||||
this.userMedia.pipe(
|
this.userMedia$.pipe(
|
||||||
switchMap((mediaItems) =>
|
switchMap((mediaItems) =>
|
||||||
mediaItems.length === 0
|
mediaItems.length === 0
|
||||||
? of([])
|
? of([])
|
||||||
: combineLatest(
|
: combineLatest(
|
||||||
mediaItems.map((m) =>
|
mediaItems.map((m) =>
|
||||||
m.vm.speaking.pipe(map((s) => [m, s] as const)),
|
m.vm.speaking$.pipe(map((s) => [m, s] as const)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -672,52 +672,53 @@ export class CallViewModel extends ViewModel {
|
|||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe(
|
private readonly grid$: Observable<UserMediaViewModel[]> =
|
||||||
switchMap((mediaItems) => {
|
this.userMedia$.pipe(
|
||||||
const bins = mediaItems.map((m) =>
|
switchMap((mediaItems) => {
|
||||||
combineLatest(
|
const bins = mediaItems.map((m) =>
|
||||||
[
|
combineLatest(
|
||||||
m.speaker,
|
[
|
||||||
m.presenter,
|
m.speaker$,
|
||||||
m.vm.videoEnabled,
|
m.presenter$,
|
||||||
m.vm instanceof LocalUserMediaViewModel
|
m.vm.videoEnabled$,
|
||||||
? m.vm.alwaysShow
|
m.vm instanceof LocalUserMediaViewModel
|
||||||
: of(false),
|
? m.vm.alwaysShow$
|
||||||
],
|
: of(false),
|
||||||
(speaker, presenter, video, alwaysShow) => {
|
],
|
||||||
let bin: SortingBin;
|
(speaker, presenter, video, alwaysShow) => {
|
||||||
if (m.vm.local)
|
let bin: SortingBin;
|
||||||
bin = alwaysShow
|
if (m.vm.local)
|
||||||
? SortingBin.SelfAlwaysShown
|
bin = alwaysShow
|
||||||
: SortingBin.SelfNotAlwaysShown;
|
? SortingBin.SelfAlwaysShown
|
||||||
else if (presenter) bin = SortingBin.Presenters;
|
: SortingBin.SelfNotAlwaysShown;
|
||||||
else if (speaker) bin = SortingBin.Speakers;
|
else if (presenter) bin = SortingBin.Presenters;
|
||||||
else if (video) bin = SortingBin.Video;
|
else if (speaker) bin = SortingBin.Speakers;
|
||||||
else bin = SortingBin.NoVideo;
|
else if (video) bin = SortingBin.Video;
|
||||||
|
else bin = SortingBin.NoVideo;
|
||||||
|
|
||||||
return [m, bin] as const;
|
return [m, bin] as const;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Sort the media by bin order and generate a tile for each one
|
// Sort the media by bin order and generate a tile for each one
|
||||||
return bins.length === 0
|
return bins.length === 0
|
||||||
? of([])
|
? of([])
|
||||||
: combineLatest(bins, (...bins) =>
|
: combineLatest(bins, (...bins) =>
|
||||||
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm),
|
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
distinctUntilChanged(shallowEquals),
|
distinctUntilChanged(shallowEquals),
|
||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly spotlight: Observable<MediaViewModel[]> =
|
private readonly spotlight$: Observable<MediaViewModel[]> =
|
||||||
this.screenShares.pipe(
|
this.screenShares$.pipe(
|
||||||
switchMap((screenShares) => {
|
switchMap((screenShares) => {
|
||||||
if (screenShares.length > 0) {
|
if (screenShares.length > 0) {
|
||||||
return of(screenShares.map((m) => m.vm));
|
return of(screenShares.map((m) => m.vm));
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.spotlightSpeaker.pipe(
|
return this.spotlightSpeaker$.pipe(
|
||||||
map((speaker) => (speaker ? [speaker] : [])),
|
map((speaker) => (speaker ? [speaker] : [])),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -725,14 +726,14 @@ export class CallViewModel extends ViewModel {
|
|||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly pip: Observable<UserMediaViewModel | null> = combineLatest([
|
private readonly pip$: Observable<UserMediaViewModel | null> = combineLatest([
|
||||||
this.screenShares,
|
this.screenShares$,
|
||||||
this.spotlightSpeaker,
|
this.spotlightSpeaker$,
|
||||||
this.mediaItems,
|
this.mediaItems$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
switchMap(([screenShares, spotlight, mediaItems]) => {
|
switchMap(([screenShares, spotlight, mediaItems]) => {
|
||||||
if (screenShares.length > 0) {
|
if (screenShares.length > 0) {
|
||||||
return this.spotlightSpeaker;
|
return this.spotlightSpeaker$;
|
||||||
}
|
}
|
||||||
if (!spotlight || spotlight.local) {
|
if (!spotlight || spotlight.local) {
|
||||||
return of(null);
|
return of(null);
|
||||||
@@ -749,7 +750,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
if (!localUserMediaViewModel) {
|
if (!localUserMediaViewModel) {
|
||||||
return of(null);
|
return of(null);
|
||||||
}
|
}
|
||||||
return localUserMediaViewModel.alwaysShow.pipe(
|
return localUserMediaViewModel.alwaysShow$.pipe(
|
||||||
map((alwaysShow) => {
|
map((alwaysShow) => {
|
||||||
if (alwaysShow) {
|
if (alwaysShow) {
|
||||||
return localUserMediaViewModel;
|
return localUserMediaViewModel;
|
||||||
@@ -762,19 +763,19 @@ export class CallViewModel extends ViewModel {
|
|||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly hasRemoteScreenShares: Observable<boolean> =
|
private readonly hasRemoteScreenShares$: Observable<boolean> =
|
||||||
this.spotlight.pipe(
|
this.spotlight$.pipe(
|
||||||
map((spotlight) =>
|
map((spotlight) =>
|
||||||
spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel),
|
spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel),
|
||||||
),
|
),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly pipEnabled: Observable<boolean> = setPipEnabled.pipe(
|
private readonly pipEnabled$: Observable<boolean> = setPipEnabled$.pipe(
|
||||||
startWith(false),
|
startWith(false),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly naturalWindowMode: Observable<WindowMode> = fromEvent(
|
private readonly naturalWindowMode$: Observable<WindowMode> = fromEvent(
|
||||||
window,
|
window,
|
||||||
"resize",
|
"resize",
|
||||||
).pipe(
|
).pipe(
|
||||||
@@ -796,30 +797,30 @@ export class CallViewModel extends ViewModel {
|
|||||||
/**
|
/**
|
||||||
* The general shape of the window.
|
* The general shape of the window.
|
||||||
*/
|
*/
|
||||||
public readonly windowMode: Observable<WindowMode> = this.pipEnabled.pipe(
|
public readonly windowMode$: Observable<WindowMode> = this.pipEnabled$.pipe(
|
||||||
switchMap((pip) => (pip ? of<WindowMode>("pip") : this.naturalWindowMode)),
|
switchMap((pip) => (pip ? of<WindowMode>("pip") : this.naturalWindowMode$)),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly spotlightExpandedToggle = new Subject<void>();
|
private readonly spotlightExpandedToggle$ = new Subject<void>();
|
||||||
public readonly spotlightExpanded: Observable<boolean> =
|
public readonly spotlightExpanded$: Observable<boolean> =
|
||||||
this.spotlightExpandedToggle.pipe(
|
this.spotlightExpandedToggle$.pipe(
|
||||||
accumulate(false, (expanded) => !expanded),
|
accumulate(false, (expanded) => !expanded),
|
||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly gridModeUserSelection = new Subject<GridMode>();
|
private readonly gridModeUserSelection$ = new Subject<GridMode>();
|
||||||
/**
|
/**
|
||||||
* The layout mode of the media tile grid.
|
* 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,
|
// If the user hasn't selected spotlight and somebody starts screen sharing,
|
||||||
// automatically switch to spotlight mode and reset when screen sharing ends
|
// automatically switch to spotlight mode and reset when screen sharing ends
|
||||||
this.gridModeUserSelection.pipe(
|
this.gridModeUserSelection$.pipe(
|
||||||
startWith(null),
|
startWith(null),
|
||||||
switchMap((userSelection) =>
|
switchMap((userSelection) =>
|
||||||
(userSelection === "spotlight"
|
(userSelection === "spotlight"
|
||||||
? EMPTY
|
? EMPTY
|
||||||
: combineLatest([this.hasRemoteScreenShares, this.windowMode]).pipe(
|
: combineLatest([this.hasRemoteScreenShares$, this.windowMode$]).pipe(
|
||||||
skip(userSelection === null ? 0 : 1),
|
skip(userSelection === null ? 0 : 1),
|
||||||
map(
|
map(
|
||||||
([hasScreenShares, windowMode]): GridMode =>
|
([hasScreenShares, windowMode]): GridMode =>
|
||||||
@@ -834,43 +835,41 @@ export class CallViewModel extends ViewModel {
|
|||||||
);
|
);
|
||||||
|
|
||||||
public setGridMode(value: GridMode): void {
|
public setGridMode(value: GridMode): void {
|
||||||
this.gridModeUserSelection.next(value);
|
this.gridModeUserSelection$.next(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly gridLayoutMedia: Observable<GridLayoutMedia> = combineLatest(
|
private readonly gridLayoutMedia$: Observable<GridLayoutMedia> =
|
||||||
[this.grid, this.spotlight],
|
combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({
|
||||||
(grid, spotlight) => ({
|
|
||||||
type: "grid",
|
type: "grid",
|
||||||
spotlight: spotlight.some((vm) => vm instanceof ScreenShareViewModel)
|
spotlight: spotlight.some((vm) => vm instanceof ScreenShareViewModel)
|
||||||
? spotlight
|
? spotlight
|
||||||
: undefined,
|
: undefined,
|
||||||
grid,
|
grid,
|
||||||
}),
|
}));
|
||||||
);
|
|
||||||
|
|
||||||
private readonly spotlightLandscapeLayoutMedia: Observable<SpotlightLandscapeLayoutMedia> =
|
private readonly spotlightLandscapeLayoutMedia$: Observable<SpotlightLandscapeLayoutMedia> =
|
||||||
combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({
|
combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({
|
||||||
type: "spotlight-landscape",
|
type: "spotlight-landscape",
|
||||||
spotlight,
|
spotlight,
|
||||||
grid,
|
grid,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
private readonly spotlightPortraitLayoutMedia: Observable<SpotlightPortraitLayoutMedia> =
|
private readonly spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
|
||||||
combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({
|
combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({
|
||||||
type: "spotlight-portrait",
|
type: "spotlight-portrait",
|
||||||
spotlight,
|
spotlight,
|
||||||
grid,
|
grid,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
private readonly spotlightExpandedLayoutMedia: Observable<SpotlightExpandedLayoutMedia> =
|
private readonly spotlightExpandedLayoutMedia$: Observable<SpotlightExpandedLayoutMedia> =
|
||||||
combineLatest([this.spotlight, this.pip], (spotlight, pip) => ({
|
combineLatest([this.spotlight$, this.pip$], (spotlight, pip) => ({
|
||||||
type: "spotlight-expanded",
|
type: "spotlight-expanded",
|
||||||
spotlight,
|
spotlight,
|
||||||
pip: pip ?? undefined,
|
pip: pip ?? undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
private readonly oneOnOneLayoutMedia: Observable<OneOnOneLayoutMedia | null> =
|
private readonly oneOnOneLayoutMedia$: Observable<OneOnOneLayoutMedia | null> =
|
||||||
this.mediaItems.pipe(
|
this.mediaItems$.pipe(
|
||||||
map((mediaItems) => {
|
map((mediaItems) => {
|
||||||
if (mediaItems.length !== 2) return null;
|
if (mediaItems.length !== 2) return null;
|
||||||
const local = mediaItems.find((vm) => vm.vm.local)?.vm as
|
const local = mediaItems.find((vm) => vm.vm.local)?.vm as
|
||||||
@@ -888,86 +887,91 @@ export class CallViewModel extends ViewModel {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly pipLayoutMedia: Observable<LayoutMedia> =
|
private readonly pipLayoutMedia$: Observable<LayoutMedia> =
|
||||||
this.spotlight.pipe(map((spotlight) => ({ type: "pip", spotlight })));
|
this.spotlight$.pipe(map((spotlight) => ({ type: "pip", spotlight })));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The media to be used to produce a layout.
|
* The media to be used to produce a layout.
|
||||||
*/
|
*/
|
||||||
private readonly layoutMedia: Observable<LayoutMedia> = this.windowMode.pipe(
|
private readonly layoutMedia$: Observable<LayoutMedia> =
|
||||||
switchMap((windowMode) => {
|
this.windowMode$.pipe(
|
||||||
switch (windowMode) {
|
switchMap((windowMode) => {
|
||||||
case "normal":
|
switch (windowMode) {
|
||||||
return this.gridMode.pipe(
|
case "normal":
|
||||||
switchMap((gridMode) => {
|
return this.gridMode$.pipe(
|
||||||
switch (gridMode) {
|
switchMap((gridMode) => {
|
||||||
case "grid":
|
switch (gridMode) {
|
||||||
return this.oneOnOneLayoutMedia.pipe(
|
case "grid":
|
||||||
switchMap((oneOnOne) =>
|
return this.oneOnOneLayoutMedia$.pipe(
|
||||||
oneOnOne === null ? this.gridLayoutMedia : of(oneOnOne),
|
switchMap((oneOnOne) =>
|
||||||
),
|
oneOnOne === null
|
||||||
);
|
? this.gridLayoutMedia$
|
||||||
case "spotlight":
|
: of(oneOnOne),
|
||||||
return this.spotlightExpanded.pipe(
|
),
|
||||||
switchMap((expanded) =>
|
);
|
||||||
expanded
|
case "spotlight":
|
||||||
? this.spotlightExpandedLayoutMedia
|
return this.spotlightExpanded$.pipe(
|
||||||
: this.spotlightLandscapeLayoutMedia,
|
switchMap((expanded) =>
|
||||||
),
|
expanded
|
||||||
);
|
? this.spotlightExpandedLayoutMedia$
|
||||||
}
|
: this.spotlightLandscapeLayoutMedia$,
|
||||||
}),
|
),
|
||||||
);
|
);
|
||||||
case "narrow":
|
}
|
||||||
return this.oneOnOneLayoutMedia.pipe(
|
}),
|
||||||
switchMap((oneOnOne) =>
|
);
|
||||||
oneOnOne === null
|
case "narrow":
|
||||||
? combineLatest(
|
return this.oneOnOneLayoutMedia$.pipe(
|
||||||
[this.grid, this.spotlight],
|
switchMap((oneOnOne) =>
|
||||||
(grid, spotlight) =>
|
oneOnOne === null
|
||||||
grid.length > smallMobileCallThreshold ||
|
? combineLatest(
|
||||||
spotlight.some((vm) => vm instanceof ScreenShareViewModel)
|
[this.grid$, this.spotlight$],
|
||||||
? this.spotlightPortraitLayoutMedia
|
(grid, spotlight) =>
|
||||||
: this.gridLayoutMedia,
|
grid.length > smallMobileCallThreshold ||
|
||||||
).pipe(switchAll())
|
spotlight.some(
|
||||||
: // The expanded spotlight layout makes for a better one-on-one
|
(vm) => vm instanceof ScreenShareViewModel,
|
||||||
// experience in narrow windows
|
)
|
||||||
this.spotlightExpandedLayoutMedia,
|
? this.spotlightPortraitLayoutMedia$
|
||||||
),
|
: this.gridLayoutMedia$,
|
||||||
);
|
).pipe(switchAll())
|
||||||
case "flat":
|
: // The expanded spotlight layout makes for a better one-on-one
|
||||||
return this.gridMode.pipe(
|
// experience in narrow windows
|
||||||
switchMap((gridMode) => {
|
this.spotlightExpandedLayoutMedia$,
|
||||||
switch (gridMode) {
|
),
|
||||||
case "grid":
|
);
|
||||||
// Yes, grid mode actually gets you a "spotlight" layout in
|
case "flat":
|
||||||
// this window mode.
|
return this.gridMode$.pipe(
|
||||||
return this.spotlightLandscapeLayoutMedia;
|
switchMap((gridMode) => {
|
||||||
case "spotlight":
|
switch (gridMode) {
|
||||||
return this.spotlightExpandedLayoutMedia;
|
case "grid":
|
||||||
}
|
// Yes, grid mode actually gets you a "spotlight" layout in
|
||||||
}),
|
// this window mode.
|
||||||
);
|
return this.spotlightLandscapeLayoutMedia$;
|
||||||
case "pip":
|
case "spotlight":
|
||||||
return this.pipLayoutMedia;
|
return this.spotlightExpandedLayoutMedia$;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
this.scope.state(),
|
);
|
||||||
);
|
case "pip":
|
||||||
|
return this.pipLayoutMedia$;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
this.scope.state(),
|
||||||
|
);
|
||||||
|
|
||||||
// There is a cyclical dependency here: the layout algorithms want to know
|
// 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
|
// which tiles are on screen, but to know which tiles are on screen we have to
|
||||||
// first render a layout. To deal with this we assume initially that no tiles
|
// first render a layout. To deal with this we assume initially that no tiles
|
||||||
// are visible, and loop the data back into the layouts with a Subject.
|
// are visible, and loop the data back into the layouts with a Subject.
|
||||||
private readonly visibleTiles = new Subject<number>();
|
private readonly visibleTiles$ = new Subject<number>();
|
||||||
private readonly setVisibleTiles = (value: number): void =>
|
private readonly setVisibleTiles = (value: number): void =>
|
||||||
this.visibleTiles.next(value);
|
this.visibleTiles$.next(value);
|
||||||
|
|
||||||
public readonly layoutInternals: Observable<
|
public readonly layoutInternals$: Observable<
|
||||||
LayoutScanState & { layout: Layout }
|
LayoutScanState & { layout: Layout }
|
||||||
> = combineLatest([
|
> = combineLatest([
|
||||||
this.layoutMedia,
|
this.layoutMedia$,
|
||||||
this.visibleTiles.pipe(startWith(0), distinctUntilChanged()),
|
this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()),
|
||||||
]).pipe(
|
]).pipe(
|
||||||
scan<
|
scan<
|
||||||
[LayoutMedia, number],
|
[LayoutMedia, number],
|
||||||
@@ -1009,7 +1013,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
/**
|
/**
|
||||||
* The layout of tiles in the call interface.
|
* 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),
|
map(({ layout }) => layout),
|
||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
@@ -1017,18 +1021,18 @@ export class CallViewModel extends ViewModel {
|
|||||||
/**
|
/**
|
||||||
* The current generation of the tile store, exposed for debugging purposes.
|
* The current generation of the tile store, exposed for debugging purposes.
|
||||||
*/
|
*/
|
||||||
public readonly tileStoreGeneration: Observable<number> =
|
public readonly tileStoreGeneration$: Observable<number> =
|
||||||
this.layoutInternals.pipe(
|
this.layoutInternals$.pipe(
|
||||||
map(({ tiles }) => tiles.generation),
|
map(({ tiles }) => tiles.generation),
|
||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
|
|
||||||
public showSpotlightIndicators: Observable<boolean> = this.layout.pipe(
|
public showSpotlightIndicators$: Observable<boolean> = this.layout$.pipe(
|
||||||
map((l) => l.type !== "grid"),
|
map((l) => l.type !== "grid"),
|
||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
|
|
||||||
public showSpeakingIndicators: Observable<boolean> = this.layout.pipe(
|
public showSpeakingIndicators$: Observable<boolean> = this.layout$.pipe(
|
||||||
switchMap((l) => {
|
switchMap((l) => {
|
||||||
switch (l.type) {
|
switch (l.type) {
|
||||||
case "spotlight-landscape":
|
case "spotlight-landscape":
|
||||||
@@ -1036,7 +1040,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
// If the spotlight is showing the active speaker, we can do without
|
// If the spotlight is showing the active speaker, we can do without
|
||||||
// speaking indicators as they're a redundant visual cue. But if
|
// speaking indicators as they're a redundant visual cue. But if
|
||||||
// screen sharing feeds are in the spotlight we still need them.
|
// screen sharing feeds are in the spotlight we still need them.
|
||||||
return l.spotlight.media.pipe(
|
return l.spotlight.media$.pipe(
|
||||||
map((models: MediaViewModel[]) =>
|
map((models: MediaViewModel[]) =>
|
||||||
models.some((m) => m instanceof ScreenShareViewModel),
|
models.some((m) => m instanceof ScreenShareViewModel),
|
||||||
),
|
),
|
||||||
@@ -1055,11 +1059,11 @@ export class CallViewModel extends ViewModel {
|
|||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
|
|
||||||
public readonly toggleSpotlightExpanded: Observable<(() => void) | null> =
|
public readonly toggleSpotlightExpanded$: Observable<(() => void) | null> =
|
||||||
this.windowMode.pipe(
|
this.windowMode$.pipe(
|
||||||
switchMap((mode) =>
|
switchMap((mode) =>
|
||||||
mode === "normal"
|
mode === "normal"
|
||||||
? this.layout.pipe(
|
? this.layout$.pipe(
|
||||||
map(
|
map(
|
||||||
(l) =>
|
(l) =>
|
||||||
l.type === "spotlight-landscape" ||
|
l.type === "spotlight-landscape" ||
|
||||||
@@ -1070,50 +1074,50 @@ export class CallViewModel extends ViewModel {
|
|||||||
),
|
),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
map((enabled) =>
|
map((enabled) =>
|
||||||
enabled ? (): void => this.spotlightExpandedToggle.next() : null,
|
enabled ? (): void => this.spotlightExpandedToggle$.next() : null,
|
||||||
),
|
),
|
||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly screenTap = new Subject<void>();
|
private readonly screenTap$ = new Subject<void>();
|
||||||
private readonly controlsTap = new Subject<void>();
|
private readonly controlsTap$ = new Subject<void>();
|
||||||
private readonly screenHover = new Subject<void>();
|
private readonly screenHover$ = new Subject<void>();
|
||||||
private readonly screenUnhover = new Subject<void>();
|
private readonly screenUnhover$ = new Subject<void>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback for when the user taps the call view.
|
* Callback for when the user taps the call view.
|
||||||
*/
|
*/
|
||||||
public tapScreen(): void {
|
public tapScreen(): void {
|
||||||
this.screenTap.next();
|
this.screenTap$.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback for when the user taps the call's controls.
|
* Callback for when the user taps the call's controls.
|
||||||
*/
|
*/
|
||||||
public tapControls(): void {
|
public tapControls(): void {
|
||||||
this.controlsTap.next();
|
this.controlsTap$.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback for when the user hovers over the call view.
|
* Callback for when the user hovers over the call view.
|
||||||
*/
|
*/
|
||||||
public hoverScreen(): void {
|
public hoverScreen(): void {
|
||||||
this.screenHover.next();
|
this.screenHover$.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback for when the user stops hovering over the call view.
|
* Callback for when the user stops hovering over the call view.
|
||||||
*/
|
*/
|
||||||
public unhoverScreen(): void {
|
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"),
|
map((mode) => mode !== "pip" && mode !== "flat"),
|
||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
|
|
||||||
public readonly showFooter: Observable<boolean> = this.windowMode.pipe(
|
public readonly showFooter$: Observable<boolean> = this.windowMode$.pipe(
|
||||||
switchMap((mode) => {
|
switchMap((mode) => {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case "pip":
|
case "pip":
|
||||||
@@ -1128,9 +1132,9 @@ export class CallViewModel extends ViewModel {
|
|||||||
if (isFirefox()) return of(true);
|
if (isFirefox()) return of(true);
|
||||||
// Show/hide the footer in response to interactions
|
// Show/hide the footer in response to interactions
|
||||||
return merge(
|
return merge(
|
||||||
this.screenTap.pipe(map(() => "tap screen" as const)),
|
this.screenTap$.pipe(map(() => "tap screen" as const)),
|
||||||
this.controlsTap.pipe(map(() => "tap controls" as const)),
|
this.controlsTap$.pipe(map(() => "tap controls" as const)),
|
||||||
this.screenHover.pipe(map(() => "hover" as const)),
|
this.screenHover$.pipe(map(() => "hover" as const)),
|
||||||
).pipe(
|
).pipe(
|
||||||
switchScan((state, interaction) => {
|
switchScan((state, interaction) => {
|
||||||
switch (interaction) {
|
switch (interaction) {
|
||||||
@@ -1153,7 +1157,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
// Show on hover and hide after a timeout
|
// Show on hover and hide after a timeout
|
||||||
return race(
|
return race(
|
||||||
timer(showFooterMs),
|
timer(showFooterMs),
|
||||||
this.screenUnhover.pipe(take(1)),
|
this.screenUnhover$.pipe(take(1)),
|
||||||
).pipe(
|
).pipe(
|
||||||
map(() => false),
|
map(() => false),
|
||||||
startWith(true),
|
startWith(true),
|
||||||
@@ -1172,7 +1176,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
private readonly matrixRTCSession: MatrixRTCSession,
|
private readonly matrixRTCSession: MatrixRTCSession,
|
||||||
private readonly livekitRoom: LivekitRoom,
|
private readonly livekitRoom: LivekitRoom,
|
||||||
private readonly encryptionSystem: EncryptionSystem,
|
private readonly encryptionSystem: EncryptionSystem,
|
||||||
private readonly connectionState: Observable<ECConnectionState>,
|
private readonly connectionState$: Observable<ECConnectionState>,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ test("control a participant's volume", async () => {
|
|||||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expectObservable(vm.localVolume).toBe("ab(cd)(ef)g", {
|
expectObservable(vm.localVolume$).toBe("ab(cd)(ef)g", {
|
||||||
a: 1,
|
a: 1,
|
||||||
b: 0,
|
b: 0,
|
||||||
c: 0.6,
|
c: 0.6,
|
||||||
@@ -69,7 +69,7 @@ test("toggle fit/contain for a participant's video", async () => {
|
|||||||
a: () => vm.toggleFitContain(),
|
a: () => vm.toggleFitContain(),
|
||||||
b: () => vm.toggleFitContain(),
|
b: () => vm.toggleFitContain(),
|
||||||
});
|
});
|
||||||
expectObservable(vm.cropVideo).toBe("abc", {
|
expectObservable(vm.cropVideo$).toBe("abc", {
|
||||||
a: true,
|
a: true,
|
||||||
b: false,
|
b: false,
|
||||||
c: true,
|
c: true,
|
||||||
@@ -82,7 +82,7 @@ test("local media remembers whether it should always be shown", async () => {
|
|||||||
await withLocalMedia(rtcMembership, {}, (vm) =>
|
await withLocalMedia(rtcMembership, {}, (vm) =>
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
|
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
|
// 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) =>
|
(vm) =>
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
|
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
|
||||||
expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true });
|
expectObservable(vm.alwaysShow$).toBe("ab", { a: false, b: true });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,11 +74,11 @@ export function useDisplayName(vm: MediaViewModel): string {
|
|||||||
return displayName;
|
return displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function observeTrackReference(
|
export function observeTrackReference$(
|
||||||
participant: Observable<Participant | undefined>,
|
participant$: Observable<Participant | undefined>,
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
): Observable<TrackReferenceOrPlaceholder | undefined> {
|
): Observable<TrackReferenceOrPlaceholder | undefined> {
|
||||||
return participant.pipe(
|
return participant$.pipe(
|
||||||
switchMap((p) => {
|
switchMap((p) => {
|
||||||
if (p) {
|
if (p) {
|
||||||
return observeParticipantMedia(p).pipe(
|
return observeParticipantMedia(p).pipe(
|
||||||
@@ -96,7 +96,7 @@ export function observeTrackReference(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function observeRemoteTrackReceivingOkay(
|
function observeRemoteTrackReceivingOkay$(
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
): Observable<boolean | undefined> {
|
): Observable<boolean | undefined> {
|
||||||
@@ -111,7 +111,7 @@ function observeRemoteTrackReceivingOkay(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return combineLatest([
|
return combineLatest([
|
||||||
observeTrackReference(of(participant), source),
|
observeTrackReference$(of(participant), source),
|
||||||
interval(1000).pipe(startWith(0)),
|
interval(1000).pipe(startWith(0)),
|
||||||
]).pipe(
|
]).pipe(
|
||||||
switchMap(async ([trackReference]) => {
|
switchMap(async ([trackReference]) => {
|
||||||
@@ -168,7 +168,7 @@ function observeRemoteTrackReceivingOkay(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function encryptionErrorObservable(
|
function encryptionErrorObservable$(
|
||||||
room: LivekitRoom,
|
room: LivekitRoom,
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
@@ -209,13 +209,13 @@ abstract class BaseMediaViewModel extends ViewModel {
|
|||||||
/**
|
/**
|
||||||
* The LiveKit video track for this media.
|
* 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.
|
* 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.
|
* Whether this media corresponds to the local participant.
|
||||||
@@ -235,7 +235,7 @@ abstract class BaseMediaViewModel extends ViewModel {
|
|||||||
public readonly member: RoomMember | undefined,
|
public readonly member: RoomMember | undefined,
|
||||||
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
|
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
|
||||||
// livekit.
|
// livekit.
|
||||||
protected readonly participant: Observable<
|
protected readonly participant$: Observable<
|
||||||
LocalParticipant | RemoteParticipant | undefined
|
LocalParticipant | RemoteParticipant | undefined
|
||||||
>,
|
>,
|
||||||
|
|
||||||
@@ -245,21 +245,21 @@ abstract class BaseMediaViewModel extends ViewModel {
|
|||||||
livekitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
const audio = observeTrackReference(participant, audioSource).pipe(
|
const audio$ = observeTrackReference$(participant$, audioSource).pipe(
|
||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
this.video = observeTrackReference(participant, videoSource).pipe(
|
this.video$ = observeTrackReference$(participant$, videoSource).pipe(
|
||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
this.unencryptedWarning = combineLatest(
|
this.unencryptedWarning$ = combineLatest(
|
||||||
[audio, this.video],
|
[audio$, this.video$],
|
||||||
(a, v) =>
|
(a, v) =>
|
||||||
encryptionSystem.kind !== E2eeType.NONE &&
|
encryptionSystem.kind !== E2eeType.NONE &&
|
||||||
(a?.publication?.isEncrypted === false ||
|
(a?.publication?.isEncrypted === false ||
|
||||||
v?.publication?.isEncrypted === false),
|
v?.publication?.isEncrypted === false),
|
||||||
).pipe(this.scope.state());
|
).pipe(this.scope.state());
|
||||||
|
|
||||||
this.encryptionStatus = this.participant.pipe(
|
this.encryptionStatus$ = this.participant$.pipe(
|
||||||
switchMap((participant): Observable<EncryptionStatus> => {
|
switchMap((participant): Observable<EncryptionStatus> => {
|
||||||
if (!participant) {
|
if (!participant) {
|
||||||
return of(EncryptionStatus.Connecting);
|
return of(EncryptionStatus.Connecting);
|
||||||
@@ -270,20 +270,20 @@ abstract class BaseMediaViewModel extends ViewModel {
|
|||||||
return of(EncryptionStatus.Okay);
|
return of(EncryptionStatus.Okay);
|
||||||
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||||
return combineLatest([
|
return combineLatest([
|
||||||
encryptionErrorObservable(
|
encryptionErrorObservable$(
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
participant,
|
participant,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
"MissingKey",
|
"MissingKey",
|
||||||
),
|
),
|
||||||
encryptionErrorObservable(
|
encryptionErrorObservable$(
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
participant,
|
participant,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
"InvalidKey",
|
"InvalidKey",
|
||||||
),
|
),
|
||||||
observeRemoteTrackReceivingOkay(participant, audioSource),
|
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||||
observeRemoteTrackReceivingOkay(participant, videoSource),
|
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||||
]).pipe(
|
]).pipe(
|
||||||
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
||||||
if (keyMissing) return EncryptionStatus.KeyMissing;
|
if (keyMissing) return EncryptionStatus.KeyMissing;
|
||||||
@@ -296,14 +296,14 @@ abstract class BaseMediaViewModel extends ViewModel {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return combineLatest([
|
return combineLatest([
|
||||||
encryptionErrorObservable(
|
encryptionErrorObservable$(
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
participant,
|
participant,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
"InvalidKey",
|
"InvalidKey",
|
||||||
),
|
),
|
||||||
observeRemoteTrackReceivingOkay(participant, audioSource),
|
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||||
observeRemoteTrackReceivingOkay(participant, videoSource),
|
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||||
]).pipe(
|
]).pipe(
|
||||||
map(
|
map(
|
||||||
([keyInvalid, audioOkay, videoOkay]):
|
([keyInvalid, audioOkay, videoOkay]):
|
||||||
@@ -339,7 +339,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
/**
|
/**
|
||||||
* Whether the participant is speaking.
|
* Whether the participant is speaking.
|
||||||
*/
|
*/
|
||||||
public readonly speaking = this.participant.pipe(
|
public readonly speaking$ = this.participant$.pipe(
|
||||||
switchMap((p) =>
|
switchMap((p) =>
|
||||||
p
|
p
|
||||||
? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe(
|
? 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).
|
* 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.
|
* 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.
|
* 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(
|
public constructor(
|
||||||
id: string,
|
id: string,
|
||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: Observable<LocalParticipant | RemoteParticipant | undefined>,
|
participant$: Observable<LocalParticipant | RemoteParticipant | undefined>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
id,
|
id,
|
||||||
member,
|
member,
|
||||||
participant,
|
participant$,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
Track.Source.Microphone,
|
Track.Source.Microphone,
|
||||||
Track.Source.Camera,
|
Track.Source.Camera,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
const media = participant.pipe(
|
const media$ = participant$.pipe(
|
||||||
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
this.audioEnabled = media.pipe(
|
this.audioEnabled$ = media$.pipe(
|
||||||
map((m) => m?.microphoneTrack?.isMuted === false),
|
map((m) => m?.microphoneTrack?.isMuted === false),
|
||||||
);
|
);
|
||||||
this.videoEnabled = media.pipe(
|
this.videoEnabled$ = media$.pipe(
|
||||||
map((m) => m?.cameraTrack?.isMuted === false),
|
map((m) => m?.cameraTrack?.isMuted === false),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleFitContain(): void {
|
public toggleFitContain(): void {
|
||||||
this._cropVideo.next(!this._cropVideo.value);
|
this._cropVideo$.next(!this._cropVideo$.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get local(): boolean {
|
public get local(): boolean {
|
||||||
@@ -410,7 +410,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
/**
|
/**
|
||||||
* Whether the video should be mirrored.
|
* Whether the video should be mirrored.
|
||||||
*/
|
*/
|
||||||
public readonly mirror = this.video.pipe(
|
public readonly mirror$ = this.video$.pipe(
|
||||||
switchMap((v) => {
|
switchMap((v) => {
|
||||||
const track = v?.publication?.track;
|
const track = v?.publication?.track;
|
||||||
if (!(track instanceof LocalTrack)) return of(false);
|
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
|
* Whether to show this tile in a highly visible location near the start of
|
||||||
* the grid.
|
* the grid.
|
||||||
*/
|
*/
|
||||||
public readonly alwaysShow = alwaysShowSelf.value;
|
public readonly alwaysShow$ = alwaysShowSelf.value$;
|
||||||
public readonly setAlwaysShow = alwaysShowSelf.setValue;
|
public readonly setAlwaysShow = alwaysShowSelf.setValue;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
id: string,
|
id: string,
|
||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: Observable<LocalParticipant | undefined>,
|
participant$: Observable<LocalParticipant | undefined>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
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.
|
* A remote participant's user media.
|
||||||
*/
|
*/
|
||||||
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||||
private readonly locallyMutedToggle = new Subject<void>();
|
private readonly locallyMutedToggle$ = new Subject<void>();
|
||||||
private readonly localVolumeAdjustment = new Subject<number>();
|
private readonly localVolumeAdjustment$ = new Subject<number>();
|
||||||
private readonly localVolumeCommit = new Subject<void>();
|
private readonly localVolumeCommit$ = new Subject<void>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The volume to which this participant's audio is set, as a scalar
|
* The volume to which this participant's audio is set, as a scalar
|
||||||
* multiplier.
|
* multiplier.
|
||||||
*/
|
*/
|
||||||
public readonly localVolume: Observable<number> = merge(
|
public readonly localVolume$: Observable<number> = merge(
|
||||||
this.locallyMutedToggle.pipe(map(() => "toggle mute" as const)),
|
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
|
||||||
this.localVolumeAdjustment,
|
this.localVolumeAdjustment$,
|
||||||
this.localVolumeCommit.pipe(map(() => "commit" as const)),
|
this.localVolumeCommit$.pipe(map(() => "commit" as const)),
|
||||||
).pipe(
|
).pipe(
|
||||||
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
|
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
|
||||||
switch (event) {
|
switch (event) {
|
||||||
@@ -487,7 +487,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
/**
|
/**
|
||||||
* Whether this participant's audio is disabled.
|
* 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),
|
map((volume) => volume === 0),
|
||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
@@ -495,29 +495,29 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
public constructor(
|
public constructor(
|
||||||
id: string,
|
id: string,
|
||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: Observable<RemoteParticipant | undefined>,
|
participant$: Observable<RemoteParticipant | undefined>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
) {
|
) {
|
||||||
super(id, member, participant, encryptionSystem, livekitRoom);
|
super(id, member, participant$, encryptionSystem, livekitRoom);
|
||||||
|
|
||||||
// Sync the local volume with LiveKit
|
// Sync the local volume with LiveKit
|
||||||
combineLatest([
|
combineLatest([
|
||||||
participant,
|
participant$,
|
||||||
this.localVolume.pipe(this.scope.bind()),
|
this.localVolume$.pipe(this.scope.bind()),
|
||||||
]).subscribe(([p, volume]) => p && p.setVolume(volume));
|
]).subscribe(([p, volume]) => p && p.setVolume(volume));
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleLocallyMuted(): void {
|
public toggleLocallyMuted(): void {
|
||||||
this.locallyMutedToggle.next();
|
this.locallyMutedToggle$.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
public setLocalVolume(value: number): void {
|
public setLocalVolume(value: number): void {
|
||||||
this.localVolumeAdjustment.next(value);
|
this.localVolumeAdjustment$.next(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public commitLocalVolume(): void {
|
public commitLocalVolume(): void {
|
||||||
this.localVolumeCommit.next();
|
this.localVolumeCommit$.next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,7 +528,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
|||||||
public constructor(
|
public constructor(
|
||||||
id: string,
|
id: string,
|
||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: Observable<LocalParticipant | RemoteParticipant>,
|
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
public readonly local: boolean,
|
public readonly local: boolean,
|
||||||
@@ -536,7 +536,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
|||||||
super(
|
super(
|
||||||
id,
|
id,
|
||||||
member,
|
member,
|
||||||
participant,
|
participant$,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
Track.Source.ScreenShareAudio,
|
Track.Source.ScreenShareAudio,
|
||||||
Track.Source.ScreenShare,
|
Track.Source.ScreenShare,
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
|
|||||||
* A scope which limits the execution lifetime of its bound Observables.
|
* A scope which limits the execution lifetime of its bound Observables.
|
||||||
*/
|
*/
|
||||||
export class ObservableScope {
|
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
|
* Binds an Observable to this scope, so that it completes when the scope
|
||||||
@@ -31,8 +31,8 @@ export class ObservableScope {
|
|||||||
return this.bindImpl;
|
return this.bindImpl;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly stateImpl: MonoTypeOperator = (o) =>
|
private readonly stateImpl: MonoTypeOperator = (o$) =>
|
||||||
o.pipe(
|
o$.pipe(
|
||||||
this.bind(),
|
this.bind(),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
shareReplay({ bufferSize: 1, refCount: false }),
|
shareReplay({ bufferSize: 1, refCount: false }),
|
||||||
@@ -51,7 +51,7 @@ export class ObservableScope {
|
|||||||
* Ends the scope, causing any bound Observables to complete.
|
* Ends the scope, causing any bound Observables to complete.
|
||||||
*/
|
*/
|
||||||
public end(): void {
|
public end(): void {
|
||||||
this.ended.next();
|
this.ended$.next();
|
||||||
this.ended.complete();
|
this.ended$.complete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,31 +18,31 @@ function debugEntries(entries: GridTileData[]): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let DEBUG_ENABLED = false;
|
let DEBUG_ENABLED = false;
|
||||||
debugTileLayout.value.subscribe((value) => (DEBUG_ENABLED = value));
|
debugTileLayout.value$.subscribe((value) => (DEBUG_ENABLED = value));
|
||||||
|
|
||||||
class SpotlightTileData {
|
class SpotlightTileData {
|
||||||
private readonly media_: BehaviorSubject<MediaViewModel[]>;
|
private readonly media$: BehaviorSubject<MediaViewModel[]>;
|
||||||
public get media(): MediaViewModel[] {
|
public get media(): MediaViewModel[] {
|
||||||
return this.media_.value;
|
return this.media$.value;
|
||||||
}
|
}
|
||||||
public set media(value: MediaViewModel[]) {
|
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 {
|
public get maximised(): boolean {
|
||||||
return this.maximised_.value;
|
return this.maximised$.value;
|
||||||
}
|
}
|
||||||
public set maximised(value: boolean) {
|
public set maximised(value: boolean) {
|
||||||
this.maximised_.next(value);
|
this.maximised$.next(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly vm: SpotlightTileViewModel;
|
public readonly vm: SpotlightTileViewModel;
|
||||||
|
|
||||||
public constructor(media: MediaViewModel[], maximised: boolean) {
|
public constructor(media: MediaViewModel[], maximised: boolean) {
|
||||||
this.media_ = new BehaviorSubject(media);
|
this.media$ = new BehaviorSubject(media);
|
||||||
this.maximised_ = new BehaviorSubject(maximised);
|
this.maximised$ = new BehaviorSubject(maximised);
|
||||||
this.vm = new SpotlightTileViewModel(this.media_, this.maximised_);
|
this.vm = new SpotlightTileViewModel(this.media$, this.maximised$);
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
@@ -51,19 +51,19 @@ class SpotlightTileData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class GridTileData {
|
class GridTileData {
|
||||||
private readonly media_: BehaviorSubject<UserMediaViewModel>;
|
private readonly media$: BehaviorSubject<UserMediaViewModel>;
|
||||||
public get media(): UserMediaViewModel {
|
public get media(): UserMediaViewModel {
|
||||||
return this.media_.value;
|
return this.media$.value;
|
||||||
}
|
}
|
||||||
public set media(value: UserMediaViewModel) {
|
public set media(value: UserMediaViewModel) {
|
||||||
this.media_.next(value);
|
this.media$.next(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly vm: GridTileViewModel;
|
public readonly vm: GridTileViewModel;
|
||||||
|
|
||||||
public constructor(media: UserMediaViewModel) {
|
public constructor(media: UserMediaViewModel) {
|
||||||
this.media_ = new BehaviorSubject(media);
|
this.media$ = new BehaviorSubject(media);
|
||||||
this.vm = new GridTileViewModel(this.media_);
|
this.vm = new GridTileViewModel(this.media$);
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
@@ -123,7 +123,10 @@ export class TileStoreBuilder {
|
|||||||
"speaking" in this.prevSpotlight.media[0] &&
|
"speaking" in this.prevSpotlight.media[0] &&
|
||||||
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),
|
this.prevGrid.map((entry, i) => [entry.media, [entry, i]] as const),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -18,15 +18,15 @@ function createId(): string {
|
|||||||
export class GridTileViewModel extends ViewModel {
|
export class GridTileViewModel extends ViewModel {
|
||||||
public readonly id = createId();
|
public readonly id = createId();
|
||||||
|
|
||||||
public constructor(public readonly media: Observable<UserMediaViewModel>) {
|
public constructor(public readonly media$: Observable<UserMediaViewModel>) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SpotlightTileViewModel extends ViewModel {
|
export class SpotlightTileViewModel extends ViewModel {
|
||||||
public constructor(
|
public constructor(
|
||||||
public readonly media: Observable<MediaViewModel[]>,
|
public readonly media$: Observable<MediaViewModel[]>,
|
||||||
public readonly maximised: Observable<boolean>,
|
public readonly maximised$: Observable<boolean>,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { describe, test } from "vitest";
|
import { describe, test } from "vitest";
|
||||||
|
|
||||||
import { withTestScheduler } from "../utils/test";
|
import { withTestScheduler } from "../utils/test";
|
||||||
import { observeSpeaker } from "./observeSpeaker";
|
import { observeSpeaker$ } from "./observeSpeaker";
|
||||||
|
|
||||||
const yesNo = {
|
const yesNo = {
|
||||||
y: true,
|
y: true,
|
||||||
@@ -22,40 +22,36 @@ describe("observeSpeaker", () => {
|
|||||||
// should default to false when no input is given
|
// should default to false when no input is given
|
||||||
const speakingInputMarbles = "";
|
const speakingInputMarbles = "";
|
||||||
withTestScheduler(({ hot, expectObservable }) => {
|
withTestScheduler(({ hot, expectObservable }) => {
|
||||||
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
|
expectObservable(
|
||||||
expectedOutputMarbles,
|
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
|
||||||
yesNo,
|
).toBe(expectedOutputMarbles, yesNo);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("after no speaking", () => {
|
test("after no speaking", () => {
|
||||||
const speakingInputMarbles = "n";
|
const speakingInputMarbles = "n";
|
||||||
withTestScheduler(({ hot, expectObservable }) => {
|
withTestScheduler(({ hot, expectObservable }) => {
|
||||||
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
|
expectObservable(
|
||||||
expectedOutputMarbles,
|
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
|
||||||
yesNo,
|
).toBe(expectedOutputMarbles, yesNo);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("with speaking for 1ms", () => {
|
test("with speaking for 1ms", () => {
|
||||||
const speakingInputMarbles = "y n";
|
const speakingInputMarbles = "y n";
|
||||||
withTestScheduler(({ hot, expectObservable }) => {
|
withTestScheduler(({ hot, expectObservable }) => {
|
||||||
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
|
expectObservable(
|
||||||
expectedOutputMarbles,
|
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
|
||||||
yesNo,
|
).toBe(expectedOutputMarbles, yesNo);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("with speaking for 999ms", () => {
|
test("with speaking for 999ms", () => {
|
||||||
const speakingInputMarbles = "y 999ms n";
|
const speakingInputMarbles = "y 999ms n";
|
||||||
withTestScheduler(({ hot, expectObservable }) => {
|
withTestScheduler(({ hot, expectObservable }) => {
|
||||||
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
|
expectObservable(
|
||||||
expectedOutputMarbles,
|
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
|
||||||
yesNo,
|
).toBe(expectedOutputMarbles, yesNo);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,20 +59,18 @@ describe("observeSpeaker", () => {
|
|||||||
const speakingInputMarbles =
|
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";
|
"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 }) => {
|
withTestScheduler(({ hot, expectObservable }) => {
|
||||||
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
|
expectObservable(
|
||||||
expectedOutputMarbles,
|
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
|
||||||
yesNo,
|
).toBe(expectedOutputMarbles, yesNo);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("with consecutive speaking then stops speaking", () => {
|
test("with consecutive speaking then stops speaking", () => {
|
||||||
const speakingInputMarbles = "y y y y y y y y y y n";
|
const speakingInputMarbles = "y y y y y y y y y y n";
|
||||||
withTestScheduler(({ hot, expectObservable }) => {
|
withTestScheduler(({ hot, expectObservable }) => {
|
||||||
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
|
expectObservable(
|
||||||
expectedOutputMarbles,
|
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
|
||||||
yesNo,
|
).toBe(expectedOutputMarbles, yesNo);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -87,10 +81,9 @@ describe("observeSpeaker", () => {
|
|||||||
const speakingInputMarbles = " y";
|
const speakingInputMarbles = " y";
|
||||||
const expectedOutputMarbles = "n 999ms y";
|
const expectedOutputMarbles = "n 999ms y";
|
||||||
withTestScheduler(({ hot, expectObservable }) => {
|
withTestScheduler(({ hot, expectObservable }) => {
|
||||||
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
|
expectObservable(
|
||||||
expectedOutputMarbles,
|
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
|
||||||
yesNo,
|
).toBe(expectedOutputMarbles, yesNo);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,10 +91,9 @@ describe("observeSpeaker", () => {
|
|||||||
const speakingInputMarbles = " y 1s n ";
|
const speakingInputMarbles = " y 1s n ";
|
||||||
const expectedOutputMarbles = "n 999ms y 60s n";
|
const expectedOutputMarbles = "n 999ms y 60s n";
|
||||||
withTestScheduler(({ hot, expectObservable }) => {
|
withTestScheduler(({ hot, expectObservable }) => {
|
||||||
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
|
expectObservable(
|
||||||
expectedOutputMarbles,
|
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
|
||||||
yesNo,
|
).toBe(expectedOutputMarbles, yesNo);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,10 +101,9 @@ describe("observeSpeaker", () => {
|
|||||||
const speakingInputMarbles = " y 5s n ";
|
const speakingInputMarbles = " y 5s n ";
|
||||||
const expectedOutputMarbles = "n 999ms y 64s n";
|
const expectedOutputMarbles = "n 999ms y 64s n";
|
||||||
withTestScheduler(({ hot, expectObservable }) => {
|
withTestScheduler(({ hot, expectObservable }) => {
|
||||||
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
|
expectObservable(
|
||||||
expectedOutputMarbles,
|
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
|
||||||
yesNo,
|
).toBe(expectedOutputMarbles, yesNo);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,16 +18,16 @@ import {
|
|||||||
* Require 1 second of continuous speaking to become a speaker, and 60 second of
|
* Require 1 second of continuous speaking to become a speaker, and 60 second of
|
||||||
* continuous silence to stop being considered a speaker
|
* continuous silence to stop being considered a speaker
|
||||||
*/
|
*/
|
||||||
export function observeSpeaker(
|
export function observeSpeaker$(
|
||||||
isSpeakingObservable: Observable<boolean>,
|
isSpeakingObservable$: Observable<boolean>,
|
||||||
): 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
|
// 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 will return the latest (toggled back) value) before the timeout.
|
||||||
audit((s) =>
|
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|->..
|
// Filter the re-emissions (marked as: | ) that happen if we toggle quickly (<1s) from false->true->false|->..
|
||||||
startWith(false),
|
startWith(false),
|
||||||
|
|||||||
@@ -83,13 +83,13 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
|||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const video = useObservableEagerState(vm.video);
|
const video = useObservableEagerState(vm.video$);
|
||||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
|
||||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus);
|
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
|
||||||
const audioEnabled = useObservableEagerState(vm.audioEnabled);
|
const audioEnabled = useObservableEagerState(vm.audioEnabled$);
|
||||||
const videoEnabled = useObservableEagerState(vm.videoEnabled);
|
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
|
||||||
const speaking = useObservableEagerState(vm.speaking);
|
const speaking = useObservableEagerState(vm.speaking$);
|
||||||
const cropVideo = useObservableEagerState(vm.cropVideo);
|
const cropVideo = useObservableEagerState(vm.cropVideo$);
|
||||||
const onSelectFitContain = useCallback(
|
const onSelectFitContain = useCallback(
|
||||||
(e: Event) => {
|
(e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -198,8 +198,8 @@ interface LocalUserMediaTileProps extends TileProps {
|
|||||||
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
|
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
|
||||||
({ vm, onOpenProfile, ...props }, ref) => {
|
({ vm, onOpenProfile, ...props }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mirror = useObservableEagerState(vm.mirror);
|
const mirror = useObservableEagerState(vm.mirror$);
|
||||||
const alwaysShow = useObservableEagerState(vm.alwaysShow);
|
const alwaysShow = useObservableEagerState(vm.alwaysShow$);
|
||||||
const latestAlwaysShow = useLatest(alwaysShow);
|
const latestAlwaysShow = useLatest(alwaysShow);
|
||||||
const onSelectAlwaysShow = useCallback(
|
const onSelectAlwaysShow = useCallback(
|
||||||
(e: Event) => {
|
(e: Event) => {
|
||||||
@@ -249,8 +249,8 @@ const RemoteUserMediaTile = forwardRef<
|
|||||||
RemoteUserMediaTileProps
|
RemoteUserMediaTileProps
|
||||||
>(({ vm, ...props }, ref) => {
|
>(({ vm, ...props }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const locallyMuted = useObservableEagerState(vm.locallyMuted);
|
const locallyMuted = useObservableEagerState(vm.locallyMuted$);
|
||||||
const localVolume = useObservableEagerState(vm.localVolume);
|
const localVolume = useObservableEagerState(vm.localVolume$);
|
||||||
const onSelectMute = useCallback(
|
const onSelectMute = useCallback(
|
||||||
(e: Event) => {
|
(e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -316,7 +316,7 @@ export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
|
|||||||
({ vm, onOpenProfile, ...props }, theirRef) => {
|
({ vm, onOpenProfile, ...props }, theirRef) => {
|
||||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||||
const ref = useMergedRefs(ourRef, theirRef);
|
const ref = useMergedRefs(ourRef, theirRef);
|
||||||
const media = useObservableEagerState(vm.media);
|
const media = useObservableEagerState(vm.media$);
|
||||||
const displayName = useDisplayName(media);
|
const displayName = useDisplayName(media);
|
||||||
|
|
||||||
if (media instanceof LocalUserMediaViewModel) {
|
if (media instanceof LocalUserMediaViewModel) {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const SpotlightLocalUserMediaItem = forwardRef<
|
|||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
SpotlightLocalUserMediaItemProps
|
SpotlightLocalUserMediaItemProps
|
||||||
>(({ vm, ...props }, ref) => {
|
>(({ vm, ...props }, ref) => {
|
||||||
const mirror = useObservableEagerState(vm.mirror);
|
const mirror = useObservableEagerState(vm.mirror$);
|
||||||
return <MediaView ref={ref} mirror={mirror} {...props} />;
|
return <MediaView ref={ref} mirror={mirror} {...props} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,8 +86,8 @@ const SpotlightUserMediaItem = forwardRef<
|
|||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
SpotlightUserMediaItemProps
|
SpotlightUserMediaItemProps
|
||||||
>(({ vm, ...props }, ref) => {
|
>(({ vm, ...props }, ref) => {
|
||||||
const videoEnabled = useObservableEagerState(vm.videoEnabled);
|
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
|
||||||
const cropVideo = useObservableEagerState(vm.cropVideo);
|
const cropVideo = useObservableEagerState(vm.cropVideo$);
|
||||||
|
|
||||||
const baseProps: SpotlightUserMediaItemBaseProps &
|
const baseProps: SpotlightUserMediaItemBaseProps &
|
||||||
RefAttributes<HTMLDivElement> = {
|
RefAttributes<HTMLDivElement> = {
|
||||||
@@ -110,7 +110,7 @@ interface SpotlightItemProps {
|
|||||||
vm: MediaViewModel;
|
vm: MediaViewModel;
|
||||||
targetWidth: number;
|
targetWidth: number;
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
intersectionObserver: Observable<IntersectionObserver>;
|
intersectionObserver$: Observable<IntersectionObserver>;
|
||||||
/**
|
/**
|
||||||
* Whether this item should act as a scroll snapping point.
|
* Whether this item should act as a scroll snapping point.
|
||||||
*/
|
*/
|
||||||
@@ -124,7 +124,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
|||||||
vm,
|
vm,
|
||||||
targetWidth,
|
targetWidth,
|
||||||
targetHeight,
|
targetHeight,
|
||||||
intersectionObserver,
|
intersectionObserver$,
|
||||||
snap,
|
snap,
|
||||||
"aria-hidden": ariaHidden,
|
"aria-hidden": ariaHidden,
|
||||||
},
|
},
|
||||||
@@ -133,15 +133,15 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
|||||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||||
const ref = useMergedRefs(ourRef, theirRef);
|
const ref = useMergedRefs(ourRef, theirRef);
|
||||||
const displayName = useDisplayName(vm);
|
const displayName = useDisplayName(vm);
|
||||||
const video = useObservableEagerState(vm.video);
|
const video = useObservableEagerState(vm.video$);
|
||||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
|
||||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus);
|
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
|
||||||
|
|
||||||
// Hook this item up to the intersection observer
|
// Hook this item up to the intersection observer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const element = ourRef.current!;
|
const element = ourRef.current!;
|
||||||
let prevIo: IntersectionObserver | null = null;
|
let prevIo: IntersectionObserver | null = null;
|
||||||
const subscription = intersectionObserver.subscribe((io) => {
|
const subscription = intersectionObserver$.subscribe((io) => {
|
||||||
prevIo?.unobserve(element);
|
prevIo?.unobserve(element);
|
||||||
io.observe(element);
|
io.observe(element);
|
||||||
prevIo = io;
|
prevIo = io;
|
||||||
@@ -150,7 +150,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
|||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
prevIo?.unobserve(element);
|
prevIo?.unobserve(element);
|
||||||
};
|
};
|
||||||
}, [intersectionObserver]);
|
}, [intersectionObserver$]);
|
||||||
|
|
||||||
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
|
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
|
||||||
ref,
|
ref,
|
||||||
@@ -208,10 +208,10 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
|||||||
theirRef,
|
theirRef,
|
||||||
) => {
|
) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [ourRef, root] = useObservableRef<HTMLDivElement | null>(null);
|
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
|
||||||
const ref = useMergedRefs(ourRef, theirRef);
|
const ref = useMergedRefs(ourRef, theirRef);
|
||||||
const maximised = useObservableEagerState(vm.maximised);
|
const maximised = useObservableEagerState(vm.maximised$);
|
||||||
const media = useObservableEagerState(vm.media);
|
const media = useObservableEagerState(vm.media$);
|
||||||
const [visibleId, setVisibleId] = useState<string | undefined>(
|
const [visibleId, setVisibleId] = useState<string | undefined>(
|
||||||
media[0]?.id,
|
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
|
// 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
|
// their effects before their parent does, we need to do this dance with an
|
||||||
// Observable to actually give them the intersection observer.
|
// Observable to actually give them the intersection observer.
|
||||||
const intersectionObserver = useInitial<Observable<IntersectionObserver>>(
|
const intersectionObserver$ = useInitial<Observable<IntersectionObserver>>(
|
||||||
() =>
|
() =>
|
||||||
root.pipe(
|
root$.pipe(
|
||||||
map(
|
map(
|
||||||
(r) =>
|
(r) =>
|
||||||
new IntersectionObserver(
|
new IntersectionObserver(
|
||||||
@@ -295,7 +295,7 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
|||||||
vm={vm}
|
vm={vm}
|
||||||
targetWidth={targetWidth}
|
targetWidth={targetWidth}
|
||||||
targetHeight={targetHeight}
|
targetHeight={targetHeight}
|
||||||
intersectionObserver={intersectionObserver}
|
intersectionObserver$={intersectionObserver$}
|
||||||
// This is how we get the container to scroll to the right media
|
// This is how we get the container to scroll to the right media
|
||||||
// when the previous/next buttons are clicked: we temporarily
|
// when the previous/next buttons are clicked: we temporarily
|
||||||
// remove all scroll snap points except for just the one media
|
// remove all scroll snap points except for just the one media
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ const nothing = Symbol("nothing");
|
|||||||
* callback will not be invoked.
|
* callback will not be invoked.
|
||||||
*/
|
*/
|
||||||
export function finalizeValue<T>(callback: (finalValue: T) => void) {
|
export function finalizeValue<T>(callback: (finalValue: T) => void) {
|
||||||
return (source: Observable<T>): Observable<T> =>
|
return (source$: Observable<T>): Observable<T> =>
|
||||||
defer(() => {
|
defer(() => {
|
||||||
let finalValue: T | typeof nothing = nothing;
|
let finalValue: T | typeof nothing = nothing;
|
||||||
return source.pipe(
|
return source$.pipe(
|
||||||
tap((value) => (finalValue = value)),
|
tap((value) => (finalValue = value)),
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
if (finalValue !== nothing) callback(finalValue);
|
if (finalValue !== nothing) callback(finalValue);
|
||||||
@@ -35,6 +35,6 @@ export function accumulate<State, Event>(
|
|||||||
initial: State,
|
initial: State,
|
||||||
update: (state: State, event: Event) => State,
|
update: (state: State, event: Event) => State,
|
||||||
) {
|
) {
|
||||||
return (events: Observable<Event>): Observable<State> =>
|
return (events$: Observable<Event>): Observable<State> =>
|
||||||
events.pipe(scan(update, initial), startWith(initial));
|
events$.pipe(scan(update, initial), startWith(initial));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,14 +77,14 @@ export function withTestScheduler(
|
|||||||
continuation({
|
continuation({
|
||||||
...helpers,
|
...helpers,
|
||||||
schedule(marbles, actions) {
|
schedule(marbles, actions) {
|
||||||
const actionsObservable = helpers
|
const actionsObservable$ = helpers
|
||||||
.cold(marbles)
|
.cold(marbles)
|
||||||
.pipe(map((value) => actions[value]()));
|
.pipe(map((value) => actions[value]()));
|
||||||
const results = Object.fromEntries(
|
const results = Object.fromEntries(
|
||||||
Object.keys(actions).map((value) => [value, undefined] as const),
|
Object.keys(actions).map((value) => [value, undefined] as const),
|
||||||
);
|
);
|
||||||
// Run the actions and verify that none of them error
|
// 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(
|
export function mockLivekitRoom(
|
||||||
room: Partial<LivekitRoom>,
|
room: Partial<LivekitRoom>,
|
||||||
{
|
{
|
||||||
remoteParticipants,
|
remoteParticipants$,
|
||||||
}: { remoteParticipants?: Observable<RemoteParticipant[]> } = {},
|
}: { remoteParticipants$?: Observable<RemoteParticipant[]> } = {},
|
||||||
): LivekitRoom {
|
): LivekitRoom {
|
||||||
const livekitRoom = {
|
const livekitRoom = {
|
||||||
...mockEmitter(),
|
...mockEmitter(),
|
||||||
...room,
|
...room,
|
||||||
} as Partial<LivekitRoom> as LivekitRoom;
|
} as Partial<LivekitRoom> as LivekitRoom;
|
||||||
if (remoteParticipants) {
|
if (remoteParticipants$) {
|
||||||
livekitRoom.remoteParticipants = new Map();
|
livekitRoom.remoteParticipants = new Map();
|
||||||
remoteParticipants.subscribe((newRemoteParticipants) => {
|
remoteParticipants$.subscribe((newRemoteParticipants) => {
|
||||||
livekitRoom.remoteParticipants.clear();
|
livekitRoom.remoteParticipants.clear();
|
||||||
newRemoteParticipants.forEach((p) => {
|
newRemoteParticipants.forEach((p) => {
|
||||||
livekitRoom.remoteParticipants.set(p.identity, p);
|
livekitRoom.remoteParticipants.set(p.identity, p);
|
||||||
@@ -238,7 +238,7 @@ export async function withRemoteMedia(
|
|||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
mockLivekitRoom({}, { remoteParticipants: of([remoteParticipant]) }),
|
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await continuation(vm);
|
await continuation(vm);
|
||||||
@@ -277,9 +277,9 @@ export class MockRTCSession extends TypedEventEmitter<
|
|||||||
}
|
}
|
||||||
|
|
||||||
public withMemberships(
|
public withMemberships(
|
||||||
rtcMembers: Observable<Partial<CallMembership>[]>,
|
rtcMembers$: Observable<Partial<CallMembership>[]>,
|
||||||
): MockRTCSession {
|
): MockRTCSession {
|
||||||
rtcMembers.subscribe((m) => {
|
rtcMembers$.subscribe((m) => {
|
||||||
const old = this.memberships;
|
const old = this.memberships;
|
||||||
// always prepend the local participant
|
// always prepend the local participant
|
||||||
const updated = [this.localMembership, ...(m as CallMembership[])];
|
const updated = [this.localMembership, ...(m as CallMembership[])];
|
||||||
|
|||||||
Reference in New Issue
Block a user