/*
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 {
observeParticipantMedia,
roomEventSelector,
} from "@livekit/components-core";
import {
type LocalParticipant,
type Participant,
type Room as LivekitRoom,
RoomEvent,
type TrackPublication,
} from "livekit-client";
import { EMPTY, filter, map, type Observable, of, switchMap } from "rxjs";
import { type ObservableScope } from "../../reactive/ObservableScope";
import { generateItems } from "../../reactive/observable";
import { E2eeType, type EncryptionSystem } from "../../encryption";
import { type Behavior } from "../../reactive/Behavior";
import { mapScoped } from "../../utils/mapScoped";
import {
type EncryptionError,
type LocalMemberMedia,
type MediaSource,
type MemberMedia,
} from "../../media-api";
import {
createLivekitMediaTrack,
createLocalLivekitMediaTrack,
} from "./LivekitMediaTrack";
/** Where a member's media comes from: its participant on a room, null while it has none. */
export type ParticipantSource
= {
participant: P;
room: LivekitRoom;
} | null;
/**
* A member's media from its participant. The only place, with
* `LivekitMediaTrack`, that reads a LiveKit participant; everything above
* sees tracks and behaviors.
*/
export function createLivekitMemberMedia(
scope: ObservableScope,
source$: Behavior,
encryptionSystem: EncryptionSystem,
): MemberMedia {
return {
tracks$: memberTracks$(
scope,
source$,
(trackScope, participant, publication, room) =>
createLivekitMediaTrack(trackScope, participant, publication, room),
),
encryptionError$: encryptionErrors$(source$, encryptionSystem),
};
}
export function createLocalLivekitMemberMedia(
scope: ObservableScope,
source$: Behavior>,
encryptionSystem: EncryptionSystem,
setEnabled: (source: MediaSource, enabled: boolean) => Promise,
): LocalMemberMedia {
return {
tracks$: memberTracks$(
scope,
source$,
(trackScope, participant, publication, room) =>
createLocalLivekitMediaTrack(
trackScope,
participant,
publication,
room,
setEnabled,
),
),
encryptionError$: encryptionErrors$(source$, encryptionSystem),
};
}
/**
* One track per publication, each living as long as its publication is in
* the participant's map; null while there is no participant.
*/
function memberTracks$(
scope: ObservableScope,
source$: Behavior>,
factory: (
scope: ObservableScope,
participant: P,
publication: TrackPublication,
room: LivekitRoom,
) => T,
): Behavior {
return scope.behavior(
mapScoped(scope, source$, (participantScope, { participant, room }) =>
participantScope.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, participant, publication$.value, room),
),
),
),
).pipe(switchMap((tracks$) => tracks$ ?? of(null))),
);
}
function encryptionErrors$(
source$: Behavior,
encryptionSystem: EncryptionSystem,
): Observable {
return source$.pipe(
switchMap((source) =>
source === null
? EMPTY
: roomEventSelector(source.room, RoomEvent.EncryptionError).pipe(
map(([error]) => error?.message ?? ""),
// The participant the error is about does not survive the trip
// from the worker, so the identity is matched in the message. A
// shared key is the same for everyone, so its errors are too.
filter(
(message) =>
encryptionSystem.kind === E2eeType.SHARED_KEY ||
message.includes(source.participant.identity),
),
map((message): EncryptionError | undefined =>
message.includes("MissingKey")
? "MissingKey"
: message.includes("InvalidKey")
? "InvalidKey"
: undefined,
),
filter((error) => error !== undefined),
),
),
);
}