Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35bc7a1e98 | ||
|
|
cee6bab9a6 | ||
|
|
44023a4c84 | ||
|
|
3086edc64a | ||
|
|
2b6bb20104 | ||
|
|
3ca252f633 | ||
|
|
6beebd3ea7 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lotusguild/element-call-embedded",
|
||||
"version": "0.25.0-lotus.13",
|
||||
"version": "0.25.0-lotus.16",
|
||||
"files": [
|
||||
"README.md",
|
||||
"LICENSE-AGPL-3.0",
|
||||
|
||||
@@ -26,6 +26,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { useReactionsSender } from "../reactions/useReactionsSender";
|
||||
import { LOTUS_TOGGLE_REACTIONS_EVENT } from "../lotus/lotusControls";
|
||||
import styles from "./ReactionToggleButton.module.css";
|
||||
import {
|
||||
type RaisedHandInfo,
|
||||
@@ -196,6 +197,15 @@ export function ReactionToggleButton({
|
||||
setErrorText(undefined);
|
||||
}, [showReactionsMenu]);
|
||||
|
||||
// [cinny #43] The Lotus host's call bar toggles this menu over the widget
|
||||
// API (lotusControls.ts) instead of clicking this button in our DOM.
|
||||
useEffect(() => {
|
||||
const toggle = (): void => setShowReactionsMenu((open) => !open);
|
||||
window.addEventListener(LOTUS_TOGGLE_REACTIONS_EVENT, toggle);
|
||||
return (): void =>
|
||||
window.removeEventListener(LOTUS_TOGGLE_REACTIONS_EVENT, toggle);
|
||||
}, []);
|
||||
|
||||
const sendRelation = useCallback(
|
||||
async (reaction: ReactionOption) => {
|
||||
try {
|
||||
|
||||
@@ -27,6 +27,9 @@ describe("LotusWidgetActions", () => {
|
||||
LotusWidgetActions.Decorations,
|
||||
LotusWidgetActions.SetDeafen,
|
||||
LotusWidgetActions.SetAudioOutput,
|
||||
LotusWidgetActions.SetLayout,
|
||||
LotusWidgetActions.OpenSettings,
|
||||
LotusWidgetActions.ToggleReactions,
|
||||
];
|
||||
|
||||
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
|
||||
@@ -44,5 +47,9 @@ describe("LotusWidgetActions", () => {
|
||||
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(
|
||||
LotusWidgetActions.DenoiseState,
|
||||
);
|
||||
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(
|
||||
LotusWidgetActions.ControlsState,
|
||||
);
|
||||
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(LotusWidgetActions.MicLevel);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,29 @@ export enum LotusWidgetActions {
|
||||
* else in the call (#39). Each fires at most once per share.
|
||||
*/
|
||||
ScreenshareNotice = "io.lotus.screenshare_notice",
|
||||
/**
|
||||
* toWidget: switch the call layout `{ layout: "grid" | "spotlight" }`
|
||||
* (cinny #43 — replaces the host clicking EC's hidden layout radio).
|
||||
*/
|
||||
SetLayout = "io.lotus.set_layout",
|
||||
/**
|
||||
* toWidget: open EC's settings modal, or close it with `{ open: false }`;
|
||||
* omit `open` to toggle (cinny #43).
|
||||
*/
|
||||
OpenSettings = "io.lotus.open_settings",
|
||||
/** toWidget: toggle the reactions / raise-hand menu (cinny #43). */
|
||||
ToggleReactions = "io.lotus.toggle_reactions",
|
||||
/**
|
||||
* fromWidget: `{ screensharing: boolean, layout: "grid" | "spotlight" | null }`
|
||||
* whenever either changes (cinny #43), so the host stops reading EC's DOM for
|
||||
* them. Its arrival also tells the host this fork supports the actions above.
|
||||
*/
|
||||
ControlsState = "io.lotus.controls_state",
|
||||
/**
|
||||
* fromWidget: local mic level `{ bars: 0 | 1 | 2 | 3 }` (cinny #146), sent
|
||||
* only when it changes (≤ 10 Hz); 0 while muted or with no mic.
|
||||
*/
|
||||
MicLevel = "io.lotus.mic_level",
|
||||
}
|
||||
|
||||
/** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */
|
||||
@@ -72,4 +95,7 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [
|
||||
LotusWidgetActions.Decorations,
|
||||
LotusWidgetActions.SetDeafen,
|
||||
LotusWidgetActions.SetAudioOutput,
|
||||
LotusWidgetActions.SetLayout,
|
||||
LotusWidgetActions.OpenSettings,
|
||||
LotusWidgetActions.ToggleReactions,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
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 { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseLayoutPayload, parseSettingsPayload } from "./lotusControls";
|
||||
|
||||
describe("parseLayoutPayload", () => {
|
||||
it("accepts grid and spotlight", () => {
|
||||
expect(parseLayoutPayload({ layout: "grid" })).toBe("grid");
|
||||
expect(parseLayoutPayload({ layout: "spotlight" })).toBe("spotlight");
|
||||
});
|
||||
it("rejects anything else", () => {
|
||||
expect(parseLayoutPayload({ layout: "pip" })).toBeUndefined();
|
||||
expect(parseLayoutPayload({})).toBeUndefined();
|
||||
expect(parseLayoutPayload(null)).toBeUndefined();
|
||||
expect(parseLayoutPayload("grid")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSettingsPayload", () => {
|
||||
it("uses an explicit open flag", () => {
|
||||
expect(parseSettingsPayload({ open: true }, true)).toBe(true);
|
||||
expect(parseSettingsPayload({ open: false }, false)).toBe(false);
|
||||
});
|
||||
it("toggles when open is missing or not a boolean", () => {
|
||||
expect(parseSettingsPayload({}, false)).toBe(true);
|
||||
expect(parseSettingsPayload(undefined, true)).toBe(false);
|
||||
expect(parseSettingsPayload({ open: "yes" }, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
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 { type IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { combineLatest, of, type Subscription } from "rxjs";
|
||||
import { distinctUntilChanged, map, switchMap } from "rxjs/operators";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
|
||||
import { widget } from "../widget";
|
||||
import { LotusWidgetActions, lotusSendToHost } from "./lotusWidget";
|
||||
|
||||
/** Window event the reactions button listens for (see ReactionToggleButton). */
|
||||
export const LOTUS_TOGGLE_REACTIONS_EVENT = "lotus:toggle-reactions";
|
||||
|
||||
export interface LotusControlsState {
|
||||
screensharing: boolean;
|
||||
/** Null while EC offers no layout switch (e.g. PiP or a 1:1 layout). */
|
||||
layout: LayoutMode | null;
|
||||
}
|
||||
|
||||
/** `{ layout }` payload → a layout mode, or undefined if invalid. Exported for tests. */
|
||||
export function parseLayoutPayload(data: unknown): LayoutMode | undefined {
|
||||
if (typeof data !== "object" || data === null) return undefined;
|
||||
const { layout } = data as { layout?: unknown };
|
||||
return layout === "grid" || layout === "spotlight" ? layout : undefined;
|
||||
}
|
||||
|
||||
/** `{ open? }` payload → the settings-open state to apply. Exported for tests. */
|
||||
export function parseSettingsPayload(data: unknown, current: boolean): boolean {
|
||||
if (typeof data === "object" && data !== null && "open" in data) {
|
||||
const { open } = data as { open?: unknown };
|
||||
if (typeof open === "boolean") return open;
|
||||
}
|
||||
return !current;
|
||||
}
|
||||
|
||||
/**
|
||||
* [cinny #43] Widget-API replacements for the host's DOM access to EC's
|
||||
* controls: layout switch, settings modal and reactions menu, plus a
|
||||
* `controls_state` report (screensharing + layout) so the host no longer reads
|
||||
* EC's DOM for them. Screensharing itself stays host-DOM driven for now:
|
||||
* `getDisplayMedia` needs the user's click to reach this frame (Capability
|
||||
* Delegation), which a plain widget message doesn't carry.
|
||||
*
|
||||
* No effect unless the host sends the actions; registering is safe whenever
|
||||
* we're a widget. Returns a teardown function.
|
||||
*/
|
||||
export function startLotusControls(vm: CallViewModel): () => void {
|
||||
const w = widget;
|
||||
if (!w) return (): void => undefined;
|
||||
|
||||
const onSetLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
w.api.transport.reply(ev.detail, {});
|
||||
const layout = parseLayoutPayload(ev.detail.data);
|
||||
if (layout) vm.layoutSwitchVm$.value?.setLayout(layout);
|
||||
};
|
||||
const onOpenSettings = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
w.api.transport.reply(ev.detail, {});
|
||||
vm.setSettingsOpen$.value(
|
||||
parseSettingsPayload(ev.detail.data, vm.settingsOpen$.value),
|
||||
);
|
||||
};
|
||||
const onToggleReactions = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
w.api.transport.reply(ev.detail, {});
|
||||
window.dispatchEvent(new Event(LOTUS_TOGGLE_REACTIONS_EVENT));
|
||||
};
|
||||
|
||||
w.lazyActions.on(LotusWidgetActions.SetLayout, onSetLayout);
|
||||
w.lazyActions.on(LotusWidgetActions.OpenSettings, onOpenSettings);
|
||||
w.lazyActions.on(LotusWidgetActions.ToggleReactions, onToggleReactions);
|
||||
|
||||
const sub: Subscription = combineLatest([
|
||||
vm.sharingScreen$,
|
||||
vm.layoutSwitchVm$.pipe(
|
||||
switchMap((l) => (l ? l.layout$ : of<LayoutMode | null>(null))),
|
||||
),
|
||||
])
|
||||
.pipe(
|
||||
map(
|
||||
([screensharing, layout]): LotusControlsState => ({
|
||||
screensharing,
|
||||
layout,
|
||||
}),
|
||||
),
|
||||
distinctUntilChanged(
|
||||
(a, b) => a.screensharing === b.screensharing && a.layout === b.layout,
|
||||
),
|
||||
)
|
||||
.subscribe((state) => {
|
||||
lotusSendToHost(LotusWidgetActions.ControlsState, state);
|
||||
});
|
||||
|
||||
return (): void => {
|
||||
sub.unsubscribe();
|
||||
w.lazyActions.off(LotusWidgetActions.SetLayout, onSetLayout);
|
||||
w.lazyActions.off(LotusWidgetActions.OpenSettings, onOpenSettings);
|
||||
w.lazyActions.off(LotusWidgetActions.ToggleReactions, onToggleReactions);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
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 { describe, expect, it } from "vitest";
|
||||
|
||||
import { MicLevelQuantizer } from "./lotusMicLevel";
|
||||
|
||||
describe("MicLevelQuantizer", () => {
|
||||
it("maps RMS to 0–3 bars", () => {
|
||||
expect(new MicLevelQuantizer().push(0.001)).toBe(0);
|
||||
expect(new MicLevelQuantizer().push(0.008)).toBe(1);
|
||||
expect(new MicLevelQuantizer().push(0.02)).toBe(2);
|
||||
expect(new MicLevelQuantizer().push(0.2)).toBe(3);
|
||||
});
|
||||
|
||||
it("rises at once and falls one bar per sample", () => {
|
||||
const q = new MicLevelQuantizer();
|
||||
expect(q.push(0.2)).toBe(3);
|
||||
expect(q.push(0)).toBe(2);
|
||||
expect(q.push(0)).toBe(1);
|
||||
expect(q.push(0.02)).toBe(2);
|
||||
expect(q.push(0)).toBe(1);
|
||||
expect(q.push(0)).toBe(0);
|
||||
expect(q.push(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("reset drops straight to 0", () => {
|
||||
const q = new MicLevelQuantizer();
|
||||
q.push(0.2);
|
||||
q.reset();
|
||||
expect(q.push(0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
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 {
|
||||
type LocalTrackPublication,
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { Observable, type Subscription, share, switchMap } from "rxjs";
|
||||
import { distinctUntilChanged, map } from "rxjs/operators";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { widget } from "../widget";
|
||||
import { LotusWidgetActions, lotusFlag, lotusSendToHost } from "./lotusWidget";
|
||||
|
||||
/**
|
||||
* [lotus #146] One local mic sampler shared by the host's mic level meter
|
||||
* (`io.lotus.mic_level`) and "talking while muted" (#37, lotusMutedSpeech).
|
||||
* It taps a CLONE of the published mic track (the post-processor track when
|
||||
* the in-source denoiser is active, so what's measured is what's sent) and
|
||||
* reads RMS at ~10 Hz while a mic track is published, muted or not.
|
||||
* Local only: nothing here reaches other participants.
|
||||
*/
|
||||
|
||||
const SAMPLE_MS = 100;
|
||||
|
||||
export interface LocalMicSample {
|
||||
rms: number;
|
||||
/** The mic is published but muted (the clone still hears it). */
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
const micPublication = (
|
||||
room: LivekitRoom,
|
||||
): { track: MediaStreamTrack; muted: boolean } | null => {
|
||||
const pub: LocalTrackPublication | undefined =
|
||||
room.localParticipant.getTrackPublication(Track.Source.Microphone);
|
||||
const track = pub?.track?.mediaStreamTrack;
|
||||
return track && track.readyState === "live"
|
||||
? { track, muted: pub?.isMuted ?? false }
|
||||
: null;
|
||||
};
|
||||
|
||||
const samplers = new WeakMap<
|
||||
CallViewModel,
|
||||
Observable<LocalMicSample | null>
|
||||
>();
|
||||
|
||||
/**
|
||||
* RMS samples of the local mic, or `null` while no mic track is published.
|
||||
* Shared per call view model, so the meter and the muted-speech detector use
|
||||
* one AudioContext between them.
|
||||
*/
|
||||
export function observeLocalMicSample$(
|
||||
vm: CallViewModel,
|
||||
): Observable<LocalMicSample | null> {
|
||||
const cached = samplers.get(vm);
|
||||
if (cached) return cached;
|
||||
const sampler$ = vm.allConnections$.pipe(
|
||||
switchMap(
|
||||
(data) =>
|
||||
new Observable<LocalMicSample | null>((subscriber) => {
|
||||
const rooms = data.getConnections().map((c) => c.livekitRoom);
|
||||
let ctx: AudioContext | null = null;
|
||||
let clone: MediaStreamTrack | null = null;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let tapped: MediaStreamTrack | null = null;
|
||||
let muted = false;
|
||||
|
||||
const stopTap = (): void => {
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
timer = undefined;
|
||||
clone?.stop();
|
||||
clone = null;
|
||||
void ctx?.close().catch(() => undefined);
|
||||
ctx = null;
|
||||
tapped = null;
|
||||
subscriber.next(null);
|
||||
};
|
||||
|
||||
const startTap = (source: MediaStreamTrack): void => {
|
||||
try {
|
||||
clone = source.clone();
|
||||
// Muting disables the published track; the clone must still hear.
|
||||
clone.enabled = true;
|
||||
ctx = new AudioContext();
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
ctx
|
||||
.createMediaStreamSource(new MediaStream([clone]))
|
||||
.connect(analyser);
|
||||
const buf = new Float32Array(analyser.fftSize);
|
||||
tapped = source;
|
||||
timer = setInterval(() => {
|
||||
analyser.getFloatTimeDomainData(buf);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < buf.length; i += 1) sum += buf[i] * buf[i];
|
||||
subscriber.next({ rms: Math.sqrt(sum / buf.length), muted });
|
||||
}, SAMPLE_MS);
|
||||
} catch {
|
||||
stopTap();
|
||||
}
|
||||
};
|
||||
|
||||
const reconcile = (): void => {
|
||||
const pub =
|
||||
rooms.map(micPublication).find((p) => p !== null) ?? null;
|
||||
muted = pub?.muted ?? false;
|
||||
const track = pub?.track ?? null;
|
||||
if (track === tapped) return;
|
||||
if (tapped) stopTap();
|
||||
if (track) startTap(track);
|
||||
};
|
||||
|
||||
const events = [
|
||||
RoomEvent.TrackMuted,
|
||||
RoomEvent.TrackUnmuted,
|
||||
RoomEvent.LocalTrackPublished,
|
||||
RoomEvent.LocalTrackUnpublished,
|
||||
RoomEvent.Reconnected,
|
||||
RoomEvent.Disconnected,
|
||||
] as const;
|
||||
rooms.forEach((room) =>
|
||||
events.forEach((ev) => room.on(ev, reconcile)),
|
||||
);
|
||||
subscriber.next(null);
|
||||
reconcile();
|
||||
return (): void => {
|
||||
rooms.forEach((room) =>
|
||||
events.forEach((ev) => room.off(ev, reconcile)),
|
||||
);
|
||||
if (tapped) stopTap();
|
||||
};
|
||||
}),
|
||||
),
|
||||
share(),
|
||||
);
|
||||
samplers.set(vm, sampler$);
|
||||
return sampler$;
|
||||
}
|
||||
|
||||
/** RMS at which each bar lights: ≈ −46, −36 and −26 dBFS. */
|
||||
export const BAR_THRESHOLDS = [0.005, 0.015, 0.05] as const;
|
||||
|
||||
/**
|
||||
* Quantise RMS to 0–3 bars with a little hysteresis: rises at once, falls one
|
||||
* bar per sample, so the meter doesn't flicker between words. Unit-tested.
|
||||
*/
|
||||
export class MicLevelQuantizer {
|
||||
private bars = 0;
|
||||
|
||||
public reset(): void {
|
||||
this.bars = 0;
|
||||
}
|
||||
|
||||
public push(rms: number): number {
|
||||
const target = BAR_THRESHOLDS.filter((t) => rms >= t).length;
|
||||
this.bars = target >= this.bars ? target : this.bars - 1;
|
||||
return this.bars;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the host `io.lotus.mic_level { bars }` (0–3) whenever the quantised
|
||||
* level changes; 0 while muted or with no mic. At most one message per sample
|
||||
* (10 Hz) and none during steady silence. Opt-in with the rest of the host
|
||||
* state stream (`lotusCallState=1`). Returns a teardown function.
|
||||
*/
|
||||
export function startLotusMicLevel(vm: CallViewModel): () => void {
|
||||
if (!lotusFlag("lotusCallState") || !widget) return (): void => undefined;
|
||||
const quantizer = new MicLevelQuantizer();
|
||||
const sub: Subscription = observeLocalMicSample$(vm)
|
||||
.pipe(
|
||||
map((sample) => {
|
||||
if (!sample || sample.muted) {
|
||||
quantizer.reset();
|
||||
return 0;
|
||||
}
|
||||
return quantizer.push(sample.rms);
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe((bars) => {
|
||||
lotusSendToHost(LotusWidgetActions.MicLevel, { bars });
|
||||
});
|
||||
return (): void => sub.unsubscribe();
|
||||
}
|
||||
@@ -6,14 +6,15 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type LocalTrackPublication,
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { Observable, distinctUntilChanged, switchMap } from "rxjs";
|
||||
type Observable,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
scan,
|
||||
startWith,
|
||||
} from "rxjs";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type LocalMicSample, observeLocalMicSample$ } from "./lotusMicLevel";
|
||||
|
||||
/**
|
||||
* [lotus #37] "Talking while muted" detection for the LOCAL participant.
|
||||
@@ -23,13 +24,12 @@ import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
* tells the user they are talking into a muted mic. This taps a CLONE of the
|
||||
* published track (the post-processor track when the in-source denoiser is
|
||||
* active, so keyboard noise doesn't count), samples RMS at ~10 Hz and emits a
|
||||
* debounced boolean. Zero cost when unmuted (tap torn down), local-only —
|
||||
* debounced boolean. Local-only —
|
||||
* the flag rides `io.lotus.call_state` to the host and never reaches other
|
||||
* participants.
|
||||
*/
|
||||
|
||||
export const MUTED_SPEECH_RMS = 0.015; // ≈ −36 dBFS; normal speech into a headset is 0.05–0.3
|
||||
const SAMPLE_MS = 100;
|
||||
const ON_SAMPLES = 3; // 300 ms of voice before we say "talking"
|
||||
const OFF_SAMPLES = 8; // 800 ms of quiet before we drop it
|
||||
|
||||
@@ -61,94 +61,24 @@ export class MutedSpeechGate {
|
||||
}
|
||||
}
|
||||
|
||||
const mutedMicTrack = (room: LivekitRoom): MediaStreamTrack | null => {
|
||||
const pub: LocalTrackPublication | undefined =
|
||||
room.localParticipant.getTrackPublication(Track.Source.Microphone);
|
||||
const track = pub?.track?.mediaStreamTrack;
|
||||
return pub?.isMuted && track && track.readyState === "live" ? track : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Emits while the local mic is muted and voice is detected on it. Emits
|
||||
* `false` whenever the mic is unmuted, unpublished or the connection changes.
|
||||
* [lotus #146] Fed by the shared local mic sampler (lotusMicLevel.ts), which
|
||||
* also drives the host's mic level meter while unmuted.
|
||||
*/
|
||||
export function observeSpeakingWhileMuted$(
|
||||
vm: CallViewModel,
|
||||
): Observable<boolean> {
|
||||
return vm.allConnections$.pipe(
|
||||
switchMap(
|
||||
(data) =>
|
||||
new Observable<boolean>((subscriber) => {
|
||||
const rooms = data.getConnections().map((c) => c.livekitRoom);
|
||||
let ctx: AudioContext | null = null;
|
||||
let clone: MediaStreamTrack | null = null;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let tapped: MediaStreamTrack | null = null;
|
||||
|
||||
const stopTap = (): void => {
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
timer = undefined;
|
||||
clone?.stop();
|
||||
clone = null;
|
||||
void ctx?.close().catch(() => undefined);
|
||||
ctx = null;
|
||||
tapped = null;
|
||||
subscriber.next(false);
|
||||
};
|
||||
|
||||
const startTap = (source: MediaStreamTrack): void => {
|
||||
try {
|
||||
clone = source.clone();
|
||||
clone.enabled = true; // the source is disabled by the mute — the clone must not be
|
||||
ctx = new AudioContext();
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
ctx
|
||||
.createMediaStreamSource(new MediaStream([clone]))
|
||||
.connect(analyser);
|
||||
const buf = new Float32Array(analyser.fftSize);
|
||||
const gate = new MutedSpeechGate();
|
||||
tapped = source;
|
||||
timer = setInterval(() => {
|
||||
analyser.getFloatTimeDomainData(buf);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < buf.length; i += 1) sum += buf[i] * buf[i];
|
||||
subscriber.next(gate.push(Math.sqrt(sum / buf.length)));
|
||||
}, SAMPLE_MS);
|
||||
} catch {
|
||||
stopTap();
|
||||
}
|
||||
};
|
||||
|
||||
const reconcile = (): void => {
|
||||
const track =
|
||||
rooms.map(mutedMicTrack).find((t) => t !== null) ?? null;
|
||||
if (track === tapped) return;
|
||||
if (tapped) stopTap();
|
||||
if (track) startTap(track);
|
||||
};
|
||||
|
||||
const events = [
|
||||
RoomEvent.TrackMuted,
|
||||
RoomEvent.TrackUnmuted,
|
||||
RoomEvent.LocalTrackPublished,
|
||||
RoomEvent.LocalTrackUnpublished,
|
||||
RoomEvent.Reconnected,
|
||||
RoomEvent.Disconnected,
|
||||
] as const;
|
||||
rooms.forEach((room) =>
|
||||
events.forEach((ev) => room.on(ev, reconcile)),
|
||||
);
|
||||
subscriber.next(false);
|
||||
reconcile();
|
||||
return () => {
|
||||
rooms.forEach((room) =>
|
||||
events.forEach((ev) => room.off(ev, reconcile)),
|
||||
);
|
||||
if (tapped) stopTap();
|
||||
};
|
||||
}),
|
||||
),
|
||||
return observeLocalMicSample$(vm).pipe(
|
||||
scan((gate: MutedSpeechGate | null, sample: LocalMicSample | null) => {
|
||||
if (!sample?.muted) return null;
|
||||
const g = gate ?? new MutedSpeechGate();
|
||||
g.push(sample.rms);
|
||||
return g;
|
||||
}, null),
|
||||
map((gate) => gate?.value ?? false),
|
||||
startWith(false),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
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 { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MAX_REMEMBERED,
|
||||
getRememberedVolume,
|
||||
rememberVolume,
|
||||
} from "./lotusVolumeMemory";
|
||||
|
||||
describe("lotusVolumeMemory", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("defaults to 1 and remembers a set volume", () => {
|
||||
expect(getRememberedVolume("@bob:x")).toBe(1);
|
||||
rememberVolume("@bob:x", 0.4);
|
||||
expect(getRememberedVolume("@bob:x")).toBe(0.4);
|
||||
});
|
||||
|
||||
it("forgets the entry when set back to 1", () => {
|
||||
rememberVolume("@bob:x", 0.4);
|
||||
rememberVolume("@bob:x", 1);
|
||||
expect(getRememberedVolume("@bob:x")).toBe(1);
|
||||
expect(localStorage.getItem("lotus-per-user-volume")).toBe("{}");
|
||||
});
|
||||
|
||||
it("keeps only the most recently set entries", () => {
|
||||
for (let i = 0; i < MAX_REMEMBERED + 5; i++)
|
||||
rememberVolume(`@u${i}:x`, 0.5);
|
||||
expect(getRememberedVolume("@u0:x")).toBe(1);
|
||||
expect(getRememberedVolume("@u4:x")).toBe(1);
|
||||
expect(getRememberedVolume("@u5:x")).toBe(0.5);
|
||||
expect(getRememberedVolume(`@u${MAX_REMEMBERED + 4}:x`)).toBe(0.5);
|
||||
});
|
||||
|
||||
it("re-setting an old entry makes it recent", () => {
|
||||
for (let i = 0; i < MAX_REMEMBERED; i++) rememberVolume(`@u${i}:x`, 0.5);
|
||||
rememberVolume("@u0:x", 0.7);
|
||||
rememberVolume("@new:x", 0.3);
|
||||
expect(getRememberedVolume("@u0:x")).toBe(0.7);
|
||||
expect(getRememberedVolume("@u1:x")).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores junk in storage and invalid volumes", () => {
|
||||
localStorage.setItem(
|
||||
"lotus-per-user-volume",
|
||||
JSON.stringify({ "@a:x": "loud", "@b:x": 0.2 }),
|
||||
);
|
||||
expect(getRememberedVolume("@a:x")).toBe(1);
|
||||
expect(getRememberedVolume("@b:x")).toBe(0.2);
|
||||
rememberVolume("@c:x", Number.NaN);
|
||||
expect(getRememberedVolume("@c:x")).toBe(1);
|
||||
localStorage.setItem("lotus-per-user-volume", "not json");
|
||||
expect(getRememberedVolume("@b:x")).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* [element-call #36] Remember the per-participant volume slider across calls,
|
||||
* reloads and reconnects. Keyed by Matrix user id (not device), so "Bob is
|
||||
* loud" sticks when Bob switches devices. Local to this browser, never synced.
|
||||
* Only voice is remembered; screenshare audio stays per-share.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "lotus-per-user-volume";
|
||||
/** Most recently set entries kept; older ones are dropped. */
|
||||
export const MAX_REMEMBERED = 50;
|
||||
|
||||
type VolumeMap = Record<string, number>;
|
||||
|
||||
const isVolume = (v: unknown): v is number =>
|
||||
typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 4;
|
||||
|
||||
function load(): VolumeMap {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) return {};
|
||||
const out: VolumeMap = {};
|
||||
for (const [k, v] of Object.entries(parsed)) if (isVolume(v)) out[k] = v;
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function save(map: VolumeMap): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
||||
} catch {
|
||||
// Storage unavailable (private mode, quota): the slider just won't stick.
|
||||
}
|
||||
}
|
||||
|
||||
/** The remembered volume for `userId`, or 1 (100 %) if none. */
|
||||
export function getRememberedVolume(userId: string): number {
|
||||
return load()[userId] ?? 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember `volume` for `userId`. 1 (the default) forgets the entry. The entry
|
||||
* moves to the end so the map is in least-recently-set order, trimmed to
|
||||
* MAX_REMEMBERED.
|
||||
*/
|
||||
export function rememberVolume(userId: string, volume: number): void {
|
||||
if (!isVolume(volume)) return;
|
||||
const map = load();
|
||||
delete map[userId];
|
||||
if (volume !== 1) map[userId] = volume;
|
||||
const keys = Object.keys(map);
|
||||
for (const k of keys.slice(0, Math.max(0, keys.length - MAX_REMEMBERED)))
|
||||
delete map[k];
|
||||
save(map);
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
import { widget } from "../widget";
|
||||
import { startLotusCallState } from "../lotus/lotusCallState";
|
||||
import { startLotusFocus } from "../lotus/lotusFocus";
|
||||
import { startLotusControls } from "../lotus/lotusControls";
|
||||
import { startLotusMicLevel } from "../lotus/lotusMicLevel";
|
||||
import { startLotusAudioInject } from "../lotus/lotusAudioInject";
|
||||
import { startLotusQuality } from "../lotus/lotusQuality";
|
||||
import { startLotusDecorations } from "../lotus/lotusDecorations";
|
||||
@@ -300,6 +302,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
// [lotus] Handle the host's io.lotus.focus_participant action to pin a
|
||||
// participant to the spotlight (#4). No-op unless the host sends it.
|
||||
useEffect(() => startLotusFocus(vm), [vm]);
|
||||
// [cinny #43] layout / settings / reactions over the widget API, plus a
|
||||
// screensharing + layout report, replacing the host's DOM access.
|
||||
useEffect(() => startLotusControls(vm), [vm]);
|
||||
// [cinny #146] Local mic level for the host's mute-button meter.
|
||||
useEffect(() => startLotusMicLevel(vm), [vm]);
|
||||
// [lotus] Handle the host's io.lotus.inject_audio action to mix a soundboard
|
||||
// clip into the call as a separate track (#3). No-op unless the host sends it.
|
||||
useEffect(() => startLotusAudioInject(vm), [vm]);
|
||||
|
||||
+71
-27
@@ -5,7 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, merge, of, Subject, switchMap } from "rxjs";
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
skip,
|
||||
Subject,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "./Behavior";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
@@ -25,7 +34,12 @@ export interface VolumeControls {
|
||||
playbackMuted$: Behavior<boolean>;
|
||||
togglePlaybackMuted: () => void;
|
||||
adjustPlaybackVolume: (value: number) => void;
|
||||
commitPlaybackVolume: () => void;
|
||||
/**
|
||||
* Commit the volume. [lotus #36] Pass the slider's committed value: with the
|
||||
* keyboard the slider commits before its last change reaches us, which left
|
||||
* the committed (and remembered) volume one step behind.
|
||||
*/
|
||||
commitPlaybackVolume: (value?: number) => void;
|
||||
}
|
||||
|
||||
interface VolumeControlsInputs {
|
||||
@@ -35,6 +49,10 @@ interface VolumeControlsInputs {
|
||||
* requested volume.
|
||||
*/
|
||||
sink$: Behavior<(volume: number) => void>;
|
||||
/** [lotus #36] Starting volume (a remembered one); defaults to 1. */
|
||||
initialVolume?: number;
|
||||
/** [lotus #36] Called with each newly committed volume. */
|
||||
onCommit?: (volume: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,38 +61,61 @@ interface VolumeControlsInputs {
|
||||
*/
|
||||
export function createVolumeControls(
|
||||
scope: ObservableScope,
|
||||
{ pretendToBeDisconnected$, sink$ }: VolumeControlsInputs,
|
||||
{
|
||||
pretendToBeDisconnected$,
|
||||
sink$,
|
||||
initialVolume = 1,
|
||||
onCommit,
|
||||
}: VolumeControlsInputs,
|
||||
): VolumeControls {
|
||||
const toggleMuted$ = new Subject<"toggle mute">();
|
||||
const adjustVolume$ = new Subject<number>();
|
||||
const commitVolume$ = new Subject<"commit">();
|
||||
|
||||
const playbackVolume$ = scope.behavior<number>(
|
||||
const state$ = scope.behavior(
|
||||
merge(toggleMuted$, adjustVolume$, commitVolume$).pipe(
|
||||
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
|
||||
switch (event) {
|
||||
case "toggle mute":
|
||||
return {
|
||||
...state,
|
||||
volume: state.volume === 0 ? state.committedVolume : 0,
|
||||
};
|
||||
case "commit":
|
||||
// Dragging the slider to zero should have the same effect as
|
||||
// muting: keep the original committed volume, as if it were never
|
||||
// dragged
|
||||
return {
|
||||
...state,
|
||||
committedVolume:
|
||||
state.volume === 0 ? state.committedVolume : state.volume,
|
||||
};
|
||||
default:
|
||||
// Volume adjustment
|
||||
return { ...state, volume: event };
|
||||
}
|
||||
}),
|
||||
map(({ volume }) => volume),
|
||||
accumulate(
|
||||
{ volume: initialVolume, committedVolume: initialVolume },
|
||||
(state, event) => {
|
||||
switch (event) {
|
||||
case "toggle mute":
|
||||
return {
|
||||
...state,
|
||||
volume: state.volume === 0 ? state.committedVolume : 0,
|
||||
};
|
||||
case "commit":
|
||||
// Dragging the slider to zero should have the same effect as
|
||||
// muting: keep the original committed volume, as if it were never
|
||||
// dragged
|
||||
return {
|
||||
...state,
|
||||
committedVolume:
|
||||
state.volume === 0 ? state.committedVolume : state.volume,
|
||||
};
|
||||
default:
|
||||
// Volume adjustment
|
||||
return { ...state, volume: event };
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
const playbackVolume$ = scope.behavior<number>(
|
||||
state$.pipe(map(({ volume }) => volume)),
|
||||
);
|
||||
|
||||
// [lotus #36] Report committed changes (not the starting value) so the
|
||||
// caller can remember them.
|
||||
if (onCommit) {
|
||||
state$
|
||||
.pipe(
|
||||
map(({ committedVolume }) => committedVolume),
|
||||
distinctUntilChanged(),
|
||||
skip(1),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe(onCommit);
|
||||
}
|
||||
|
||||
// Sync the requested volume with the audio playback module
|
||||
combineLatest([
|
||||
@@ -96,6 +137,9 @@ export function createVolumeControls(
|
||||
),
|
||||
togglePlaybackMuted: () => toggleMuted$.next("toggle mute"),
|
||||
adjustPlaybackVolume: (value: number) => adjustVolume$.next(value),
|
||||
commitPlaybackVolume: () => commitVolume$.next("commit"),
|
||||
commitPlaybackVolume: (value?: number) => {
|
||||
if (value !== undefined) adjustVolume$.next(value);
|
||||
commitVolume$.next("commit");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, onTestFinished, test, vi } from "vitest";
|
||||
import { afterEach, expect, onTestFinished, test, vi } from "vitest";
|
||||
import {
|
||||
type LocalTrackPublication,
|
||||
LocalVideoTrack,
|
||||
@@ -44,6 +44,36 @@ vi.mock("../../Platform", () => ({
|
||||
|
||||
const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
||||
|
||||
// [lotus #36] Remote volumes are remembered in localStorage; keep tests apart.
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
test("a remembered volume is restored and a committed slider value is saved", () => {
|
||||
localStorage.setItem(
|
||||
"lotus-per-user-volume",
|
||||
JSON.stringify({ "@alice:example.org": 0.5 }),
|
||||
);
|
||||
const setVolumeSpy = vi.fn();
|
||||
const vm = mockRemoteMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockRemoteParticipant({ setVolume: setVolumeSpy }),
|
||||
);
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", {
|
||||
a() {
|
||||
// Keyboard order: the slider commits with its final value before that
|
||||
// value's change event reaches the view model.
|
||||
vm.commitPlaybackVolume(0.3);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.3);
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("lotus-per-user-volume") ?? "{}"),
|
||||
).toEqual({ "@alice:example.org": 0.3 });
|
||||
},
|
||||
});
|
||||
expectObservable(vm.playbackVolume$).toBe("ab", { a: 0.5, b: 0.3 });
|
||||
});
|
||||
});
|
||||
|
||||
test("control a participant's volume", () => {
|
||||
const setVolumeSpy = vi.fn();
|
||||
const vm = mockRemoteMedia(
|
||||
|
||||
@@ -11,6 +11,10 @@ import { combineLatest, map, of, switchMap } from "rxjs";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { createVolumeControls, type VolumeControls } from "../VolumeControls";
|
||||
import {
|
||||
getRememberedVolume,
|
||||
rememberVolume,
|
||||
} from "../../lotus/lotusVolumeMemory";
|
||||
import {
|
||||
type BaseUserMediaInputs,
|
||||
type BaseUserMediaViewModel,
|
||||
@@ -52,6 +56,9 @@ export function createRemoteUserMedia(
|
||||
sink$: scope.behavior(
|
||||
inputs.participant$.pipe(map((p) => (volume) => p?.setVolume(volume))),
|
||||
),
|
||||
// [lotus #36] The slider sticks per user across calls and reconnects.
|
||||
initialVolume: getRememberedVolume(inputs.userId),
|
||||
onCommit: (volume) => rememberVolume(inputs.userId, volume),
|
||||
}),
|
||||
local: false,
|
||||
speaking$: scope.behavior(
|
||||
|
||||
@@ -337,7 +337,10 @@ const ScreenShareVolumeButton: FC<ScreenShareVolumeButtonProps> = ({ vm }) => {
|
||||
(v: number) => vm.adjustPlaybackVolume(v),
|
||||
[vm],
|
||||
);
|
||||
const onVolumeCommit = useCallback(() => vm.commitPlaybackVolume(), [vm]);
|
||||
const onVolumeCommit = useCallback(
|
||||
(value: number) => vm.commitPlaybackVolume(value),
|
||||
[vm],
|
||||
);
|
||||
|
||||
return (
|
||||
audioEnabled && (
|
||||
|
||||
Reference in New Issue
Block a user