Merge pull request #3626 from robintown/non-publishing-participants
Don't show 'waiting for media' on connected participants
This commit is contained in:
@@ -358,7 +358,7 @@ export class Publisher {
|
|||||||
const track$ = scope.behavior(
|
const track$ = scope.behavior(
|
||||||
observeTrackReference$(room.localParticipant, Track.Source.Camera).pipe(
|
observeTrackReference$(room.localParticipant, Track.Source.Camera).pipe(
|
||||||
map((trackRef) => {
|
map((trackRef) => {
|
||||||
const track = trackRef?.publication?.track;
|
const track = trackRef?.publication.track;
|
||||||
return track instanceof LocalVideoTrack ? track : null;
|
return track instanceof LocalVideoTrack ? track : null;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
ElementCallError,
|
ElementCallError,
|
||||||
FailToGetOpenIdToken,
|
FailToGetOpenIdToken,
|
||||||
} from "../../../utils/errors.ts";
|
} from "../../../utils/errors.ts";
|
||||||
|
import { mockRemoteParticipant } from "../../../utils/test.ts";
|
||||||
|
|
||||||
let testScope: ObservableScope;
|
let testScope: ObservableScope;
|
||||||
|
|
||||||
@@ -376,46 +377,32 @@ describe("Start connection states", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function fakeRemoteLivekitParticipant(
|
describe("remote participants", () => {
|
||||||
id: string,
|
it("emits the list of remote participants", () => {
|
||||||
publications: number = 1,
|
|
||||||
): RemoteParticipant {
|
|
||||||
return {
|
|
||||||
identity: id,
|
|
||||||
getTrackPublications: () => Array(publications),
|
|
||||||
} as unknown as RemoteParticipant;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Publishing participants observations", () => {
|
|
||||||
it("should emit the list of publishing participants", () => {
|
|
||||||
setupTest();
|
setupTest();
|
||||||
|
|
||||||
const connection = setupRemoteConnection();
|
const connection = setupRemoteConnection();
|
||||||
|
|
||||||
const bobIsAPublisher = Promise.withResolvers<void>();
|
const observedParticipants: RemoteParticipant[][] = [];
|
||||||
const danIsAPublisher = Promise.withResolvers<void>();
|
const s = connection.remoteParticipants$.subscribe((participants) => {
|
||||||
const observedPublishers: RemoteParticipant[][] = [];
|
observedParticipants.push(participants);
|
||||||
const s = connection.remoteParticipantsWithTracks$.subscribe(
|
});
|
||||||
(publishers) => {
|
|
||||||
observedPublishers.push(publishers);
|
|
||||||
if (publishers.some((p) => p.identity === "@bob:example.org:DEV111")) {
|
|
||||||
bobIsAPublisher.resolve();
|
|
||||||
}
|
|
||||||
if (publishers.some((p) => p.identity === "@dan:example.org:DEV333")) {
|
|
||||||
danIsAPublisher.resolve();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
onTestFinished(() => s.unsubscribe());
|
onTestFinished(() => s.unsubscribe());
|
||||||
// The remoteParticipants$ observable is derived from the current members of the
|
// The remoteParticipants$ observable is derived from the current members of the
|
||||||
// livekitRoom and the rtc membership in order to publish the members that are publishing
|
// livekitRoom and the rtc membership in order to publish the members that are publishing
|
||||||
// on this connection.
|
// on this connection.
|
||||||
|
|
||||||
let participants: RemoteParticipant[] = [
|
const participants: RemoteParticipant[] = [
|
||||||
fakeRemoteLivekitParticipant("@alice:example.org:DEV000", 0),
|
mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
|
||||||
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 0),
|
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||||
fakeRemoteLivekitParticipant("@carol:example.org:DEV222", 0),
|
mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
|
||||||
fakeRemoteLivekitParticipant("@dan:example.org:DEV333", 0),
|
// Mock Dan to have no published tracks. We want him to still show show up
|
||||||
|
// in the participants list.
|
||||||
|
mockRemoteParticipant({
|
||||||
|
identity: "@dan:example.org:DEV333",
|
||||||
|
getTrackPublication: () => undefined,
|
||||||
|
getTrackPublications: () => [],
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Let's simulate 3 members on the livekitRoom
|
// Let's simulate 3 members on the livekitRoom
|
||||||
@@ -427,21 +414,8 @@ describe("Publishing participants observations", () => {
|
|||||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
|
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
|
||||||
);
|
);
|
||||||
|
|
||||||
// At this point there should be no publishers
|
// All remote participants should be present
|
||||||
expect(observedPublishers.pop()!.length).toEqual(0);
|
expect(observedParticipants.pop()!.length).toEqual(4);
|
||||||
|
|
||||||
participants = [
|
|
||||||
fakeRemoteLivekitParticipant("@alice:example.org:DEV000", 1),
|
|
||||||
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 1),
|
|
||||||
fakeRemoteLivekitParticipant("@carol:example.org:DEV222", 1),
|
|
||||||
fakeRemoteLivekitParticipant("@dan:example.org:DEV333", 2),
|
|
||||||
];
|
|
||||||
participants.forEach((p) =>
|
|
||||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
|
|
||||||
);
|
|
||||||
|
|
||||||
// At this point there should be no publishers
|
|
||||||
expect(observedPublishers.pop()!.length).toEqual(4);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be scoped to parent scope", (): void => {
|
it("should be scoped to parent scope", (): void => {
|
||||||
@@ -449,16 +423,14 @@ describe("Publishing participants observations", () => {
|
|||||||
|
|
||||||
const connection = setupRemoteConnection();
|
const connection = setupRemoteConnection();
|
||||||
|
|
||||||
let observedPublishers: RemoteParticipant[][] = [];
|
let observedParticipants: RemoteParticipant[][] = [];
|
||||||
const s = connection.remoteParticipantsWithTracks$.subscribe(
|
const s = connection.remoteParticipants$.subscribe((participants) => {
|
||||||
(publishers) => {
|
observedParticipants.push(participants);
|
||||||
observedPublishers.push(publishers);
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
onTestFinished(() => s.unsubscribe());
|
onTestFinished(() => s.unsubscribe());
|
||||||
|
|
||||||
let participants: RemoteParticipant[] = [
|
let participants: RemoteParticipant[] = [
|
||||||
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 0),
|
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Let's simulate 3 members on the livekitRoom
|
// Let's simulate 3 members on the livekitRoom
|
||||||
@@ -470,35 +442,26 @@ describe("Publishing participants observations", () => {
|
|||||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant);
|
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant);
|
||||||
}
|
}
|
||||||
|
|
||||||
// At this point there should be no publishers
|
// We should have bob as a participant now
|
||||||
expect(observedPublishers.pop()!.length).toEqual(0);
|
const ps = observedParticipants.pop();
|
||||||
|
expect(ps?.length).toEqual(1);
|
||||||
participants = [fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 1)];
|
expect(ps?.[0]?.identity).toEqual("@bob:example.org:DEV111");
|
||||||
|
|
||||||
for (const participant of participants) {
|
|
||||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant);
|
|
||||||
}
|
|
||||||
|
|
||||||
// We should have bob has a publisher now
|
|
||||||
const publishers = observedPublishers.pop();
|
|
||||||
expect(publishers?.length).toEqual(1);
|
|
||||||
expect(publishers?.[0]?.identity).toEqual("@bob:example.org:DEV111");
|
|
||||||
|
|
||||||
// end the parent scope
|
// end the parent scope
|
||||||
testScope.end();
|
testScope.end();
|
||||||
observedPublishers = [];
|
observedParticipants = [];
|
||||||
|
|
||||||
// SHOULD NOT emit any more publishers as the scope is ended
|
// SHOULD NOT emit any more participants as the scope is ended
|
||||||
participants = participants.filter(
|
participants = participants.filter(
|
||||||
(p) => p.identity !== "@bob:example.org:DEV111",
|
(p) => p.identity !== "@bob:example.org:DEV111",
|
||||||
);
|
);
|
||||||
|
|
||||||
fakeLivekitRoom.emit(
|
fakeLivekitRoom.emit(
|
||||||
RoomEvent.ParticipantDisconnected,
|
RoomEvent.ParticipantDisconnected,
|
||||||
fakeRemoteLivekitParticipant("@bob:example.org:DEV111"),
|
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(observedPublishers.length).toEqual(0);
|
expect(observedParticipants.length).toEqual(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
ConnectionError,
|
ConnectionError,
|
||||||
type Room as LivekitRoom,
|
type Room as LivekitRoom,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
RoomEvent,
|
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { BehaviorSubject, map } from "rxjs";
|
import { BehaviorSubject, map } from "rxjs";
|
||||||
@@ -96,11 +95,13 @@ export class Connection {
|
|||||||
private scope: ObservableScope;
|
private scope: ObservableScope;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An observable of the participants that are publishing on this connection. (Excluding our local participant)
|
* The remote LiveKit participants that are visible on this connection.
|
||||||
* This is derived from `participantsIncludingSubscribers$` and `remoteTransports$`.
|
*
|
||||||
* It filters the participants to only those that are associated with a membership that claims to publish on this connection.
|
* Note that this may include participants that are connected only to
|
||||||
|
* subscribe, or publishers that are otherwise unattested in MatrixRTC state.
|
||||||
|
* It is therefore more low-level than what should be presented to the user.
|
||||||
*/
|
*/
|
||||||
public readonly remoteParticipantsWithTracks$: Behavior<RemoteParticipant[]>;
|
public readonly remoteParticipants$: Behavior<RemoteParticipant[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the connection has been stopped.
|
* Whether the connection has been stopped.
|
||||||
@@ -231,23 +232,9 @@ export class Connection {
|
|||||||
this.transport = transport;
|
this.transport = transport;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
|
|
||||||
// REMOTE participants with track!!!
|
this.remoteParticipants$ = scope.behavior(
|
||||||
// this.remoteParticipantsWithTracks$
|
// Only tracks remote participants
|
||||||
this.remoteParticipantsWithTracks$ = scope.behavior(
|
connectedParticipantsObserver(this.livekitRoom),
|
||||||
// only tracks remote participants
|
|
||||||
connectedParticipantsObserver(this.livekitRoom, {
|
|
||||||
additionalRoomEvents: [
|
|
||||||
RoomEvent.TrackPublished,
|
|
||||||
RoomEvent.TrackUnpublished,
|
|
||||||
],
|
|
||||||
}).pipe(
|
|
||||||
map((participants) => {
|
|
||||||
return participants.filter(
|
|
||||||
(participant) => participant.getTrackPublications().length > 0,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
scope.onEnd(() => {
|
scope.onEnd(() => {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ beforeEach(() => {
|
|||||||
(transport: LivekitTransport, scope: ObservableScope) => {
|
(transport: LivekitTransport, scope: ObservableScope) => {
|
||||||
const mockConnection = {
|
const mockConnection = {
|
||||||
transport,
|
transport,
|
||||||
remoteParticipantsWithTracks$: new BehaviorSubject([]),
|
remoteParticipants$: new BehaviorSubject([]),
|
||||||
} as unknown as Connection;
|
} as unknown as Connection;
|
||||||
vi.mocked(mockConnection).start = vi.fn();
|
vi.mocked(mockConnection).start = vi.fn();
|
||||||
vi.mocked(mockConnection).stop = vi.fn();
|
vi.mocked(mockConnection).stop = vi.fn();
|
||||||
@@ -200,7 +200,7 @@ describe("connections$ stream", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("connectionManagerData$ stream", () => {
|
describe("connectionManagerData$ stream", () => {
|
||||||
// Used in test to control fake connections' remoteParticipantsWithTracks$ streams
|
// Used in test to control fake connections' remoteParticipants$ streams
|
||||||
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
|
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
|
||||||
|
|
||||||
function keyForTransport(transport: LivekitTransport): string {
|
function keyForTransport(transport: LivekitTransport): string {
|
||||||
@@ -229,7 +229,7 @@ describe("connectionManagerData$ stream", () => {
|
|||||||
>([]);
|
>([]);
|
||||||
const mockConnection = {
|
const mockConnection = {
|
||||||
transport,
|
transport,
|
||||||
remoteParticipantsWithTracks$: getRemoteParticipantsFor(transport),
|
remoteParticipants$: getRemoteParticipantsFor(transport),
|
||||||
} as unknown as Connection;
|
} as unknown as Connection;
|
||||||
vi.mocked(mockConnection).start = vi.fn();
|
vi.mocked(mockConnection).start = vi.fn();
|
||||||
vi.mocked(mockConnection).stop = vi.fn();
|
vi.mocked(mockConnection).stop = vi.fn();
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
type LivekitTransport,
|
|
||||||
type ParticipantId,
|
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
|
||||||
import { combineLatest, map, of, switchMap, tap } from "rxjs";
|
import { combineLatest, map, of, switchMap, tap } from "rxjs";
|
||||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type RemoteParticipant } from "livekit-client";
|
import { type RemoteParticipant } from "livekit-client";
|
||||||
@@ -57,34 +54,20 @@ export class ConnectionManagerData {
|
|||||||
const key = transport.livekit_service_url + "|" + transport.livekit_alias;
|
const key = transport.livekit_service_url + "|" + transport.livekit_alias;
|
||||||
return this.store.get(key)?.[1] ?? [];
|
return this.store.get(key)?.[1] ?? [];
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Get all connections where the given participant is publishing.
|
|
||||||
* In theory, there could be several connections where the same participant is publishing but with
|
|
||||||
* only well behaving clients a participant should only be publishing on a single connection.
|
|
||||||
* @param participantId
|
|
||||||
*/
|
|
||||||
public getConnectionsForParticipant(
|
|
||||||
participantId: ParticipantId,
|
|
||||||
): Connection[] {
|
|
||||||
const connections: Connection[] = [];
|
|
||||||
for (const [connection, participants] of this.store.values()) {
|
|
||||||
if (participants.some((p) => p.identity === participantId)) {
|
|
||||||
connections.push(connection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return connections;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
scope: ObservableScope;
|
scope: ObservableScope;
|
||||||
connectionFactory: ConnectionFactory;
|
connectionFactory: ConnectionFactory;
|
||||||
inputTransports$: Behavior<Epoch<LivekitTransport[]>>;
|
inputTransports$: Behavior<Epoch<LivekitTransport[]>>;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO - write test for scopes (do we really need to bind scope)
|
// TODO - write test for scopes (do we really need to bind scope)
|
||||||
export interface IConnectionManager {
|
export interface IConnectionManager {
|
||||||
connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>;
|
connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crete a `ConnectionManager`
|
* Crete a `ConnectionManager`
|
||||||
* @param scope the observable scope used by this object.
|
* @param scope the observable scope used by this object.
|
||||||
@@ -169,7 +152,7 @@ export function createConnectionManager$({
|
|||||||
// Map the connections to list of {connection, participants}[]
|
// Map the connections to list of {connection, participants}[]
|
||||||
const listOfConnectionsWithRemoteParticipants = connections.value.map(
|
const listOfConnectionsWithRemoteParticipants = connections.value.map(
|
||||||
(connection) => {
|
(connection) => {
|
||||||
return connection.remoteParticipantsWithTracks$.pipe(
|
return connection.remoteParticipants$.pipe(
|
||||||
map((participants) => ({
|
map((participants) => ({
|
||||||
connection,
|
connection,
|
||||||
participants,
|
participants,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
createLocalMedia,
|
createLocalMedia,
|
||||||
createRemoteMedia,
|
createRemoteMedia,
|
||||||
withTestScheduler,
|
withTestScheduler,
|
||||||
|
mockRemoteParticipant,
|
||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
import { getValue } from "../utils/observable";
|
import { getValue } from "../utils/observable";
|
||||||
import { constant } from "./Behavior";
|
import { constant } from "./Behavior";
|
||||||
@@ -44,7 +45,11 @@ const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
|||||||
|
|
||||||
test("control a participant's volume", () => {
|
test("control a participant's volume", () => {
|
||||||
const setVolumeSpy = vi.fn();
|
const setVolumeSpy = vi.fn();
|
||||||
const vm = createRemoteMedia(rtcMembership, {}, { setVolume: setVolumeSpy });
|
const vm = createRemoteMedia(
|
||||||
|
rtcMembership,
|
||||||
|
{},
|
||||||
|
mockRemoteParticipant({ setVolume: setVolumeSpy }),
|
||||||
|
);
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-ab---c---d|", {
|
schedule("-ab---c---d|", {
|
||||||
a() {
|
a() {
|
||||||
@@ -88,7 +93,7 @@ test("control a participant's volume", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("toggle fit/contain for a participant's video", () => {
|
test("toggle fit/contain for a participant's video", () => {
|
||||||
const vm = createRemoteMedia(rtcMembership, {}, {});
|
const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-ab|", {
|
schedule("-ab|", {
|
||||||
a: () => vm.toggleFitContain(),
|
a: () => vm.toggleFitContain(),
|
||||||
@@ -199,3 +204,35 @@ test("switch cameras", async () => {
|
|||||||
});
|
});
|
||||||
expect(deviceId).toBe("front camera");
|
expect(deviceId).toBe("front camera");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("remote media is in waiting state when participant has not yet connected", () => {
|
||||||
|
const vm = createRemoteMedia(rtcMembership, {}, null); // null participant
|
||||||
|
expect(vm.waitingForMedia$.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remote media is not in waiting state when participant is connected", () => {
|
||||||
|
const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
|
||||||
|
expect(vm.waitingForMedia$.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remote media is not in waiting state when participant is connected with no publications", () => {
|
||||||
|
const vm = createRemoteMedia(
|
||||||
|
rtcMembership,
|
||||||
|
{},
|
||||||
|
mockRemoteParticipant({
|
||||||
|
getTrackPublication: () => undefined,
|
||||||
|
getTrackPublications: () => [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(vm.waitingForMedia$.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remote media is not in waiting state when user does not intend to publish anywhere", () => {
|
||||||
|
const vm = createRemoteMedia(
|
||||||
|
rtcMembership,
|
||||||
|
{},
|
||||||
|
mockRemoteParticipant({}),
|
||||||
|
undefined, // No room (no advertised transport)
|
||||||
|
);
|
||||||
|
expect(vm.waitingForMedia$.value).toBe(false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type AudioSource,
|
type AudioSource,
|
||||||
type TrackReferenceOrPlaceholder,
|
|
||||||
type VideoSource,
|
type VideoSource,
|
||||||
|
type TrackReference,
|
||||||
observeParticipantEvents,
|
observeParticipantEvents,
|
||||||
observeParticipantMedia,
|
observeParticipantMedia,
|
||||||
roomEventSelector,
|
roomEventSelector,
|
||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
type Observable,
|
type Observable,
|
||||||
Subject,
|
Subject,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
distinctUntilKeyChanged,
|
|
||||||
filter,
|
filter,
|
||||||
fromEvent,
|
fromEvent,
|
||||||
interval,
|
interval,
|
||||||
@@ -60,14 +59,11 @@ import { type ObservableScope } from "./ObservableScope";
|
|||||||
export function observeTrackReference$(
|
export function observeTrackReference$(
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
): Observable<TrackReferenceOrPlaceholder> {
|
): Observable<TrackReference | undefined> {
|
||||||
return observeParticipantMedia(participant).pipe(
|
return observeParticipantMedia(participant).pipe(
|
||||||
map(() => ({
|
map(() => participant.getTrackPublication(source)),
|
||||||
participant: participant,
|
distinctUntilChanged(),
|
||||||
publication: participant.getTrackPublication(source),
|
map((publication) => publication && { participant, publication, source }),
|
||||||
source,
|
|
||||||
})),
|
|
||||||
distinctUntilKeyChanged("publication"),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +222,7 @@ abstract class BaseMediaViewModel {
|
|||||||
/**
|
/**
|
||||||
* The LiveKit video track for this media.
|
* The LiveKit video track for this media.
|
||||||
*/
|
*/
|
||||||
public readonly video$: Behavior<TrackReferenceOrPlaceholder | null>;
|
public readonly video$: Behavior<TrackReference | undefined>;
|
||||||
/**
|
/**
|
||||||
* Whether there should be a warning that this media is unencrypted.
|
* Whether there should be a warning that this media is unencrypted.
|
||||||
*/
|
*/
|
||||||
@@ -241,10 +237,12 @@ abstract class BaseMediaViewModel {
|
|||||||
|
|
||||||
private observeTrackReference$(
|
private observeTrackReference$(
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
): Behavior<TrackReferenceOrPlaceholder | null> {
|
): Behavior<TrackReference | undefined> {
|
||||||
return this.scope.behavior(
|
return this.scope.behavior(
|
||||||
this.participant$.pipe(
|
this.participant$.pipe(
|
||||||
switchMap((p) => (!p ? of(null) : observeTrackReference$(p, source))),
|
switchMap((p) =>
|
||||||
|
!p ? of(undefined) : observeTrackReference$(p, source),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -268,7 +266,7 @@ abstract class BaseMediaViewModel {
|
|||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
audioSource: AudioSource,
|
audioSource: AudioSource,
|
||||||
videoSource: VideoSource,
|
videoSource: VideoSource,
|
||||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
protected readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
public readonly focusUrl$: Behavior<string | undefined>,
|
public readonly focusUrl$: Behavior<string | undefined>,
|
||||||
public readonly displayName$: Behavior<string>,
|
public readonly displayName$: Behavior<string>,
|
||||||
public readonly mxcAvatarUrl$: Behavior<string | undefined>,
|
public readonly mxcAvatarUrl$: Behavior<string | undefined>,
|
||||||
@@ -281,8 +279,8 @@ abstract class BaseMediaViewModel {
|
|||||||
[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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -471,7 +469,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
private readonly videoTrack$: Observable<LocalVideoTrack | null> =
|
private readonly videoTrack$: Observable<LocalVideoTrack | null> =
|
||||||
this.video$.pipe(
|
this.video$.pipe(
|
||||||
switchMap((v) => {
|
switchMap((v) => {
|
||||||
const track = v?.publication?.track;
|
const track = v?.publication.track;
|
||||||
if (!(track instanceof LocalVideoTrack)) return of(null);
|
if (!(track instanceof LocalVideoTrack)) return of(null);
|
||||||
return merge(
|
return merge(
|
||||||
// Watch for track restarts because they indicate a camera switch.
|
// Watch for track restarts because they indicate a camera switch.
|
||||||
@@ -596,6 +594,21 @@ 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 {
|
||||||
|
/**
|
||||||
|
* Whether we are waiting for this user's LiveKit participant to exist. This
|
||||||
|
* could be because either we or the remote party are still connecting.
|
||||||
|
*/
|
||||||
|
public readonly waitingForMedia$ = this.scope.behavior<boolean>(
|
||||||
|
combineLatest(
|
||||||
|
[this.livekitRoom$, this.participant$],
|
||||||
|
(livekitRoom, participant) =>
|
||||||
|
// If livekitRoom is undefined, the user is not attempting to publish on
|
||||||
|
// any transport and so we shouldn't expect a participant. (They might
|
||||||
|
// be a subscribe-only bot for example.)
|
||||||
|
livekitRoom !== undefined && participant === null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// This private field is used to override the value from the superclass
|
// This private field is used to override the value from the superclass
|
||||||
private __speaking$: Behavior<boolean>;
|
private __speaking$: Behavior<boolean>;
|
||||||
public get speaking$(): Behavior<boolean> {
|
public get speaking$(): Behavior<boolean> {
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import { axe } from "vitest-axe";
|
|||||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
import { GridTile } from "./GridTile";
|
import { GridTile } from "./GridTile";
|
||||||
import { mockRtcMembership, createRemoteMedia } from "../utils/test";
|
import {
|
||||||
|
mockRtcMembership,
|
||||||
|
createRemoteMedia,
|
||||||
|
mockRemoteParticipant,
|
||||||
|
} from "../utils/test";
|
||||||
import { GridTileViewModel } from "../state/TileViewModel";
|
import { GridTileViewModel } from "../state/TileViewModel";
|
||||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||||
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
@@ -31,11 +35,11 @@ test("GridTile is accessible", async () => {
|
|||||||
rawDisplayName: "Alice",
|
rawDisplayName: "Alice",
|
||||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||||
},
|
},
|
||||||
{
|
mockRemoteParticipant({
|
||||||
setVolume() {},
|
setVolume() {},
|
||||||
getTrackPublication: () =>
|
getTrackPublication: () =>
|
||||||
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const fakeRtcSession = {
|
const fakeRtcSession = {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ interface UserMediaTileProps extends TileProps {
|
|||||||
vm: UserMediaViewModel;
|
vm: UserMediaViewModel;
|
||||||
mirror: boolean;
|
mirror: boolean;
|
||||||
locallyMuted: boolean;
|
locallyMuted: boolean;
|
||||||
|
waitingForMedia?: boolean;
|
||||||
primaryButton?: ReactNode;
|
primaryButton?: ReactNode;
|
||||||
menuStart?: ReactNode;
|
menuStart?: ReactNode;
|
||||||
menuEnd?: ReactNode;
|
menuEnd?: ReactNode;
|
||||||
@@ -79,6 +80,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
vm,
|
vm,
|
||||||
showSpeakingIndicators,
|
showSpeakingIndicators,
|
||||||
locallyMuted,
|
locallyMuted,
|
||||||
|
waitingForMedia,
|
||||||
primaryButton,
|
primaryButton,
|
||||||
menuStart,
|
menuStart,
|
||||||
menuEnd,
|
menuEnd,
|
||||||
@@ -148,7 +150,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
const tile = (
|
const tile = (
|
||||||
<MediaView
|
<MediaView
|
||||||
ref={ref}
|
ref={ref}
|
||||||
video={video ?? undefined}
|
video={video}
|
||||||
userId={vm.userId}
|
userId={vm.userId}
|
||||||
unencryptedWarning={unencryptedWarning}
|
unencryptedWarning={unencryptedWarning}
|
||||||
encryptionStatus={encryptionStatus}
|
encryptionStatus={encryptionStatus}
|
||||||
@@ -194,7 +196,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
raisedHandTime={handRaised ?? undefined}
|
raisedHandTime={handRaised ?? undefined}
|
||||||
currentReaction={reaction ?? undefined}
|
currentReaction={reaction ?? undefined}
|
||||||
raisedHandOnClick={raisedHandOnClick}
|
raisedHandOnClick={raisedHandOnClick}
|
||||||
localParticipant={vm.local}
|
waitingForMedia={waitingForMedia}
|
||||||
focusUrl={focusUrl}
|
focusUrl={focusUrl}
|
||||||
audioStreamStats={audioStreamStats}
|
audioStreamStats={audioStreamStats}
|
||||||
videoStreamStats={videoStreamStats}
|
videoStreamStats={videoStreamStats}
|
||||||
@@ -290,6 +292,7 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const waitingForMedia = useBehavior(vm.waitingForMedia$);
|
||||||
const locallyMuted = useBehavior(vm.locallyMuted$);
|
const locallyMuted = useBehavior(vm.locallyMuted$);
|
||||||
const localVolume = useBehavior(vm.localVolume$);
|
const localVolume = useBehavior(vm.localVolume$);
|
||||||
const onSelectMute = useCallback(
|
const onSelectMute = useCallback(
|
||||||
@@ -311,6 +314,7 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
|||||||
<UserMediaTile
|
<UserMediaTile
|
||||||
ref={ref}
|
ref={ref}
|
||||||
vm={vm}
|
vm={vm}
|
||||||
|
waitingForMedia={waitingForMedia}
|
||||||
locallyMuted={locallyMuted}
|
locallyMuted={locallyMuted}
|
||||||
mirror={false}
|
mirror={false}
|
||||||
menuStart={
|
menuStart={
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ describe("MediaView", () => {
|
|||||||
video: trackReference,
|
video: trackReference,
|
||||||
userId: "@alice:example.com",
|
userId: "@alice:example.com",
|
||||||
mxcAvatarUrl: undefined,
|
mxcAvatarUrl: undefined,
|
||||||
localParticipant: false,
|
|
||||||
focusable: true,
|
focusable: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,24 +65,13 @@ describe("MediaView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("with no participant", () => {
|
describe("with no video", () => {
|
||||||
it("shows avatar for local user", () => {
|
it("shows avatar", () => {
|
||||||
render(
|
render(<MediaView {...baseProps} video={undefined} />);
|
||||||
<MediaView {...baseProps} video={undefined} localParticipant={true} />,
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("img", { name: "@alice:example.com" }),
|
screen.getByRole("img", { name: "@alice:example.com" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(screen.queryAllByText("Waiting for media...").length).toBe(0);
|
expect(screen.queryByTestId("video")).toBe(null);
|
||||||
});
|
|
||||||
it("shows avatar and label for remote user", () => {
|
|
||||||
render(
|
|
||||||
<MediaView {...baseProps} video={undefined} localParticipant={false} />,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
screen.getByRole("img", { name: "@alice:example.com" }),
|
|
||||||
).toBeVisible();
|
|
||||||
expect(screen.getByText("Waiting for media...")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,6 +82,22 @@ describe("MediaView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("waitingForMedia", () => {
|
||||||
|
test("defaults to false", () => {
|
||||||
|
render(<MediaView {...baseProps} />);
|
||||||
|
expect(screen.queryAllByText("Waiting for media...").length).toBe(0);
|
||||||
|
});
|
||||||
|
test("shows and is accessible", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<MediaView {...baseProps} waitingForMedia={true} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
expect(await axe(container)).toHaveNoViolations();
|
||||||
|
expect(screen.getByText("Waiting for media...")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("unencryptedWarning", () => {
|
describe("unencryptedWarning", () => {
|
||||||
test("is shown and accessible", async () => {
|
test("is shown and accessible", async () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ interface Props extends ComponentProps<typeof animated.div> {
|
|||||||
raisedHandTime?: Date;
|
raisedHandTime?: Date;
|
||||||
currentReaction?: ReactionOption;
|
currentReaction?: ReactionOption;
|
||||||
raisedHandOnClick?: () => void;
|
raisedHandOnClick?: () => void;
|
||||||
localParticipant: boolean;
|
waitingForMedia?: boolean;
|
||||||
audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||||
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||||
// The focus url, mainly for debugging purposes
|
// The focus url, mainly for debugging purposes
|
||||||
@@ -71,7 +71,7 @@ export const MediaView: FC<Props> = ({
|
|||||||
raisedHandTime,
|
raisedHandTime,
|
||||||
currentReaction,
|
currentReaction,
|
||||||
raisedHandOnClick,
|
raisedHandOnClick,
|
||||||
localParticipant,
|
waitingForMedia,
|
||||||
audioStreamStats,
|
audioStreamStats,
|
||||||
videoStreamStats,
|
videoStreamStats,
|
||||||
focusUrl,
|
focusUrl,
|
||||||
@@ -129,7 +129,7 @@ export const MediaView: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!video && !localParticipant && (
|
{waitingForMedia && (
|
||||||
<div className={styles.status}>
|
<div className={styles.status}>
|
||||||
{t("video_tile.waiting_for_media")}
|
{t("video_tile.waiting_for_media")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
mockRtcMembership,
|
mockRtcMembership,
|
||||||
createLocalMedia,
|
createLocalMedia,
|
||||||
createRemoteMedia,
|
createRemoteMedia,
|
||||||
|
mockRemoteParticipant,
|
||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||||
import { constant } from "../state/Behavior";
|
import { constant } from "../state/Behavior";
|
||||||
@@ -33,7 +34,7 @@ test("SpotlightTile is accessible", async () => {
|
|||||||
rawDisplayName: "Alice",
|
rawDisplayName: "Alice",
|
||||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||||
},
|
},
|
||||||
{},
|
mockRemoteParticipant({}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const vm2 = createLocalMedia(
|
const vm2 = createLocalMedia(
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
type MediaViewModel,
|
type MediaViewModel,
|
||||||
ScreenShareViewModel,
|
ScreenShareViewModel,
|
||||||
type UserMediaViewModel,
|
type UserMediaViewModel,
|
||||||
|
type RemoteUserMediaViewModel,
|
||||||
} from "../state/MediaViewModel";
|
} from "../state/MediaViewModel";
|
||||||
import { useInitial } from "../useInitial";
|
import { useInitial } from "../useInitial";
|
||||||
import { useMergedRefs } from "../useMergedRefs";
|
import { useMergedRefs } from "../useMergedRefs";
|
||||||
@@ -84,6 +85,21 @@ const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
|
|||||||
|
|
||||||
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
|
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
|
||||||
|
|
||||||
|
interface SpotlightRemoteUserMediaItemProps
|
||||||
|
extends SpotlightUserMediaItemBaseProps {
|
||||||
|
vm: RemoteUserMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpotlightRemoteUserMediaItem: FC<SpotlightRemoteUserMediaItemProps> = ({
|
||||||
|
vm,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const waitingForMedia = useBehavior(vm.waitingForMedia$);
|
||||||
|
return (
|
||||||
|
<MediaView waitingForMedia={waitingForMedia} mirror={false} {...props} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
|
interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
|
||||||
vm: UserMediaViewModel;
|
vm: UserMediaViewModel;
|
||||||
}
|
}
|
||||||
@@ -103,7 +119,7 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
|||||||
return vm instanceof LocalUserMediaViewModel ? (
|
return vm instanceof LocalUserMediaViewModel ? (
|
||||||
<SpotlightLocalUserMediaItem vm={vm} {...baseProps} />
|
<SpotlightLocalUserMediaItem vm={vm} {...baseProps} />
|
||||||
) : (
|
) : (
|
||||||
<MediaView mirror={false} {...baseProps} />
|
<SpotlightRemoteUserMediaItem vm={vm} {...baseProps} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -319,12 +319,12 @@ export function mockLocalParticipant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createLocalMedia(
|
export function createLocalMedia(
|
||||||
localRtcMember: CallMembership,
|
rtcMember: CallMembership,
|
||||||
roomMember: Partial<RoomMember>,
|
roomMember: Partial<RoomMember>,
|
||||||
localParticipant: LocalParticipant,
|
localParticipant: LocalParticipant,
|
||||||
mediaDevices: MediaDevices,
|
mediaDevices: MediaDevices,
|
||||||
): LocalUserMediaViewModel {
|
): LocalUserMediaViewModel {
|
||||||
const member = mockMatrixRoomMember(localRtcMember, roomMember);
|
const member = mockMatrixRoomMember(rtcMember, roomMember);
|
||||||
return new LocalUserMediaViewModel(
|
return new LocalUserMediaViewModel(
|
||||||
testScope(),
|
testScope(),
|
||||||
"local",
|
"local",
|
||||||
@@ -359,23 +359,26 @@ export function mockRemoteParticipant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createRemoteMedia(
|
export function createRemoteMedia(
|
||||||
localRtcMember: CallMembership,
|
rtcMember: CallMembership,
|
||||||
roomMember: Partial<RoomMember>,
|
roomMember: Partial<RoomMember>,
|
||||||
participant: Partial<RemoteParticipant>,
|
participant: RemoteParticipant | null,
|
||||||
|
livekitRoom: LivekitRoom | undefined = mockLivekitRoom(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
remoteParticipants$: of(participant ? [participant] : []),
|
||||||
|
},
|
||||||
|
),
|
||||||
): RemoteUserMediaViewModel {
|
): RemoteUserMediaViewModel {
|
||||||
const member = mockMatrixRoomMember(localRtcMember, roomMember);
|
const member = mockMatrixRoomMember(rtcMember, roomMember);
|
||||||
const remoteParticipant = mockRemoteParticipant(participant);
|
|
||||||
return new RemoteUserMediaViewModel(
|
return new RemoteUserMediaViewModel(
|
||||||
testScope(),
|
testScope(),
|
||||||
"remote",
|
"remote",
|
||||||
member.userId,
|
member.userId,
|
||||||
of(remoteParticipant),
|
constant(participant),
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
constant(
|
constant(livekitRoom),
|
||||||
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
|
||||||
),
|
|
||||||
constant("https://rtc-example.org"),
|
constant("https://rtc-example.org"),
|
||||||
constant(false),
|
constant(false),
|
||||||
constant(member.rawDisplayName ?? "nodisplayname"),
|
constant(member.rawDisplayName ?? "nodisplayname"),
|
||||||
|
|||||||
Reference in New Issue
Block a user