fix(lotus): screenshare-audio mute survives a re-share — mute via the renderer, not setVolume
"Mute Screenshare Audio" (io.lotus.set_deafen screenshareAudioMuted) used RemoteParticipant.setVolume(0, ScreenShareAudio). EC's own createVolumeControls writes volume 1 through the same setter the moment a new screenshare media item resolves, so when the sharer stopped and re-shared (or a late joiner shared) the audio came back at full volume while the host button still said "Unmute Screenshare Audio". Reproduced on the local calls stack with two headless clients: after a re-share the screen_share_audio element read vol=1. Now the flag is a global behavior (muteScreenshareAudio$) that LivekitRoomAudioRenderer turns into the `muted` prop of every Track.Source.ScreenShareAudio element — the exact mechanism deafen already uses (pub.setEnabled(false): the server stops sending). Verified via the RemoteTrackPublication behind each <audio>: the re-published track (new sid) mounts with enabled=false while muted and re-enables on unmute; deafen + undeafen leaves it muted; teardown resets the flag so the next call starts clean. Unit tests updated; renderer test asserts only ScreenShareAudio elements get muted by the new prop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5
parent
bc0e5ed432
commit
ea579cb998
@@ -52,9 +52,16 @@ afterEach(() => {
|
|||||||
vi.mock("@livekit/components-react", async (importOriginal) => {
|
vi.mock("@livekit/components-react", async (importOriginal) => {
|
||||||
return {
|
return {
|
||||||
...(await importOriginal()),
|
...(await importOriginal()),
|
||||||
AudioTrack: (props: { trackRef: TrackReference }): ReactNode => {
|
AudioTrack: (props: {
|
||||||
|
trackRef: TrackReference;
|
||||||
|
muted?: boolean;
|
||||||
|
}): ReactNode => {
|
||||||
return (
|
return (
|
||||||
<audio data-testid={"audio"}>
|
<audio
|
||||||
|
data-testid={"audio"}
|
||||||
|
data-source={props.trackRef.publication.source}
|
||||||
|
data-muted={String(!!props.muted)}
|
||||||
|
>
|
||||||
{getTrackReferenceId(props.trackRef)}
|
{getTrackReferenceId(props.trackRef)}
|
||||||
</audio>
|
</audio>
|
||||||
);
|
);
|
||||||
@@ -83,6 +90,7 @@ function renderTestComponent(
|
|||||||
kind: Track.Kind;
|
kind: Track.Kind;
|
||||||
source: Track.Source;
|
source: Track.Source;
|
||||||
}[],
|
}[],
|
||||||
|
props: { muted?: boolean; screenshareAudioMuted?: boolean } = {},
|
||||||
): RenderResult {
|
): RenderResult {
|
||||||
const liveKitParticipants = livekitParticipantIdentities.map((identity) =>
|
const liveKitParticipants = livekitParticipantIdentities.map((identity) =>
|
||||||
mockRemoteParticipant({ identity }),
|
mockRemoteParticipant({ identity }),
|
||||||
@@ -117,6 +125,7 @@ function renderTestComponent(
|
|||||||
validIdentities={participants.map((p) => p.identity)}
|
validIdentities={participants.map((p) => p.identity)}
|
||||||
livekitRoom={livekitRoom}
|
livekitRoom={livekitRoom}
|
||||||
url={""}
|
url={""}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
</MediaDevicesProvider>,
|
</MediaDevicesProvider>,
|
||||||
);
|
);
|
||||||
@@ -286,3 +295,55 @@ it("should setup audioContext gain and pan", () => {
|
|||||||
expect(testAudioContext.gain.gain.value).toEqual(0.1);
|
expect(testAudioContext.gain.gain.value).toEqual(0.1);
|
||||||
expect(testAudioContext.pan.pan.value).toEqual(1);
|
expect(testAudioContext.pan.pan.value).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// [lotus] The host's "Mute Screenshare Audio" mutes ONLY screenshare-audio
|
||||||
|
// elements, via the same `muted` prop path as deafen (so it survives re-renders
|
||||||
|
// and later-published shares); deafen still mutes everything.
|
||||||
|
it("screenshareAudioMuted mutes only ScreenShareAudio tracks", () => {
|
||||||
|
const explicitTracks = [
|
||||||
|
{
|
||||||
|
participantId: "@alice:DEV0",
|
||||||
|
kind: Track.Kind.Audio,
|
||||||
|
source: Track.Source.Microphone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
participantId: "@alice:DEV0",
|
||||||
|
kind: Track.Kind.Audio,
|
||||||
|
source: Track.Source.ScreenShareAudio,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const mutedBySource = (r: RenderResult): Record<string, string> =>
|
||||||
|
Object.fromEntries(
|
||||||
|
r
|
||||||
|
.queryAllByTestId("audio")
|
||||||
|
.map((el) => [el.dataset.source, el.dataset.muted]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
mutedBySource(
|
||||||
|
renderTestComponent(
|
||||||
|
[{ userId: "@alice", deviceId: "DEV0" }],
|
||||||
|
["@alice:DEV0"],
|
||||||
|
explicitTracks,
|
||||||
|
{ screenshareAudioMuted: true },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
[Track.Source.Microphone]: "false",
|
||||||
|
[Track.Source.ScreenShareAudio]: "true",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
mutedBySource(
|
||||||
|
renderTestComponent(
|
||||||
|
[{ userId: "@alice", deviceId: "DEV0" }],
|
||||||
|
["@alice:DEV0"],
|
||||||
|
explicitTracks,
|
||||||
|
{ muted: true },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
[Track.Source.Microphone]: "true",
|
||||||
|
[Track.Source.ScreenShareAudio]: "true",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export interface MatrixAudioRendererProps {
|
|||||||
* If set to `true`, the server will stop sending audio track data to the client.
|
* If set to `true`, the server will stop sending audio track data to the client.
|
||||||
*/
|
*/
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
|
/**
|
||||||
|
* [lotus] If set to `true`, mutes only the `Track.Source.ScreenShareAudio`
|
||||||
|
* tracks (the host's "Mute Screenshare Audio" control) — same mechanism as
|
||||||
|
* `muted`, so it holds across re-renders and later-published shares.
|
||||||
|
*/
|
||||||
|
screenshareAudioMuted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +64,7 @@ export function LivekitRoomAudioRenderer({
|
|||||||
livekitRoom,
|
livekitRoom,
|
||||||
validIdentities,
|
validIdentities,
|
||||||
muted,
|
muted,
|
||||||
|
screenshareAudioMuted,
|
||||||
}: MatrixAudioRendererProps): ReactNode {
|
}: MatrixAudioRendererProps): ReactNode {
|
||||||
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
|
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
|
||||||
const tracks = useTracks(
|
const tracks = useTracks(
|
||||||
@@ -143,7 +150,11 @@ export function LivekitRoomAudioRenderer({
|
|||||||
<AudioTrackWithAudioNodes
|
<AudioTrackWithAudioNodes
|
||||||
key={getTrackReferenceId(trackRef)}
|
key={getTrackReferenceId(trackRef)}
|
||||||
trackRef={trackRef}
|
trackRef={trackRef}
|
||||||
muted={muted}
|
muted={
|
||||||
|
muted ||
|
||||||
|
(screenshareAudioMuted &&
|
||||||
|
trackRef.publication.source === Track.Source.ScreenShareAudio)
|
||||||
|
}
|
||||||
audioContext={shouldUseAudioContext ? audioContext : undefined}
|
audioContext={shouldUseAudioContext ? audioContext : undefined}
|
||||||
audioNodes={audioNodes}
|
audioNodes={audioNodes}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
import { of } from "rxjs";
|
|
||||||
import { Track } from "livekit-client";
|
|
||||||
|
|
||||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
|
||||||
import { setAudioEnabled$ } from "../controls";
|
import { setAudioEnabled$ } from "../controls";
|
||||||
import { startLotusDeafen } from "./lotusDeafen";
|
import { startLotusDeafen } from "./lotusDeafen";
|
||||||
import { LotusWidgetActions } from "./lotusActions";
|
import { LotusWidgetActions } from "./lotusActions";
|
||||||
|
import { setScreenshareAudioMuted$ } from "./lotusScreenshareAudio";
|
||||||
|
|
||||||
const lazyActions = new EventEmitter();
|
const lazyActions = new EventEmitter();
|
||||||
|
|
||||||
@@ -27,18 +25,6 @@ vi.mock("../widget", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/** Minimal CallViewModel stub: no connections, so no livekit rooms. */
|
|
||||||
function mockVm(participants: unknown[] = []): CallViewModel {
|
|
||||||
const livekitRoom = {
|
|
||||||
remoteParticipants: new Map(participants.map((p, i) => [String(i), p])),
|
|
||||||
on: vi.fn(),
|
|
||||||
off: vi.fn(),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
allConnections$: of({ getConnections: () => [{ livekitRoom }] }),
|
|
||||||
} as unknown as CallViewModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
function send(data: unknown): void {
|
function send(data: unknown): void {
|
||||||
lazyActions.emit(LotusWidgetActions.SetDeafen, {
|
lazyActions.emit(LotusWidgetActions.SetDeafen, {
|
||||||
detail: { data },
|
detail: { data },
|
||||||
@@ -60,7 +46,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("deafen mutes, and undeafen unmutes, EC's global audio output", () => {
|
test("deafen mutes, and undeafen unmutes, EC's global audio output", () => {
|
||||||
const stop = startLotusDeafen(mockVm());
|
const stop = startLotusDeafen();
|
||||||
|
|
||||||
send({ deafened: true, screenshareAudioMuted: false });
|
send({ deafened: true, screenshareAudioMuted: false });
|
||||||
expect(emissions).toEqual([false]);
|
expect(emissions).toEqual([false]);
|
||||||
@@ -73,7 +59,7 @@ test("deafen mutes, and undeafen unmutes, EC's global audio output", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("re-sending the same state is idempotent (host resendForkState)", () => {
|
test("re-sending the same state is idempotent (host resendForkState)", () => {
|
||||||
const stop = startLotusDeafen(mockVm());
|
const stop = startLotusDeafen();
|
||||||
|
|
||||||
send({ deafened: true, screenshareAudioMuted: false });
|
send({ deafened: true, screenshareAudioMuted: false });
|
||||||
send({ deafened: true, screenshareAudioMuted: false });
|
send({ deafened: true, screenshareAudioMuted: false });
|
||||||
@@ -86,7 +72,7 @@ test("re-sending the same state is idempotent (host resendForkState)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("undeafen does not re-enable audio the user had muted themselves", () => {
|
test("undeafen does not re-enable audio the user had muted themselves", () => {
|
||||||
const stop = startLotusDeafen(mockVm());
|
const stop = startLotusDeafen();
|
||||||
|
|
||||||
// The user mutes all audio through EC's own control first.
|
// The user mutes all audio through EC's own control first.
|
||||||
setAudioEnabled$.next(false);
|
setAudioEnabled$.next(false);
|
||||||
@@ -101,48 +87,44 @@ test("undeafen does not re-enable audio the user had muted themselves", () => {
|
|||||||
expect(emissions).toEqual([false]);
|
expect(emissions).toEqual([false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("deafen never touches per-participant volume", () => {
|
test("deafen never touches the screenshare-audio flag", () => {
|
||||||
const participant = { setVolume: vi.fn() };
|
const stop = startLotusDeafen();
|
||||||
const stop = startLotusDeafen(mockVm([participant]));
|
|
||||||
|
|
||||||
send({ deafened: true, screenshareAudioMuted: false });
|
send({ deafened: true, screenshareAudioMuted: false });
|
||||||
send({ deafened: false, screenshareAudioMuted: false });
|
send({ deafened: false, screenshareAudioMuted: false });
|
||||||
expect(participant.setVolume).not.toHaveBeenCalled();
|
expect(setScreenshareAudioMuted$.value).toBe(false);
|
||||||
|
|
||||||
stop();
|
stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("screenshare-audio mute is applied per source and only undone for participants we muted", () => {
|
test("screenshare-audio mute is a global flag the renderer reads, reset on teardown", () => {
|
||||||
const participant = { setVolume: vi.fn() };
|
const seen: boolean[] = [];
|
||||||
const stop = startLotusDeafen(mockVm([participant]));
|
const flagSub = setScreenshareAudioMuted$.subscribe((v) => seen.push(v));
|
||||||
|
const stop = startLotusDeafen();
|
||||||
|
|
||||||
// Not muted yet: no volume writes at all.
|
// Not muted yet: nothing pushed beyond the BehaviorSubject's initial value.
|
||||||
send({ deafened: false, screenshareAudioMuted: false });
|
send({ deafened: false, screenshareAudioMuted: false });
|
||||||
expect(participant.setVolume).not.toHaveBeenCalled();
|
expect(seen).toEqual([false]);
|
||||||
|
|
||||||
send({ deafened: false, screenshareAudioMuted: true });
|
send({ deafened: false, screenshareAudioMuted: true });
|
||||||
expect(participant.setVolume).toHaveBeenCalledWith(
|
expect(setScreenshareAudioMuted$.value).toBe(true);
|
||||||
0,
|
// Re-applying the same state (host resend after reconnect) is a no-op.
|
||||||
Track.Source.ScreenShareAudio,
|
send({ deafened: false, screenshareAudioMuted: true });
|
||||||
);
|
expect(seen).toEqual([false, true]);
|
||||||
|
|
||||||
participant.setVolume.mockClear();
|
// Deafen + undeafen while screenshare audio is muted leaves it muted.
|
||||||
send({ deafened: false, screenshareAudioMuted: false });
|
send({ deafened: true, screenshareAudioMuted: true });
|
||||||
expect(participant.setVolume).toHaveBeenCalledWith(
|
send({ deafened: false, screenshareAudioMuted: true });
|
||||||
1,
|
expect(setScreenshareAudioMuted$.value).toBe(true);
|
||||||
Track.Source.ScreenShareAudio,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Releasing again writes nothing: we no longer own that participant.
|
|
||||||
participant.setVolume.mockClear();
|
|
||||||
send({ deafened: false, screenshareAudioMuted: false });
|
|
||||||
expect(participant.setVolume).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
|
// Leaving the call clears it so the next call starts clean.
|
||||||
stop();
|
stop();
|
||||||
|
expect(setScreenshareAudioMuted$.value).toBe(false);
|
||||||
|
flagSub.unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a partial payload only moves the flag it names", () => {
|
test("a partial payload only moves the flag it names", () => {
|
||||||
const stop = startLotusDeafen(mockVm());
|
const stop = startLotusDeafen();
|
||||||
|
|
||||||
send({ deafened: true });
|
send({ deafened: true });
|
||||||
expect(emissions).toEqual([false]);
|
expect(emissions).toEqual([false]);
|
||||||
|
|||||||
+17
-71
@@ -5,19 +5,13 @@ 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 {
|
|
||||||
type RemoteParticipant,
|
|
||||||
type Room as LivekitRoom,
|
|
||||||
RoomEvent,
|
|
||||||
Track,
|
|
||||||
} from "livekit-client";
|
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||||
|
|
||||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
|
||||||
import { setAudioEnabled$ } from "../controls";
|
import { setAudioEnabled$ } from "../controls";
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
import { LotusWidgetActions } from "./lotusActions";
|
import { LotusWidgetActions } from "./lotusActions";
|
||||||
|
import { setScreenshareAudioMuted$ } from "./lotusScreenshareAudio";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the host's `io.lotus.set_deafen` toWidget action, replacing cinny's
|
* Handle the host's `io.lotus.set_deafen` toWidget action, replacing cinny's
|
||||||
@@ -41,22 +35,23 @@ import { LotusWidgetActions } from "./lotusActions";
|
|||||||
* volume/mute state.
|
* volume/mute state.
|
||||||
*
|
*
|
||||||
* `screenshareAudioMuted` is a NARROWER, independent host control (drop shared
|
* `screenshareAudioMuted` is a NARROWER, independent host control (drop shared
|
||||||
* tab/game audio while still hearing voices). EC has no global per-source mute,
|
* tab/game audio while still hearing voices). It goes through the same
|
||||||
* so that one still has to go through
|
* mechanism as deafen — a global behavior (`muteScreenshareAudio$`) that
|
||||||
* `RemoteParticipant.setVolume(volume, Track.Source.ScreenShareAudio)` — whose
|
* `LivekitRoomAudioRenderer` turns into the `muted` prop of every
|
||||||
* verified signature in livekit-client ^2.18.1 is
|
* `Track.Source.ScreenShareAudio` element — rather than
|
||||||
* `setVolume(volume, source?: Track.Source.Microphone | Track.Source.ScreenShareAudio)`.
|
* `RemoteParticipant.setVolume(0, ScreenShareAudio)`: EC's own
|
||||||
* It is re-applied to late joiners via `RoomEvent.ParticipantConnected`, and we
|
* `createVolumeControls` writes volume 1 through that very setter whenever a
|
||||||
* only ever restore participants we muted ourselves. Known limitation: EC's own
|
* new screenshare media item resolves, so a sharer who stopped and re-shared
|
||||||
* screenshare volume slider writes the same `volumeMap`, so a user who moves
|
* (or a late joiner's share) came back at full volume while the host's button
|
||||||
* that slider while screenshare audio is host-muted wins; the mic path (the
|
* still said "Unmute Screenshare Audio".
|
||||||
* actual deafen) is no longer affected by that race at all.
|
|
||||||
*
|
*
|
||||||
* Undeafen restores the user's OWN output-enabled state as it was before the
|
* Undeafen restores the user's OWN output-enabled state as it was before the
|
||||||
* deafen (and never touches the `mute-all-audio` setting), so a user who had
|
* deafen (and never touches the `mute-all-audio` setting), so a user who had
|
||||||
* already muted all audio themselves stays muted.
|
* already muted all audio themselves stays muted.
|
||||||
*
|
*
|
||||||
* State is closure-scoped (per invocation, matching the sibling lotus modules).
|
* State is closure-scoped (per invocation, matching the sibling lotus modules);
|
||||||
|
* the screenshare-audio flag additionally lives in `setScreenshareAudioMuted$`
|
||||||
|
* so the renderer can read it, and is reset on teardown.
|
||||||
* Applying the same state twice is a no-op, so the host's
|
* Applying the same state twice is a no-op, so the host's
|
||||||
* `CallControl.resendForkState()` after a reconnect is safe. The host re-sends
|
* `CallControl.resendForkState()` after a reconnect is safe. The host re-sends
|
||||||
* the current state on every call join (CallControl.forceState), so a fresh
|
* the current state on every call join (CallControl.forceState), so a fresh
|
||||||
@@ -64,7 +59,7 @@ import { LotusWidgetActions } from "./lotusActions";
|
|||||||
*
|
*
|
||||||
* No effect unless the host sends the action. Returns a teardown function.
|
* No effect unless the host sends the action. Returns a teardown function.
|
||||||
*/
|
*/
|
||||||
export function startLotusDeafen(vm: CallViewModel): () => void {
|
export function startLotusDeafen(): () => void {
|
||||||
const w = widget;
|
const w = widget;
|
||||||
if (!w) return () => undefined;
|
if (!w) return () => undefined;
|
||||||
|
|
||||||
@@ -107,53 +102,6 @@ export function startLotusDeafen(vm: CallViewModel): () => void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Participants whose screenshare audio WE turned down, so undoing the host's
|
|
||||||
// screenshare mute never writes a volume to anyone else.
|
|
||||||
const screenshareMuted = new Set<RemoteParticipant>();
|
|
||||||
|
|
||||||
const applyToParticipant = (p: RemoteParticipant): void => {
|
|
||||||
if (screenshareAudioMuted) {
|
|
||||||
p.setVolume(0, Track.Source.ScreenShareAudio);
|
|
||||||
screenshareMuted.add(p);
|
|
||||||
} else if (screenshareMuted.delete(p)) {
|
|
||||||
p.setVolume(1, Track.Source.ScreenShareAudio);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyToRoom = (room: LivekitRoom): void =>
|
|
||||||
room.remoteParticipants.forEach(applyToParticipant);
|
|
||||||
|
|
||||||
// Per-room ParticipantConnected listeners, so LATE JOINERS pick up the
|
|
||||||
// current screenshare-audio mute the moment they connect. Drive off the
|
|
||||||
// local participant's connection(s), not `livekitRoomItems$` — that stream is
|
|
||||||
// empty until a matrix-validated REMOTE member resolves, so listeners would
|
|
||||||
// be attached too late for the first joiner (the sibling lotus modules all
|
|
||||||
// use `allConnections$` for the same reason).
|
|
||||||
const roomListeners = new Map<LivekitRoom, () => void>();
|
|
||||||
let rooms: LivekitRoom[] = [];
|
|
||||||
|
|
||||||
const sub = vm.allConnections$.subscribe((data) => {
|
|
||||||
const next = data.getConnections().map((c) => c.livekitRoom);
|
|
||||||
rooms = next;
|
|
||||||
// Detach listeners for rooms that went away.
|
|
||||||
for (const [room, off] of roomListeners) {
|
|
||||||
if (!next.includes(room)) {
|
|
||||||
off();
|
|
||||||
roomListeners.delete(room);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Attach to new rooms + apply the current state to their participants.
|
|
||||||
for (const room of next) {
|
|
||||||
if (!roomListeners.has(room)) {
|
|
||||||
room.on(RoomEvent.ParticipantConnected, applyToParticipant);
|
|
||||||
roomListeners.set(room, () =>
|
|
||||||
room.off(RoomEvent.ParticipantConnected, applyToParticipant),
|
|
||||||
);
|
|
||||||
applyToRoom(room);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handler = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
const handler = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||||
w.api.transport.reply(ev.detail, {});
|
w.api.transport.reply(ev.detail, {});
|
||||||
const data = ev.detail.data as
|
const data = ev.detail.data as
|
||||||
@@ -168,20 +116,18 @@ export function startLotusDeafen(vm: CallViewModel): () => void {
|
|||||||
`[lotus] set_deafen: deafened=${deafened} screenshareAudioMuted=${screenshareAudioMuted}`,
|
`[lotus] set_deafen: deafened=${deafened} screenshareAudioMuted=${screenshareAudioMuted}`,
|
||||||
);
|
);
|
||||||
applyGlobalMute();
|
applyGlobalMute();
|
||||||
rooms.forEach(applyToRoom);
|
if (setScreenshareAudioMuted$.value !== screenshareAudioMuted)
|
||||||
|
setScreenshareAudioMuted$.next(screenshareAudioMuted);
|
||||||
};
|
};
|
||||||
|
|
||||||
w.lazyActions.on(LotusWidgetActions.SetDeafen, handler);
|
w.lazyActions.on(LotusWidgetActions.SetDeafen, handler);
|
||||||
return () => {
|
return () => {
|
||||||
sub.unsubscribe();
|
|
||||||
for (const off of roomListeners.values()) off();
|
|
||||||
roomListeners.clear();
|
|
||||||
// Leave the user's own output state as they had it before deafen.
|
// Leave the user's own output state as they had it before deafen.
|
||||||
if (deafened) {
|
if (deafened) {
|
||||||
deafened = false;
|
deafened = false;
|
||||||
applyGlobalMute();
|
applyGlobalMute();
|
||||||
}
|
}
|
||||||
screenshareMuted.clear();
|
if (setScreenshareAudioMuted$.value) setScreenshareAudioMuted$.next(false);
|
||||||
audioSub.unsubscribe();
|
audioSub.unsubscribe();
|
||||||
w.lazyActions.off(LotusWidgetActions.SetDeafen, handler);
|
w.lazyActions.off(LotusWidgetActions.SetDeafen, handler);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
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 { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
|
import { globalScope } from "../state/ObservableScope";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the host has muted remote screenshare AUDIO (`io.lotus.set_deafen`
|
||||||
|
* `screenshareAudioMuted`). Consumed by `LivekitRoomAudioRenderer` exactly the
|
||||||
|
* way `muteAllAudio$` is: it becomes the `muted` prop of every
|
||||||
|
* `Track.Source.ScreenShareAudio` element, so it survives re-renders, applies
|
||||||
|
* to tracks that are published LATER (a sharer stopping and re-sharing, a late
|
||||||
|
* joiner) and never fights EC's per-tile volume controls — which was the
|
||||||
|
* failure mode of the old `RemoteParticipant.setVolume(0, ScreenShareAudio)`
|
||||||
|
* approach: `createVolumeControls` writes its own volume (1) through the same
|
||||||
|
* sink the moment a new screenshare media item appears, un-muting it.
|
||||||
|
*/
|
||||||
|
export const setScreenshareAudioMuted$ = new BehaviorSubject(false);
|
||||||
|
|
||||||
|
export const muteScreenshareAudio$ = globalScope.behavior(
|
||||||
|
setScreenshareAudioMuted$,
|
||||||
|
);
|
||||||
@@ -73,6 +73,7 @@ import { matrixRTCMode as matrixRTCModeSetting } from "../settings/settings";
|
|||||||
import { ReactionsReader } from "../reactions/ReactionsReader";
|
import { ReactionsReader } from "../reactions/ReactionsReader";
|
||||||
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
||||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||||
|
import { muteScreenshareAudio$ } from "../lotus/lotusScreenshareAudio";
|
||||||
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
||||||
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -261,6 +262,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const { showControls, header: headerStyle } = useUrlParams();
|
const { showControls, header: headerStyle } = useUrlParams();
|
||||||
|
|
||||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||||
|
// [lotus] host-driven "Mute Screenshare Audio" (io.lotus.set_deafen).
|
||||||
|
const muteScreenshareAudio = useBehavior(muteScreenshareAudio$);
|
||||||
const toggleAudio = useBehavior(muteStates.audio.toggle$);
|
const toggleAudio = useBehavior(muteStates.audio.toggle$);
|
||||||
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
||||||
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
||||||
@@ -309,7 +312,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
// [lotus] Handle the host's io.lotus.set_deafen action to silence remote
|
// [lotus] Handle the host's io.lotus.set_deafen action to silence remote
|
||||||
// audio (and optionally screenshare audio) at the LiveKit source. No-op
|
// audio (and optionally screenshare audio) at the LiveKit source. No-op
|
||||||
// unless the host sends the action.
|
// unless the host sends the action.
|
||||||
useEffect(() => startLotusDeafen(vm), [vm]);
|
useEffect(() => startLotusDeafen(), []);
|
||||||
|
|
||||||
const fatalCallError = useBehavior(vm.fatalError$);
|
const fatalCallError = useBehavior(vm.fatalError$);
|
||||||
// Stop the rendering and throw for the error boundary
|
// Stop the rendering and throw for the error boundary
|
||||||
@@ -657,6 +660,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
livekitRoom={livekitRoom}
|
livekitRoom={livekitRoom}
|
||||||
validIdentities={participants}
|
validIdentities={participants}
|
||||||
muted={muteAllAudio}
|
muted={muteAllAudio}
|
||||||
|
screenshareAudioMuted={muteScreenshareAudio}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
|
|||||||
Reference in New Issue
Block a user