From 2b6bb20104c5c43dbb5f0d653df3d6655e763f8a Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 25 Sep 2026 09:57:55 -0400 Subject: [PATCH] feat(lotus): remember each person's volume across calls (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/lotus/lotusVolumeMemory.test.ts | 60 +++++++++++++++++++ src/lotus/lotusVolumeMemory.ts | 65 +++++++++++++++++++++ src/state/VolumeControls.ts | 60 ++++++++++++++++--- src/state/media/MediaViewModel.test.ts | 32 +++++++++- src/state/media/RemoteUserMediaViewModel.ts | 7 +++ src/tile/SpotlightTile.tsx | 5 +- 6 files changed, 219 insertions(+), 10 deletions(-) create mode 100644 src/lotus/lotusVolumeMemory.test.ts create mode 100644 src/lotus/lotusVolumeMemory.ts diff --git a/src/lotus/lotusVolumeMemory.test.ts b/src/lotus/lotusVolumeMemory.test.ts new file mode 100644 index 00000000..3cbf2105 --- /dev/null +++ b/src/lotus/lotusVolumeMemory.test.ts @@ -0,0 +1,60 @@ +/* +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); + }); +}); diff --git a/src/lotus/lotusVolumeMemory.ts b/src/lotus/lotusVolumeMemory.ts new file mode 100644 index 00000000..9d89c43f --- /dev/null +++ b/src/lotus/lotusVolumeMemory.ts @@ -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; + +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); +} diff --git a/src/state/VolumeControls.ts b/src/state/VolumeControls.ts index beb7ae00..19911686 100644 --- a/src/state/VolumeControls.ts +++ b/src/state/VolumeControls.ts @@ -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; 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,15 +61,22 @@ 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(); const commitVolume$ = new Subject<"commit">(); - const playbackVolume$ = scope.behavior( + const state$ = scope.behavior( merge(toggleMuted$, adjustVolume$, commitVolume$).pipe( - accumulate({ volume: 1, committedVolume: 1 }, (state, event) => { + accumulate( + { volume: initialVolume, committedVolume: initialVolume }, + (state, event) => { switch (event) { case "toggle mute": return { @@ -71,10 +96,26 @@ export function createVolumeControls( // Volume adjustment return { ...state, volume: event }; } - }), - map(({ volume }) => volume), + }, + ), ), ); + const playbackVolume$ = scope.behavior( + 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"); + }, }; } diff --git a/src/state/media/MediaViewModel.test.ts b/src/state/media/MediaViewModel.test.ts index 9d873ccb..c4ba9840 100644 --- a/src/state/media/MediaViewModel.test.ts +++ b/src/state/media/MediaViewModel.test.ts @@ -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( diff --git a/src/state/media/RemoteUserMediaViewModel.ts b/src/state/media/RemoteUserMediaViewModel.ts index 4307dea4..4cff7262 100644 --- a/src/state/media/RemoteUserMediaViewModel.ts +++ b/src/state/media/RemoteUserMediaViewModel.ts @@ -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( diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index abc073fa..1847c88b 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -337,7 +337,10 @@ const ScreenShareVolumeButton: FC = ({ vm }) => { (v: number) => vm.adjustPlaybackVolume(v), [vm], ); - const onVolumeCommit = useCallback(() => vm.commitPlaybackVolume(), [vm]); + const onVolumeCommit = useCallback( + (value: number) => vm.commitPlaybackVolume(value), + [vm], + ); return ( audioEnabled && (