mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
renames and restructure
- session -> matrixrtc (folder) - move MatrixRtcClient to top level of sdk - RtcTransport -> Transport - backend -> media-backend
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ConnectionState as LivekitConnectionState,
|
||||
type LocalTrackPublication,
|
||||
ParticipantEvent,
|
||||
type Room as LivekitRoom,
|
||||
type Track,
|
||||
} from "livekit-client";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import {
|
||||
type LocalMediaInputs,
|
||||
type MediaSource,
|
||||
type PublishRequest,
|
||||
} from "../../api";
|
||||
import { livekitSources, mediaSources } from "./LivekitMediaTrack";
|
||||
import {
|
||||
audioCaptureOptions,
|
||||
screenShareCaptureOptions,
|
||||
screenSharePublishOptions,
|
||||
videoCaptureOptions,
|
||||
videoPublishOptions,
|
||||
} from "./publishOptions";
|
||||
|
||||
/** What the host asked to publish, kept across publishers for a reconnection. */
|
||||
export interface DesiredPublication {
|
||||
request: PublishRequest;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type DesiredMedia = Map<MediaSource, DesiredPublication>;
|
||||
|
||||
/**
|
||||
* Publishes the local media on one LiveKit room, following the host's
|
||||
* requests.
|
||||
*
|
||||
* LiveKit publishes a track the moment it is created, but a member must not be
|
||||
* heard before it has joined the MatrixRTC session, nor after it has left. The
|
||||
* tracks are therefore kept, and their upstream paused, while `shouldPublish`
|
||||
* is false: the local preview stays live while nothing reaches the room.
|
||||
*/
|
||||
export class Publisher {
|
||||
public shouldPublish = false;
|
||||
/** Whether the initial requests have been published; `publish` is immediate from then on. */
|
||||
public started = false;
|
||||
private readonly scope = new ObservableScope();
|
||||
private readonly room: LivekitRoom;
|
||||
|
||||
public constructor(
|
||||
room: LivekitRoom,
|
||||
private readonly inputs: LocalMediaInputs,
|
||||
private readonly desired: DesiredMedia,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.room = room;
|
||||
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
|
||||
this.logger.error("Failed to enable E2EE on the room", e);
|
||||
});
|
||||
this.followAudioOutput();
|
||||
this.onLocalTrackPublished = this.onLocalTrackPublished.bind(this);
|
||||
room.localParticipant.on(
|
||||
ParticipantEvent.LocalTrackPublished,
|
||||
this.onLocalTrackPublished,
|
||||
);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
this.scope.end();
|
||||
this.room.localParticipant.off(
|
||||
ParticipantEvent.LocalTrackPublished,
|
||||
this.onLocalTrackPublished,
|
||||
);
|
||||
try {
|
||||
for (const { track } of this.publications())
|
||||
if (track) await this.room.localParticipant.unpublishTrack(track, true);
|
||||
} catch (e) {
|
||||
this.logger.error("Failed to stop the local tracks", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Publishes what the host asked for, less what it has muted since. Safe to call more than once. */
|
||||
public start(): void {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
const requests = [...this.desired.values()]
|
||||
.filter(({ enabled }) => enabled)
|
||||
.map(({ request }) => request);
|
||||
const sources = new Set(requests.map((request) => request.source));
|
||||
// Microphone and camera in one call so that the browser asks for
|
||||
// permission once; the room defaults carry their options. LiveKit
|
||||
// resolves these once the track is published, which may block on the
|
||||
// connection; LocalTrackPublished is what tells us a track exists.
|
||||
if (sources.has("microphone") && sources.has("camera"))
|
||||
void this.room.localParticipant.enableCameraAndMicrophone();
|
||||
for (const request of requests)
|
||||
if (
|
||||
request.source === "screenShare" ||
|
||||
!(sources.has("microphone") && sources.has("camera"))
|
||||
)
|
||||
this.publish(request).catch((e) => {
|
||||
this.logger.error(`Failed to publish the ${request.source}`, e);
|
||||
});
|
||||
}
|
||||
|
||||
/** Publishes one request and resolves with its publication once LiveKit has it. */
|
||||
public async publish(
|
||||
request: PublishRequest,
|
||||
): Promise<LocalTrackPublication | undefined> {
|
||||
const participant = this.room.localParticipant;
|
||||
let publication: LocalTrackPublication | undefined;
|
||||
if (request.source === "microphone")
|
||||
publication = await participant.setMicrophoneEnabled(
|
||||
true,
|
||||
audioCaptureOptions(request),
|
||||
);
|
||||
else if (request.source === "camera")
|
||||
publication = await participant.setCameraEnabled(
|
||||
true,
|
||||
videoCaptureOptions(request),
|
||||
videoPublishOptions(request.capture),
|
||||
);
|
||||
else
|
||||
publication = await participant.setScreenShareEnabled(
|
||||
true,
|
||||
screenShareCaptureOptions(request),
|
||||
screenSharePublishOptions(request.capture),
|
||||
);
|
||||
// Until the member has joined, the upstream has to stay paused
|
||||
if (!this.shouldPublish) await this.pauseUpstreams();
|
||||
return publication;
|
||||
}
|
||||
|
||||
public async unpublish(source: MediaSource): Promise<void> {
|
||||
const participant = this.room.localParticipant;
|
||||
// Takes the screen share audio with it
|
||||
if (source === "screenShare") {
|
||||
await participant.setScreenShareEnabled(false);
|
||||
return;
|
||||
}
|
||||
const track = participant.getTrackPublication(
|
||||
livekitSources[source],
|
||||
)?.track;
|
||||
if (track) await participant.unpublishTrack(track, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutes or unmutes a source and reports what resulted: the request where
|
||||
* it succeeded, the state LiveKit is left in where it did not (a denied
|
||||
* permission, a missing device).
|
||||
*/
|
||||
public async setEnabled(
|
||||
source: MediaSource,
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
const participant = this.room.localParticipant;
|
||||
const livekitSource = livekitSources[source];
|
||||
try {
|
||||
if (source === "microphone")
|
||||
await participant.setMicrophoneEnabled(enabled);
|
||||
else if (source === "camera") await participant.setCameraEnabled(enabled);
|
||||
else {
|
||||
const publication = participant.getTrackPublication(livekitSource);
|
||||
if (!publication) return false;
|
||||
if (enabled) await publication.unmute();
|
||||
else await publication.mute();
|
||||
}
|
||||
// Unmuting restarts the upstream; until the member has joined, it has
|
||||
// to stay paused
|
||||
if (enabled && !this.shouldPublish) await this.pauseUpstreams();
|
||||
return enabled;
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to set ${source} enabled=${enabled}`, e);
|
||||
return !(participant.getTrackPublication(livekitSource)?.isMuted ?? true);
|
||||
}
|
||||
}
|
||||
|
||||
public async startPublishing(): Promise<void> {
|
||||
if (this.shouldPublish) return;
|
||||
this.shouldPublish = true;
|
||||
// Enabling a track does not resume an upstream that was paused while it
|
||||
// was already enabled, so it is done explicitly
|
||||
for (const { track } of this.publications())
|
||||
if (track?.isUpstreamPaused) await track.resumeUpstream();
|
||||
}
|
||||
|
||||
public async stopPublishing(): Promise<void> {
|
||||
this.shouldPublish = false;
|
||||
await this.pauseUpstreams();
|
||||
}
|
||||
|
||||
private onLocalTrackPublished(publication: LocalTrackPublication): void {
|
||||
this.logger.info(`Local ${publication.source} track published`);
|
||||
if (!this.shouldPublish)
|
||||
this.pauseUpstreams().catch((e) => {
|
||||
this.logger.error("Failed to pause the upstream", e);
|
||||
});
|
||||
// The host may have changed its mind while the track was being created
|
||||
const source = mediaSources[publication.source as Track.Source];
|
||||
if (this.desired.get(source)?.enabled === false)
|
||||
void this.setEnabled(source, false);
|
||||
}
|
||||
|
||||
private async pauseUpstreams(): Promise<void> {
|
||||
for (const { track } of this.publications())
|
||||
if (track && !track.isUpstreamPaused) await track.pauseUpstream();
|
||||
}
|
||||
|
||||
private publications(): LocalTrackPublication[] {
|
||||
return [...this.room.localParticipant.trackPublications.values()];
|
||||
}
|
||||
|
||||
private followAudioOutput(): void {
|
||||
this.inputs.audioOutputDeviceId$
|
||||
.pipe(this.scope.bind())
|
||||
.subscribe((deviceId) => {
|
||||
if (
|
||||
deviceId === undefined ||
|
||||
this.room.state !== LivekitConnectionState.Connected ||
|
||||
this.room.getActiveDevice("audiooutput") === deviceId
|
||||
)
|
||||
return;
|
||||
this.room
|
||||
.switchActiveDevice("audiooutput", deviceId)
|
||||
.catch((e) => this.logger.error("Failed to switch audiooutput", e));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user