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:
Timo K.
2026-10-08 13:53:57 +02:00
co-authored by Claude Fable 5.1
parent 63ccfa50be
commit 5a43eaf5a6
11 changed files with 279 additions and 158 deletions
-3
View File
@@ -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
View File
@@ -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.
-3
View File
@@ -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
View File
@@ -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>;
} }
+26 -1
View File
@@ -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 & {
+87 -13
View File
@@ -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,
+46 -35
View File
@@ -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,
+3 -3
View File
@@ -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 },
+34 -10
View File
@@ -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$,
+1
View File
@@ -93,6 +93,7 @@ export function createLocalRTCMember(
participant, participant,
room, room,
context.encryptionSystem, context.encryptionSystem,
localMembership.setEnabled,
), ),
), ),
sharingScreen$: localMembership.sharingScreen$, sharingScreen$: localMembership.sharingScreen$,
+9 -53
View File
@@ -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);
});
}
}); });
} }
} }