Merge branch 'livekit' into robin/switch-camera-tile
This commit is contained in:
26
src/state/Behavior.ts
Normal file
26
src/state/Behavior.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
/**
|
||||
* A stateful, read-only reactive value. As an Observable, it is "hot" and
|
||||
* always replays the current value upon subscription.
|
||||
*
|
||||
* A Behavior is to BehaviorSubject what Observable is to Subject; it does not
|
||||
* provide a way to imperatively set new values. For more info on the
|
||||
* distinction between Behaviors and Observables, see
|
||||
* https://monoid.dk/post/behaviors-and-streams-why-both/.
|
||||
*/
|
||||
export type Behavior<T> = Omit<BehaviorSubject<T>, "next" | "observers">;
|
||||
|
||||
/**
|
||||
* Creates a Behavior which never changes in value.
|
||||
*/
|
||||
export function constant<T>(value: T): Behavior<T> {
|
||||
return new BehaviorSubject(value);
|
||||
}
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
NEVER,
|
||||
type Observable,
|
||||
of,
|
||||
skip,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
@@ -32,7 +32,11 @@ import {
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
import { CallViewModel, type Layout } from "./CallViewModel";
|
||||
import {
|
||||
CallViewModel,
|
||||
type CallViewModelOptions,
|
||||
type Layout,
|
||||
} from "./CallViewModel";
|
||||
import {
|
||||
mockLivekitRoom,
|
||||
mockLocalParticipant,
|
||||
@@ -71,14 +75,23 @@ import {
|
||||
local,
|
||||
localId,
|
||||
localRtcMember,
|
||||
localRtcMemberDevice2,
|
||||
} from "../utils/test-fixtures";
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import { MediaDevices } from "./MediaDevices";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { type Behavior, constant } from "./Behavior";
|
||||
|
||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||
vi.mock("../UrlParams", () => ({ getUrlParams }));
|
||||
|
||||
vi.mock("rxjs", async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
// Disable interval Observables for the following tests since the test
|
||||
// scheduler will loop on them forever and never call the test 'done'
|
||||
interval: (): Observable<number> => NEVER,
|
||||
}));
|
||||
|
||||
vi.mock("@livekit/components-core");
|
||||
|
||||
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
|
||||
@@ -157,9 +170,10 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
case "grid":
|
||||
return combineLatest(
|
||||
[
|
||||
l.spotlight?.media$ ?? of(undefined),
|
||||
l.spotlight?.media$ ?? constant(undefined),
|
||||
...l.grid.map((vm) => vm.media$),
|
||||
],
|
||||
// eslint-disable-next-line rxjs/finnish -- false positive
|
||||
(spotlight, ...grid) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight?.map((vm) => vm.id),
|
||||
@@ -178,7 +192,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
);
|
||||
case "spotlight-expanded":
|
||||
return combineLatest(
|
||||
[l.spotlight.media$, l.pip?.media$ ?? of(undefined)],
|
||||
[l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
|
||||
// eslint-disable-next-line rxjs/finnish -- false positive
|
||||
(spotlight, pip) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight.map((vm) => vm.id),
|
||||
@@ -212,8 +227,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
}
|
||||
|
||||
function withCallViewModel(
|
||||
remoteParticipants$: Observable<RemoteParticipant[]>,
|
||||
rtcMembers$: Observable<Partial<CallMembership>[]>,
|
||||
remoteParticipants$: Behavior<RemoteParticipant[]>,
|
||||
rtcMembers$: Behavior<Partial<CallMembership>[]>,
|
||||
connectionState$: Observable<ECConnectionState>,
|
||||
speaking: Map<Participant, Observable<boolean>>,
|
||||
mediaDevices: MediaDevices,
|
||||
@@ -221,6 +236,10 @@ function withCallViewModel(
|
||||
vm: CallViewModel,
|
||||
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
|
||||
) => void,
|
||||
options: CallViewModelOptions = {
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
): void {
|
||||
const room = mockMatrixRoom({
|
||||
client: {
|
||||
@@ -271,9 +290,7 @@ function withCallViewModel(
|
||||
rtcSession as unknown as MatrixRTCSession,
|
||||
liveKitRoom,
|
||||
mediaDevices,
|
||||
{
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
options,
|
||||
connectionState$,
|
||||
raisedHands$,
|
||||
new BehaviorSubject({}),
|
||||
@@ -291,7 +308,7 @@ function withCallViewModel(
|
||||
}
|
||||
|
||||
test("participants are retained during a focus switch", () => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
// Participants disappear on frame 2 and come back on frame 3
|
||||
const participantInputMarbles = "a-ba";
|
||||
// Start switching focus on frame 1 and reconnect on frame 3
|
||||
@@ -300,12 +317,12 @@ test("participants are retained during a focus switch", () => {
|
||||
const expectedLayoutMarbles = " a";
|
||||
|
||||
withCallViewModel(
|
||||
hot(participantInputMarbles, {
|
||||
behavior(participantInputMarbles, {
|
||||
a: [aliceParticipant, bobParticipant],
|
||||
b: [],
|
||||
}),
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
hot(connectionInputMarbles, {
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
behavior(connectionInputMarbles, {
|
||||
c: ConnectionState.Connected,
|
||||
s: ECAddonConnectionState.ECSwitchingFocus,
|
||||
}),
|
||||
@@ -328,7 +345,7 @@ test("participants are retained during a focus switch", () => {
|
||||
});
|
||||
|
||||
test("screen sharing activates spotlight layout", () => {
|
||||
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
// Start with no screen shares, then have Alice and Bob share their screens,
|
||||
// then return to no screen shares, then have just Alice share for a bit
|
||||
const participantInputMarbles = " abcda-ba";
|
||||
@@ -341,13 +358,13 @@ test("screen sharing activates spotlight layout", () => {
|
||||
const expectedLayoutMarbles = " abcdaefeg";
|
||||
const expectedShowSpeakingMarbles = "y----nyny";
|
||||
withCallViewModel(
|
||||
hot(participantInputMarbles, {
|
||||
behavior(participantInputMarbles, {
|
||||
a: [aliceParticipant, bobParticipant],
|
||||
b: [aliceSharingScreen, bobParticipant],
|
||||
c: [aliceSharingScreen, bobSharingScreen],
|
||||
d: [aliceParticipant, bobSharingScreen],
|
||||
}),
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -413,7 +430,7 @@ test("screen sharing activates spotlight layout", () => {
|
||||
});
|
||||
|
||||
test("participants stay in the same order unless to appear/disappear", () => {
|
||||
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
const visibilityInputMarbles = "a";
|
||||
// First Bob speaks, then Dave, then Alice
|
||||
const aSpeakingInputMarbles = " n- 1998ms - 1999ms y";
|
||||
@@ -426,13 +443,22 @@ test("participants stay in the same order unless to appear/disappear", () => {
|
||||
const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map([
|
||||
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
|
||||
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
||||
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
||||
[
|
||||
aliceParticipant,
|
||||
behavior(aSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
bobParticipant,
|
||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
daveParticipant,
|
||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
]),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
@@ -472,7 +498,7 @@ test("participants stay in the same order unless to appear/disappear", () => {
|
||||
});
|
||||
|
||||
test("participants adjust order when space becomes constrained", () => {
|
||||
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
// Start with all tiles on screen then shrink to 3
|
||||
const visibilityInputMarbles = "a-b";
|
||||
// Bob and Dave speak
|
||||
@@ -484,12 +510,18 @@ test("participants adjust order when space becomes constrained", () => {
|
||||
const expectedLayoutMarbles = " a-b";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map([
|
||||
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
||||
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
||||
[
|
||||
bobParticipant,
|
||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
daveParticipant,
|
||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
]),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
@@ -523,7 +555,7 @@ test("participants adjust order when space becomes constrained", () => {
|
||||
});
|
||||
|
||||
test("spotlight speakers swap places", () => {
|
||||
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
// Go immediately into spotlight mode for the test
|
||||
const modeInputMarbles = " s";
|
||||
// First Bob speaks, then Dave, then Alice
|
||||
@@ -537,13 +569,22 @@ test("spotlight speakers swap places", () => {
|
||||
const expectedLayoutMarbles = "abcd";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map([
|
||||
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
|
||||
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
||||
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
||||
[
|
||||
aliceParticipant,
|
||||
behavior(aSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
bobParticipant,
|
||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
daveParticipant,
|
||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
]),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
@@ -587,8 +628,8 @@ test("layout enters picture-in-picture mode when requested", () => {
|
||||
const expectedLayoutMarbles = " aba";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
constant([aliceParticipant, bobParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -629,8 +670,8 @@ test("spotlight remembers whether it's expanded", () => {
|
||||
const expectedLayoutMarbles = "abcbada";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
constant([aliceParticipant, bobParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -678,7 +719,7 @@ test("spotlight remembers whether it's expanded", () => {
|
||||
});
|
||||
|
||||
test("participants must have a MatrixRTCSession to be visible", () => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
// iterate through a number of combinations of participants and MatrixRTC memberships
|
||||
// Bob never has an MatrixRTC membership
|
||||
const scenarioInputMarbles = " abcdec";
|
||||
@@ -686,14 +727,14 @@ test("participants must have a MatrixRTCSession to be visible", () => {
|
||||
const expectedLayoutMarbles = "a-bc-b";
|
||||
|
||||
withCallViewModel(
|
||||
hot(scenarioInputMarbles, {
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [bobParticipant],
|
||||
c: [aliceParticipant, bobParticipant],
|
||||
d: [aliceParticipant, daveParticipant, bobParticipant],
|
||||
e: [aliceParticipant, daveParticipant, bobSharingScreen],
|
||||
}),
|
||||
hot(scenarioInputMarbles, {
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [],
|
||||
c: [aliceRtcMember],
|
||||
@@ -734,17 +775,17 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
|
||||
try {
|
||||
// enable the setting:
|
||||
showNonMemberTiles.setValue(true);
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const scenarioInputMarbles = " abc";
|
||||
const expectedLayoutMarbles = "abc";
|
||||
|
||||
withCallViewModel(
|
||||
hot(scenarioInputMarbles, {
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [aliceParticipant],
|
||||
c: [aliceParticipant, bobParticipant],
|
||||
}),
|
||||
of([]), // No one joins the MatrixRTC session
|
||||
constant([]), // No one joins the MatrixRTC session
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -779,15 +820,15 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
|
||||
});
|
||||
|
||||
it("should show at least one tile per MatrixRTCSession", () => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
// iterate through some combinations of MatrixRTC memberships
|
||||
const scenarioInputMarbles = " abcd";
|
||||
// There should always be one tile for each MatrixRTCSession
|
||||
const expectedLayoutMarbles = "abcd";
|
||||
|
||||
withCallViewModel(
|
||||
of([]),
|
||||
hot(scenarioInputMarbles, {
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [aliceRtcMember],
|
||||
c: [aliceRtcMember, daveRtcMember],
|
||||
@@ -829,13 +870,13 @@ it("should show at least one tile per MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
test("should disambiguate users with the same displayname", () => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const scenarioInputMarbles = "abcde";
|
||||
const expectedLayoutMarbles = "abcde";
|
||||
|
||||
withCallViewModel(
|
||||
of([]),
|
||||
hot(scenarioInputMarbles, {
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [aliceRtcMember],
|
||||
c: [aliceRtcMember, aliceDoppelgangerRtcMember],
|
||||
@@ -846,50 +887,46 @@ test("should disambiguate users with the same displayname", () => {
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
// Skip the null state.
|
||||
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, alice.rawDisplayName],
|
||||
]),
|
||||
// The second alice joins.
|
||||
c: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, "Alice (@alice:example.org)"],
|
||||
[aliceDoppelgangerId, "Alice (@alice2:example.org)"],
|
||||
]),
|
||||
// Bob also joins
|
||||
d: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, "Alice (@alice:example.org)"],
|
||||
[aliceDoppelgangerId, "Alice (@alice2:example.org)"],
|
||||
[bobId, bob.rawDisplayName],
|
||||
]),
|
||||
// Alice leaves, and the displayname should reset.
|
||||
e: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceDoppelgangerId, "Alice"],
|
||||
[bobId, bob.rawDisplayName],
|
||||
]),
|
||||
},
|
||||
);
|
||||
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, alice.rawDisplayName],
|
||||
]),
|
||||
// The second alice joins.
|
||||
c: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, "Alice (@alice:example.org)"],
|
||||
[aliceDoppelgangerId, "Alice (@alice2:example.org)"],
|
||||
]),
|
||||
// Bob also joins
|
||||
d: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, "Alice (@alice:example.org)"],
|
||||
[aliceDoppelgangerId, "Alice (@alice2:example.org)"],
|
||||
[bobId, bob.rawDisplayName],
|
||||
]),
|
||||
// Alice leaves, and the displayname should reset.
|
||||
e: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceDoppelgangerId, "Alice"],
|
||||
[bobId, bob.rawDisplayName],
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should disambiguate users with invisible characters", () => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const scenarioInputMarbles = "ab";
|
||||
const expectedLayoutMarbles = "ab";
|
||||
|
||||
withCallViewModel(
|
||||
of([]),
|
||||
hot(scenarioInputMarbles, {
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [bobRtcMember, bobZeroWidthSpaceRtcMember],
|
||||
}),
|
||||
@@ -897,36 +934,32 @@ test("should disambiguate users with invisible characters", () => {
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
// Skip the null state.
|
||||
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
// Both Bobs join, and should handle zero width hacks.
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
[bobId, `Bob (${bob.userId})`],
|
||||
[
|
||||
bobZeroWidthSpaceId,
|
||||
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
|
||||
],
|
||||
]),
|
||||
},
|
||||
);
|
||||
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
// Both Bobs join, and should handle zero width hacks.
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
[bobId, `Bob (${bob.userId})`],
|
||||
[
|
||||
bobZeroWidthSpaceId,
|
||||
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
|
||||
],
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should strip RTL characters from displayname", () => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const scenarioInputMarbles = "ab";
|
||||
const expectedLayoutMarbles = "ab";
|
||||
|
||||
withCallViewModel(
|
||||
of([]),
|
||||
hot(scenarioInputMarbles, {
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [daveRtcMember, daveRTLRtcMember],
|
||||
}),
|
||||
@@ -934,35 +967,31 @@ test("should strip RTL characters from displayname", () => {
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
// Skip the null state.
|
||||
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
// Both Dave's join. Since after stripping
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
// Not disambiguated
|
||||
[daveId, "Dave"],
|
||||
// This one is, since it's using RTL.
|
||||
[daveRTLId, `evaD (${daveRTL.userId})`],
|
||||
]),
|
||||
},
|
||||
);
|
||||
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
// Both Dave's join. Since after stripping
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
// Not disambiguated
|
||||
[daveId, "Dave"],
|
||||
// This one is, since it's using RTL.
|
||||
[daveRTLId, `evaD (${daveRTL.userId})`],
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should rank raised hands above video feeds and below speakers and presenters", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
withTestScheduler(({ schedule, expectObservable, behavior }) => {
|
||||
// There should always be one tile for each MatrixRTCSession
|
||||
const expectedLayoutMarbles = "ab";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
constant([aliceParticipant, bobParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -1015,6 +1044,176 @@ it("should rank raised hands above video feeds and below speakers and presenters
|
||||
});
|
||||
});
|
||||
|
||||
function nooneEverThere$<T>(
|
||||
hot: (marbles: string, values: Record<string, T[]>) => Observable<T[]>,
|
||||
): Observable<T[]> {
|
||||
return hot("a-b-c-d", {
|
||||
a: [], // Start empty
|
||||
b: [], // Alice joins
|
||||
c: [], // Alice still there
|
||||
d: [], // Alice leaves
|
||||
});
|
||||
}
|
||||
|
||||
function participantJoinLeave$(
|
||||
hot: (
|
||||
marbles: string,
|
||||
values: Record<string, RemoteParticipant[]>,
|
||||
) => Observable<RemoteParticipant[]>,
|
||||
): Observable<RemoteParticipant[]> {
|
||||
return hot("a-b-c-d", {
|
||||
a: [], // Start empty
|
||||
b: [aliceParticipant], // Alice joins
|
||||
c: [aliceParticipant], // Alice still there
|
||||
d: [], // Alice leaves
|
||||
});
|
||||
}
|
||||
|
||||
function rtcMemberJoinLeave$(
|
||||
hot: (
|
||||
marbles: string,
|
||||
values: Record<string, CallMembership[]>,
|
||||
) => Observable<CallMembership[]>,
|
||||
): Observable<CallMembership[]> {
|
||||
return hot("a-b-c-d", {
|
||||
a: [], // Start empty
|
||||
b: [aliceRtcMember], // Alice joins
|
||||
c: [aliceRtcMember], // Alice still there
|
||||
d: [], // Alice leaves
|
||||
});
|
||||
}
|
||||
|
||||
test("allOthersLeft$ emits only when someone joined and then all others left", () => {
|
||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||
// Test scenario 1: No one ever joins - should only emit initial false and never emit again
|
||||
withCallViewModel(
|
||||
scope.behavior(nooneEverThere$(hot), []),
|
||||
scope.behavior(nooneEverThere$(hot), []),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
expectObservable(vm.allOthersLeft$).toBe("n------", { n: false });
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("allOthersLeft$ emits true when someone joined and then all others left", () => {
|
||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||
withCallViewModel(
|
||||
scope.behavior(participantJoinLeave$(hot), []),
|
||||
scope.behavior(rtcMemberJoinLeave$(hot), []),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
expectObservable(vm.allOthersLeft$).toBe(
|
||||
"n-----u", // false initially, then at frame 6: true then false emissions in same frame
|
||||
{ n: false, u: true }, // map(() => {})
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is enabled", () => {
|
||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||
withCallViewModel(
|
||||
scope.behavior(participantJoinLeave$(hot), []),
|
||||
scope.behavior(rtcMemberJoinLeave$(hot), []),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe(
|
||||
"------e", // false initially, then at frame 6: true then false emissions in same frame
|
||||
{ e: undefined },
|
||||
);
|
||||
},
|
||||
{
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => {
|
||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||
withCallViewModel(
|
||||
scope.behavior(nooneEverThere$(hot), []),
|
||||
scope.behavior(nooneEverThere$(hot), []),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------");
|
||||
},
|
||||
{
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => {
|
||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||
withCallViewModel(
|
||||
scope.behavior(participantJoinLeave$(hot), []),
|
||||
scope.behavior(rtcMemberJoinLeave$(hot), []),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------");
|
||||
},
|
||||
{
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => {
|
||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||
withCallViewModel(
|
||||
scope.behavior(
|
||||
hot("a-b-c-d", {
|
||||
a: [], // Alone
|
||||
b: [aliceParticipant], // Alice joins
|
||||
c: [aliceParticipant],
|
||||
d: [], // Local joins with a second device
|
||||
}),
|
||||
[], //Alice leaves
|
||||
),
|
||||
scope.behavior(
|
||||
hot("a-b-c-d", {
|
||||
a: [localRtcMember], // Start empty
|
||||
b: [localRtcMember, aliceRtcMember], // Alice joins
|
||||
c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there
|
||||
d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves
|
||||
}),
|
||||
[],
|
||||
),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("------e", {
|
||||
e: undefined,
|
||||
});
|
||||
},
|
||||
{
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("audio output changes when toggling earpiece mode", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
getUrlParams.mockReturnValue({ controlledAudioDevices: true });
|
||||
@@ -1026,7 +1225,7 @@ test("audio output changes when toggling earpiece mode", () => {
|
||||
|
||||
window.controls.setAvailableAudioDevices([
|
||||
{ id: "speaker", name: "Speaker", isSpeaker: true },
|
||||
{ id: "earpiece", name: "Earpiece", isEarpiece: true },
|
||||
{ id: "earpiece", name: "Handset", isEarpiece: true },
|
||||
{ id: "headphones", name: "Headphones" },
|
||||
]);
|
||||
window.controls.setAudioDevice("headphones");
|
||||
@@ -1036,8 +1235,8 @@ test("audio output changes when toggling earpiece mode", () => {
|
||||
const expectedTargetStateMarbles = " sese";
|
||||
|
||||
withCallViewModel(
|
||||
of([]),
|
||||
of([]),
|
||||
constant([]),
|
||||
constant([]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
devices,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ import {
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
pairwise,
|
||||
startWith,
|
||||
Subject,
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
type Observable,
|
||||
} from "rxjs";
|
||||
import { createMediaDeviceObserver } from "@livekit/components-core";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
audioInput as audioInputSetting,
|
||||
@@ -34,11 +33,11 @@ import {
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { platform } from "../Platform";
|
||||
import { switchWhen } from "../utils/observable";
|
||||
import { type Behavior, constant } from "./Behavior";
|
||||
|
||||
// This hardcoded id is used in EX ios! It can only be changed in coordination with
|
||||
// the ios swift team.
|
||||
const EARPIECE_CONFIG_ID = "earpiece-id";
|
||||
const logger = rootLogger.getChild("[MediaDevices]");
|
||||
|
||||
export type DeviceLabel =
|
||||
| { type: "name"; name: string }
|
||||
@@ -74,11 +73,11 @@ export interface MediaDevice<Label, Selected> {
|
||||
/**
|
||||
* A map from available device IDs to labels.
|
||||
*/
|
||||
available$: Observable<Map<string, Label>>;
|
||||
available$: Behavior<Map<string, Label>>;
|
||||
/**
|
||||
* The selected device.
|
||||
*/
|
||||
selected$: Observable<Selected | undefined>;
|
||||
selected$: Behavior<Selected | undefined>;
|
||||
/**
|
||||
* Selects a new device.
|
||||
*/
|
||||
@@ -94,35 +93,37 @@ export interface MediaDevice<Label, Selected> {
|
||||
* `availableOutputDevices$.includes((d)=>d.forEarpiece)`
|
||||
*/
|
||||
export const iosDeviceMenu$ =
|
||||
platform === "ios" ? of(true) : alwaysShowIphoneEarpieceSetting.value$;
|
||||
platform === "ios" ? constant(true) : alwaysShowIphoneEarpieceSetting.value$;
|
||||
|
||||
function availableRawDevices$(
|
||||
kind: MediaDeviceKind,
|
||||
usingNames$: Observable<boolean>,
|
||||
usingNames$: Behavior<boolean>,
|
||||
scope: ObservableScope,
|
||||
): Observable<MediaDeviceInfo[]> {
|
||||
logger: Logger,
|
||||
): Behavior<MediaDeviceInfo[]> {
|
||||
const logError = (e: Error): void =>
|
||||
logger.error("Error creating MediaDeviceObserver", e);
|
||||
const devices$ = createMediaDeviceObserver(kind, logError, false);
|
||||
const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true);
|
||||
|
||||
return usingNames$.pipe(
|
||||
switchMap((withNames) =>
|
||||
withNames
|
||||
? // It might be that there is already a media stream running somewhere,
|
||||
// and so we can do without requesting a second one. Only switch to the
|
||||
// device observer that explicitly requests the names if we see that
|
||||
// names are in fact missing from the initial device enumeration.
|
||||
devices$.pipe(
|
||||
switchWhen(
|
||||
(devices, i) => i === 0 && devices.every((d) => !d.label),
|
||||
devicesWithNames$,
|
||||
),
|
||||
)
|
||||
: devices$,
|
||||
return scope.behavior(
|
||||
usingNames$.pipe(
|
||||
switchMap((withNames) =>
|
||||
withNames
|
||||
? // It might be that there is already a media stream running somewhere,
|
||||
// and so we can do without requesting a second one. Only switch to the
|
||||
// device observer that explicitly requests the names if we see that
|
||||
// names are in fact missing from the initial device enumeration.
|
||||
devices$.pipe(
|
||||
switchWhen(
|
||||
(devices, i) => i === 0 && devices.every((d) => !d.label),
|
||||
devicesWithNames$,
|
||||
),
|
||||
)
|
||||
: devices$,
|
||||
),
|
||||
),
|
||||
startWith([]),
|
||||
scope.state(),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,34 +162,40 @@ function selectDevice$<Label>(
|
||||
}
|
||||
|
||||
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
private readonly availableRaw$: Observable<MediaDeviceInfo[]> =
|
||||
availableRawDevices$("audioinput", this.usingNames$, this.scope);
|
||||
private logger = rootLogger.getChild("[MediaDevices AudioInput]");
|
||||
|
||||
public readonly available$ = this.availableRaw$.pipe(
|
||||
map(buildDeviceMap),
|
||||
this.scope.state(),
|
||||
private readonly availableRaw$: Behavior<MediaDeviceInfo[]> =
|
||||
availableRawDevices$(
|
||||
"audioinput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
public readonly available$ = this.scope.behavior(
|
||||
this.availableRaw$.pipe(map(buildDeviceMap)),
|
||||
);
|
||||
|
||||
public readonly selected$ = selectDevice$(
|
||||
this.available$,
|
||||
audioInputSetting.value$,
|
||||
).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
// We can identify when the hardware device has changed by watching for
|
||||
// changes in the group ID
|
||||
hardwareDeviceChange$: this.availableRaw$.pipe(
|
||||
map((devices) => devices.find((d) => d.deviceId === id)?.groupId),
|
||||
pairwise(),
|
||||
filter(([before, after]) => before !== after),
|
||||
map(() => undefined),
|
||||
),
|
||||
},
|
||||
public readonly selected$ = this.scope.behavior(
|
||||
selectDevice$(this.available$, audioInputSetting.value$).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
// We can identify when the hardware device has changed by watching for
|
||||
// changes in the group ID
|
||||
hardwareDeviceChange$: this.availableRaw$.pipe(
|
||||
map(
|
||||
(devices) => devices.find((d) => d.deviceId === id)?.groupId,
|
||||
),
|
||||
pairwise(),
|
||||
filter(([before, after]) => before !== after),
|
||||
map(() => undefined),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public select(id: string): void {
|
||||
@@ -196,11 +203,11 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
this.available$.subscribe((available) => {
|
||||
logger.info("[audio-input] available devices:", available);
|
||||
this.logger.info("[audio-input] available devices:", available);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -208,55 +215,61 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
class AudioOutput
|
||||
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
|
||||
{
|
||||
public readonly available$ = availableRawDevices$(
|
||||
"audiooutput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
).pipe(
|
||||
map((availableRaw) => {
|
||||
const available: Map<string, AudioOutputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// Create a virtual default audio output for browsers that don't have one.
|
||||
// Its device ID must be the empty string because that's what setSinkId
|
||||
// recognizes.
|
||||
if (available.size && !available.has("") && !available.has("default"))
|
||||
available.set("", {
|
||||
type: "default",
|
||||
name: availableRaw[0]?.label || null,
|
||||
});
|
||||
// Note: creating virtual default input devices would be another problem
|
||||
// entirely, because requesting a media stream from deviceId "" won't
|
||||
// automatically track the default device.
|
||||
return available;
|
||||
}),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public readonly selected$ = selectDevice$(
|
||||
this.available$,
|
||||
audioOutputSetting.value$,
|
||||
).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
virtualEarpiece: false,
|
||||
},
|
||||
private logger = rootLogger.getChild("[MediaDevices AudioOutput]");
|
||||
public readonly available$ = this.scope.behavior(
|
||||
availableRawDevices$(
|
||||
"audiooutput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
this.logger,
|
||||
).pipe(
|
||||
map((availableRaw) => {
|
||||
let available: Map<string, AudioOutputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// Create a virtual default audio output for browsers that don't have one.
|
||||
// Its device ID must be the empty string because that's what setSinkId
|
||||
// recognizes.
|
||||
if (available.size && !available.has("") && !available.has("default"))
|
||||
available.set("", {
|
||||
type: "default",
|
||||
name: availableRaw[0]?.label || null,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const isSafari = !!(window as any).GestureEvent; // non standard api only found on Safari. https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent#browser_compatibility
|
||||
if (isSafari) {
|
||||
// set to empty map if we are on Safari, because it does not support setSinkId
|
||||
available = new Map();
|
||||
}
|
||||
// Note: creating virtual default input devices would be another problem
|
||||
// entirely, because requesting a media stream from deviceId "" won't
|
||||
// automatically track the default device.
|
||||
return available;
|
||||
}),
|
||||
),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public readonly selected$ = this.scope.behavior(
|
||||
selectDevice$(this.available$, audioOutputSetting.value$).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
virtualEarpiece: false,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
public select(id: string): void {
|
||||
audioOutputSetting.setValue(id);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
this.available$.subscribe((available) => {
|
||||
logger.info("[audio-output] available devices:", available);
|
||||
this.logger.info("[audio-output] available devices:", available);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -264,30 +277,43 @@ class AudioOutput
|
||||
class ControlledAudioOutput
|
||||
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
|
||||
{
|
||||
public readonly available$ = combineLatest(
|
||||
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
|
||||
(availableRaw, iosDeviceMenu) => {
|
||||
const available = new Map<string, AudioOutputDeviceLabel>(
|
||||
availableRaw.map(
|
||||
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
|
||||
let deviceLabel: AudioOutputDeviceLabel;
|
||||
// if (isExternalHeadset) // Do we want this?
|
||||
if (isEarpiece) deviceLabel = { type: "earpiece" };
|
||||
else if (isSpeaker) deviceLabel = { type: "speaker" };
|
||||
else deviceLabel = { type: "name", name };
|
||||
return [id, deviceLabel];
|
||||
},
|
||||
),
|
||||
);
|
||||
private logger = rootLogger.getChild("[MediaDevices ControlledAudioOutput]");
|
||||
// We need to subscribe to the raw devices so that the OS does update the input
|
||||
// back to what it was before. otherwise we will switch back to the default
|
||||
// whenever we allocate a new stream.
|
||||
public readonly availableRaw$ = availableRawDevices$(
|
||||
"audiooutput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
// Create a virtual earpiece device in case a non-earpiece device is
|
||||
// designated for this purpose
|
||||
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece))
|
||||
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
|
||||
public readonly available$ = this.scope.behavior(
|
||||
combineLatest(
|
||||
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
|
||||
(availableRaw, iosDeviceMenu) => {
|
||||
const available = new Map<string, AudioOutputDeviceLabel>(
|
||||
availableRaw.map(
|
||||
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
|
||||
let deviceLabel: AudioOutputDeviceLabel;
|
||||
// if (isExternalHeadset) // Do we want this?
|
||||
if (isEarpiece) deviceLabel = { type: "earpiece" };
|
||||
else if (isSpeaker) deviceLabel = { type: "speaker" };
|
||||
else deviceLabel = { type: "name", name };
|
||||
return [id, deviceLabel];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return available;
|
||||
},
|
||||
).pipe(this.scope.state());
|
||||
// Create a virtual earpiece device in case a non-earpiece device is
|
||||
// designated for this purpose
|
||||
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece))
|
||||
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
|
||||
|
||||
return available;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
private readonly deviceSelection$ = new Subject<string>();
|
||||
|
||||
@@ -295,67 +321,82 @@ class ControlledAudioOutput
|
||||
this.deviceSelection$.next(id);
|
||||
}
|
||||
|
||||
public readonly selected$ = combineLatest(
|
||||
[
|
||||
this.available$,
|
||||
merge(
|
||||
controlledOutputSelection$.pipe(startWith(undefined)),
|
||||
this.deviceSelection$,
|
||||
),
|
||||
],
|
||||
(available, preferredId) => {
|
||||
const id = preferredId ?? available.keys().next().value;
|
||||
return id === undefined
|
||||
? undefined
|
||||
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
|
||||
},
|
||||
).pipe(this.scope.state());
|
||||
public readonly selected$ = this.scope.behavior(
|
||||
combineLatest(
|
||||
[
|
||||
this.available$,
|
||||
merge(
|
||||
controlledOutputSelection$.pipe(startWith(undefined)),
|
||||
this.deviceSelection$,
|
||||
),
|
||||
],
|
||||
(available, preferredId) => {
|
||||
const id = preferredId ?? available.keys().next().value;
|
||||
return id === undefined
|
||||
? undefined
|
||||
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
public constructor(private readonly scope: ObservableScope) {
|
||||
public constructor(
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
this.selected$.subscribe((device) => {
|
||||
// Let the hosting application know which output device has been selected.
|
||||
// This information is probably only of interest if the earpiece mode has
|
||||
// been selected - for example, Element X iOS listens to this to determine
|
||||
// whether it should enable the proximity sensor.
|
||||
if (device !== undefined) {
|
||||
logger.info("[controlled-output] setAudioDeviceSelect called:", device);
|
||||
this.logger.info(
|
||||
"[controlled-output] onAudioDeviceSelect called:",
|
||||
device,
|
||||
);
|
||||
window.controls.onAudioDeviceSelect?.(device.id);
|
||||
// Also invoke the deprecated callback for backward compatibility
|
||||
window.controls.onOutputDeviceSelect?.(device.id);
|
||||
}
|
||||
});
|
||||
this.available$.subscribe((available) => {
|
||||
logger.info("[controlled-output] available devices:", available);
|
||||
this.logger.info("[controlled-output] available devices:", available);
|
||||
});
|
||||
this.availableRaw$.subscribe((availableRaw) => {
|
||||
this.logger.info(
|
||||
"[controlled-output] available raw devices:",
|
||||
availableRaw,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
|
||||
public readonly available$ = availableRawDevices$(
|
||||
"videoinput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
).pipe(map(buildDeviceMap));
|
||||
private logger = rootLogger.getChild("[MediaDevices VideoInput]");
|
||||
|
||||
public readonly selected$ = selectDevice$(
|
||||
this.available$,
|
||||
videoInputSetting.value$,
|
||||
).pipe(
|
||||
map((id) => (id === undefined ? undefined : { id })),
|
||||
this.scope.state(),
|
||||
public readonly available$ = this.scope.behavior(
|
||||
availableRawDevices$(
|
||||
"videoinput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
this.logger,
|
||||
).pipe(map(buildDeviceMap)),
|
||||
);
|
||||
public readonly selected$ = this.scope.behavior(
|
||||
selectDevice$(this.available$, videoInputSetting.value$).pipe(
|
||||
map((id) => (id === undefined ? undefined : { id })),
|
||||
),
|
||||
);
|
||||
|
||||
public select(id: string): void {
|
||||
videoInputSetting.setValue(id);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
// This also has the purpose of subscribing to the available devices
|
||||
this.available$.subscribe((available) => {
|
||||
logger.info("[video-input] available devices:", available);
|
||||
this.logger.info("[video-input] available devices:", available);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -378,12 +419,10 @@ export class MediaDevices {
|
||||
// you to do to receive device names in lieu of a more explicit permissions
|
||||
// API. This flag never resets to false, because once permissions are granted
|
||||
// the first time, the user won't be prompted again until reload of the page.
|
||||
private readonly usingNames$ = this.deviceNamesRequest$.pipe(
|
||||
map(() => true),
|
||||
startWith(false),
|
||||
this.scope.state(),
|
||||
private readonly usingNames$ = this.scope.behavior(
|
||||
this.deviceNamesRequest$.pipe(map(() => true)),
|
||||
false,
|
||||
);
|
||||
|
||||
public readonly audioInput: MediaDevice<
|
||||
DeviceLabel,
|
||||
SelectedAudioInputDevice
|
||||
@@ -393,7 +432,7 @@ export class MediaDevices {
|
||||
AudioOutputDeviceLabel,
|
||||
SelectedAudioOutputDevice
|
||||
> = getUrlParams().controlledAudioDevices
|
||||
? new ControlledAudioOutput(this.scope)
|
||||
? new ControlledAudioOutput(this.usingNames$, this.scope)
|
||||
: new AudioOutput(this.usingNames$, this.scope);
|
||||
|
||||
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
|
||||
|
||||
@@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, onTestFinished, test, vi } from "vitest";
|
||||
import { of } from "rxjs";
|
||||
import {
|
||||
type LocalTrackPublication,
|
||||
LocalVideoTrack,
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
withTestScheduler,
|
||||
} from "../utils/test";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { constant } from "./Behavior";
|
||||
|
||||
global.MediaStreamTrack = class {} as unknown as {
|
||||
new (): MediaStreamTrack;
|
||||
@@ -174,8 +174,8 @@ test("switch cameras", async () => {
|
||||
}),
|
||||
mockMediaDevices({
|
||||
videoInput: {
|
||||
available$: of(new Map()),
|
||||
selected$: of(undefined),
|
||||
available$: constant(new Map()),
|
||||
selected$: constant(undefined),
|
||||
select: selectVideoInput,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -55,26 +55,19 @@ import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { type ReactionOption } from "../reactions";
|
||||
import { platform } from "../Platform";
|
||||
import { type MediaDevices } from "./MediaDevices";
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
export function observeTrackReference$(
|
||||
participant$: Observable<Participant | undefined>,
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<TrackReferenceOrPlaceholder | undefined> {
|
||||
return participant$.pipe(
|
||||
switchMap((p) => {
|
||||
if (p) {
|
||||
return observeParticipantMedia(p).pipe(
|
||||
map(() => ({
|
||||
participant: p,
|
||||
publication: p.getTrackPublication(source),
|
||||
source,
|
||||
})),
|
||||
distinctUntilKeyChanged("publication"),
|
||||
);
|
||||
} else {
|
||||
return of(undefined);
|
||||
}
|
||||
}),
|
||||
): Observable<TrackReferenceOrPlaceholder> {
|
||||
return observeParticipantMedia(participant).pipe(
|
||||
map(() => ({
|
||||
participant: participant,
|
||||
publication: participant.getTrackPublication(source),
|
||||
source,
|
||||
})),
|
||||
distinctUntilKeyChanged("publication"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +79,7 @@ export function observeRtpStreamStats$(
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
> {
|
||||
return combineLatest([
|
||||
observeTrackReference$(of(participant), source),
|
||||
observeTrackReference$(participant, source),
|
||||
interval(1000).pipe(startWith(0)),
|
||||
]).pipe(
|
||||
switchMap(async ([trackReference]) => {
|
||||
@@ -227,19 +220,31 @@ abstract class BaseMediaViewModel extends ViewModel {
|
||||
/**
|
||||
* The LiveKit video track for this media.
|
||||
*/
|
||||
public readonly video$: Observable<TrackReferenceOrPlaceholder | undefined>;
|
||||
public readonly video$: Behavior<TrackReferenceOrPlaceholder | undefined>;
|
||||
/**
|
||||
* Whether there should be a warning that this media is unencrypted.
|
||||
*/
|
||||
public readonly unencryptedWarning$: Observable<boolean>;
|
||||
public readonly unencryptedWarning$: Behavior<boolean>;
|
||||
|
||||
public readonly encryptionStatus$: Observable<EncryptionStatus>;
|
||||
public readonly encryptionStatus$: Behavior<EncryptionStatus>;
|
||||
|
||||
/**
|
||||
* Whether this media corresponds to the local participant.
|
||||
*/
|
||||
public abstract readonly local: boolean;
|
||||
|
||||
private observeTrackReference$(
|
||||
source: Track.Source,
|
||||
): Behavior<TrackReferenceOrPlaceholder | undefined> {
|
||||
return this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p === undefined ? of(undefined) : observeTrackReference$(p, source),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
/**
|
||||
* An opaque identifier for this media.
|
||||
@@ -261,84 +266,85 @@ abstract class BaseMediaViewModel extends ViewModel {
|
||||
audioSource: AudioSource,
|
||||
videoSource: VideoSource,
|
||||
livekitRoom: LivekitRoom,
|
||||
public readonly displayname$: Observable<string>,
|
||||
public readonly displayName$: Behavior<string>,
|
||||
) {
|
||||
super();
|
||||
const audio$ = observeTrackReference$(participant$, audioSource).pipe(
|
||||
this.scope.state(),
|
||||
);
|
||||
this.video$ = observeTrackReference$(participant$, videoSource).pipe(
|
||||
this.scope.state(),
|
||||
);
|
||||
this.unencryptedWarning$ = combineLatest(
|
||||
[audio$, this.video$],
|
||||
(a, v) =>
|
||||
encryptionSystem.kind !== E2eeType.NONE &&
|
||||
(a?.publication?.isEncrypted === false ||
|
||||
v?.publication?.isEncrypted === false),
|
||||
).pipe(this.scope.state());
|
||||
|
||||
this.encryptionStatus$ = this.participant$.pipe(
|
||||
switchMap((participant): Observable<EncryptionStatus> => {
|
||||
if (!participant) {
|
||||
return of(EncryptionStatus.Connecting);
|
||||
} else if (
|
||||
participant.isLocal ||
|
||||
encryptionSystem.kind === E2eeType.NONE
|
||||
) {
|
||||
return of(EncryptionStatus.Okay);
|
||||
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"MissingKey",
|
||||
),
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
||||
if (keyMissing) return EncryptionStatus.KeyMissing;
|
||||
if (keyInvalid) return EncryptionStatus.KeyInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
}),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
} else {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(
|
||||
([keyInvalid, audioOkay, videoOkay]):
|
||||
| EncryptionStatus
|
||||
| undefined => {
|
||||
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
|
||||
const audio$ = this.observeTrackReference$(audioSource);
|
||||
this.video$ = this.observeTrackReference$(videoSource);
|
||||
|
||||
this.unencryptedWarning$ = this.scope.behavior(
|
||||
combineLatest(
|
||||
[audio$, this.video$],
|
||||
(a, v) =>
|
||||
encryptionSystem.kind !== E2eeType.NONE &&
|
||||
(a?.publication?.isEncrypted === false ||
|
||||
v?.publication?.isEncrypted === false),
|
||||
),
|
||||
);
|
||||
|
||||
this.encryptionStatus$ = this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((participant): Observable<EncryptionStatus> => {
|
||||
if (!participant) {
|
||||
return of(EncryptionStatus.Connecting);
|
||||
} else if (
|
||||
participant.isLocal ||
|
||||
encryptionSystem.kind === E2eeType.NONE
|
||||
) {
|
||||
return of(EncryptionStatus.Okay);
|
||||
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"MissingKey",
|
||||
),
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
||||
if (keyMissing) return EncryptionStatus.KeyMissing;
|
||||
if (keyInvalid) return EncryptionStatus.KeyInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
},
|
||||
),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
}
|
||||
}),
|
||||
this.scope.state(),
|
||||
}),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
} else {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(
|
||||
([keyInvalid, audioOkay, videoOkay]):
|
||||
| EncryptionStatus
|
||||
| undefined => {
|
||||
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
},
|
||||
),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -358,31 +364,33 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
/**
|
||||
* Whether the participant is speaking.
|
||||
*/
|
||||
public readonly speaking$ = this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe(
|
||||
map((p) => p.isSpeaking),
|
||||
)
|
||||
: of(false),
|
||||
public readonly speaking$ = this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
? observeParticipantEvents(
|
||||
p,
|
||||
ParticipantEvent.IsSpeakingChanged,
|
||||
).pipe(map((p) => p.isSpeaking))
|
||||
: of(false),
|
||||
),
|
||||
),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
||||
*/
|
||||
public readonly audioEnabled$: Observable<boolean>;
|
||||
public readonly audioEnabled$: Behavior<boolean>;
|
||||
/**
|
||||
* Whether this participant is sending video.
|
||||
*/
|
||||
public readonly videoEnabled$: Observable<boolean>;
|
||||
public readonly videoEnabled$: Behavior<boolean>;
|
||||
|
||||
private readonly _cropVideo$ = new BehaviorSubject(true);
|
||||
/**
|
||||
* Whether the tile video should be contained inside the tile or be cropped to fit.
|
||||
*/
|
||||
public readonly cropVideo$: Observable<boolean> = this._cropVideo$;
|
||||
public readonly cropVideo$: Behavior<boolean> = this._cropVideo$;
|
||||
|
||||
public constructor(
|
||||
id: string,
|
||||
@@ -390,9 +398,9 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant | undefined>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
displayname$: Observable<string>,
|
||||
public readonly handRaised$: Observable<Date | null>,
|
||||
public readonly reaction$: Observable<ReactionOption | null>,
|
||||
displayName$: Behavior<string>,
|
||||
public readonly handRaised$: Behavior<Date | null>,
|
||||
public readonly reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
id,
|
||||
@@ -402,18 +410,19 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
Track.Source.Microphone,
|
||||
Track.Source.Camera,
|
||||
livekitRoom,
|
||||
displayname$,
|
||||
displayName$,
|
||||
);
|
||||
|
||||
const media$ = participant$.pipe(
|
||||
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
||||
this.scope.state(),
|
||||
const media$ = this.scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
||||
),
|
||||
);
|
||||
this.audioEnabled$ = media$.pipe(
|
||||
map((m) => m?.microphoneTrack?.isMuted === false),
|
||||
this.audioEnabled$ = this.scope.behavior(
|
||||
media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
|
||||
);
|
||||
this.videoEnabled$ = media$.pipe(
|
||||
map((m) => m?.cameraTrack?.isMuted === false),
|
||||
this.videoEnabled$ = this.scope.behavior(
|
||||
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -460,13 +469,15 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
/**
|
||||
* Whether the video should be mirrored.
|
||||
*/
|
||||
public readonly mirror$ = this.videoTrack$.pipe(
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
map(
|
||||
(track) =>
|
||||
track !== null && facingModeFromLocalTrack(track).facingMode === "user",
|
||||
public readonly mirror$ = this.scope.behavior(
|
||||
this.videoTrack$.pipe(
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
map(
|
||||
(track) =>
|
||||
track !== null &&
|
||||
facingModeFromLocalTrack(track).facingMode === "user",
|
||||
),
|
||||
),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -479,46 +490,48 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
/**
|
||||
* Callback for switching between the front and back cameras.
|
||||
*/
|
||||
public readonly switchCamera$: Observable<(() => void) | null> =
|
||||
platform === "desktop"
|
||||
? of(null)
|
||||
: this.videoTrack$.pipe(
|
||||
map((track) => {
|
||||
if (track === null) return null;
|
||||
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
||||
// If the camera isn't front or back-facing, don't provide a switch
|
||||
// camera shortcut at all
|
||||
if (facingMode !== "user" && facingMode !== "environment")
|
||||
return null;
|
||||
// Restart the track with a camera facing the opposite direction
|
||||
return (): void =>
|
||||
void track
|
||||
.restartTrack({
|
||||
facingMode: facingMode === "user" ? "environment" : "user",
|
||||
})
|
||||
.then(() => {
|
||||
// Inform the MediaDevices which camera was chosen
|
||||
const deviceId =
|
||||
track.mediaStreamTrack.getSettings().deviceId;
|
||||
if (deviceId !== undefined)
|
||||
this.mediaDevices.videoInput.select(deviceId);
|
||||
})
|
||||
.catch((e) =>
|
||||
logger.error("Failed to switch camera", facingMode, e),
|
||||
);
|
||||
}),
|
||||
);
|
||||
public readonly switchCamera$: Behavior<(() => void) | null> =
|
||||
this.scope.behavior(
|
||||
platform === "desktop"
|
||||
? of(null)
|
||||
: this.videoTrack$.pipe(
|
||||
map((track) => {
|
||||
if (track === null) return null;
|
||||
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
||||
// If the camera isn't front or back-facing, don't provide a switch
|
||||
// camera shortcut at all
|
||||
if (facingMode !== "user" && facingMode !== "environment")
|
||||
return null;
|
||||
// Restart the track with a camera facing the opposite direction
|
||||
return (): void =>
|
||||
void track
|
||||
.restartTrack({
|
||||
facingMode: facingMode === "user" ? "environment" : "user",
|
||||
})
|
||||
.then(() => {
|
||||
// Inform the MediaDevices which camera was chosen
|
||||
const deviceId =
|
||||
track.mediaStreamTrack.getSettings().deviceId;
|
||||
if (deviceId !== undefined)
|
||||
this.mediaDevices.videoInput.select(deviceId);
|
||||
})
|
||||
.catch((e) =>
|
||||
logger.error("Failed to switch camera", facingMode, e),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
id: string,
|
||||
member: RoomMember | undefined,
|
||||
participant$: Observable<LocalParticipant | undefined>,
|
||||
participant$: Behavior<LocalParticipant | undefined>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
private readonly mediaDevices: MediaDevices,
|
||||
displayname$: Observable<string>,
|
||||
handRaised$: Observable<Date | null>,
|
||||
reaction$: Observable<ReactionOption | null>,
|
||||
displayName$: Behavior<string>,
|
||||
handRaised$: Behavior<Date | null>,
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
id,
|
||||
@@ -526,7 +539,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
participant$,
|
||||
encryptionSystem,
|
||||
livekitRoom,
|
||||
displayname$,
|
||||
displayName$,
|
||||
handRaised$,
|
||||
reaction$,
|
||||
);
|
||||
@@ -565,42 +578,42 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
* The volume to which this participant's audio is set, as a scalar
|
||||
* multiplier.
|
||||
*/
|
||||
public readonly localVolume$: Observable<number> = merge(
|
||||
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
|
||||
this.localVolumeAdjustment$,
|
||||
this.localVolumeCommit$.pipe(map(() => "commit" as const)),
|
||||
).pipe(
|
||||
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
|
||||
switch (event) {
|
||||
case "toggle mute":
|
||||
return {
|
||||
...state,
|
||||
volume: state.volume === 0 ? state.committedVolume : 0,
|
||||
};
|
||||
case "commit":
|
||||
// Dragging the slider to zero should have the same effect as
|
||||
// muting: keep the original committed volume, as if it were never
|
||||
// dragged
|
||||
return {
|
||||
...state,
|
||||
committedVolume:
|
||||
state.volume === 0 ? state.committedVolume : state.volume,
|
||||
};
|
||||
default:
|
||||
// Volume adjustment
|
||||
return { ...state, volume: event };
|
||||
}
|
||||
}),
|
||||
map(({ volume }) => volume),
|
||||
this.scope.state(),
|
||||
public readonly localVolume$ = this.scope.behavior<number>(
|
||||
merge(
|
||||
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
|
||||
this.localVolumeAdjustment$,
|
||||
this.localVolumeCommit$.pipe(map(() => "commit" as const)),
|
||||
).pipe(
|
||||
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
|
||||
switch (event) {
|
||||
case "toggle mute":
|
||||
return {
|
||||
...state,
|
||||
volume: state.volume === 0 ? state.committedVolume : 0,
|
||||
};
|
||||
case "commit":
|
||||
// Dragging the slider to zero should have the same effect as
|
||||
// muting: keep the original committed volume, as if it were never
|
||||
// dragged
|
||||
return {
|
||||
...state,
|
||||
committedVolume:
|
||||
state.volume === 0 ? state.committedVolume : state.volume,
|
||||
};
|
||||
default:
|
||||
// Volume adjustment
|
||||
return { ...state, volume: event };
|
||||
}
|
||||
}),
|
||||
map(({ volume }) => volume),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether this participant's audio is disabled.
|
||||
*/
|
||||
public readonly locallyMuted$: Observable<boolean> = this.localVolume$.pipe(
|
||||
map((volume) => volume === 0),
|
||||
this.scope.state(),
|
||||
public readonly locallyMuted$ = this.scope.behavior<boolean>(
|
||||
this.localVolume$.pipe(map((volume) => volume === 0)),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
@@ -609,9 +622,9 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
participant$: Observable<RemoteParticipant | undefined>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
displayname$: Observable<string>,
|
||||
handRaised$: Observable<Date | null>,
|
||||
reaction$: Observable<ReactionOption | null>,
|
||||
displayname$: Behavior<string>,
|
||||
handRaised$: Behavior<Date | null>,
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
id,
|
||||
@@ -674,7 +687,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
displayname$: Observable<string>,
|
||||
displayname$: Behavior<string>,
|
||||
public readonly local: boolean,
|
||||
) {
|
||||
super(
|
||||
|
||||
@@ -26,11 +26,9 @@ test("muteAllAudio$", () => {
|
||||
|
||||
muteAllAudio.unsubscribe();
|
||||
|
||||
expect(valueMock).toHaveBeenCalledTimes(6);
|
||||
expect(valueMock).toHaveBeenCalledTimes(4);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(1, false); // startWith([false, muteAllAudioSetting.getValue()]);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(2, true); // setAudioEnabled$.next(false);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(3, false); // setAudioEnabled$.next(true);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(4, false); // muteAllAudioSetting.setValue(false);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(5, true); // muteAllAudioSetting.setValue(true);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(6, true); // setAudioEnabled$.next(false);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(4, true); // muteAllAudioSetting.setValue(true);
|
||||
});
|
||||
|
||||
@@ -9,11 +9,14 @@ import { combineLatest, startWith } from "rxjs";
|
||||
|
||||
import { setAudioEnabled$ } from "../controls";
|
||||
import { muteAllAudio as muteAllAudioSetting } from "../settings/settings";
|
||||
import { globalScope } from "./ObservableScope";
|
||||
|
||||
/**
|
||||
* This can transition into sth more complete: `GroupCallViewModel.ts`
|
||||
*/
|
||||
export const muteAllAudio$ = combineLatest(
|
||||
[setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$],
|
||||
(outputEnabled, settingsMute) => !outputEnabled || settingsMute,
|
||||
export const muteAllAudio$ = globalScope.behavior(
|
||||
combineLatest(
|
||||
[setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$],
|
||||
(outputEnabled, settingsMute) => !outputEnabled || settingsMute,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,15 +6,19 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
BehaviorSubject,
|
||||
distinctUntilChanged,
|
||||
type Observable,
|
||||
shareReplay,
|
||||
Subject,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
|
||||
|
||||
const nothing = Symbol("nothing");
|
||||
|
||||
/**
|
||||
* A scope which limits the execution lifetime of its bound Observables.
|
||||
*/
|
||||
@@ -31,20 +35,31 @@ export class ObservableScope {
|
||||
return this.bindImpl;
|
||||
}
|
||||
|
||||
private readonly stateImpl: MonoTypeOperator = (o$) =>
|
||||
o$.pipe(
|
||||
this.bind(),
|
||||
distinctUntilChanged(),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
/**
|
||||
* Transforms an Observable into a hot state Observable which replays its
|
||||
* latest value upon subscription, skips updates with identical values, and
|
||||
* is bound to this scope.
|
||||
* Converts an Observable to a Behavior. If no initial value is specified, the
|
||||
* Observable must synchronously emit an initial value.
|
||||
*/
|
||||
public state(): MonoTypeOperator {
|
||||
return this.stateImpl;
|
||||
public behavior<T>(
|
||||
setValue$: Observable<T>,
|
||||
initialValue: T | typeof nothing = nothing,
|
||||
): Behavior<T> {
|
||||
const subject$ = new BehaviorSubject(initialValue);
|
||||
// Push values from the Observable into the BehaviorSubject.
|
||||
// BehaviorSubjects have an undesirable feature where if you call 'complete',
|
||||
// they will no longer re-emit their current value upon subscription. We want
|
||||
// to support Observables that complete (for example `of({})`), so we have to
|
||||
// take care to not propagate the completion event.
|
||||
setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({
|
||||
next(value) {
|
||||
subject$.next(value);
|
||||
},
|
||||
error(err: unknown) {
|
||||
subject$.error(err);
|
||||
},
|
||||
});
|
||||
if (subject$.value === nothing)
|
||||
throw new Error("Behavior failed to synchronously emit an initial value");
|
||||
return subject$ as Behavior<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,3 +70,8 @@ export class ObservableScope {
|
||||
this.ended$.complete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The global scope, a scope which never ends.
|
||||
*/
|
||||
export const globalScope = new ObservableScope();
|
||||
|
||||
@@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Observable } from "rxjs";
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
let nextId = 0;
|
||||
function createId(): string {
|
||||
@@ -18,15 +17,15 @@ function createId(): string {
|
||||
export class GridTileViewModel extends ViewModel {
|
||||
public readonly id = createId();
|
||||
|
||||
public constructor(public readonly media$: Observable<UserMediaViewModel>) {
|
||||
public constructor(public readonly media$: Behavior<UserMediaViewModel>) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class SpotlightTileViewModel extends ViewModel {
|
||||
public constructor(
|
||||
public readonly media$: Observable<MediaViewModel[]>,
|
||||
public readonly maximised$: Observable<boolean>,
|
||||
public readonly media$: Behavior<MediaViewModel[]>,
|
||||
public readonly maximised$: Behavior<boolean>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user