mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Give the local tracks their controls
Our own tracks are LocalAudioMediaTrack and LocalVideoMediaTrack, with setEnabled and setDevice, and on video facingMode$, switchFacingMode and setProcessor. switchCamera on the local media goes, as do the device and processor behaviors in LocalMediaInputs: a host calls the track when its setting changes instead of the SDK watching behaviors it does not own. The input device ids and the processor stay as initial values for the tracks created at the join. setMicrophoneEnabled and setCameraEnabled stay for the case of no track yet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
63ccfa50be
commit
5a43eaf5a6
@@ -32,10 +32,7 @@ const rtcClient = createMatrixRTCClient(
|
|||||||
{
|
{
|
||||||
microphoneEnabled: true,
|
microphoneEnabled: true,
|
||||||
cameraEnabled: true,
|
cameraEnabled: true,
|
||||||
audioInputDeviceId$: constant(undefined),
|
|
||||||
videoInputDeviceId$: constant(undefined),
|
|
||||||
audioOutputDeviceId$: constant(undefined),
|
audioOutputDeviceId$: constant(undefined),
|
||||||
videoProcessor$: constant(undefined),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
|||||||
+36
-21
@@ -198,20 +198,20 @@ export interface MatrixRTCClientOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the local member publishes. The enabled flags are the initial state;
|
* What the local member publishes when it joins. Initial values: from then on
|
||||||
* from then on `setMicrophoneEnabled` and `setCameraEnabled` on the client
|
* `setMicrophoneEnabled` and `setCameraEnabled` on the client, and the
|
||||||
* change it, and report what the device allowed. Device enumeration,
|
* controls on the local tracks, change it. Device enumeration, permission
|
||||||
* permission prompts and the lobby preview stay with the host.
|
* prompts and the lobby preview stay with the host.
|
||||||
*/
|
*/
|
||||||
export interface LocalMediaInputs {
|
export interface LocalMediaInputs {
|
||||||
microphoneEnabled: boolean;
|
microphoneEnabled: boolean;
|
||||||
cameraEnabled: boolean;
|
cameraEnabled: boolean;
|
||||||
audioInputDeviceId$: Behavior<string | undefined>;
|
audioInputDeviceId?: string;
|
||||||
videoInputDeviceId$: Behavior<string | undefined>;
|
videoInputDeviceId?: string;
|
||||||
|
/** Background blur and the like. */
|
||||||
|
videoProcessor?: TrackProcessor<Track.Kind.Video>;
|
||||||
/** Undefined where the host routes audio itself, or to leave the browser's choice. */
|
/** Undefined where the host routes audio itself, or to leave the browser's choice. */
|
||||||
audioOutputDeviceId$: Behavior<string | undefined>;
|
audioOutputDeviceId$: Behavior<string | undefined>;
|
||||||
/** Background blur and the like. */
|
|
||||||
videoProcessor$: Behavior<TrackProcessor<Track.Kind.Video> | undefined>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -500,8 +500,28 @@ export interface AudioMediaTrack extends MediaTrack {
|
|||||||
|
|
||||||
export interface VideoMediaTrack extends MediaTrack {
|
export interface VideoMediaTrack extends MediaTrack {
|
||||||
kind: "video";
|
kind: "video";
|
||||||
/** For mirroring and switching cameras. Undefined for remote tracks. */
|
}
|
||||||
facingMode$?: Behavior<"user" | "environment" | undefined>;
|
|
||||||
|
/** The controls a member has over a track it publishes itself. */
|
||||||
|
export interface LocalMediaTrack {
|
||||||
|
/** Mutes or unmutes. Resolves with the state that resulted. */
|
||||||
|
setEnabled(enabled: boolean): Promise<boolean>;
|
||||||
|
/** Captures from another device. Rejects for a screen share, which has none. */
|
||||||
|
setDevice(deviceId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalAudioMediaTrack
|
||||||
|
extends AudioMediaTrack, LocalMediaTrack {}
|
||||||
|
|
||||||
|
export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
|
||||||
|
/** For mirroring; undefined where the camera does not say which way it faces. */
|
||||||
|
facingMode$: Behavior<"user" | "environment" | undefined>;
|
||||||
|
/** Restarts the camera facing the other way; resolves with the device now in use. */
|
||||||
|
switchFacingMode(): Promise<string | undefined>;
|
||||||
|
/** Background blur and the like; undefined removes the processor. */
|
||||||
|
setProcessor(
|
||||||
|
processor: TrackProcessor<Track.Kind.Video> | undefined,
|
||||||
|
): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EncryptionError = "MissingKey" | "InvalidKey";
|
export type EncryptionError = "MissingKey" | "InvalidKey";
|
||||||
@@ -532,12 +552,7 @@ export function trackBySource$<S extends MediaSource>(
|
|||||||
|
|
||||||
export interface LocalMemberMedia extends MemberMedia {
|
export interface LocalMemberMedia extends MemberMedia {
|
||||||
local: true;
|
local: true;
|
||||||
/**
|
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[]>;
|
||||||
* Restarts the camera facing the other way, on devices with a front and a
|
|
||||||
* back camera, and resolves with the id of the device now in use. Does
|
|
||||||
* nothing without a camera track or where the facing mode is unknown.
|
|
||||||
*/
|
|
||||||
switchCamera(): Promise<string | undefined>;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -630,11 +645,11 @@ public API only.
|
|||||||
|
|
||||||
## Open decisions
|
## Open decisions
|
||||||
|
|
||||||
- **`LocalMediaInputs` shape.** The enabled flags are initial values and the
|
- **`LocalMediaInputs` shape.** Everything in it is an initial value; the
|
||||||
setters on the client change them, because the setters have to report what
|
setters on the client and the controls on the local tracks change it from
|
||||||
the device allowed; the device ids and the processor stay behaviors. Creating
|
then on, because they have to report what the device allowed. Creating the
|
||||||
the tracks before `join()` for a preview is still missing: today they are
|
tracks before `join()` for a preview is still missing: today they are created
|
||||||
created at the join.
|
at the join.
|
||||||
- **`applicationData` after the join.** The option is read once. The client
|
- **`applicationData` after the join.** The option is read once. The client
|
||||||
still updates `m.call.intent` from the camera state, as Element Call did; that
|
still updates `m.call.intent` from the camera state, as Element Call did; that
|
||||||
moves to the host once the data can be updated.
|
moves to the host once the data can be updated.
|
||||||
|
|||||||
@@ -76,10 +76,7 @@ async function start(
|
|||||||
{
|
{
|
||||||
microphoneEnabled: true,
|
microphoneEnabled: true,
|
||||||
cameraEnabled: true,
|
cameraEnabled: true,
|
||||||
audioInputDeviceId$: constant(undefined),
|
|
||||||
videoInputDeviceId$: constant(undefined),
|
|
||||||
audioOutputDeviceId$: constant(undefined),
|
audioOutputDeviceId$: constant(undefined),
|
||||||
videoProcessor$: constant(undefined),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
|||||||
+37
-16
@@ -84,19 +84,19 @@ export interface VideoCaptureSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the local member publishes. The enabled flags are the initial state;
|
* What the local member publishes when it joins. Initial values: from then on
|
||||||
* from then on `setMicrophoneEnabled` and `setCameraEnabled` on the client
|
* `setMicrophoneEnabled` and `setCameraEnabled` on the client, and the
|
||||||
* change it, and report what the device allowed.
|
* controls on the local tracks, change it.
|
||||||
*/
|
*/
|
||||||
export interface LocalMediaInputs {
|
export interface LocalMediaInputs {
|
||||||
microphoneEnabled: boolean;
|
microphoneEnabled: boolean;
|
||||||
cameraEnabled: boolean;
|
cameraEnabled: boolean;
|
||||||
audioInputDeviceId$: Behavior<string | undefined>;
|
audioInputDeviceId?: string;
|
||||||
videoInputDeviceId$: Behavior<string | undefined>;
|
videoInputDeviceId?: string;
|
||||||
|
/** Background blur and the like. */
|
||||||
|
videoProcessor?: TrackProcessor<Track.Kind.Video>;
|
||||||
/** Undefined where the host routes audio itself, or to leave the browser's choice. */
|
/** Undefined where the host routes audio itself, or to leave the browser's choice. */
|
||||||
audioOutputDeviceId$: Behavior<string | undefined>;
|
audioOutputDeviceId$: Behavior<string | undefined>;
|
||||||
/** Background blur and the like. */
|
|
||||||
videoProcessor$: Behavior<TrackProcessor<Track.Kind.Video> | undefined>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectionStatus =
|
export type ConnectionStatus =
|
||||||
@@ -300,8 +300,35 @@ export interface AudioMediaTrack extends MediaTrack {
|
|||||||
|
|
||||||
export interface VideoMediaTrack extends MediaTrack {
|
export interface VideoMediaTrack extends MediaTrack {
|
||||||
kind: "video";
|
kind: "video";
|
||||||
/** Undefined for remote tracks. */
|
}
|
||||||
facingMode$?: Behavior<"user" | "environment" | undefined>;
|
|
||||||
|
/** The controls a member has over a track it publishes itself. */
|
||||||
|
export interface LocalMediaTrack {
|
||||||
|
/**
|
||||||
|
* Mutes or unmutes. Resolves with the state that resulted, which differs
|
||||||
|
* from the request where the device could not be used.
|
||||||
|
*/
|
||||||
|
setEnabled(enabled: boolean): Promise<boolean>;
|
||||||
|
/** Captures from another device. Rejects for a screen share, which has none. */
|
||||||
|
setDevice(deviceId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalAudioMediaTrack
|
||||||
|
extends AudioMediaTrack, LocalMediaTrack {}
|
||||||
|
|
||||||
|
export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
|
||||||
|
/** For mirroring; undefined where the camera does not say which way it faces. */
|
||||||
|
facingMode$: Behavior<"user" | "environment" | undefined>;
|
||||||
|
/**
|
||||||
|
* Restarts the camera facing the other way, on devices with a front and a
|
||||||
|
* back camera, and resolves with the id of the device now in use. Does
|
||||||
|
* nothing where the facing mode is unknown.
|
||||||
|
*/
|
||||||
|
switchFacingMode(): Promise<string | undefined>;
|
||||||
|
/** Background blur and the like; undefined removes the processor. */
|
||||||
|
setProcessor(
|
||||||
|
processor: TrackProcessor<Track.Kind.Video> | undefined,
|
||||||
|
): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EncryptionError = "MissingKey" | "InvalidKey";
|
export type EncryptionError = "MissingKey" | "InvalidKey";
|
||||||
@@ -325,11 +352,5 @@ export interface MemberMedia {
|
|||||||
|
|
||||||
export interface LocalMemberMedia extends MemberMedia {
|
export interface LocalMemberMedia extends MemberMedia {
|
||||||
local: true;
|
local: true;
|
||||||
/**
|
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[]>;
|
||||||
* Restarts the camera facing the other way, on devices with a front and a
|
|
||||||
* back camera, and resolves with the id of the device now in use. Does
|
|
||||||
* nothing without a camera track or where the facing mode is unknown (see
|
|
||||||
* `VideoMediaTrack.facingMode$`).
|
|
||||||
*/
|
|
||||||
switchCamera(): Promise<string | undefined>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import {
|
import {
|
||||||
|
type LocalParticipant,
|
||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
RemoteAudioTrack,
|
RemoteAudioTrack,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
@@ -19,7 +20,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import { ObservableScope } from "../reactive/ObservableScope";
|
import { ObservableScope } from "../reactive/ObservableScope";
|
||||||
import { type AudioMediaTrack } from "../api";
|
import { type AudioMediaTrack } from "../api";
|
||||||
import { createLivekitMediaTrack } from "./LivekitMediaTrack";
|
import {
|
||||||
|
createLivekitMediaTrack,
|
||||||
|
createLocalLivekitMediaTrack,
|
||||||
|
} from "./LivekitMediaTrack";
|
||||||
|
|
||||||
describe("createLivekitMediaTrack", () => {
|
describe("createLivekitMediaTrack", () => {
|
||||||
let scope: ObservableScope;
|
let scope: ObservableScope;
|
||||||
@@ -127,6 +131,27 @@ describe("createLivekitMediaTrack", () => {
|
|||||||
emitter.emit(ParticipantEvent.TrackMuted, publication);
|
emitter.emit(ParticipantEvent.TrackMuted, publication);
|
||||||
expect(track.isActive$.value).toBe(false);
|
expect(track.isActive$.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives our own track its controls", async () => {
|
||||||
|
const { participant, publication, room } = fakes({
|
||||||
|
kind: Track.Kind.Audio,
|
||||||
|
});
|
||||||
|
const setEnabled = vi.fn().mockResolvedValue(false);
|
||||||
|
const switchActiveDevice = vi.fn().mockResolvedValue(true);
|
||||||
|
const track = createLocalLivekitMediaTrack(
|
||||||
|
scope,
|
||||||
|
Object.assign(participant, {
|
||||||
|
isLocal: true,
|
||||||
|
}) as unknown as LocalParticipant,
|
||||||
|
publication as unknown as TrackPublication,
|
||||||
|
Object.assign(room, { switchActiveDevice }),
|
||||||
|
setEnabled,
|
||||||
|
);
|
||||||
|
await expect(track.setEnabled(false)).resolves.toBe(false);
|
||||||
|
expect(setEnabled).toHaveBeenCalledWith("microphone", false);
|
||||||
|
await track.setDevice("mic-2");
|
||||||
|
expect(switchActiveDevice).toHaveBeenCalledWith("audioinput", "mic-2");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
type FakeTrack = Track & {
|
type FakeTrack = Track & {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@livekit/components-core";
|
} from "@livekit/components-core";
|
||||||
import {
|
import {
|
||||||
facingModeFromLocalTrack,
|
facingModeFromLocalTrack,
|
||||||
|
type LocalParticipant,
|
||||||
LocalTrack,
|
LocalTrack,
|
||||||
LocalVideoTrack,
|
LocalVideoTrack,
|
||||||
type Participant,
|
type Participant,
|
||||||
@@ -41,11 +42,14 @@ import { type Behavior } from "../reactive/Behavior";
|
|||||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||||
import {
|
import {
|
||||||
type AudioMediaTrack,
|
type AudioMediaTrack,
|
||||||
|
type LocalAudioMediaTrack,
|
||||||
|
type LocalVideoMediaTrack,
|
||||||
type MediaSource,
|
type MediaSource,
|
||||||
type MediaStreamStats,
|
type MediaStreamStats,
|
||||||
type MediaTrack,
|
type MediaTrack,
|
||||||
type VideoMediaTrack,
|
type VideoMediaTrack,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import { MatrixRTCError } from "../errors";
|
||||||
import { LazyBehavior } from "../utils/LazyBehavior";
|
import { LazyBehavior } from "../utils/LazyBehavior";
|
||||||
|
|
||||||
const sources: Record<Track.Source, MediaSource> = {
|
const sources: Record<Track.Source, MediaSource> = {
|
||||||
@@ -56,6 +60,14 @@ const sources: Record<Track.Source, MediaSource> = {
|
|||||||
[Track.Source.Unknown]: "unknown",
|
[Track.Source.Unknown]: "unknown",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const livekitSources: Record<MediaSource, Track.Source> = {
|
||||||
|
microphone: Track.Source.Microphone,
|
||||||
|
camera: Track.Source.Camera,
|
||||||
|
screenShare: Track.Source.ScreenShare,
|
||||||
|
screenShareAudio: Track.Source.ScreenShareAudio,
|
||||||
|
unknown: Track.Source.Unknown,
|
||||||
|
};
|
||||||
|
|
||||||
// One timer for every track so that a large session does not keep hundreds of
|
// One timer for every track so that a large session does not keep hundreds of
|
||||||
// them, each firing a statistics request, in the event loop.
|
// them, each firing a statistics request, in the event loop.
|
||||||
const refreshStats$ = interval(1000).pipe(startWith(0), share());
|
const refreshStats$ = interval(1000).pipe(startWith(0), share());
|
||||||
@@ -72,17 +84,7 @@ export function createLivekitMediaTrack(
|
|||||||
room: LivekitRoom,
|
room: LivekitRoom,
|
||||||
): AudioMediaTrack | VideoMediaTrack {
|
): AudioMediaTrack | VideoMediaTrack {
|
||||||
const mediaChanged$ = observeParticipantMedia(participant);
|
const mediaChanged$ = observeParticipantMedia(participant);
|
||||||
const track$: Behavior<Track | undefined> = scope.behavior(
|
const track$ = publicationTrack$(scope, participant, publication);
|
||||||
merge(
|
|
||||||
mediaChanged$,
|
|
||||||
fromEvent(publication, TrackEvent.Subscribed),
|
|
||||||
fromEvent(publication, TrackEvent.Unsubscribed),
|
|
||||||
).pipe(
|
|
||||||
map(() => publication.track),
|
|
||||||
startWith(publication.track),
|
|
||||||
distinctUntilChanged(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const attached = new Set<HTMLMediaElement>();
|
const attached = new Set<HTMLMediaElement>();
|
||||||
let audioContext: AudioContext | undefined;
|
let audioContext: AudioContext | undefined;
|
||||||
@@ -172,13 +174,85 @@ export function createLivekitMediaTrack(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return { ...base, kind: "video" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of our own publications, with the controls over it. `setEnabled` goes
|
||||||
|
* through the local member so that what it remembers for a reconnection
|
||||||
|
* stays in step.
|
||||||
|
*/
|
||||||
|
export function createLocalLivekitMediaTrack(
|
||||||
|
scope: ObservableScope,
|
||||||
|
participant: LocalParticipant,
|
||||||
|
publication: TrackPublication,
|
||||||
|
room: LivekitRoom,
|
||||||
|
setEnabled: (source: MediaSource, enabled: boolean) => Promise<boolean>,
|
||||||
|
): LocalAudioMediaTrack | LocalVideoMediaTrack {
|
||||||
|
const base = createLivekitMediaTrack(scope, participant, publication, room);
|
||||||
|
const local = {
|
||||||
|
setEnabled: async (enabled: boolean) => setEnabled(base.source, enabled),
|
||||||
|
setDevice: async (deviceId: string): Promise<void> => {
|
||||||
|
if (base.source !== "microphone" && base.source !== "camera")
|
||||||
|
throw new MatrixRTCError(`A ${base.source} track has no device`);
|
||||||
|
await room.switchActiveDevice(
|
||||||
|
base.kind === "audio" ? "audioinput" : "videoinput",
|
||||||
|
deviceId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (base.kind === "audio") return { ...base, ...local };
|
||||||
|
|
||||||
|
const videoTrack = (): LocalVideoTrack | undefined =>
|
||||||
|
publication.track instanceof LocalVideoTrack
|
||||||
|
? publication.track
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
kind: "video",
|
...local,
|
||||||
...(participant.isLocal && { facingMode$: facingMode$(scope, track$) }),
|
facingMode$: facingMode$(
|
||||||
|
scope,
|
||||||
|
publicationTrack$(scope, participant, publication),
|
||||||
|
),
|
||||||
|
switchFacingMode: async () => {
|
||||||
|
const track = videoTrack();
|
||||||
|
if (!track) return;
|
||||||
|
const { facingMode } = facingModeFromLocalTrack(track);
|
||||||
|
if (facingMode !== "user" && facingMode !== "environment") return;
|
||||||
|
await track.restartTrack({
|
||||||
|
facingMode: facingMode === "user" ? "environment" : "user",
|
||||||
|
});
|
||||||
|
return track.mediaStreamTrack.getSettings().deviceId;
|
||||||
|
},
|
||||||
|
setProcessor: async (processor) => {
|
||||||
|
const track = videoTrack();
|
||||||
|
// A processor cannot be built on a track that has already ended
|
||||||
|
if (!track || track.mediaStreamTrack.readyState === "ended") return;
|
||||||
|
if (processor) await track.setProcessor(processor);
|
||||||
|
else if (track.getProcessor()) await track.stopProcessor();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The track behind a publication; a remote one arrives on subscription. */
|
||||||
|
function publicationTrack$(
|
||||||
|
scope: ObservableScope,
|
||||||
|
participant: Participant,
|
||||||
|
publication: TrackPublication,
|
||||||
|
): Behavior<Track | undefined> {
|
||||||
|
return scope.behavior(
|
||||||
|
merge(
|
||||||
|
observeParticipantMedia(participant),
|
||||||
|
fromEvent(publication, TrackEvent.Subscribed),
|
||||||
|
fromEvent(publication, TrackEvent.Unsubscribed),
|
||||||
|
).pipe(
|
||||||
|
map(() => publication.track),
|
||||||
|
startWith(publication.track),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function rtpStreamStats(
|
async function rtpStreamStats(
|
||||||
publication: TrackPublication,
|
publication: TrackPublication,
|
||||||
local: boolean,
|
local: boolean,
|
||||||
|
|||||||
@@ -10,13 +10,11 @@ import {
|
|||||||
roomEventSelector,
|
roomEventSelector,
|
||||||
} from "@livekit/components-core";
|
} from "@livekit/components-core";
|
||||||
import {
|
import {
|
||||||
facingModeFromLocalTrack,
|
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
LocalVideoTrack,
|
|
||||||
type Participant,
|
type Participant,
|
||||||
type Room as LivekitRoom,
|
type Room as LivekitRoom,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
Track,
|
type TrackPublication,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { filter, map, type Observable } from "rxjs";
|
import { filter, map, type Observable } from "rxjs";
|
||||||
|
|
||||||
@@ -24,12 +22,17 @@ import { type ObservableScope } from "../reactive/ObservableScope";
|
|||||||
import { generateItems } from "../reactive/observable";
|
import { generateItems } from "../reactive/observable";
|
||||||
import { E2eeType } from "../encryption";
|
import { E2eeType } from "../encryption";
|
||||||
import { type EncryptionSystem } from "../encryption";
|
import { type EncryptionSystem } from "../encryption";
|
||||||
|
import { type Behavior } from "../reactive/Behavior";
|
||||||
import {
|
import {
|
||||||
type EncryptionError,
|
type EncryptionError,
|
||||||
type LocalMemberMedia,
|
type LocalMemberMedia,
|
||||||
|
type MediaSource,
|
||||||
type MemberMedia,
|
type MemberMedia,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { createLivekitMediaTrack } from "./LivekitMediaTrack";
|
import {
|
||||||
|
createLivekitMediaTrack,
|
||||||
|
createLocalLivekitMediaTrack,
|
||||||
|
} from "./LivekitMediaTrack";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A participant as `MemberMedia`. The only place, with `LivekitMediaTrack`,
|
* A participant as `MemberMedia`. The only place, with `LivekitMediaTrack`,
|
||||||
@@ -43,26 +46,9 @@ export function createLivekitMemberMedia(
|
|||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
): MemberMedia {
|
): MemberMedia {
|
||||||
return {
|
return {
|
||||||
local: participant.isLocal,
|
local: false,
|
||||||
// One track per publication, each living as long as its publication is
|
tracks$: memberTracks$(scope, participant, (trackScope, publication) =>
|
||||||
// in the participant's map
|
createLivekitMediaTrack(trackScope, participant, publication, room),
|
||||||
tracks$: scope.behavior(
|
|
||||||
observeParticipantMedia(participant).pipe(
|
|
||||||
generateItems(
|
|
||||||
`${participant.identity} tracks$`,
|
|
||||||
function* () {
|
|
||||||
for (const publication of participant.trackPublications.values())
|
|
||||||
yield { keys: [publication.trackSid], data: publication };
|
|
||||||
},
|
|
||||||
(trackScope, publication$) =>
|
|
||||||
createLivekitMediaTrack(
|
|
||||||
trackScope,
|
|
||||||
participant,
|
|
||||||
publication$.value,
|
|
||||||
room,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
encryptionError$: encryptionErrors$(
|
encryptionError$: encryptionErrors$(
|
||||||
scope,
|
scope,
|
||||||
@@ -78,23 +64,48 @@ export function createLocalLivekitMemberMedia(
|
|||||||
participant: LocalParticipant,
|
participant: LocalParticipant,
|
||||||
room: LivekitRoom,
|
room: LivekitRoom,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
|
setEnabled: (source: MediaSource, enabled: boolean) => Promise<boolean>,
|
||||||
): LocalMemberMedia {
|
): LocalMemberMedia {
|
||||||
return {
|
return {
|
||||||
...createLivekitMemberMedia(scope, participant, room, encryptionSystem),
|
|
||||||
local: true,
|
local: true,
|
||||||
switchCamera: async () => {
|
tracks$: memberTracks$(scope, participant, (trackScope, publication) =>
|
||||||
const track = participant.getTrackPublication(Track.Source.Camera)?.track;
|
createLocalLivekitMediaTrack(
|
||||||
if (!(track instanceof LocalVideoTrack)) return;
|
trackScope,
|
||||||
const { facingMode } = facingModeFromLocalTrack(track);
|
participant,
|
||||||
if (facingMode !== "user" && facingMode !== "environment") return;
|
publication,
|
||||||
await track.restartTrack({
|
room,
|
||||||
facingMode: facingMode === "user" ? "environment" : "user",
|
setEnabled,
|
||||||
});
|
),
|
||||||
return track.mediaStreamTrack.getSettings().deviceId;
|
),
|
||||||
},
|
encryptionError$: encryptionErrors$(
|
||||||
|
scope,
|
||||||
|
participant,
|
||||||
|
room,
|
||||||
|
encryptionSystem,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One track per publication, each living as long as its publication is in the participant's map. */
|
||||||
|
function memberTracks$<T>(
|
||||||
|
scope: ObservableScope,
|
||||||
|
participant: Participant,
|
||||||
|
factory: (scope: ObservableScope, publication: TrackPublication) => T,
|
||||||
|
): Behavior<T[]> {
|
||||||
|
return scope.behavior(
|
||||||
|
observeParticipantMedia(participant).pipe(
|
||||||
|
generateItems(
|
||||||
|
`${participant.identity} tracks$`,
|
||||||
|
function* () {
|
||||||
|
for (const publication of participant.trackPublications.values())
|
||||||
|
yield { keys: [publication.trackSid], data: publication };
|
||||||
|
},
|
||||||
|
(trackScope, publication$) => factory(trackScope, publication$.value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function encryptionErrors$(
|
function encryptionErrors$(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
|
|||||||
@@ -138,8 +138,8 @@ function roomOptions(
|
|||||||
...base,
|
...base,
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults: {
|
||||||
...base.videoCaptureDefaults,
|
...base.videoCaptureDefaults,
|
||||||
deviceId: localMedia.videoInputDeviceId$.value,
|
deviceId: localMedia.videoInputDeviceId,
|
||||||
processor: localMedia.videoProcessor$.value,
|
processor: localMedia.videoProcessor,
|
||||||
...(camera?.resolution && { resolution: camera.resolution }),
|
...(camera?.resolution && { resolution: camera.resolution }),
|
||||||
},
|
},
|
||||||
publishDefaults: {
|
publishDefaults: {
|
||||||
@@ -154,7 +154,7 @@ function roomOptions(
|
|||||||
},
|
},
|
||||||
audioCaptureDefaults: {
|
audioCaptureDefaults: {
|
||||||
...base.audioCaptureDefaults,
|
...base.audioCaptureDefaults,
|
||||||
deviceId: localMedia.audioInputDeviceId$.value,
|
deviceId: localMedia.audioInputDeviceId,
|
||||||
...capture?.audio,
|
...capture?.audio,
|
||||||
},
|
},
|
||||||
audioOutput: { deviceId: localMedia.audioOutputDeviceId$.value },
|
audioOutput: { deviceId: localMedia.audioOutputDeviceId$.value },
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ import {
|
|||||||
type MatrixRTCMode,
|
type MatrixRTCMode,
|
||||||
type SessionTimings,
|
type SessionTimings,
|
||||||
} from "../config";
|
} from "../config";
|
||||||
import { type DisconnectReason, type VideoCaptureSettings } from "../api";
|
import {
|
||||||
|
type DisconnectReason,
|
||||||
|
type MediaSource,
|
||||||
|
type VideoCaptureSettings,
|
||||||
|
} from "../api";
|
||||||
import {
|
import {
|
||||||
FailToStartLivekitConnection,
|
FailToStartLivekitConnection,
|
||||||
type MatrixRTCError,
|
type MatrixRTCError,
|
||||||
@@ -58,6 +62,7 @@ import { type Connection, ConnectionState } from "./Connection";
|
|||||||
import { type IConnectionManager } from "./ConnectionManager";
|
import { type IConnectionManager } from "./ConnectionManager";
|
||||||
import { type LocalTransport } from "./LocalTransport";
|
import { type LocalTransport } from "./LocalTransport";
|
||||||
import { getSFUConfigWithOpenID } from "./openIDSFU";
|
import { getSFUConfigWithOpenID } from "./openIDSFU";
|
||||||
|
import { livekitSources } from "../media/LivekitMediaTrack";
|
||||||
import { type DesiredMedia, type Publisher } from "./Publisher";
|
import { type DesiredMedia, type Publisher } from "./Publisher";
|
||||||
|
|
||||||
export enum TransportState {
|
export enum TransportState {
|
||||||
@@ -118,6 +123,8 @@ export interface LocalMembership {
|
|||||||
disconnectReason$: Behavior<DisconnectReason | null>;
|
disconnectReason$: Behavior<DisconnectReason | null>;
|
||||||
setMicrophoneEnabled: (enabled: boolean) => Promise<boolean>;
|
setMicrophoneEnabled: (enabled: boolean) => Promise<boolean>;
|
||||||
setCameraEnabled: (enabled: boolean) => Promise<boolean>;
|
setCameraEnabled: (enabled: boolean) => Promise<boolean>;
|
||||||
|
/** Mutes or unmutes one of our publications; the result is the state that resulted. */
|
||||||
|
setEnabled: (source: MediaSource, enabled: boolean) => Promise<boolean>;
|
||||||
sharingScreen$: Behavior<boolean>;
|
sharingScreen$: Behavior<boolean>;
|
||||||
toggleScreenSharing: (() => void) | null;
|
toggleScreenSharing: (() => void) | null;
|
||||||
screenShareError$: Behavior<Error | null>;
|
screenShareError$: Behavior<Error | null>;
|
||||||
@@ -392,22 +399,40 @@ export function createLocalMembership$({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const setEnabled = async (
|
const setEnabled = async (
|
||||||
source: Track.Source.Microphone | Track.Source.Camera,
|
source: MediaSource,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
|
if (source !== "microphone" && source !== "camera")
|
||||||
|
return setPublicationEnabled(source, enabled);
|
||||||
const desired$ =
|
const desired$ =
|
||||||
source === Track.Source.Microphone
|
source === "microphone" ? desired.microphone$ : desired.camera$;
|
||||||
? desired.microphone$
|
|
||||||
: desired.camera$;
|
|
||||||
desired$.next(enabled);
|
desired$.next(enabled);
|
||||||
// Without a publisher the request waits for the tracks to be created
|
// Without a publisher the request waits for the tracks to be created
|
||||||
const publisher = publisher$.value;
|
const publisher = publisher$.value;
|
||||||
if (publisher === null) return enabled;
|
if (publisher === null) return enabled;
|
||||||
const result = await publisher.setEnabled(source, enabled);
|
const result = await publisher.setEnabled(
|
||||||
|
source === "microphone" ? Track.Source.Microphone : Track.Source.Camera,
|
||||||
|
enabled,
|
||||||
|
);
|
||||||
if (result !== enabled) desired$.next(result);
|
if (result !== enabled) desired$.next(result);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A publication the publisher does not recreate on reconnection, so there
|
||||||
|
// is nothing to remember for it
|
||||||
|
const setPublicationEnabled = async (
|
||||||
|
source: MediaSource,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const publication = participant$.value?.getTrackPublication(
|
||||||
|
livekitSources[source],
|
||||||
|
);
|
||||||
|
if (!publication) return false;
|
||||||
|
if (enabled) await publication.unmute();
|
||||||
|
else await publication.mute();
|
||||||
|
return !publication.isMuted;
|
||||||
|
};
|
||||||
|
|
||||||
const sharingScreen$ = scope.behavior(
|
const sharingScreen$ = scope.behavior(
|
||||||
participant$.pipe(
|
participant$.pipe(
|
||||||
switchMap((p) => (p === null ? of(false) : observeSharingScreen$(p))),
|
switchMap((p) => (p === null ? of(false) : observeSharingScreen$(p))),
|
||||||
@@ -451,10 +476,9 @@ export function createLocalMembership$({
|
|||||||
connected$,
|
connected$,
|
||||||
reconnecting$,
|
reconnecting$,
|
||||||
disconnectReason$,
|
disconnectReason$,
|
||||||
setMicrophoneEnabled: async (enabled) =>
|
setMicrophoneEnabled: async (enabled) => setEnabled("microphone", enabled),
|
||||||
setEnabled(Track.Source.Microphone, enabled),
|
setCameraEnabled: async (enabled) => setEnabled("camera", enabled),
|
||||||
setCameraEnabled: async (enabled) =>
|
setEnabled,
|
||||||
setEnabled(Track.Source.Camera, enabled),
|
|
||||||
sharingScreen$,
|
sharingScreen$,
|
||||||
toggleScreenSharing,
|
toggleScreenSharing,
|
||||||
screenShareError$,
|
screenShareError$,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export function createLocalRTCMember(
|
|||||||
participant,
|
participant,
|
||||||
room,
|
room,
|
||||||
context.encryptionSystem,
|
context.encryptionSystem,
|
||||||
|
localMembership.setEnabled,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
sharingScreen$: localMembership.sharingScreen$,
|
sharingScreen$: localMembership.sharingScreen$,
|
||||||
|
|||||||
@@ -5,24 +5,16 @@ 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 { observeParticipantMedia } from "@livekit/components-core";
|
|
||||||
import {
|
import {
|
||||||
ConnectionState as LivekitConnectionState,
|
ConnectionState as LivekitConnectionState,
|
||||||
type LocalTrackPublication,
|
type LocalTrackPublication,
|
||||||
LocalVideoTrack,
|
|
||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
type Room as LivekitRoom,
|
type Room as LivekitRoom,
|
||||||
Track,
|
Track,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import { type BehaviorSubject } from "rxjs";
|
||||||
type BehaviorSubject,
|
|
||||||
combineLatest,
|
|
||||||
distinctUntilChanged,
|
|
||||||
map,
|
|
||||||
} from "rxjs";
|
|
||||||
|
|
||||||
import { type Behavior } from "../reactive/Behavior";
|
|
||||||
import { ObservableScope } from "../reactive/ObservableScope";
|
import { ObservableScope } from "../reactive/ObservableScope";
|
||||||
import { type LocalMediaInputs } from "../api";
|
import { type LocalMediaInputs } from "../api";
|
||||||
|
|
||||||
@@ -57,8 +49,7 @@ export class Publisher {
|
|||||||
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
|
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
|
||||||
this.logger.error("Failed to enable E2EE on the room", e);
|
this.logger.error("Failed to enable E2EE on the room", e);
|
||||||
});
|
});
|
||||||
this.followVideoProcessor();
|
this.followAudioOutput();
|
||||||
this.followDevices();
|
|
||||||
this.onLocalTrackPublished = this.onLocalTrackPublished.bind(this);
|
this.onLocalTrackPublished = this.onLocalTrackPublished.bind(this);
|
||||||
room.localParticipant.on(
|
room.localParticipant.on(
|
||||||
ParticipantEvent.LocalTrackPublished,
|
ParticipantEvent.LocalTrackPublished,
|
||||||
@@ -188,54 +179,19 @@ export class Publisher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private followDevices(): void {
|
private followAudioOutput(): void {
|
||||||
const sync = (
|
this.inputs.audioOutputDeviceId$
|
||||||
kind: MediaDeviceKind,
|
.pipe(this.scope.bind())
|
||||||
deviceId$: Behavior<string | undefined>,
|
.subscribe((deviceId) => {
|
||||||
): void => {
|
|
||||||
deviceId$.pipe(this.scope.bind()).subscribe((deviceId) => {
|
|
||||||
if (
|
if (
|
||||||
deviceId === undefined ||
|
deviceId === undefined ||
|
||||||
this.room.state !== LivekitConnectionState.Connected ||
|
this.room.state !== LivekitConnectionState.Connected ||
|
||||||
this.room.getActiveDevice(kind) === deviceId
|
this.room.getActiveDevice("audiooutput") === deviceId
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
this.room
|
this.room
|
||||||
.switchActiveDevice(kind, deviceId)
|
.switchActiveDevice("audiooutput", deviceId)
|
||||||
.catch((e) => this.logger.error(`Failed to switch ${kind}`, e));
|
.catch((e) => this.logger.error("Failed to switch audiooutput", e));
|
||||||
});
|
|
||||||
};
|
|
||||||
sync("audioinput", this.inputs.audioInputDeviceId$);
|
|
||||||
sync("audiooutput", this.inputs.audioOutputDeviceId$);
|
|
||||||
sync("videoinput", this.inputs.videoInputDeviceId$);
|
|
||||||
}
|
|
||||||
|
|
||||||
private followVideoProcessor(): void {
|
|
||||||
const participant = this.room.localParticipant;
|
|
||||||
const cameraTrack$ = observeParticipantMedia(participant).pipe(
|
|
||||||
map(() => {
|
|
||||||
const track = participant.getTrackPublication(
|
|
||||||
Track.Source.Camera,
|
|
||||||
)?.track;
|
|
||||||
return track instanceof LocalVideoTrack ? track : undefined;
|
|
||||||
}),
|
|
||||||
distinctUntilChanged(),
|
|
||||||
);
|
|
||||||
combineLatest([cameraTrack$, this.inputs.videoProcessor$])
|
|
||||||
.pipe(this.scope.bind())
|
|
||||||
.subscribe(([track, processor]) => {
|
|
||||||
if (!track) return;
|
|
||||||
if (processor && !track.getProcessor()) {
|
|
||||||
// A processor cannot be built on a track that has already ended
|
|
||||||
if (track.mediaStreamTrack.readyState === "ended") return;
|
|
||||||
track.setProcessor(processor).catch((e) => {
|
|
||||||
this.logger.warn("Failed to attach the video processor", e);
|
|
||||||
});
|
|
||||||
} else if (!processor && track.getProcessor()) {
|
|
||||||
track.stopProcessor().catch((e) => {
|
|
||||||
this.logger.warn("Failed to stop the video processor", e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user