Fixes #20 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
119 lines
4.0 KiB
TypeScript
119 lines
4.0 KiB
TypeScript
/*
|
|
Copyright 2026 Lotus Guild
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import { combineLatest, of, type Subscription } from "rxjs";
|
|
import {
|
|
distinctUntilChanged,
|
|
map,
|
|
switchMap,
|
|
throttleTime,
|
|
} from "rxjs/operators";
|
|
|
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
|
import { widget } from "../widget";
|
|
import { LotusWidgetActions, lotusFlag, lotusSendToHost } from "./lotusWidget";
|
|
|
|
interface ParticipantState {
|
|
/** EC media id (`${userId}:${deviceId}`), stable per participant device. */
|
|
id: string;
|
|
/** Matrix user id this media belongs to. */
|
|
userId: string;
|
|
speaking: boolean;
|
|
audioEnabled: boolean;
|
|
videoEnabled: boolean;
|
|
}
|
|
|
|
/**
|
|
* [lotus #20] Field-wise equality for `ParticipantState[]`, used in place of
|
|
* `JSON.stringify` comparison: cheaper (no serialisation of every
|
|
* participant on every emission) and just as correct, since array order here
|
|
* is stable (it mirrors `members` from `userMedia$`).
|
|
*/
|
|
function participantsEqual(
|
|
a: ParticipantState[],
|
|
b: ParticipantState[],
|
|
): boolean {
|
|
return (
|
|
a.length === b.length &&
|
|
a.every(
|
|
(p, i) =>
|
|
p.id === b[i].id &&
|
|
p.userId === b[i].userId &&
|
|
p.speaking === b[i].speaking &&
|
|
p.audioEnabled === b[i].audioEnabled &&
|
|
p.videoEnabled === b[i].videoEnabled,
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stream per-participant speaking / mute / camera state to the Lotus host
|
|
* (cinny) over the widget API, so the host can drive speaking rings, mute
|
|
* badges and PiP from real events instead of scraping Element Call's rendered
|
|
* DOM (`useCallSpeakers.ts`).
|
|
*
|
|
* Opt-in: does nothing unless the host set `lotusCallState=1` on the widget
|
|
* URL. Returns a teardown function.
|
|
*/
|
|
export function startLotusCallState(vm: CallViewModel): () => void {
|
|
if (!lotusFlag("lotusCallState")) return () => undefined;
|
|
// [lotus] Standalone (non-widget) mode has no host to send state to;
|
|
// skip building the whole stream pipeline, mirroring lotusFocus.ts /
|
|
// lotusDecorations.ts.
|
|
if (!widget) return () => undefined;
|
|
|
|
const sub: Subscription = vm.userMedia$
|
|
.pipe(
|
|
switchMap((members) =>
|
|
members.length === 0
|
|
? of([] as ParticipantState[])
|
|
: combineLatest(
|
|
members.map((m) =>
|
|
combineLatest([
|
|
m.speaking$,
|
|
m.audioEnabled$,
|
|
m.videoEnabled$,
|
|
]).pipe(
|
|
map(
|
|
([
|
|
speaking,
|
|
audioEnabled,
|
|
videoEnabled,
|
|
]): ParticipantState => ({
|
|
id: m.id,
|
|
userId: m.userId,
|
|
speaking,
|
|
audioEnabled,
|
|
videoEnabled,
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// `speaking` flips rapidly; drop no-op repeats BEFORE throttling so
|
|
// the throttle window isn't spent re-emitting an unchanged value.
|
|
// Field-wise (cheaper than JSON.stringify, and correct: array order is
|
|
// stable since it mirrors `members` from userMedia$).
|
|
distinctUntilChanged(participantsEqual),
|
|
// [lotus #20] `speaking` is the field that flips constantly in an active
|
|
// conversation; mute/camera toggles are rare and user-intentional and
|
|
// would ideally stay prompt, but a single combined stream is far
|
|
// simpler than splitting it, and the leading+trailing 250ms window
|
|
// previously allowed ~8 sends/sec (each re-serialising every
|
|
// participant) in a busy call. Trailing-only + a longer window caps
|
|
// that to 2/sec while still reflecting mute/camera changes within
|
|
// 500ms.
|
|
throttleTime(500, undefined, { leading: false, trailing: true }),
|
|
)
|
|
.subscribe((participants) => {
|
|
lotusSendToHost(LotusWidgetActions.CallState, { participants });
|
|
});
|
|
|
|
return () => sub.unsubscribe();
|
|
}
|