Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 44023a4c84 style(lotus): oxfmt the volume memory change (#36)
CI / Build embedded bundle (push) Successful in 1m51s
CI / Publish to Gitea npm registry (push) Successful in 1m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 10:03:49 -04:00
Lotus CIandClaude Opus 5.5 3086edc64a chore(lotus): 0.25.0-lotus.15
CI / Build embedded bundle (push) Failing after 48s
CI / Publish to Gitea npm registry (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 09:57:55 -04:00
Lotus CIandClaude Opus 5.5 2b6bb20104 feat(lotus): remember each person's volume across calls (#36)
The per-participant volume slider lived only in the tile's view model, so it
reset to 100 % on the next call, a reload, or when that person reconnected.

- lotus/lotusVolumeMemory.ts: per-Matrix-user volume in localStorage
  (`lotus-per-user-volume`), keyed by user id so it follows them across
  devices; 1 (100 %) forgets the entry; at most 50 people, least recently set
  dropped first; junk in storage ignored. Local only, never synced.
- VolumeControls take an optional initialVolume and onCommit; remote user
  media seeds from and saves to the memory. Screenshare audio stays per-share.
- commitPlaybackVolume now takes the slider's committed value. With the
  keyboard the slider commits before its last change arrives, so the
  committed (and remembered) volume was one step behind, and returning to
  100 % with End never registered.

Verified in a two-person local call: Alice sets Bob to 70 % with the
keyboard → audio element 0.70, stored 0.7; leave + rejoin → slider and audio
0.70; End → entry removed. Unit tests: memory module (LRU, reset, junk),
remembered volume restore + commit-with-value; state/tile/room/lotus suites
all pass on Node 22 (410 tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 09:57:55 -04:00
Lotus CIandClaude Opus 5.5 3ca252f633 chore(lotus): 0.25.0-lotus.14
CI / Build embedded bundle (push) Successful in 2m58s
CI / Publish to Gitea npm registry (push) Successful in 1m3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:16:39 -04:00
Lotus CIandClaude Opus 5.5 6beebd3ea7 feat(lotus): layout, settings and reactions over the widget API (cinny #43)
The Lotus host drove these by clicking EC's hidden controls in our DOM and
read screenshare/layout state from it, which only works while the frame is
same-origin and breaks whenever a data-testid changes. New actions:

- toWidget io.lotus.set_layout { layout: "grid" | "spotlight" } → the layout
  switch view model's setLayout.
- toWidget io.lotus.open_settings { open?: boolean } → setSettingsOpen
  (toggle when `open` is omitted).
- toWidget io.lotus.toggle_reactions → a window event the reactions button
  listens for.
- fromWidget io.lotus.controls_state { screensharing, layout } on change; its
  arrival tells the host the fork supports the actions above.

Screensharing stays host-driven for now: getDisplayMedia needs the user's
click to reach this frame (Capability Delegation), which a widget message
doesn't carry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:16:24 -04:00
13 changed files with 420 additions and 30 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lotusguild/element-call-embedded",
"version": "0.25.0-lotus.13",
"version": "0.25.0-lotus.15",
"files": [
"README.md",
"LICENSE-AGPL-3.0",
+10
View File
@@ -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 {
+6
View File
@@ -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,8 @@ describe("LotusWidgetActions", () => {
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(
LotusWidgetActions.DenoiseState,
);
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(
LotusWidgetActions.ControlsState,
);
});
});
+21
View File
@@ -62,6 +62,24 @@ 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",
}
/** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */
@@ -72,4 +90,7 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [
LotusWidgetActions.Decorations,
LotusWidgetActions.SetDeafen,
LotusWidgetActions.SetAudioOutput,
LotusWidgetActions.SetLayout,
LotusWidgetActions.OpenSettings,
LotusWidgetActions.ToggleReactions,
];
+35
View File
@@ -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);
});
});
+104
View File
@@ -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);
};
}
+61
View File
@@ -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);
});
});
+65
View File
@@ -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);
}
+4
View File
@@ -31,6 +31,7 @@ import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { widget } from "../widget";
import { startLotusCallState } from "../lotus/lotusCallState";
import { startLotusFocus } from "../lotus/lotusFocus";
import { startLotusControls } from "../lotus/lotusControls";
import { startLotusAudioInject } from "../lotus/lotusAudioInject";
import { startLotusQuality } from "../lotus/lotusQuality";
import { startLotusDecorations } from "../lotus/lotusDecorations";
@@ -300,6 +301,9 @@ 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]);
// [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
View File
@@ -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");
},
};
}
+31 -1
View File
@@ -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(
+4 -1
View File
@@ -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 && (